diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..14b468b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,45 @@ +root = true + + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +tab_width = 4 +insert_final_newline = true +trim_trailing_whitespace = true + + +[*.md] +trim_trailing_whitespace = false + + +[*.{yml,yaml,toml,json}] +indent_size = 2 + + +[*.{kt,kts}] +max_line_length = 120 +ktlint_code_style = ktlint_official +ktlint_experimental = disabled +ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 4 +ktlint_function_signature_body_expression_wrapping = multiline +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 +ktlint_chain_method_rule_force_multiline_when_chain_operator_count_greater_or_equal_than = 3 +ktlint_function_naming_ignore_when_annotated_with = Composable,DsglDsl +ktlint_ignore_back_ticked_identifier = true + +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +ij_kotlin_packages_to_use_import_on_demand = org.dreamfinity.dsgl.core.animation.*,org.dreamfinity.dsgl.core.colorpicker.*,org.dreamfinity.dsgl.core.dnd.*,org.dreamfinity.dsgl.core.dom.elements.*,org.dreamfinity.dsgl.core.dom.elements.support.*,org.dreamfinity.dsgl.core.dom.layout.*,org.dreamfinity.dsgl.core.dsl.*,org.dreamfinity.dsgl.core.event.*,org.dreamfinity.dsgl.core.font.*,org.dreamfinity.dsgl.core.inspector.*,org.dreamfinity.dsgl.core.style.*,org.dreamfinity.dsgl.core.text.*,org.lwjgl.opengl.* +ij_kotlin_allow_trailing_comma = true +# ij_kotlin_allow_trailing_comma_on_call_site = true # disabled due to breaking DSL callsite by IDE code linter +ij_kotlin_indent_before_arrow_on_new_line = false +ij_formatter_tags_enabled = true +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on + +[**/src/test/kotlin/**/*.kt] +max_line_length = 160 +ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 4 +ij_kotlin_packages_to_use_import_on_demand = kotlin.test.*,org.dreamfinity.dsgl.core.colorpicker.*,org.dreamfinity.dsgl.core.dnd.*,org.dreamfinity.dsgl.core.event.* diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..f6dc4bb --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,46 @@ +#!/bin/sh +# Runs ktlintFormat and detektAll on staged Kotlin files before commit. +# ktlint auto-correctable violations are fixed and re-staged automatically. +# Non-auto-correctable ktlint violations or detekt violations abort the commit. + +exec 1>&2 + +STAGED_KT=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(kt|kts)$') + +if [ -z "$STAGED_KT" ]; then + exit 0 +fi + +echo "[pre-commit] Running ktlintFormat..." + +./gradlew ktlintFormat +STATUS=$? + +# Re-stage any files that ktlint auto-corrected (only from the originally staged set) +echo "$STAGED_KT" | tr ' ' '\n' | while IFS= read -r file; do + [ -f "$file" ] && git add "$file" +done + +if [ $STATUS -ne 0 ]; then + echo "" + echo "[pre-commit] ktlintFormat found violations that cannot be auto-corrected." + echo "[pre-commit] Fix them manually, then re-run git commit." + exit 1 +fi + +echo "[pre-commit] ktlintFormat passed." + +echo "[pre-commit] Running detektAll..." + +./gradlew detektAll +STATUS=$? + +if [ $STATUS -ne 0 ]; then + echo "" + echo "[pre-commit] detektAll found violations." + echo "[pre-commit] Fix them manually or update the baseline, then re-run git commit." + exit 1 +fi + +echo "[pre-commit] detektAll passed." +exit 0 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e71afd1..df7b58d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ build # docs generated folder /site/ +# Added by code-review-graph +.code-review-graph/ diff --git a/adapters/mc-forge-1-7-10/adapter-build-logic/settings.gradle.kts b/adapters/mc-forge-1-7-10/adapter-build-logic/settings.gradle.kts index 6e9783e..e66bc4b 100644 --- a/adapters/mc-forge-1-7-10/adapter-build-logic/settings.gradle.kts +++ b/adapters/mc-forge-1-7-10/adapter-build-logic/settings.gradle.kts @@ -1,5 +1,3 @@ -import org.gradle.kotlin.dsl.maven - pluginManagement { includeBuild("../../../build-logic") repositories { @@ -8,3 +6,11 @@ pluginManagement { maven("https://maven.minecraftforge.net") } } + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../../../gradle/libs.versions.toml")) + } + } +} diff --git a/adapters/mc-forge-1-7-10/build.gradle.kts b/adapters/mc-forge-1-7-10/build.gradle.kts index 097e256..c6424dc 100644 --- a/adapters/mc-forge-1-7-10/build.gradle.kts +++ b/adapters/mc-forge-1-7-10/build.gradle.kts @@ -2,6 +2,8 @@ plugins { id("dsgl-mc-adapter.conventions") id("dsgl-mc-forge-1-7-10.conventions") id("dsgl-releaseable-module.conventions") + id("dsgl-linter.conventions") + id("dsgl-static-analysis.conventions") } dsglRelease { @@ -9,9 +11,10 @@ dsglRelease { } dependencies { - val coreProject = findProject(":core") - ?: findProject(":dsgl:core") - ?: error("DSGL core project not found (expected :core or :dsgl:core).") + val coreProject = + findProject(":core") + ?: findProject(":dsgl:core") + ?: error("DSGL core project not found (expected :core or :dsgl:core).") implementation(coreProject) testImplementation(kotlin("test-junit")) testImplementation(kotlin("test")) diff --git a/adapters/mc-forge-1-7-10/demo/build.gradle.kts b/adapters/mc-forge-1-7-10/demo/build.gradle.kts index 00b01ad..5245f0d 100644 --- a/adapters/mc-forge-1-7-10/demo/build.gradle.kts +++ b/adapters/mc-forge-1-7-10/demo/build.gradle.kts @@ -1,6 +1,8 @@ plugins { id("dsgl-mc-adapter.conventions") id("dsgl-mc-forge-1-7-10.conventions") + id("dsgl-linter.conventions") + id("dsgl-static-analysis.conventions") } val modId: String by project @@ -19,20 +21,22 @@ val msdfDebugDecorations: String by project val msdfDebugPerformance: String by project val rebuildTrace: String by project val perfDebug: String by project -val dsglOverlayDebug: String by project -val dsglOverlayControls: String by project +val dsglDomainDebug: String by project +val dsglDomainControls: String by project +val dsglColorPickerDebugCounters: String by project val hotReloadAgentLibraryName: String? by project -val baseModMetadataTokens = mapOf( - "modId" to modId, - "modGroup" to modGroup, - "modName" to modName, - "modAuthor" to modAuthor, - "modDescription" to modDescription, - "modCredits" to modCredits, - "modIcon" to modIcon, - "gameVersion" to gameVersion -) +val baseModMetadataTokens = + mapOf( + "modId" to modId, + "modGroup" to modGroup, + "modName" to modName, + "modAuthor" to modAuthor, + "modDescription" to modDescription, + "modCredits" to modCredits, + "modIcon" to modIcon, + "gameVersion" to gameVersion, + ) fun currentModVersion(): String { val dynamic = (findProperty("modVersion") as? String)?.trim() @@ -41,25 +45,24 @@ fun currentModVersion(): String { throw GradleException("Missing required property 'modVersion' for mc-forge-1-7-10-demo module.") } -fun currentModMetadataTokens(): Map { - return baseModMetadataTokens + ("modVersion" to currentModVersion()) -} +fun currentModMetadataTokens(): Map = baseModMetadataTokens + ("modVersion" to currentModVersion()) fun hotReloadAgentLibraryFile(): File { val explicitLibraryName = hotReloadAgentLibraryName?.trim()?.takeIf { it.isNotEmpty() } val osName = System.getProperty("os.name")?.lowercase() - val libraryName = explicitLibraryName ?: when { - osName == null -> throw GradleException( - "Unable to determine current operating system for DSGL hot-reload agent, and 'hotReloadAgentLibraryName' is not set." - ) - - osName.startsWith("windows") -> "dsgl_hot_reload_agent.dll" - osName.startsWith("linux") -> "libdsgl_hot_reload_agent.so" - osName.startsWith("mac") || osName.startsWith("darwin") -> "libdsgl_hot_reload_agent.dylib" - else -> throw GradleException( - "Unsupported operating system for DSGL hot-reload agent: $osName, and 'hotReloadAgentLibraryName' is not set." - ) - } + val libraryName = + explicitLibraryName ?: when { + osName == null -> throw GradleException( + "Unable to determine current operating system for DSGL hot-reload agent, and 'hotReloadAgentLibraryName' is not set.", + ) + + osName.startsWith("windows") -> "dsgl_hot_reload_agent.dll" + osName.startsWith("linux") -> "libdsgl_hot_reload_agent.so" + osName.startsWith("mac") || osName.startsWith("darwin") -> "libdsgl_hot_reload_agent.dylib" + else -> throw GradleException( + "Unsupported operating system for DSGL hot-reload agent: $osName, and 'hotReloadAgentLibraryName' is not set.", + ) + } return project.rootDir.resolve("dsgl-hot-reload-agent/target/release/$libraryName") } @@ -93,22 +96,24 @@ val generateModMetadata by tasks.registering { const val MOD_CREDITS: String = "${tokens["modCredits"]}" const val MOD_ICON: String = "${tokens["modIcon"]}" } - """.trimIndent() + """.trimIndent() + System.lineSeparator(), ) } } tasks { runClient { - var jvmArgs = listOf( - "-Ddsgl.msdf.debug=$msdfDebug", - "-Ddsgl.msdf.debug.decorations=$msdfDebugDecorations", - "-Ddsgl.msdf.debug.performance=$msdfDebugPerformance", - "-Ddsgl.rebuild.trace=$rebuildTrace", - "-Ddsgl.perf.debug=$perfDebug", - "-Ddsgl.overlay.debug=$dsglOverlayDebug", - "-Ddsgl.overlay.controls=$dsglOverlayControls", - ) + var jvmArgs = + listOf( + "-Ddsgl.msdf.debug=$msdfDebug", + "-Ddsgl.msdf.debug.decorations=$msdfDebugDecorations", + "-Ddsgl.msdf.debug.performance=$msdfDebugPerformance", + "-Ddsgl.rebuild.trace=$rebuildTrace", + "-Ddsgl.perf.debug=$perfDebug", + "-Ddsgl.domain.debug=$dsglDomainDebug", + "-Ddsgl.domain.controls=$dsglDomainControls", + "-Ddsgl.colorPicker.debugCounters=$dsglColorPickerDebugCounters", + ) if (hotReload.toBoolean()) { jvmArgs = jvmArgs + listOf("-agentpath:${hotReloadAgentLibraryFile().absolutePath}") @@ -131,7 +136,10 @@ tasks { } kotlin { - sourceSets.getByName("main").kotlin.srcDir(generatedModMetadataDir) + sourceSets + .getByName("main") + .kotlin + .srcDir(generatedModMetadataDir) } tasks.named("compileKotlin") { @@ -150,6 +158,10 @@ tasks.named("dokkaGeneratePublicationHtml") { dependsOn(generateModMetadata) } +tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }.configureEach { + dependsOn(generateModMetadata) +} + tasks.named("processResources") { inputs.properties(baseModMetadataTokens) inputs.property("modVersion", providers.provider { currentModVersion() }) @@ -182,6 +194,10 @@ listOf( } } +tasks.named("test") { + mustRunAfter(":adapters:mc-forge-1-7-10:reobf") +} + tasks.withType().configureEach { enabled = false } @@ -203,8 +219,10 @@ repositories { } dependencies { - implementation("org.dreamfinity:dsgl-core:0.0.1") - implementation("org.dreamfinity:dsgl-mc-forge-1-7-10:0.0.1:dev") + implementation(project(":core")) + implementation(project(":adapters:mc-forge-1-7-10")) +// implementation("org.dreamfinity:dsgl-core:0.0.1") +// implementation("org.dreamfinity:dsgl-mc-forge-1-7-10:0.0.1:dev") testImplementation(kotlin("test-junit")) testImplementation(kotlin("test")) } diff --git a/adapters/mc-forge-1-7-10/demo/detekt-baseline.xml b/adapters/mc-forge-1-7-10/demo/detekt-baseline.xml new file mode 100644 index 0000000..caec4a7 --- /dev/null +++ b/adapters/mc-forge-1-7-10/demo/detekt-baseline.xml @@ -0,0 +1,207 @@ + + + + + CyclomaticComplexMethod:AnimationsSection.kt$fun UiScope.animationsSection(onInfo: (String) -> Unit) + CyclomaticComplexMethod:CapabilityChecklistCatalog.kt$CapabilityChecklistCatalog$fun capabilitiesForSection(section: DemoSection): Set<CapabilityId> + CyclomaticComplexMethod:ContextMenuSection.kt$fun UiScope.contextMenuSection(onInfo: (String) -> Unit) + CyclomaticComplexMethod:CssCascadeSection.kt$fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> Unit) + CyclomaticComplexMethod:DragDropSection.kt$fun UiScope.dragNDropSection( onInfo: (String) -> Unit, onClearLogs: () -> Unit, onLogHook: (String, Event, String?) -> Unit, ) + CyclomaticComplexMethod:EventFormatting.kt$fun formatEventLine(hookName: String, event: Event, note: String? = null): String + CyclomaticComplexMethod:InputsGallerySection.kt$fun UiScope.inputsGallerySection(clippingScrollDemoText: String, onClippingScrollDemoTextChange: (String) -> Unit) + CyclomaticComplexMethod:InteractionsSection.kt$fun UiScope.interactionsSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + CyclomaticComplexMethod:LayoutStyleSection.kt$fun UiScope.layoutStyleSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + CyclomaticComplexMethod:McFeaturesSection.kt$fun UiScope.mcFeaturesSection(props: McFeaturesSection) + CyclomaticComplexMethod:ShowcaseWindow.kt$ShowcaseWindow$override fun render(): DomTree + LargeClass:ShowcaseWindow.kt$ShowcaseWindow : DsglWindow + LongMethod:AnimationsSection.kt$fun UiScope.animationsSection(onInfo: (String) -> Unit) + LongMethod:CapabilityChecklistCatalog.kt$CapabilityChecklistCatalog$fun capabilitiesForSection(section: DemoSection): Set<CapabilityId> + LongMethod:ColorPickerSection.kt$fun UiScope.colorPickerSection() + LongMethod:ContextMenuSection.kt$fun UiScope.contextMenuEntryTile(file: ContextMenuDemoFile) + LongMethod:ContextMenuSection.kt$fun UiScope.contextMenuSection(onInfo: (String) -> Unit) + LongMethod:CssCascadeSection.kt$fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> Unit) + LongMethod:DisplaySection.kt$fun UiScope.displaySection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + LongMethod:DragDropSection.kt$fun UiScope.dragNDropSection( onInfo: (String) -> Unit, onClearLogs: () -> Unit, onLogHook: (String, Event, String?) -> Unit, ) + LongMethod:DragDropSection.kt$private fun UiScope.originalModeReorder( state: DndSectionState, sourceKey: Any?, onStart: (DndDemoItem, DragStartEvent) -> Unit, onDrag: (DragEvent) -> Unit, onEnd: (DragEndEvent) -> Unit, onLaneOver: (DragOverEvent) -> Unit, onLaneLeave: () -> Unit, onLaneDrop: (DropEvent) -> Unit, onCardOver: (String, Boolean, DragOverEvent) -> Unit, onCardDrop: (String, Boolean, DropEvent) -> Unit, laneIndicatorForCard: (String, Any?) -> DndLaneIndicator, shouldShowLaneAppendGap: (Any?) -> Boolean, ) + LongMethod:FocusRebuildSection.kt$fun UiScope.focusRebuildSection( renderPasses: Int, onManualInvalidate: (String) -> Unit, onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit, ) + LongMethod:HooksSection.kt$private fun UiScope.overviewUseRef(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + LongMethod:InputEventsSection.kt$fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) + LongMethod:InputsGallerySection.kt$fun UiScope.inputsGallerySection(clippingScrollDemoText: String, onClippingScrollDemoTextChange: (String) -> Unit) + LongMethod:InspectorSection.kt$fun UiScope.inspectorSection(onInfo: (String) -> Unit) + LongMethod:InteractionsSection.kt$fun UiScope.interactionsSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + LongMethod:LayoutDebugSection.kt$fun UiScope.layoutDebugSection(onClearLogs: () -> Unit, onInfo: (String) -> Unit) + LongMethod:LayoutStyleSection.kt$fun UiScope.layoutStyleSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) + LongMethod:McFeaturesSection.kt$fun UiScope.mcFeaturesSection(props: McFeaturesSection) + LongMethod:MsdfFontsSection.kt$fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) + LongMethod:OverflowScrollSection.kt$fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) + LongMethod:OverflowScrollSection.kt$private fun UiScope.overflowDemoCard( title: String, note: String, viewportWidth: Int, viewportHeight: Int, contentWidth: Int, contentHeight: Int, overflowX: Overflow, overflowY: Overflow, keyPrefix: String, onVisibleClick: () -> Unit, onEdgeClick: () -> Unit, ) + LongMethod:PositionedLayoutSection.kt$fun UiScope.positionedLayoutSection(viewportWidthPx: Int) + LongMethod:PositionedLayoutSection.kt$private fun UiScope.controls(props: ControlsProps) + LongMethod:PositionedLayoutSection.kt$private fun UiScope.stickyHorizontalGroup() + LongMethod:PositionedLayoutSection.kt$private fun UiScope.stickyVerticalGroup( onSetLastHover: (String) -> Unit, onSetLastClick: (String) -> Unit, stickyTopClicks: Int, onStickyTopClick: () -> Unit, ) + LongMethod:ShowcaseWindow.kt$ShowcaseWindow$override fun render(): DomTree + LongMethod:ShowcaseWindow.kt$ShowcaseWindow$private fun prepareCascadeStylesheet() + LongMethod:ShowcaseWindow.kt$ShowcaseWindow$private fun prepareDemoStylesheet() + LongMethod:StylesheetsSection.kt$fun UiScope.stylesheetsSection( onLogHook: (String, Event, String?) -> Unit, onInfo: (String) -> Unit, loadStylesheetText: () -> String, saveStylesheetText: (String) -> Unit, onReloadStylesheets: () -> Unit, ) + LongMethod:TextEditingSection.kt$fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) + LongMethod:TextWrapSection.kt$fun UiScope.textWrapSection(onInfo: (String) -> Unit) + LongParameterList:DragDropSection.kt$( state: DndSectionState, boxKey: Any, title: String, boxId: String, color: Int, cards: List<DndDemoItem>, onStart: (DndDemoItem, DragStartEvent) -> Unit, onDrag: (DragEvent) -> Unit, onEnd: (DragEndEvent) -> Unit, onBoxOver: (String, DragOverEvent) -> Unit, onBoxDrop: (String, DropEvent) -> Unit, onHoverZone: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit, ) + LongParameterList:DragDropSection.kt$( state: DndSectionState, sourceKey: Any?, onStart: (DndDemoItem, DragStartEvent) -> Unit, onDrag: (DragEvent) -> Unit, onEnd: (DragEndEvent) -> Unit, onLaneOver: (DragOverEvent) -> Unit, onLaneLeave: () -> Unit, onLaneDrop: (DropEvent) -> Unit, onCardOver: (String, Boolean, DragOverEvent) -> Unit, onCardDrop: (String, Boolean, DropEvent) -> Unit, laneIndicatorForCard: (String, Any?) -> DndLaneIndicator, shouldShowLaneAppendGap: (Any?) -> Boolean, ) + LongParameterList:OverflowScrollSection.kt$( title: String, note: String, viewportWidth: Int, viewportHeight: Int, contentWidth: Int, contentHeight: Int, overflowX: Overflow, overflowY: Overflow, keyPrefix: String, onVisibleClick: () -> Unit, onEdgeClick: () -> Unit, ) + MagicNumber:AnimationsSection.kt$0.5f + MagicNumber:AnimationsSection.kt$0.65f + MagicNumber:AnimationsSection.kt$1.08f + MagicNumber:AnimationsSection.kt$12f + MagicNumber:AnimationsSection.kt$1400L + MagicNumber:AnimationsSection.kt$17L + MagicNumber:AnimationsSection.kt$200 + MagicNumber:AnimationsSection.kt$20f + MagicNumber:AnimationsSection.kt$220 + MagicNumber:AnimationsSection.kt$260 + MagicNumber:AnimationsSection.kt$6000 + MagicNumber:AnimationsSection.kt$67L + MagicNumber:AnimationsSection.kt$83L + MagicNumber:AnimationsSection.kt$8f + MagicNumber:ColorPickerSection.kt$0.19f + MagicNumber:ColorPickerSection.kt$0.28f + MagicNumber:ColorPickerSection.kt$0.29f + MagicNumber:ColorPickerSection.kt$0.31f + MagicNumber:ColorPickerSection.kt$0.41f + MagicNumber:ColorPickerSection.kt$0.45f + MagicNumber:ColorPickerSection.kt$0.46f + MagicNumber:ColorPickerSection.kt$0.52f + MagicNumber:ColorPickerSection.kt$0.73f + MagicNumber:ColorPickerSection.kt$0.82f + MagicNumber:ColorPickerSection.kt$0.88f + MagicNumber:ColorPickerSection.kt$0.8f + MagicNumber:ColorPickerSection.kt$0.91f + MagicNumber:ColorPickerSection.kt$0.96f + MagicNumber:ColorPickerSection.kt$0.9f + MagicNumber:ContextMenuSection.kt$0x66000000 + MagicNumber:ContextMenuSection.kt$11L + MagicNumber:ContextMenuSection.kt$12L + MagicNumber:ContextMenuSection.kt$13L + MagicNumber:ContextMenuSection.kt$14L + MagicNumber:ContextMenuSection.kt$15L + MagicNumber:ContextMenuSection.kt$17L + MagicNumber:ContextMenuSection.kt$18 + MagicNumber:ContextMenuSection.kt$18L + MagicNumber:ContextMenuSection.kt$19 + MagicNumber:ContextMenuSection.kt$24 + MagicNumber:ContextMenuSection.kt$5 + MagicNumber:ContextMenuSection.kt$5L + MagicNumber:ContextMenuSection.kt$6 + MagicNumber:ContextMenuSection.kt$6L + MagicNumber:ContextMenuSection.kt$7 + MagicNumber:ContextMenuSection.kt$7L + MagicNumber:ContextMenuSection.kt$9L + MagicNumber:DisplaySection.kt$0x00040401 + MagicNumber:DisplaySection.kt$0x000A0A00 + MagicNumber:DisplaySection.kt$0xFF3A4B60 + MagicNumber:DisplaySection.kt$0xFF3D5873 + MagicNumber:DisplaySection.kt$132L + MagicNumber:DisplaySection.kt$14 + MagicNumber:DisplaySection.kt$20 + MagicNumber:DisplaySection.kt$26 + MagicNumber:DisplaySection.kt$320 + MagicNumber:DisplaySection.kt$6 + MagicNumber:DisplaySection.kt$96 + MagicNumber:DragDropSection.kt$0x2219222B + MagicNumber:DragDropSection.kt$0x2A9EC4E3 + MagicNumber:DragDropSection.kt$0x44333F4D + MagicNumber:DragDropSection.kt$0x44405058 + MagicNumber:DragDropSection.kt$0x553A4452 + MagicNumber:DragDropSection.kt$0xFF + MagicNumber:DragDropSection.kt$12 + MagicNumber:DragDropSection.kt$24 + MagicNumber:DragDropSection.kt$4.0 + MagicNumber:DragDropSection.kt$5 + MagicNumber:DragDropSection.kt$96.0 + MagicNumber:DragNDropWindow.kt$32 + MagicNumber:EventFormatting.kt$118 + MagicNumber:EventFormatting.kt$32 + MagicNumber:FocusRebuildSection.kt$127 + MagicNumber:HooksSection.kt$10007 + MagicNumber:HooksSection.kt$5 + MagicNumber:HooksSection.kt$5000 + MagicNumber:InputEventsSection.kt$35L + MagicNumber:InputsGallerySection.kt$35L + MagicNumber:InputsGallerySection.kt$9 + MagicNumber:InspectorSection.kt$0x22496699 + MagicNumber:InspectorSection.kt$0x3338424F + MagicNumber:InspectorSection.kt$0x665A9CE0 + MagicNumber:InspectorSection.kt$6 + MagicNumber:InteractionsSection.kt$6 + MagicNumber:LayoutDebugSection.kt$148L + MagicNumber:LayoutDebugSection.kt$320 + MagicNumber:LayoutDebugSection.kt$96 + MagicNumber:LayoutStyleSection.kt$148 + MagicNumber:LayoutStyleSection.kt$24 + MagicNumber:LayoutStyleSection.kt$26 + MagicNumber:LayoutStyleSection.kt$92 + MagicNumber:McFeaturesSection.kt$11.0 + MagicNumber:McFeaturesSection.kt$160.0 + MagicNumber:McFeaturesSection.kt$18 + MagicNumber:McFeaturesSection.kt$20 + MagicNumber:McFeaturesSection.kt$360.0 + MagicNumber:McFeaturesSection.kt$360L + MagicNumber:McFeaturesSection.kt$5f + MagicNumber:McFeaturesSection.kt$89.0 + MagicNumber:McFeaturesSection.kt$89L + MagicNumber:MsdfFontsSection.kt$15L + MagicNumber:MsdfFontsSection.kt$48 + MagicNumber:MsdfFontsSection.kt$6 + MagicNumber:MsdfFontsSection.kt$9L + MagicNumber:OverflowScrollSection.kt$118L + MagicNumber:OverflowScrollSection.kt$126L + MagicNumber:OverflowScrollSection.kt$132L + MagicNumber:OverflowScrollSection.kt$180 + MagicNumber:OverflowScrollSection.kt$260 + MagicNumber:OverflowScrollSection.kt$420 + MagicNumber:OverflowScrollSection.kt$48 + MagicNumber:OverflowScrollSection.kt$56 + MagicNumber:OverflowScrollSection.kt$60 + MagicNumber:OverflowScrollSection.kt$76L + MagicNumber:OverflowScrollSection.kt$88 + MagicNumber:PositionedLayoutSection.kt$100 + MagicNumber:PositionedLayoutSection.kt$104 + MagicNumber:PositionedLayoutSection.kt$112 + MagicNumber:PositionedLayoutSection.kt$12 + MagicNumber:PositionedLayoutSection.kt$14 + MagicNumber:PositionedLayoutSection.kt$14L + MagicNumber:PositionedLayoutSection.kt$18 + MagicNumber:PositionedLayoutSection.kt$18L + MagicNumber:PositionedLayoutSection.kt$236 + MagicNumber:PositionedLayoutSection.kt$24 + MagicNumber:PositionedLayoutSection.kt$24L + MagicNumber:PositionedLayoutSection.kt$26L + MagicNumber:PositionedLayoutSection.kt$28 + MagicNumber:PositionedLayoutSection.kt$40 + MagicNumber:PositionedLayoutSection.kt$5 + MagicNumber:PositionedLayoutSection.kt$58 + MagicNumber:PositionedLayoutSection.kt$6 + MagicNumber:PositionedLayoutSection.kt$7 + MagicNumber:PositionedLayoutSection.kt$72 + MagicNumber:PositionedLayoutSection.kt$999 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$0.35f + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$1.08f + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$12 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$13 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$14 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$15 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$17 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$18 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$180f + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$360f + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$40 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$5 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$50f + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$7 + MagicNumber:ShowcaseWindow.kt$ShowcaseWindow$9 + MagicNumber:TextWrapSection.kt$176L + MagicNumber:TextWrapSection.kt$320 + MagicNumber:TextWrapSection.kt$96 + ReturnCount:ContextMenuSection.kt$fun contextMenuCanDropIntoDirectory(entryId: String, destinationDirectoryId: String): Boolean + TooManyFunctions:ShowcaseWindow.kt$ShowcaseWindow : DsglWindow + + diff --git a/adapters/mc-forge-1-7-10/demo/gradle.properties b/adapters/mc-forge-1-7-10/demo/gradle.properties index e47993f..8f2674f 100644 --- a/adapters/mc-forge-1-7-10/demo/gradle.properties +++ b/adapters/mc-forge-1-7-10/demo/gradle.properties @@ -26,8 +26,9 @@ msdfDebugDecorations=false msdfDebugPerformance=false rebuildTrace=false perfDebug=false -dsglOverlayDebug=true -dsglOverlayControls=true +dsglDomainDebug=true +dsglDomainControls=true +dsglColorPickerDebugCounters=false startParameter.offline=true diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglClientHotkeys.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglClientHotkeys.kt index 16f488e..d75608f 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglClientHotkeys.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglClientHotkeys.kt @@ -16,26 +16,31 @@ import org.lwjgl.input.Keyboard */ @SideOnly(Side.CLIENT) object DsglClientHotkeys { - private val openShowcaseKey = KeyBinding( - "key.dsgl.open_showcase", - Keyboard.KEY_J, - "key.categories.dsgl" - ) + private val openShowcaseKey = + KeyBinding( + "key.dsgl.open_showcase", + Keyboard.KEY_J, + "key.categories.dsgl", + ) private var registered: Boolean = false fun register() { if (registered) return ClientRegistry.registerKeyBinding(openShowcaseKey) - FMLCommonHandler.instance().bus().register(this) + FMLCommonHandler + .instance() + .bus() + .register(this) registered = true } @SubscribeEvent - fun onKeyInput(event: InputEvent.KeyInputEvent) { + fun onKeyInput(_event: InputEvent.KeyInputEvent) { when { - openShowcaseKey.isPressed -> Minecraft - .getMinecraft() - .displayGuiScreen(object : DsglScreenHost({ ShowcaseWindow() }) {}) + openShowcaseKey.isPressed -> + Minecraft + .getMinecraft() + .displayGuiScreen(object : DsglScreenHost({ ShowcaseWindow() }) {}) } } } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglMc1710ModContainer.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglMc1710ModContainer.kt index 3591169..e972ab9 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglMc1710ModContainer.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglMc1710ModContainer.kt @@ -13,12 +13,15 @@ import net.minecraft.client.Minecraft name = DsglMc1710DemoGeneratedMetadata.MOD_NAME, version = DsglMc1710DemoGeneratedMetadata.MOD_VERSION, acceptedMinecraftVersions = DsglMc1710DemoGeneratedMetadata.MC_VERSION_RANGE, - useMetadata = true + useMetadata = true, ) class DsglMc1710ModContainer { @Mod.EventHandler - fun onInit(event: FMLInitializationEvent) { - if (FMLCommonHandler.instance().side.isClient) { + fun onInit(_event: FMLInitializationEvent) { + if (FMLCommonHandler + .instance() + .side.isClient + ) { DsglFonts.ensureInitialized(Minecraft.getMinecraft().mcDataDir, javaClass.classLoader) DsglClientHotkeys.register() } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/ShowcaseWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/ShowcaseWindow.kt index 2e078bb..83f132f 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/ShowcaseWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/ShowcaseWindow.kt @@ -10,22 +10,55 @@ import org.dreamfinity.dsgl.core.DsglColors import org.dreamfinity.dsgl.core.DsglWindow import org.dreamfinity.dsgl.core.animation.keyframes import org.dreamfinity.dsgl.core.components.modal.ModalSpec -import org.dreamfinity.dsgl.core.components.modal.modalHost +import org.dreamfinity.dsgl.core.components.modal.modalPortal +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.JustifyContent import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.mcForge1710.McItemStackRef -import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.mcForge1710.demo.sections.* -import org.dreamfinity.dsgl.mcForge1710.demo.support.* +import org.dreamfinity.dsgl.mcForge1710.demo.sections.McFeaturesSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.animationsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.colorPickerSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.contextMenuSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.cssCascadeCombinatorsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.displaySection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.dragNDropSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.focusRebuildSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.hooksSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.inputEventsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.inputsGallerySection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.inspectorSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.interactionsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.layoutDebugSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.layoutStyleSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.mcFeaturesSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.modalsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.msdfFontsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.overflowScrollSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.overviewSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.positionedLayoutSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.stylesheetsSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.textEditingSection +import org.dreamfinity.dsgl.mcForge1710.demo.sections.textWrapSection +import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityChecklistCatalog +import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityId +import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_ACCENT +import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_BG +import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_OK +import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_SURFACE +import org.dreamfinity.dsgl.mcForge1710.demo.support.DemoSection +import org.dreamfinity.dsgl.mcForge1710.demo.support.EventLogEntry +import org.dreamfinity.dsgl.mcForge1710.demo.support.formatEventLine +import org.dreamfinity.dsgl.mcForge1710.demo.support.renderChecklistPanel +import org.dreamfinity.dsgl.mcForge1710.demo.support.renderEventInspectorPanel import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO class ShowcaseWindow : DsglWindow() { - private var viewportWidth: Int = 320 private var viewportHeight: Int = 240 internal val viewportWidthPx: Int @@ -76,7 +109,7 @@ class ShowcaseWindow : DsglWindow() { renderPasses += 1 return ui { - modalHost(modals = demoModals, modalKey = "showcase.modalHost") { + modalPortal(modals = demoModals, key = "showcase.modalPortal") { div({ key = "showcase.root" style = { @@ -91,13 +124,15 @@ class ShowcaseWindow : DsglWindow() { }) { text("DSGL Showcase Window", { style = { color = DsglColors.WHITE } }) text( - "renderPasses=$renderPasses section=${selectedSection.title} viewport=${viewportWidth}x$viewportHeight", + "renderPasses=$renderPasses " + + "section=${selectedSection.title} " + + "viewport=${viewportWidth}x$viewportHeight", { style = { color = DEMO_MUTED padding = 4.px } - } + }, ) div({ @@ -117,9 +152,11 @@ class ShowcaseWindow : DsglWindow() { gap = 4.px backgroundColor = DEMO_SURFACE color = DsglColors.TEXT - border { width = 1.px; color = DsglColors.BORDER } + border { + width = 1.px + color = DsglColors.BORDER + } } - }) { text("Sections", { style = { color = DsglColors.WHITE } }) DemoSection.entries.forEach { section -> @@ -143,144 +180,174 @@ class ShowcaseWindow : DsglWindow() { gap = 4.px backgroundColor = DEMO_SURFACE color = DsglColors.TEXT - border { width = 1.px; color = DsglColors.BORDER } + border { + width = 1.px + color = DsglColors.BORDER + } } }) { text(selectedSection.title, { style = { color = DsglColors.WHITE } }) text(selectedSection.subtitle, { style = { color = DEMO_MUTED } }) when (selectedSection) { - DemoSection.OVERVIEW -> overviewSection( - implementedCapabilities = implementedCapabilities, - onManualInvalidate = ::requestManualInvalidate, - onInfo = ::appendInfo - ) - - DemoSection.INSPECTOR -> inspectorSection( - onInfo = ::appendInfo - ) - - DemoSection.LAYOUT_STYLE -> layoutStyleSection( - onInfo = ::appendInfo, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.LAYOUT_DEBUG -> layoutDebugSection( - onClearLogs = ::clearEventLogs, - onInfo = ::appendInfo - ) - - DemoSection.POSITIONED_LAYOUT -> positionedLayoutSection( - viewportWidthPx = viewportWidthPx - ) - - DemoSection.OVERFLOW_SCROLL -> overflowScrollSection( - onInfo = ::appendInfo - ) - - DemoSection.DISPLAY -> displaySection( - onInfo = ::appendInfo, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.TEXT_WRAP -> textWrapSection( - onInfo = ::appendInfo - ) - - DemoSection.MSDF_FONTS -> msdfFontsSection( - onInfo = ::appendInfo - ) - - DemoSection.ANIMATIONS -> animationsSection( - onInfo = ::appendInfo - ) - - DemoSection.MODALS -> modalsSection( - modals = demoModals, - onPushModal = ::pushModal, - onRemoveModal = ::removeModal, - onPopTopModal = ::popTopModal, - onClearModals = { demoModals = emptyList() }, - onInfo = ::appendInfo - ) - - DemoSection.CONTEXT_MENU -> contextMenuSection( - onInfo = ::appendInfo - ) - - DemoSection.STYLESHEETS -> stylesheetsSection( - onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, - onInfo = ::appendInfo, - loadStylesheetText = { loadStylesheetEditorFromFile("styles section load") }, - saveStylesheetText = { content -> - saveStylesheetEditorToFile( - content, - "styles section save" - ) - }, - onReloadStylesheets = { reloadStylesheetsProgrammatically("styles section button") } - ) - - DemoSection.CSS_CASCADE -> cssCascadeCombinatorsSection( - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.INPUTS -> inputsGallerySection( - clippingScrollDemoText = clippingScrollDemoText, - onClippingScrollDemoTextChange = { clippingScrollDemoText = it } - ) - - DemoSection.INPUT_EVENTS -> inputEventsSection( - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) + DemoSection.OVERVIEW -> + overviewSection( + implementedCapabilities = implementedCapabilities, + onManualInvalidate = ::requestManualInvalidate, + onInfo = ::appendInfo, + ) - DemoSection.COLOR_PICKER -> colorPickerSection() + DemoSection.INSPECTOR -> + inspectorSection( + onInfo = ::appendInfo, + ) + + DemoSection.LAYOUT_STYLE -> + layoutStyleSection( + onInfo = ::appendInfo, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) - DemoSection.TEXT_EDITING -> textEditingSection( - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.REFS -> hooksSection( - onInfo = ::appendInfo, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.DRAG_DROP -> dragNDropSection( - onInfo = ::appendInfo, - onClearLogs = ::clearEventLogs, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.INTERACTIONS -> interactionsSection( - onInfo = ::appendInfo, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.FOCUS_REBUILD -> focusRebuildSection( - renderPasses = renderPasses, - onManualInvalidate = ::requestManualInvalidate, - onInfo = ::appendInfo, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } - ) - - DemoSection.MC_FEATURES -> mcFeaturesSection( - props = McFeaturesShellProps( + DemoSection.LAYOUT_DEBUG -> + layoutDebugSection( + onClearLogs = ::clearEventLogs, + onInfo = ::appendInfo, + ) + + DemoSection.POSITIONED_LAYOUT -> + positionedLayoutSection( viewportWidthPx = viewportWidthPx, - viewportHeightPx = viewportHeightPx, - mediaReady = mediaReady, - resourceImageSource = resourceImageSource, - fileImageSource = fileImageSource, - httpImageSource = httpImageSource, - flatItemRef = flatItemRef, - blockItemRef = blockItemRef, + ) + + DemoSection.OVERFLOW_SCROLL -> + overflowScrollSection( + onInfo = ::appendInfo, + ) + + DemoSection.DISPLAY -> + displaySection( + onInfo = ::appendInfo, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.TEXT_WRAP -> + textWrapSection( + onInfo = ::appendInfo, + ) + + DemoSection.MSDF_FONTS -> + msdfFontsSection( + onInfo = ::appendInfo, + ) + + DemoSection.ANIMATIONS -> + animationsSection( + onInfo = ::appendInfo, + ) + + DemoSection.MODALS -> + modalsSection( + modals = demoModals, + onPushModal = ::pushModal, + onRemoveModal = ::removeModal, + onPopTopModal = ::popTopModal, + onClearModals = { demoModals = emptyList() }, + onInfo = ::appendInfo, + ) + + DemoSection.CONTEXT_MENU -> + contextMenuSection( + onInfo = ::appendInfo, + ) + + DemoSection.STYLESHEETS -> + stylesheetsSection( + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + onInfo = ::appendInfo, + loadStylesheetText = { loadStylesheetEditorFromFile("styles section load") }, + saveStylesheetText = { content -> + saveStylesheetEditorToFile( + content, + "styles section save", + ) + }, + onReloadStylesheets = { + reloadStylesheetsProgrammatically( + "styles section button", + ) + }, + ) + + DemoSection.CSS_CASCADE -> + cssCascadeCombinatorsSection( + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.INPUTS -> + inputsGallerySection( clippingScrollDemoText = clippingScrollDemoText, onClippingScrollDemoTextChange = { clippingScrollDemoText = it }, - currentGuiScale = ::currentGuiScale, - guiScaleLabel = ::guiScaleLabel, - setGuiScale = ::setGuiScale, - cycleGuiScale = ::cycleGuiScale, - onLogHook = { hookName, event, note -> logHook(hookName, event, note) } ) - ) + + DemoSection.INPUT_EVENTS -> + inputEventsSection( + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.COLOR_PICKER -> colorPickerSection() + + DemoSection.TEXT_EDITING -> + textEditingSection( + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.REFS -> + hooksSection( + onInfo = ::appendInfo, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.DRAG_DROP -> + dragNDropSection( + onInfo = ::appendInfo, + onClearLogs = ::clearEventLogs, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.INTERACTIONS -> + interactionsSection( + onInfo = ::appendInfo, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.FOCUS_REBUILD -> + focusRebuildSection( + renderPasses = renderPasses, + onManualInvalidate = ::requestManualInvalidate, + onInfo = ::appendInfo, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ) + + DemoSection.MC_FEATURES -> + mcFeaturesSection( + props = + McFeaturesSection( + viewportWidthPx = viewportWidthPx, + viewportHeightPx = viewportHeightPx, + mediaReady = mediaReady, + resourceImageSource = resourceImageSource, + fileImageSource = fileImageSource, + httpImageSource = httpImageSource, + flatItemRef = flatItemRef, + blockItemRef = blockItemRef, + clippingScrollDemoText = clippingScrollDemoText, + onClippingScrollDemoTextChange = { clippingScrollDemoText = it }, + currentGuiScale = ::currentGuiScale, + guiScaleLabel = ::guiScaleLabel, + setGuiScale = ::setGuiScale, + cycleGuiScale = ::cycleGuiScale, + onLogHook = { hookName, event, note -> logHook(hookName, event, note) }, + ), + ) } } @@ -297,14 +364,14 @@ class ShowcaseWindow : DsglWindow() { eventLogs = eventLogs, maxEventLogs = maxEventLogs, visibleEventLines = visibleEventLines, - onClearLogs = ::clearEventLogs + onClearLogs = ::clearEventLogs, ) renderChecklistPanel( implementedCapabilities = implementedCapabilities, checklistPage = checklistPage, checklistPageSize = checklistPageSize, onSetChecklistPage = { checklistPage = it }, - onMoveChecklistPage = ::moveChecklistPage + onMoveChecklistPage = ::moveChecklistPage, ) } } @@ -344,11 +411,16 @@ class ShowcaseWindow : DsglWindow() { checklistPage = (checklistPage + delta).coerceIn(0, pageCount - 1) } - internal fun requestManualInvalidate(reason: String) { + internal fun requestManualInvalidate(_reason: String) { invalidate() } - internal fun logHook(hookName: String, event: Event, note: String? = null, color: Int = DsglColors.TEXT) { + internal fun logHook( + hookName: String, + event: Event, + note: String? = null, + color: Int = DsglColors.TEXT, + ) { val line = formatEventLine(hookName, event, note) appendLog(line, color) } @@ -357,19 +429,20 @@ class ShowcaseWindow : DsglWindow() { appendLog(message, DEMO_OK) } - internal fun currentGuiScale(): Int { - return Minecraft.getMinecraft().gameSettings.guiScale.coerceIn(0, 4) - } + internal fun currentGuiScale(): Int = + Minecraft + .getMinecraft() + .gameSettings.guiScale + .coerceIn(0, 4) - internal fun guiScaleLabel(value: Int = currentGuiScale()): String { - return when (value.coerceIn(0, 4)) { + internal fun guiScaleLabel(value: Int = currentGuiScale()): String = + when (value.coerceIn(0, 4)) { 0 -> "Auto" 1 -> "1x" 2 -> "2x" 3 -> "3x" else -> "4x" } - } internal fun setGuiScale(value: Int) { val mc = Minecraft.getMinecraft() @@ -402,7 +475,7 @@ class ShowcaseWindow : DsglWindow() { val content = file.readText() appendInfo("Stylesheet loaded by $source") return content - } catch (ex: Exception) { + } catch (ex: java.io.IOException) { appendLog("Stylesheet load failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) throw ex } @@ -414,7 +487,7 @@ class ShowcaseWindow : DsglWindow() { file.parentFile?.mkdirs() file.writeText(content) appendInfo("Stylesheet saved by $source") - } catch (ex: Exception) { + } catch (ex: java.io.IOException) { appendLog("Stylesheet save failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) throw ex } @@ -438,18 +511,18 @@ class ShowcaseWindow : DsglWindow() { writeDemoImage( File(dataDir, "dsgl/demo/local_showcase.png"), 0xFF3B71A5.toInt(), - 0xFFF7B25B.toInt() + 0xFFF7B25B.toInt(), ) writeDemoImage( File(dataDir, "dsgl/cache/downloads/demo.local/assets/showcase_http.png"), 0xFF2D8757.toInt(), - 0xFFC8E66B.toInt() + 0xFFC8E66B.toInt(), ) writeDemoFolderIcon(File(dataDir, "dsgl/demo/folder.png")) writeDemoDocumentIcon(File(dataDir, "dsgl/demo/document.png")) mediaReady = true appendInfo("Prepared local file:// and cached http image assets") - } catch (ex: Exception) { + } catch (ex: java.io.IOException) { mediaReady = false appendLog("Media prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) } @@ -469,7 +542,7 @@ class ShowcaseWindow : DsglWindow() { --danger: #A34343; --fg: #E9F1FF; } - + button { border-width: 1px; border-color: #000000; @@ -502,7 +575,7 @@ class ShowcaseWindow : DsglWindow() { border-color: #555555; color: #8E8E8E; } - + .style-card { margin: 2px 0px 0px 0px; background-color: #2A3440; @@ -510,39 +583,39 @@ class ShowcaseWindow : DsglWindow() { border-width: 1px; padding: 4px; } - + .accent { background-color: #3F5A70; } - + button.primary { background-color: var(--primary); color: var(--fg); } - + #dangerAction { background-color: var(--danger); color: #FFFFFFFF; } - + #hoverActiveTarget:hover { background-color: #365F7D; } - + #hoverActiveTarget:active { background-color: #274356; } - + #focusInput:focus { border-color: var(--accent); border-width: 2px; } - + #disabledTarget:disabled { background-color: #444444; color: #999999; } - + .vars-demo { background-color: #213348; border-color: var(--accent); @@ -596,7 +669,7 @@ class ShowcaseWindow : DsglWindow() { border-width: 1px; padding: 2px 4px; } - """.trimIndent() + """.trimIndent(), ) appendInfo("Created demo stylesheet: ${stylesheetFile.name}") created = true @@ -654,7 +727,7 @@ class ShowcaseWindow : DsglWindow() { border-width: 1px; padding: 2px 4px; } - """.trimIndent() + """.trimIndent(), ) appendInfo("Patched demo stylesheet with CSS units section") created = true @@ -689,7 +762,7 @@ class ShowcaseWindow : DsglWindow() { border-color: #555555; color: #8E8E8E; } - """.trimIndent() + """.trimIndent(), ) appendInfo("Patched demo stylesheet with select styles") created = true @@ -698,7 +771,7 @@ class ShowcaseWindow : DsglWindow() { if (created) { StyleEngine.forceReloadStylesheets() } - } catch (ex: Exception) { + } catch (ex: java.io.IOException) { appendLog("Stylesheet prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) } } @@ -711,7 +784,10 @@ class ShowcaseWindow : DsglWindow() { color = 0xFFFF6B6B.toInt() } at(50f) { - transform { rotate(180f); scale(1.08f) } + transform { + rotate(180f) + scale(1.08f) + } opacity = 1f color = 0xFF6BCB77.toInt() } @@ -735,61 +811,61 @@ class ShowcaseWindow : DsglWindow() { border-width: 1px; padding: 4px; } - + .cascade-demo-root.dark { color: #FFE4C7; } - + .cascade-demo-root.light { color: #D7E8FF; } - + .cascade-demo-root .panel { background-color: #1E2935; border-width: 1px; border-color: #516071; padding: 3px; } - + .cascade-demo-root .panel .item { color: #7EC8FF; } - + .cascade-demo-root .panel > .item { color: #9BE66F; } - + .cascade-demo-root .btn { background-color: #4A5568; color: #FFFFFFFF; border-color: #1F2937; border-width: 1px; } - + .cascade-demo-root #primary.btn { background-color: #2B6CB0; } - + .cascade-demo-root .order-target { color: #F56565; } - + .cascade-demo-root .order-target { color: #48BB78; } - + .cascade-demo-root .important-target { color: #DD6B20 !important; } - + .cascade-demo-root .important-target { color: #3182CE; } - + .cascade-demo-root.rule-a .toggle-target { color: #D69E2E; } - + .cascade-demo-root.rule-b .toggle-target { color: #63B3ED; } @@ -846,10 +922,10 @@ class ShowcaseWindow : DsglWindow() { .cascade-mixed > .header + .body .title { color: #F6D66F; } - """.trimIndent() + """.trimIndent(), ) StyleEngine.forceReloadStylesheets() - } catch (ex: Exception) { + } catch (ex: java.io.IOException) { appendLog("Cascade stylesheet prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) } } @@ -950,6 +1026,3 @@ class ShowcaseWindow : DsglWindow() { return out.toString() } } - - - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/centeredFlexWrapper.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/CenteredFlexWrapper.kt similarity index 99% rename from adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/centeredFlexWrapper.kt rename to adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/CenteredFlexWrapper.kt index dc587ec..a94918e 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/centeredFlexWrapper.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/containers/CenteredFlexWrapper.kt @@ -20,4 +20,4 @@ internal fun UiScope.centeredFlexWrapper(direction: FlexDirection = FlexDirectio } }) { content() - } \ No newline at end of file + } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ContextMenuWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ContextMenuWindow.kt index 0d58d16..fcefd81 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ContextMenuWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ContextMenuWindow.kt @@ -11,11 +11,12 @@ import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWrapper class ContextMenuWindow : DsglWindow() { - override fun render() = ui { - centeredFlexWrapper { - contextMenuRecipe() + override fun render() = + ui { + centeredFlexWrapper { + contextMenuRecipe() + } } - } } fun UiScope.contextMenuRecipe() { @@ -33,7 +34,7 @@ fun UiScope.contextMenuRecipe() { item("Rename") { onClick { lastAction = "rename" } } separator() item("Delete") { onClick { lastAction = "delete" } } - } + }, ) } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/DragNDropWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/DragNDropWindow.kt index fcb2ea4..01c42c6 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/DragNDropWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/DragNDropWindow.kt @@ -18,31 +18,35 @@ import org.dreamfinity.dsgl.mcForge1710.McItemStackRef import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWrapper class DragNDropWindow : DsglWindow() { - override fun render() = ui { - centeredFlexWrapper { - dragBucketRecipe() + override fun render() = + ui { + centeredFlexWrapper { + dragBucketRecipe() + } } - } } - -private data class Card(val id: String, val label: String) +private data class Card( + val id: String, + val label: String, +) fun UiScope.dragBucketRecipe() { var lane by useState(listOf(Card("apple", "Apple"), Card("bread", "Bread"))) var done by useState(emptyList()) - val doneDrop = useDroppable( - id = "bucket.done", - nodeKey = "bucket.done", - accepts = { active -> !active.id.isNullOrBlank() }, - onDrop = { _, active -> - val movedId = active?.id ?: return@useDroppable - val moved = lane.firstOrNull { it.id == movedId } ?: return@useDroppable - lane = lane.filterNot { it.id == movedId } - done = done + moved - } - ) + val doneDrop = + useDroppable( + id = "bucket.done", + nodeKey = "bucket.done", + accepts = { active -> !active.id.isNullOrBlank() }, + onDrop = { _, active -> + val movedId = active?.id ?: return@useDroppable + val moved = lane.firstOrNull { it.id == movedId } ?: return@useDroppable + lane = lane.filterNot { it.id == movedId } + done = done + moved + }, + ) div({ key = "recipe.done.bucket" @@ -52,11 +56,21 @@ fun UiScope.dragBucketRecipe() { } applyDroppable(doneDrop) }) { - div({ style = { display = Display.Flex; alignItems = AlignItems.Center } }) { + div({ + style = { + display = Display.Flex + alignItems = AlignItems.Center + } + }) { text("Done (${done.size})") } done.forEach { card -> - div({ style = { display = Display.Flex; alignItems = AlignItems.Center } }) { + div({ + style = { + display = Display.Flex + alignItems = AlignItems.Center + } + }) { GameRegistry.findItem("minecraft", card.id)?.let { item -> itemStack(McItemStackRef(ItemStack(item, 1, 0)), { size = 32 }) } ?: text("?") @@ -82,4 +96,3 @@ fun UiScope.dragBucketRecipe() { } } } - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ModalStackWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ModalStackWindow.kt index 8398563..044b491 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ModalStackWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/ModalStackWindow.kt @@ -1,7 +1,12 @@ package org.dreamfinity.dsgl.mcForge1710.demo.examples.cookbook import org.dreamfinity.dsgl.core.DsglWindow -import org.dreamfinity.dsgl.core.components.modal.* +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.components.modal.modalBody +import org.dreamfinity.dsgl.core.components.modal.modalFooter +import org.dreamfinity.dsgl.core.components.modal.modalHeader +import org.dreamfinity.dsgl.core.components.modal.modalPortal +import org.dreamfinity.dsgl.core.components.modal.modalTitle import org.dreamfinity.dsgl.core.dsl.UiScope import org.dreamfinity.dsgl.core.dsl.button import org.dreamfinity.dsgl.core.dsl.text @@ -9,11 +14,12 @@ import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWrapper class ModalStackWindow : DsglWindow() { - override fun render() = ui { - centeredFlexWrapper { - modalStackRecipe() + override fun render() = + ui { + centeredFlexWrapper { + modalStackRecipe() + } } - } } private fun UiScope.modalStackRecipe() { @@ -23,26 +29,28 @@ private fun UiScope.modalStackRecipe() { modals = modals.filterNot { it.key == key } } - modalHost(modals = modals, modalKey = "recipe.modal.host") { + modalPortal(modals = modals, key = "recipe.modal.host") { button("Open modal", { onMouseClick = { - modals += ModalSpec( - key = "recipe.modal.basic", - onHide = { removeModal("recipe.modal.basic") } - ) { scope -> - modalHeader(closeButton = true, onHide = scope.dismiss) { - modalTitle("Recipe modal") - } - modalBody { - text("Modal content") - button("Open another modal", { onMouseClick = { - - } }) - } - modalFooter { - button("Close", { onMouseClick = { scope.dismiss?.invoke() } }) + modals += + ModalSpec( + key = "recipe.modal.basic", + onHide = { removeModal("recipe.modal.basic") }, + ) { scope -> + modalHeader(closeButton = true, onHide = scope.dismiss) { + modalTitle("Recipe modal") + } + modalBody { + text("Modal content") + button("Open another modal", { + onMouseClick = { + } + }) + } + modalFooter { + button("Close", { onMouseClick = { scope.dismiss?.invoke() } }) + } } - } } }) } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/RefUsageWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/RefUsageWindow.kt index e0a3b0e..d6e48df 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/RefUsageWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/cookbook/RefUsageWindow.kt @@ -10,11 +10,12 @@ import org.dreamfinity.dsgl.core.hooks.ref.useRef import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWrapper class RefUsageWindow : DsglWindow() { - override fun render() = ui { - centeredFlexWrapper { - focusRecipe() + override fun render() = + ui { + centeredFlexWrapper { + focusRecipe() + } } - } } fun UiScope.focusRecipe() { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindow.kt index 38713b9..7293d5c 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindow.kt @@ -11,15 +11,16 @@ import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWra class HelloWindow : DsglWindow() { private var clicks by state(0) - override fun render(): DomTree = ui { - centeredFlexWrapper { - helloDSGL(clicks, { clicks += 1 }) + override fun render(): DomTree = + ui { + centeredFlexWrapper { + helloDSGL(clicks, { clicks += 1 }) + } } - } } private fun UiScope.helloDSGL(clicks: Int, setClicks: (_: Event) -> Unit) { text("Hello DSGL") text("Clicks: $clicks") button("Click me #${clicks + 1}th time", { onMouseClick = setClicks }) -} \ No newline at end of file +} diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindowWithComponents.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindowWithComponents.kt index b89af77..8aaceac 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindowWithComponents.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/quickstart/HelloWindowWithComponents.kt @@ -11,12 +11,13 @@ import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWrapper class HelloWindowWithComponents : DsglWindow() { - override fun render() = ui { - centeredFlexWrapper(direction = FlexDirection.Row) { - counterCard("Left panel") - counterCard("Right panel") + override fun render() = + ui { + centeredFlexWrapper(direction = FlexDirection.Row) { + counterCard("Left panel") + counterCard("Right panel") + } } - } } private fun UiScope.counterCard(title: String) { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/stateAndReactivity/GlobalStateWindow.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/stateAndReactivity/GlobalStateWindow.kt index 25c6fdb..eff7017 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/stateAndReactivity/GlobalStateWindow.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/examples/stateAndReactivity/GlobalStateWindow.kt @@ -10,14 +10,15 @@ import org.dreamfinity.dsgl.mcForge1710.demo.examples.containers.centeredFlexWra class GlobalStateWindow : DsglWindow() { private var counter by state(0) - override fun render() = ui { - centeredFlexWrapper { - globalStateCounter(counter, { counter += 1 }) + override fun render() = + ui { + centeredFlexWrapper { + globalStateCounter(counter, { counter += 1 }) + } } - } } private fun UiScope.globalStateCounter(counter: Int, setCounter: (_: Event) -> Unit) { button("Increment", { onMouseClick = setCounter }) text("Counter: $counter") -} \ No newline at end of file +} diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/AnimationsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/AnimationsSection.kt index 97c3d6b..8b66019 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/AnimationsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/AnimationsSection.kt @@ -10,27 +10,30 @@ import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.JustifyContent import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED -private val easingOptions: List> = listOf( - "linear" to Easings.LINEAR, - "ease" to Easings.EASE, - "ease-in" to Easings.EASE_IN, - "ease-out" to Easings.EASE_OUT, - "ease-in-out" to Easings.EASE_IN_OUT -) +private val easingOptions: List> = + listOf( + "linear" to Easings.LINEAR, + "ease" to Easings.EASE, + "ease-in" to Easings.EASE_IN, + "ease-out" to Easings.EASE_OUT, + "ease-in-out" to Easings.EASE_IN_OUT, + ) -private val directionOptions: List = listOf( - AnimationDirection.Normal, - AnimationDirection.Reverse, - AnimationDirection.Alternate, - AnimationDirection.AlternateReverse -) +private val directionOptions: List = + listOf( + AnimationDirection.Normal, + AnimationDirection.Reverse, + AnimationDirection.Alternate, + AnimationDirection.AlternateReverse, + ) -private val fillModeOptions: List = listOf( - AnimationFillMode.None, - AnimationFillMode.Forwards, - AnimationFillMode.Backwards, - AnimationFillMode.Both -) +private val fillModeOptions: List = + listOf( + AnimationFillMode.None, + AnimationFillMode.Forwards, + AnimationFillMode.Backwards, + AnimationFillMode.Both, + ) fun UiScope.animationsSection(onInfo: (String) -> Unit) { var animationsToggle by useState(false) @@ -47,15 +50,19 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { var animationsBezierY2 by useState(67L) val duration = animationsDurationMs.toInt().coerceIn(200, 6000) - val customBezier = cubicBezier( - animationsBezierX1.toFloat() / 100f, - animationsBezierY1.toFloat() / 100f, - animationsBezierX2.toFloat() / 100f, - animationsBezierY2.toFloat() / 100f - ) - val dynamicEasingOptions = easingOptions + listOf( - "custom($animationsBezierX1,$animationsBezierY1,$animationsBezierX2,$animationsBezierY2)" to customBezier - ) + val customBezier = + cubicBezier( + animationsBezierX1.toFloat() / 100f, + animationsBezierY1.toFloat() / 100f, + animationsBezierX2.toFloat() / 100f, + animationsBezierY2.toFloat() / 100f, + ) + val dynamicEasingOptions = + easingOptions + + listOf( + "custom($animationsBezierX1,$animationsBezierY1,$animationsBezierX2,$animationsBezierY2)" to + customBezier, + ) val easingIndex = animationsEasingIndex.coerceIn(0, dynamicEasingOptions.lastIndex) val directionIndex = animationsDirectionIndex.coerceIn(0, directionOptions.lastIndex) val fillIndex = animationsFillModeIndex.coerceIn(0, fillModeOptions.lastIndex) @@ -77,7 +84,7 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { text("Transforms + Transitions + Keyframes") text( "Transforms are layout-neutral; hit testing follows transformed geometry.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -87,15 +94,12 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { flexDirection = FlexDirection.Row } }) { - button( - if (animationsToggle) "Retarget: ON" else "Retarget: OFF", - { - onMouseClick = { - animationsToggle = !animationsToggle - onInfo("Animation retarget toggle=$animationsToggle") - } + button(if (animationsToggle) "Retarget: ON" else "Retarget: OFF", { + onMouseClick = { + animationsToggle = !animationsToggle + onInfo("Animation retarget toggle=$animationsToggle") } - ) + }) button(if (animationsPaused) "Play" else "Pause", { onMouseClick = { animationsPaused = !animationsPaused @@ -136,7 +140,7 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { value = duration.toLong(), min = 200, max = 6000, - step = 50 + step = 50, ), { key = "animations.duration.slider" @@ -145,7 +149,7 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: duration.toLong() animationsDurationMs = next.coerceIn(200, 6000) } - } + }, ) } @@ -173,7 +177,10 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { flexDirection = FlexDirection.Row alignItems = AlignItems.Center justifyContent = JustifyContent.Start - border { width = 1.px; color = 0xFF3F4D5E.toInt() } + border { + width = 1.px + color = 0xFF3F4D5E.toInt() + } } }) { div({ @@ -199,10 +206,16 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { scale(scale) rotate(if (animationsToggle) 8f else 0f) } - transformOrigin { x = 0.5f; y = 0.5f } + transformOrigin { + x = 0.5f + y = 0.5f + } opacity = if (animationsToggle) 0.65f else 1f foregroundColor = if (animationsToggle) 0xFFA4F0C2.toInt() else 0xFFEAF3FF.toInt() - border { width = 1.px; color = 0xFF56677A.toInt() } + border { + width = 1.px + color = 0xFF56677A.toInt() + } padding { all(4.px) } } }) { @@ -226,11 +239,17 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { iterationCount = iterations, direction = direction, fillMode = fillMode, - playState = playState + playState = playState, ) } - transformOrigin { x = 0.5f; y = 0.5f } - border { width = 1.px; color = 0xFF5F5F72.toInt() } + transformOrigin { + x = 0.5f + y = 0.5f + } + border { + width = 1.px + color = 0xFF5F5F72.toInt() + } padding { all(4.px) } } }) { @@ -248,11 +267,17 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { transform { rotate(if (animationsToggle) 12f else 0f) } - transformOrigin { x = 0.5f; y = 0.5f } + transformOrigin { + x = 0.5f + y = 0.5f + } transition { property(StyleAnimProps.transform, 260, easing = Easings.EASE_IN_OUT) } - border { width = 1.px; color = 0xFF4C6077.toInt() } + border { + width = 1.px + color = 0xFF4C6077.toInt() + } } }) { div({ @@ -264,13 +289,16 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { transform { translate( if (animationsToggle) 10f else 0f, - if (animationsToggle) 4f else 0f + if (animationsToggle) 4f else 0f, ) } transition { property(StyleAnimProps.transform, 220, easing = Easings.EASE_OUT) } - border { width = 1.px; color = 0xFF7593B8.toInt() } + border { + width = 1.px + color = 0xFF7593B8.toInt() + } } }) { text("Nested", { style = { color = 0xFFEAF3FF.toInt() } }) @@ -280,16 +308,27 @@ fun UiScope.animationsSection(onInfo: (String) -> Unit) { text({ val debug = StyleAnimationEngine.debugSnapshotForKey("animations.keyframes.card") - val transitionDebug = debug?.activeTransitions?.joinToString(", ").orEmpty().ifBlank { "-" } - val keyframesDebug = debug?.activeKeyframes?.joinToString(", ").orEmpty().ifBlank { "-" } - val transformDebug = debug?.effectiveTransform?.let { - "tx=${it.translateX},ty=${it.translateY},sx=${it.scaleX},sy=${it.scaleY},rot=${it.rotateDeg}" - } ?: "-" + val transitionDebug = + debug + ?.activeTransitions + ?.joinToString(", ") + .orEmpty() + .ifBlank { "-" } + val keyframesDebug = + debug + ?.activeKeyframes + ?.joinToString(", ") + .orEmpty() + .ifBlank { "-" } + val transformDebug = + debug?.effectiveTransform?.let { + "tx=${it.translateX},ty=${it.translateY},sx=${it.scaleX},sy=${it.scaleY},rot=${it.rotateDeg}" + } ?: "-" "debug: hover=$animationsHover toggle=$animationsToggle " + - "easing=$easingName direction=${direction.name} fill=${fillMode.name} " + - "play=${playState.name} iterations=${if (animationsUseInfinite) "infinite" else "3"} " + - "activeTransitions=$transitionDebug activeKeyframes=$keyframesDebug " + - "effectiveOpacity=${debug?.effectiveOpacity ?: 1f} transform={$transformDebug}" + "easing=$easingName direction=${direction.name} fill=${fillMode.name} " + + "play=${playState.name} iterations=${if (animationsUseInfinite) "infinite" else "3"} " + + "activeTransitions=$transitionDebug activeKeyframes=$keyframesDebug " + + "effectiveOpacity=${debug?.effectiveOpacity ?: 1f} transform={$transformDebug}" style = { color = DEMO_MUTED } }) @@ -300,7 +339,7 @@ private fun UiScope.bezierSliderRow( label: String, key: String, value: Long, - onChange: (Long) -> Unit + onChange: (Long) -> Unit, ) { div({ style = { @@ -315,7 +354,7 @@ private fun UiScope.bezierSliderRow( value = value, min = 0, max = 100, - step = 1 + step = 1, ), { this.key = key @@ -324,7 +363,7 @@ private fun UiScope.bezierSliderRow( val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: value onChange(next.coerceIn(0, 100)) } - } + }, ) } } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ColorPickerSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ColorPickerSection.kt index 0d142a5..d6105e4 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ColorPickerSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ColorPickerSection.kt @@ -1,13 +1,13 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.colorpicker.* import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.hooks.useEffect import org.dreamfinity.dsgl.core.hooks.useMemo import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED fun UiScope.colorPickerSection() { @@ -30,13 +30,14 @@ fun UiScope.colorPickerSection() { sharedColorPickerManager.open( anchorRect = Rect(mouseX, mouseY, 1, 1), title = "Shared Picker [$target]", - state = ColorPickerState( - color = current, - previous = current, - mode = colorInlineMode, - alphaEnabled = colorPickerAlphaEnabled, - closeOnSelect = false - ), + state = + ColorPickerState( + color = current, + previous = current, + mode = colorInlineMode, + alphaEnabled = colorPickerAlphaEnabled, + closeOnSelect = false, + ), closeOnOutsideClick = false, onPreview = { color -> if (target == "A") { @@ -59,7 +60,7 @@ fun UiScope.colorPickerSection() { colorSharedB = color } colorPickerLastCommit = colorLabel(color) - } + }, ) } @@ -74,11 +75,11 @@ fun UiScope.colorPickerSection() { text("Reusable color picker: inline + popup pane + shared popup manager") text( "Pipette samples current rendered game window surface. Copy/paste accepts hex/rgb/hsl/hsb.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) text( - "Inline picker follows app styling. Inspector picker (F8) is rendered in isolated system overlay styles.", - { style = { color = DEMO_MUTED } } + "Inline picker follows app styling. Inspector picker (F12) is rendered in isolated system portal styles.", + { style = { color = DEMO_MUTED } }, ) div({ @@ -197,6 +198,5 @@ fun UiScope.colorPickerSection() { } } -private fun colorLabel(color: RgbaColor): String { - return ColorTextCodec.format(color, ColorFormatMode.HEX, includeAlpha = true) -} +private fun colorLabel(color: RgbaColor): String = + ColorTextCodec.format(color, ColorFormatMode.HEX, includeAlpha = true) diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ContextMenuSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ContextMenuSection.kt index 9c31171..1cb2078 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ContextMenuSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ContextMenuSection.kt @@ -1,20 +1,20 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.core.contextmenu.ContextMenuRuntime import org.dreamfinity.dsgl.core.contextmenu.ContextMenuStyle import org.dreamfinity.dsgl.core.contextmenu.contextMenu import org.dreamfinity.dsgl.core.dnd.* import org.dreamfinity.dsgl.core.dom.elements.InputType import org.dreamfinity.dsgl.core.dom.onContextMenu +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.portal.DomainPortalServices import org.dreamfinity.dsgl.core.style.AlignItems import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.JustifyContent -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import java.util.ArrayDeque @@ -33,12 +33,12 @@ private data class ContextMenuDemoFile( val sizeKb: Int, val isDirectory: Boolean, val locked: Boolean, - val updatedAtOrder: Long + val updatedAtOrder: Long, ) private data class ContextMenuBreadcrumb( val id: String, - val label: String + val label: String, ) private data class ContextMenuSectionState( @@ -67,18 +67,19 @@ private data class ContextMenuSectionState( val files: List = defaultContextMenuFiles(), val fileSequence: Long = 100L, val lastClickEntryId: String? = null, - val lastClickMs: Long = 0L + val lastClickMs: Long = 0L, ) fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { var state by useState(ContextMenuSectionState()) fun recordContextMenuAction(target: String, action: String) { - state = state.copy( - lastTarget = target, - lastAction = action, - actionCount = state.actionCount + 1 - ) + state = + state.copy( + lastTarget = target, + lastAction = action, + actionCount = state.actionCount + 1, + ) onInfo("Context menu [$target]: $action") } @@ -87,15 +88,16 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { mouseX: Int, mouseY: Int, localX: Int, - localY: Int + localY: Int, ) { - state = state.copy( - cursorOwner = owner, - cursorX = mouseX, - cursorY = mouseY, - cursorLocalX = localX, - cursorLocalY = localY - ) + state = + state.copy( + cursorOwner = owner, + cursorX = mouseX, + cursorY = mouseY, + cursorLocalX = localX, + cursorLocalY = localY, + ) } fun contextMenuEntryById(entryId: String?): ContextMenuDemoFile? { @@ -148,38 +150,41 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { backHistory = backHistory + currentState.currentDirectoryId forwardHistory = emptyList() } - state = currentState.copy( - currentDirectoryId = directory.id, - backHistory = backHistory, - forwardHistory = forwardHistory, - renameTargetId = null, - dragHoverDirectoryId = null - ) + state = + currentState.copy( + currentDirectoryId = directory.id, + backHistory = backHistory, + forwardHistory = forwardHistory, + renameTargetId = null, + dragHoverDirectoryId = null, + ) recordContextMenuAction("navigator", "open ${contextMenuCurrentPath()}") } fun contextMenuNavigateBack() { if (!contextMenuCanGoBack()) return val previous = state.backHistory.last() - state = state.copy( - backHistory = state.backHistory.dropLast(1), - forwardHistory = listOf(state.currentDirectoryId) + state.forwardHistory, - currentDirectoryId = previous, - renameTargetId = null, - dragHoverDirectoryId = null - ) + state = + state.copy( + backHistory = state.backHistory.dropLast(1), + forwardHistory = listOf(state.currentDirectoryId) + state.forwardHistory, + currentDirectoryId = previous, + renameTargetId = null, + dragHoverDirectoryId = null, + ) recordContextMenuAction("navigator", "back to ${contextMenuCurrentPath()}") } fun contextMenuNavigateForward() { val target = state.forwardHistory.firstOrNull() ?: return - state = state.copy( - forwardHistory = state.forwardHistory.drop(1), - backHistory = state.backHistory + state.currentDirectoryId, - currentDirectoryId = target, - renameTargetId = null, - dragHoverDirectoryId = null - ) + state = + state.copy( + forwardHistory = state.forwardHistory.drop(1), + backHistory = state.backHistory + state.currentDirectoryId, + currentDirectoryId = target, + renameTargetId = null, + dragHoverDirectoryId = null, + ) recordContextMenuAction("navigator", "forward to ${contextMenuCurrentPath()}") } @@ -188,28 +193,29 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val parentId = current.parentId ?: return contextMenuOpenDirectory(parentId, pushHistory = true) } + fun contextMenuSetSortMode(mode: String) { state = state.copy(sortMode = mode) recordContextMenuAction("background", "sort by ${mode.lowercase()}") } - fun contextMenuVisibleFiles(): List { - return sortedChildrenForDirectory( + fun contextMenuVisibleFiles(): List = + sortedChildrenForDirectory( files = state.files, directoryId = state.currentDirectoryId, - sortMode = state.sortMode + sortMode = state.sortMode, ) - } fun contextMenuHandleEntryClick(file: ContextMenuDemoFile) { val now = System.currentTimeMillis() val isDoubleClick = state.lastClickEntryId == file.id && (now - state.lastClickMs) <= CONTEXT_MENU_DOUBLE_CLICK_MS - state = state.copy( - fileSelection = file.name, - lastClickEntryId = file.id, - lastClickMs = now - ) + state = + state.copy( + fileSelection = file.name, + lastClickEntryId = file.id, + lastClickMs = now, + ) if (file.isDirectory && isDoubleClick) { contextMenuOpenDirectory(file.id, pushHistory = true) } @@ -220,20 +226,22 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val parent = currentState.files.firstOrNull { it.id == parentId && it.isDirectory } ?: return var sequence = currentState.fileSequence sequence += 1L - val created = ContextMenuDemoFile( - id = "fs.$sequence", - parentId = parent.id, - name = uniqueContextMenuName(currentState.files, parent.id, "New Folder"), - sizeKb = 0, - isDirectory = true, - locked = false, - updatedAtOrder = sequence - ) - state = currentState.copy( - files = currentState.files + created, - fileSelection = created.name, - fileSequence = sequence - ) + val created = + ContextMenuDemoFile( + id = "fs.$sequence", + parentId = parent.id, + name = uniqueContextMenuName(currentState.files, parent.id, "New Folder"), + sizeKb = 0, + isDirectory = true, + locked = false, + updatedAtOrder = sequence, + ) + state = + currentState.copy( + files = currentState.files + created, + fileSelection = created.name, + fileSequence = sequence, + ) recordContextMenuAction("background", "new folder ${created.name}") } @@ -243,34 +251,37 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { var updatedState = currentState if (parent.id != currentState.currentDirectoryId) { val backHistory = currentState.backHistory + currentState.currentDirectoryId - updatedState = currentState.copy( - currentDirectoryId = parent.id, - backHistory = backHistory, - forwardHistory = emptyList(), - renameTargetId = null, - dragHoverDirectoryId = null - ) + updatedState = + currentState.copy( + currentDirectoryId = parent.id, + backHistory = backHistory, + forwardHistory = emptyList(), + renameTargetId = null, + dragHoverDirectoryId = null, + ) } var sequence = updatedState.fileSequence sequence += 1L val name = uniqueContextMenuName(updatedState.files, parent.id, "new-file.txt") - val created = ContextMenuDemoFile( - id = "fs.$sequence", - parentId = parent.id, - name = name, - sizeKb = 1, - isDirectory = false, - locked = false, - updatedAtOrder = sequence - ) - state = updatedState.copy( - files = updatedState.files + created, - fileSelection = created.name, - renameTargetId = created.id, - renameDraft = created.name, - fileSequence = sequence - ) + val created = + ContextMenuDemoFile( + id = "fs.$sequence", + parentId = parent.id, + name = name, + sizeKb = 1, + isDirectory = false, + locked = false, + updatedAtOrder = sequence, + ) + state = + updatedState.copy( + files = updatedState.files + created, + fileSelection = created.name, + renameTargetId = created.id, + renameDraft = created.name, + fileSequence = sequence, + ) recordContextMenuAction("background", "new file $name") } @@ -285,11 +296,12 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { fun contextMenuBeginRename(file: ContextMenuDemoFile) { if (file.locked) return - state = state.copy( - renameTargetId = file.id, - renameDraft = file.name, - fileSelection = file.name - ) + state = + state.copy( + renameTargetId = file.id, + renameDraft = file.name, + fileSelection = file.name, + ) } fun contextMenuCancelRename() { @@ -298,10 +310,11 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { fun contextMenuApplyRename() { val targetId = state.renameTargetId ?: return - val target = contextMenuEntryById(targetId) ?: run { - state = state.copy(renameTargetId = null) - return - } + val target = + contextMenuEntryById(targetId) ?: run { + state = state.copy(renameTargetId = null) + return + } if (target.locked) { state = state.copy(renameTargetId = null) return @@ -309,42 +322,46 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val draft = state.renameDraft.trim() if (draft.isEmpty()) return - val resolved = if (draft == target.name) { - draft - } else { - uniqueContextMenuName(state.files, target.parentId ?: CONTEXT_MENU_ROOT_ID, draft) - } + val resolved = + if (draft == target.name) { + draft + } else { + uniqueContextMenuName(state.files, target.parentId ?: CONTEXT_MENU_ROOT_ID, draft) + } val sequence = state.fileSequence + 1L - state = state.copy( - files = state.files.map { current -> - if (current.id == target.id) { - current.copy(name = resolved, updatedAtOrder = sequence) - } else { - current - } - }, - fileSelection = resolved, - renameTargetId = null, - renameDraft = "", - fileSequence = sequence - ) + state = + state.copy( + files = + state.files.map { current -> + if (current.id == target.id) { + current.copy(name = resolved, updatedAtOrder = sequence) + } else { + current + } + }, + fileSelection = resolved, + renameTargetId = null, + renameDraft = "", + fileSequence = sequence, + ) recordContextMenuAction(target.name, "rename to $resolved") } fun contextMenuCopyFile(file: ContextMenuDemoFile) { - state = state.copy( - clipboardHasData = true, - clipboardEntryName = file.name, - clipboardEntryId = file.id, - fileSelection = file.name - ) + state = + state.copy( + clipboardHasData = true, + clipboardEntryName = file.name, + clipboardEntryId = file.id, + fileSelection = file.name, + ) recordContextMenuAction(file.name, "copied to clipboard") } fun contextMenuDuplicateFile( file: ContextMenuDemoFile, - targetParentId: String = file.parentId ?: state.currentDirectoryId + targetParentId: String = file.parentId ?: state.currentDirectoryId, ) { val currentState = state val targetParent = currentState.files.firstOrNull { it.id == targetParentId && it.isDirectory } ?: return @@ -354,6 +371,7 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val idRemap = linkedMapOf() var sequence = currentState.fileSequence + fun nextId(): String { sequence += 1L return "fs.$sequence" @@ -368,32 +386,36 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { idRemap[file.id] = rootCopyId val rootName = uniqueContextMenuName(currentState.files, targetParent.id, file.name, "copy") val copies = ArrayList(descendants.size) - copies += file.copy( - id = rootCopyId, - parentId = targetParent.id, - name = rootName, - locked = false, - updatedAtOrder = nextOrder() - ) + copies += + file.copy( + id = rootCopyId, + parentId = targetParent.id, + name = rootName, + locked = false, + updatedAtOrder = nextOrder(), + ) descendants.drop(1).forEach { child -> val parentCopyId = idRemap[child.parentId] ?: return@forEach val childCopyId = nextId() idRemap[child.id] = childCopyId - copies += child.copy( - id = childCopyId, - parentId = parentCopyId, - locked = false, - updatedAtOrder = nextOrder() - ) + copies += + child.copy( + id = childCopyId, + parentId = parentCopyId, + locked = false, + updatedAtOrder = nextOrder(), + ) } - state = currentState.copy( - files = currentState.files + copies, - fileSelection = rootName, - fileSequence = sequence - ) + state = + currentState.copy( + files = currentState.files + copies, + fileSelection = rootName, + fileSequence = sequence, + ) recordContextMenuAction(file.name, "duplicate as $rootName") } + fun contextMenuCanDropIntoDirectory(entryId: String, destinationDirectoryId: String): Boolean { val entry = contextMenuEntryById(entryId) ?: return false val destination = contextMenuEntryById(destinationDirectoryId) ?: return false @@ -409,22 +431,24 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val destination = contextMenuEntryById(destinationDirectoryId) ?: return val resolvedName = uniqueContextMenuName(state.files, destination.id, file.name) val sequence = state.fileSequence + 1L - state = state.copy( - files = state.files.map { current -> - if (current.id == file.id) { - current.copy( - parentId = destination.id, - name = resolvedName, - updatedAtOrder = sequence - ) - } else { - current - } - }, - fileSelection = resolvedName, - dragHoverDirectoryId = null, - fileSequence = sequence - ) + state = + state.copy( + files = + state.files.map { current -> + if (current.id == file.id) { + current.copy( + parentId = destination.id, + name = resolvedName, + updatedAtOrder = sequence, + ) + } else { + current + } + }, + fileSelection = resolvedName, + dragHoverDirectoryId = null, + fileSequence = sequence, + ) recordContextMenuAction(file.name, "move to ${destination.name}") } @@ -453,29 +477,31 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { val nextFiles = state.files.filterNot { subtreeIds.contains(it.id) } val nextRenameTarget = state.renameTargetId?.takeUnless { subtreeIds.contains(it) } var nextSelection = state.fileSelection - if (nextSelection != "none" && sortedChildrenForDirectory( + if (nextSelection != "none" && + sortedChildrenForDirectory( files = nextFiles, directoryId = nextCurrentDirectory, - sortMode = state.sortMode + sortMode = state.sortMode, ).none { it.name == nextSelection } ) { nextSelection = sortedChildrenForDirectory( files = nextFiles, directoryId = nextCurrentDirectory, - sortMode = state.sortMode + sortMode = state.sortMode, ).firstOrNull()?.name ?: "none" } - state = state.copy( - files = nextFiles, - currentDirectoryId = nextCurrentDirectory, - backHistory = nextBackHistory, - forwardHistory = nextForwardHistory, - clipboardHasData = nextClipboardHasData, - clipboardEntryId = nextClipboardEntryId, - renameTargetId = nextRenameTarget, - fileSelection = nextSelection - ) + state = + state.copy( + files = nextFiles, + currentDirectoryId = nextCurrentDirectory, + backHistory = nextBackHistory, + forwardHistory = nextForwardHistory, + clipboardHasData = nextClipboardHasData, + clipboardEntryId = nextClipboardEntryId, + renameTargetId = nextRenameTarget, + fileSelection = nextSelection, + ) recordContextMenuAction(file.name, "delete") } @@ -488,218 +514,230 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { } fun contextMenuRefreshWorkspace() { - state = state.copy( - files = state.files.map { file -> - file.copy(updatedAtOrder = file.updatedAtOrder + 1L) - } - ) + state = + state.copy( + files = + state.files.map { file -> + file.copy(updatedAtOrder = file.updatedAtOrder + 1L) + }, + ) recordContextMenuAction("background", "refresh") } - fun buildBackgroundMenu() = contextMenu(id = "demo.context.background") { - submenu("Create", id = "create") { - icon("+") - item("File", id = "create.file") { - icon("FI") - onClick { contextMenuCreateFile() } - } - item("Directory", id = "create.dir") { - icon("FD") - onClick { contextMenuCreateFolder() } + fun buildBackgroundMenu() = + contextMenu(id = "demo.context.background") { + submenu("Create", id = "create") { + icon("+") + item("File", id = "create.file") { + icon("FI") + onClick { contextMenuCreateFile() } + } + item("Directory", id = "create.dir") { + icon("FD") + onClick { contextMenuCreateFolder() } + } } - } - - item("Paste", id = "paste") { - icon("CL") - hint("Ctrl+V") - enabledIf { state.clipboardHasData } - onClick { contextMenuPasteIntoWorkspace() } - } - submenu("Sort by", id = "sort") { - icon("AZ") - item("Name", id = "sort.name") { - checkedIf { state.sortMode == "Name" } - onClick { contextMenuSetSortMode("Name") } + item("Paste", id = "paste") { + icon("CL") + hint("Ctrl+V") + enabledIf { state.clipboardHasData } + onClick { contextMenuPasteIntoWorkspace() } } - item("Date", id = "sort.date") { - checkedIf { state.sortMode == "Date" } - onClick { contextMenuSetSortMode("Date") } - } - item("Size", id = "sort.size") { - checkedIf { state.sortMode == "Size" } - onClick { contextMenuSetSortMode("Size") } + + submenu("Sort by", id = "sort") { + icon("AZ") + item("Name", id = "sort.name") { + checkedIf { state.sortMode == "Name" } + onClick { contextMenuSetSortMode("Name") } + } + item("Date", id = "sort.date") { + checkedIf { state.sortMode == "Date" } + onClick { contextMenuSetSortMode("Date") } + } + item("Size", id = "sort.size") { + checkedIf { state.sortMode == "Size" } + onClick { contextMenuSetSortMode("Size") } + } } - } - separator("main.sep") + separator("main.sep") - item("Refresh", id = "refresh") { - icon("RF") - onClick { contextMenuRefreshWorkspace() } + item("Refresh", id = "refresh") { + icon("RF") + onClick { contextMenuRefreshWorkspace() } + } } - } - fun buildEntryMenu(file: ContextMenuDemoFile) = contextMenu(id = "demo.context.entry.${file.id}") { - if (file.isDirectory) { - item("Open", id = "entry.open") { - icon("OP") - onClick { contextMenuOpenDirectory(file.id, pushHistory = true) } - } - submenu("Create Inside", id = "entry.createInside") { - icon("+") - item("File", id = "entry.createInside.file") { - icon("FI") - onClick { contextMenuCreateFile(file.id) } + fun buildEntryMenu(file: ContextMenuDemoFile) = + contextMenu(id = "demo.context.entry.${file.id}") { + if (file.isDirectory) { + item("Open", id = "entry.open") { + icon("OP") + onClick { contextMenuOpenDirectory(file.id, pushHistory = true) } } - item("Directory", id = "entry.createInside.dir") { - icon("FD") - onClick { contextMenuCreateFolder(file.id) } + submenu("Create Inside", id = "entry.createInside") { + icon("+") + item("File", id = "entry.createInside.file") { + icon("FI") + onClick { contextMenuCreateFile(file.id) } + } + item("Directory", id = "entry.createInside.dir") { + icon("FD") + onClick { contextMenuCreateFolder(file.id) } + } } + separator("entry.sep.open") } - separator("entry.sep.open") - } - item("Duplicate", id = "entry.duplicate") { - icon("CP") - onClick { contextMenuDuplicateFile(file) } - } + item("Duplicate", id = "entry.duplicate") { + icon("CP") + onClick { contextMenuDuplicateFile(file) } + } - item("Rename", id = "entry.rename") { - icon("RN") - enabledIf { !file.locked } - onClick { contextMenuBeginRename(file) } - } + item("Rename", id = "entry.rename") { + icon("RN") + enabledIf { !file.locked } + onClick { contextMenuBeginRename(file) } + } - item("Delete", id = "entry.delete") { - icon("DL") - enabledIf { !file.locked } - onClick { contextMenuDeleteFile(file) } - } + item("Delete", id = "entry.delete") { + icon("DL") + enabledIf { !file.locked } + onClick { contextMenuDeleteFile(file) } + } - separator("entry.sep.copy") + separator("entry.sep.copy") - item("Copy", id = "entry.copy") { - icon("CY") - onClick { contextMenuCopyFile(file) } + item("Copy", id = "entry.copy") { + icon("CY") + onClick { contextMenuCopyFile(file) } + } } - } + fun UiScope.contextMenuEntryTile(file: ContextMenuDemoFile) { val tileKey = "context.fs.tile.${file.id}" val iconURL = iconFor(file) - val draggable = useDraggable( - id = file.id, - nodeKey = tileKey, - type = "context.fs.entry", - data = file.id, - previewMode = DragPreviewMode.GHOST, - hideSourceWhileDragging = false, - renderPreview = { - val offset = TILE_GHOST_SIZE / 2 - image(iconURL, -offset, -offset, TILE_GHOST_SIZE, TILE_GHOST_SIZE) - rect(-offset, -offset, TILE_GHOST_SIZE, TILE_GHOST_SIZE, 0x66000000) - }, - onDragStart = { event -> - event.dataTransfer.setDragImage(tileKey, 0, 0) - } - ) - val droppable = if (file.isDirectory) { - useDroppable( - id = "context.fs.dir.${file.id}", + val draggable = + useDraggable( + id = file.id, nodeKey = tileKey, - accepts = { active -> - val activeId = active.id ?: return@useDroppable false - contextMenuCanDropIntoDirectory(activeId, file.id) - }, - onDragEnter = { event, active -> - val activeId = active?.id - if (activeId != null && contextMenuCanDropIntoDirectory(activeId, file.id)) { - state = state.copy(dragHoverDirectoryId = file.id) - event.cancelled = true - } - }, - onDragOver = { event, active -> - val activeId = active?.id - if (activeId != null && contextMenuCanDropIntoDirectory(activeId, file.id)) { - state = state.copy(dragHoverDirectoryId = file.id) - event.cancelled = true - } + type = "context.fs.entry", + data = file.id, + previewMode = DragPreviewMode.GHOST, + hideSourceWhileDragging = false, + renderPreview = { + val offset = TILE_GHOST_SIZE / 2 + image(iconURL, -offset, -offset, TILE_GHOST_SIZE, TILE_GHOST_SIZE) + rect(-offset, -offset, TILE_GHOST_SIZE, TILE_GHOST_SIZE, 0x66000000) }, - onDragLeave = { event, _ -> - if (state.dragHoverDirectoryId == file.id) { - state = state.copy(dragHoverDirectoryId = null) - } - event.cancelled = true + onDragStart = { event -> + event.dataTransfer.setDragImage(tileKey, 0, 0) }, - onDrop = { event, active -> - val moving = contextMenuEntryById(active?.id) ?: return@useDroppable - contextMenuMoveFile(moving, file.id) - event.cancelled = true - } ) - } else { - null - } + val droppable = + if (file.isDirectory) { + useDroppable( + id = "context.fs.dir.${file.id}", + nodeKey = tileKey, + accepts = { active -> + val activeId = active.id ?: return@useDroppable false + contextMenuCanDropIntoDirectory(activeId, file.id) + }, + onDragEnter = { event, active -> + val activeId = active?.id + if (activeId != null && contextMenuCanDropIntoDirectory(activeId, file.id)) { + state = state.copy(dragHoverDirectoryId = file.id) + event.cancelled = true + } + }, + onDragOver = { event, active -> + val activeId = active?.id + if (activeId != null && contextMenuCanDropIntoDirectory(activeId, file.id)) { + state = state.copy(dragHoverDirectoryId = file.id) + event.cancelled = true + } + }, + onDragLeave = { event, _ -> + if (state.dragHoverDirectoryId == file.id) { + state = state.copy(dragHoverDirectoryId = null) + } + event.cancelled = true + }, + onDrop = { event, active -> + val moving = contextMenuEntryById(active?.id) ?: return@useDroppable + contextMenuMoveFile(moving, file.id) + event.cancelled = true + }, + ) + } else { + null + } val isEditingName = state.renameTargetId == file.id val isSelected = state.fileSelection == file.name val isDropHover = state.dragHoverDirectoryId == file.id - val tileNode = div({ - key = tileKey - onMouseClick = { event -> - if (!isEditingName && event.mouseButton == MouseButton.LEFT) { - contextMenuHandleEntryClick(file) - } - } - style = { - width = TILE_WIDTH.px - minHeight = (TILE_WIDTH + 18).px - backgroundColor = when { - isDropHover -> 0xFF43607A.toInt() - isSelected -> 0xFF3A5168.toInt() - else -> 0xFF33414E.toInt() + val tileNode = + div({ + key = tileKey + onMouseClick = { event -> + if (!isEditingName && event.mouseButton == MouseButton.LEFT) { + contextMenuHandleEntryClick(file) + } } - border { width = 1.px; color = if (isDropHover) 0xFF9BC2E9.toInt() else 0xFF596B7D.toInt() } - display = Display.Flex - flexDirection = FlexDirection.Column - alignItems = AlignItems.Center - justifyContent = JustifyContent.Center - } - applyDraggable(draggable) - if (droppable != null) { - applyDroppable(droppable) - } - }) { - img(iconURL, { style = { - width = TILE_ICON_SIZE.px - height = TILE_ICON_SIZE.px - } - }) - if (isEditingName) { - input( - InputType.Text( - value = state.renameDraft, - placeholder = "Name" - ), - { - key = "contextMenu.rename.inline.${file.id}" - onInput = { event -> - state = state.copy(renameDraft = event.value) - } - onKeyDown = { event -> - when (event.keyCode) { - KeyCodes.ENTER -> contextMenuApplyRename() - KeyCodes.ESCAPE -> contextMenuCancelRename() - } + width = TILE_WIDTH.px + minHeight = (TILE_WIDTH + 18).px + backgroundColor = + when { + isDropHover -> 0xFF43607A.toInt() + isSelected -> 0xFF3A5168.toInt() + else -> 0xFF33414E.toInt() } + border { + width = 1.px + color = if (isDropHover) 0xFF9BC2E9.toInt() else 0xFF596B7D.toInt() + } + display = Display.Flex + flexDirection = FlexDirection.Column + alignItems = AlignItems.Center + justifyContent = JustifyContent.Center + } + applyDraggable(draggable) + if (droppable != null) { + applyDroppable(droppable) + } + }) { + img(iconURL, { + style = { + width = TILE_ICON_SIZE.px + height = TILE_ICON_SIZE.px } - ) - } else { - text(file.name, { - style = { color = if (file.locked) 0xFFE9A56E.toInt() else 0xFFEAF2FD.toInt() } }) + if (isEditingName) { + input( + InputType.Text( + value = state.renameDraft, + placeholder = "Name", + ), + { + key = "contextMenu.rename.inline.${file.id}" + onInput = { event -> + state = state.copy(renameDraft = event.value) + } + onKeyDown = { event -> + when (event.keyCode) { + KeyCodes.ENTER -> contextMenuApplyRename() + KeyCodes.ESCAPE -> contextMenuCancelRename() + } + } + }, + ) + } else { + text(file.name, { + style = { color = if (file.locked) 0xFFE9A56E.toInt() else 0xFFEAF2FD.toInt() } + }) + } } - } tileNode.onContextMenu { val anchorX = anchorRect?.x ?: mouseX val anchorY = anchorRect?.y ?: mouseY @@ -708,14 +746,14 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { mouseX = mouseX, mouseY = mouseY, localX = mouseX - anchorX, - localY = mouseY - anchorY + localY = mouseY - anchorY, ) openMenu(buildEntryMenu(file)) } } val entries = contextMenuVisibleFiles() - ContextMenuRuntime.engine.setStyle( + DomainPortalServices.applicationContextMenuEngine.setStyle( ContextMenuStyle( panelPaddingX = 4, panelPaddingY = 4, @@ -734,8 +772,8 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { hintTextColor = 0xFFC5D2E1.toInt(), separatorColor = 0xFF4C6074.toInt(), checkMarkColor = 0xFF8BD59D.toInt(), - submenuArrowColor = 0xFFC9D7E6.toInt() - ) + submenuArrowColor = 0xFFC9D7E6.toInt(), + ), ) div({ @@ -749,11 +787,11 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { text("Pseudo filesystem: tile view + context menu + drag/drop") text( "path=${contextMenuCurrentPath()} sort=${state.sortMode} selected=${state.fileSelection}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) text( "lastAction=${state.lastAction} target=${state.lastTarget} actions=${state.actionCount}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -785,7 +823,10 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { padding = 3.px gap = 2.px backgroundColor = 0xFF2A313B.toInt() - border { width = 1.px; color = 0xFF5B6A7A.toInt() } + border { + width = 1.px + color = 0xFF5B6A7A.toInt() + } } }) { div({ @@ -796,7 +837,10 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { backgroundColor = 0xFF25303A.toInt() display = Display.Flex flexDirection = FlexDirection.Row - border { width = 1.px; color = 0xFF4F6175.toInt() } + border { + width = 1.px + color = 0xFF4F6175.toInt() + } } }) { button("<", { @@ -815,43 +859,44 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { text("/", { style = { color = DEMO_MUTED } }) } val breadcrumbKey = "section.contextMenu.path.${breadcrumb.id}" - val breadcrumbDrop = useDroppable( - id = "context.fs.path.${breadcrumb.id}", - nodeKey = breadcrumbKey, - accepts = { active -> - val activeId = active.id ?: return@useDroppable false - contextMenuCanDropIntoDirectory(activeId, breadcrumb.id) - }, - onDragEnter = { event, active -> - if (event.target?.key != breadcrumbKey) return@useDroppable - val activeId = active?.id ?: return@useDroppable - if (contextMenuCanDropIntoDirectory(activeId, breadcrumb.id)) { - state = state.copy(dragHoverDirectoryId = breadcrumb.id) + val breadcrumbDrop = + useDroppable( + id = "context.fs.path.${breadcrumb.id}", + nodeKey = breadcrumbKey, + accepts = { active -> + val activeId = active.id ?: return@useDroppable false + contextMenuCanDropIntoDirectory(activeId, breadcrumb.id) + }, + onDragEnter = { event, active -> + if (event.target?.key != breadcrumbKey) return@useDroppable + val activeId = active?.id ?: return@useDroppable + if (contextMenuCanDropIntoDirectory(activeId, breadcrumb.id)) { + state = state.copy(dragHoverDirectoryId = breadcrumb.id) + event.cancelled = true + } + }, + onDragOver = { event, active -> + if (event.target?.key != breadcrumbKey) return@useDroppable + val activeId = active?.id ?: return@useDroppable + if (contextMenuCanDropIntoDirectory(activeId, breadcrumb.id)) { + state = state.copy(dragHoverDirectoryId = breadcrumb.id) + event.cancelled = true + } + }, + onDragLeave = { event, _ -> + if (event.target?.key != breadcrumbKey) return@useDroppable + if (state.dragHoverDirectoryId == breadcrumb.id) { + state = state.copy(dragHoverDirectoryId = null) + } event.cancelled = true - } - }, - onDragOver = { event, active -> - if (event.target?.key != breadcrumbKey) return@useDroppable - val activeId = active?.id ?: return@useDroppable - if (contextMenuCanDropIntoDirectory(activeId, breadcrumb.id)) { - state = state.copy(dragHoverDirectoryId = breadcrumb.id) + }, + onDrop = { event, active -> + if (event.target?.key != breadcrumbKey) return@useDroppable + val moving = contextMenuEntryById(active?.id) ?: return@useDroppable + contextMenuMoveFile(moving, breadcrumb.id) event.cancelled = true - } - }, - onDragLeave = { event, _ -> - if (event.target?.key != breadcrumbKey) return@useDroppable - if (state.dragHoverDirectoryId == breadcrumb.id) { - state = state.copy(dragHoverDirectoryId = null) - } - event.cancelled = true - }, - onDrop = { event, active -> - if (event.target?.key != breadcrumbKey) return@useDroppable - val moving = contextMenuEntryById(active?.id) ?: return@useDroppable - contextMenuMoveFile(moving, breadcrumb.id) - event.cancelled = true - } - ) + }, + ) val isCurrent = breadcrumb.id == state.currentDirectoryId val isDropHover = state.dragHoverDirectoryId == breadcrumb.id button(breadcrumb.label, { @@ -860,75 +905,94 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { contextMenuOpenDirectory(breadcrumb.id, pushHistory = true) } style = { - backgroundColor = when { - isDropHover -> 0xFF40617F.toInt() - isCurrent -> 0xFF364A5E.toInt() - else -> 0xFF2B3A4A.toInt() + backgroundColor = + when { + isDropHover -> 0xFF40617F.toInt() + isCurrent -> 0xFF364A5E.toInt() + else -> 0xFF2B3A4A.toInt() + } + border { + width = 1.px + color = if (isDropHover) 0xFF9BC2E9.toInt() else 0xFF5B6F84.toInt() } - border { width = 1.px; color = if (isDropHover) 0xFF9BC2E9.toInt() else 0xFF5B6F84.toInt() } } applyDroppable(breadcrumbDrop) }) } } - val listDroppable = useDroppable( - id = "context.fs.current.${state.currentDirectoryId}", - nodeKey = "section.contextMenu.list", - accepts = { active -> - val activeId = active.id ?: return@useDroppable false - contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId) - }, - onDragEnter = { event, active -> - if (event.target?.key != "section.contextMenu.list") return@useDroppable - val activeId = active?.id - if (activeId != null && contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId)) { - state = state.copy(dragHoverDirectoryId = state.currentDirectoryId) - } - }, - onDragOver = { event, active -> - if (event.target?.key != "section.contextMenu.list") return@useDroppable - val activeId = active?.id - if (activeId != null && contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId)) { - state = state.copy(dragHoverDirectoryId = state.currentDirectoryId) - } - }, - onDragLeave = { event, _ -> - if (event.target?.key != "section.contextMenu.list") return@useDroppable - if (state.dragHoverDirectoryId == state.currentDirectoryId) { - state = state.copy(dragHoverDirectoryId = null) - } - }, - onDrop = { event, active -> - if (event.target?.key != "section.contextMenu.list") return@useDroppable - val moving = contextMenuEntryById(active?.id) ?: return@useDroppable - contextMenuMoveFile(moving, state.currentDirectoryId) - } - ) + val listDroppable = + useDroppable( + id = "context.fs.current.${state.currentDirectoryId}", + nodeKey = "section.contextMenu.list", + accepts = { active -> + val activeId = active.id ?: return@useDroppable false + contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId) + }, + onDragEnter = { event, active -> + if (event.target?.key != "section.contextMenu.list") return@useDroppable + val activeId = active?.id + if (activeId != null && contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId)) { + state = state.copy(dragHoverDirectoryId = state.currentDirectoryId) + } + }, + onDragOver = { event, active -> + if (event.target?.key != "section.contextMenu.list") return@useDroppable + val activeId = active?.id + if (activeId != null && contextMenuCanDropIntoDirectory(activeId, state.currentDirectoryId)) { + state = state.copy(dragHoverDirectoryId = state.currentDirectoryId) + } + }, + onDragLeave = { event, _ -> + if (event.target?.key != "section.contextMenu.list") return@useDroppable + if (state.dragHoverDirectoryId == state.currentDirectoryId) { + state = state.copy(dragHoverDirectoryId = null) + } + }, + onDrop = { event, active -> + if (event.target?.key != "section.contextMenu.list") return@useDroppable + val moving = contextMenuEntryById(active?.id) ?: return@useDroppable + contextMenuMoveFile(moving, state.currentDirectoryId) + }, + ) - val listNode = div({ - key = "section.contextMenu.list" - style = { - gap = 4.px - padding = 4.px - backgroundColor = if (state.dragHoverDirectoryId == state.currentDirectoryId) 0xFF2F4358.toInt() else 0xFF2B343F.toInt() - border { width = 1.px; color = 0xFF4F6175.toInt() } - display = Display.Grid - gridColumns = 4 - flexGrow = 1f - } - applyDroppable(listDroppable) - }) { - if (entries.isEmpty()) { - div({ style = { padding = 2.px } }) { - text("Folder is empty. Right-click to create file/folder.", { style = { color = DEMO_MUTED } }) + val listNode = + div({ + key = "section.contextMenu.list" + style = { + gap = 4.px + padding = 4.px + backgroundColor = + if (state.dragHoverDirectoryId == + state.currentDirectoryId + ) { + 0xFF2F4358.toInt() + } else { + 0xFF2B343F.toInt() + } + border { + width = 1.px + color = 0xFF4F6175.toInt() + } + display = Display.Grid + gridColumns = 4 + flexGrow = 1f } - } else { - entries.forEach { file -> - contextMenuEntryTile(file) + applyDroppable(listDroppable) + }) { + if (entries.isEmpty()) { + div({ style = { padding = 2.px } }) { + text( + "Folder is empty. Right-click to create file/folder.", + { style = { color = DEMO_MUTED } }, + ) + } + } else { + entries.forEach { file -> + contextMenuEntryTile(file) + } } } - } listNode.onContextMenu { val anchorX = anchorRect?.x ?: mouseX val anchorY = anchorRect?.y ?: mouseY @@ -937,7 +1001,7 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { mouseX = mouseX, mouseY = mouseY, localX = mouseX - anchorX, - localY = mouseY - anchorY + localY = mouseY - anchorY, ) openMenu(buildBackgroundMenu()) } @@ -945,32 +1009,31 @@ fun UiScope.contextMenuSection(onInfo: (String) -> Unit) { } } -private fun iconFor(file: ContextMenuDemoFile): String { - return if (file.isDirectory) ICON_FOLDER else ICON_DOCUMENT -} +private fun iconFor(file: ContextMenuDemoFile): String = if (file.isDirectory) ICON_FOLDER else ICON_DOCUMENT private fun uniqueContextMenuName( files: List, parentId: String, baseName: String, - variant: String = "new" + variant: String = "new", ): String { - val existing = files - .asSequence() - .filter { it.parentId == parentId } - .map { it.name } - .toHashSet() + val existing = + files + .asSequence() + .filter { it.parentId == parentId } + .map { it.name } + .toHashSet() if (!existing.contains(baseName)) { return baseName } val (stem, extension) = splitContextMenuName(baseName) - fun candidate(index: Int): String { - return when (variant) { + + fun candidate(index: Int): String = + when (variant) { "copy" -> if (index == 1) "$stem copy$extension" else "$stem copy $index$extension" "renamed" -> if (index == 1) "$stem (renamed)$extension" else "$stem (renamed $index)$extension" else -> "$stem $index$extension" } - } var index = 1 var next = candidate(index) @@ -989,24 +1052,23 @@ private fun splitContextMenuName(name: String): Pair { name to "" } } + private fun sortedChildrenForDirectory( files: List, directoryId: String, - sortMode: String + sortMode: String, ): List { val children = files.filter { it.parentId == directoryId } - val comparator = when (sortMode) { - "Date" -> compareByDescending { it.updatedAtOrder }.thenBy { it.name.lowercase() } - "Size" -> compareByDescending { it.sizeKb }.thenBy { it.name.lowercase() } - else -> compareBy { it.name.lowercase() } - } + val comparator = + when (sortMode) { + "Date" -> compareByDescending { it.updatedAtOrder }.thenBy { it.name.lowercase() } + "Size" -> compareByDescending { it.sizeKb }.thenBy { it.name.lowercase() } + else -> compareBy { it.name.lowercase() } + } return children.sortedWith(compareBy { !it.isDirectory }.then(comparator)) } -private fun collectSubtree( - rootId: String, - byId: Map -): List { +private fun collectSubtree(rootId: String, byId: Map): List { val root = byId[rootId] ?: return emptyList() val queue = ArrayDeque() val orderedIds = ArrayList() @@ -1022,15 +1084,15 @@ private fun collectSubtree( return orderedIds.mapNotNull { byId[it] } } -private fun collectSubtreeIds(files: List, rootId: String): Set { - return collectSubtree(rootId, files.associateBy { it.id }).map { it.id }.toSet() -} +private fun collectSubtreeIds(files: List, rootId: String): Set = + collectSubtree( + rootId, + files.associateBy { + it.id + }, + ).map { it.id }.toSet() -private fun isDescendantDirectory( - files: List, - ancestorId: String, - candidateId: String -): Boolean { +private fun isDescendantDirectory(files: List, ancestorId: String, candidateId: String): Boolean { if (ancestorId == candidateId) return true val byId = files.associateBy { it.id } var current = byId[candidateId] @@ -1041,8 +1103,8 @@ private fun isDescendantDirectory( return false } -private fun defaultContextMenuFiles(): List { - return listOf( +private fun defaultContextMenuFiles(): List = + listOf( ContextMenuDemoFile(CONTEXT_MENU_ROOT_ID, null, "Workspace", 0, true, true, 1L), ContextMenuDemoFile("fs.docs", CONTEXT_MENU_ROOT_ID, "Documents", 0, true, false, 2L), ContextMenuDemoFile("fs.downloads", CONTEXT_MENU_ROOT_ID, "Downloads", 0, true, false, 3L), @@ -1060,6 +1122,5 @@ private fun defaultContextMenuFiles(): List { ContextMenuDemoFile("fs.archive.2025", "fs.projects.archive", "2025", 0, true, false, 15L), ContextMenuDemoFile("fs.archive.2026", "fs.projects.archive", "2026", 0, true, false, 16L), ContextMenuDemoFile("fs.archive.2026.q1", "fs.archive.2026", "Q1-report.md", 7, false, false, 17L), - ContextMenuDemoFile("fs.downloads.asset", "fs.downloads", "texture-pack.zip", 24, false, false, 18L) + ContextMenuDemoFile("fs.downloads.asset", "fs.downloads", "texture-pack.zip", 24, false, false, 18L), ) -} diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/CssCascadeSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/CssCascadeSection.kt index f5983ad..484eeba 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/CssCascadeSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/CssCascadeSection.kt @@ -2,9 +2,9 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> Unit) { @@ -18,23 +18,28 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> val parentThemeClass = if (cascadeParentDark) "dark" else "light" val ruleBlockClass = if (cascadeRuleAEnabled) "rule-a" else "rule-b" - val adjacentOrder = if (cascadeAdjacentSwapOrder) { - listOf("adj-target-1", "adj-source", "adj-target-2") - } else { - listOf("adj-source", "adj-target-1", "adj-target-2") - } - val generalItems = buildList { - add("gen-0") - if (cascadeGeneralInsertExtra) { - add("gen-extra") + val adjacentOrder = + if (cascadeAdjacentSwapOrder) { + listOf("adj-target-1", "adj-source", "adj-target-2") + } else { + listOf("adj-source", "adj-target-1", "adj-target-2") + } + val generalItems = + buildList { + add("gen-0") + if (cascadeGeneralInsertExtra) { + add("gen-extra") + } + add("gen-1") + add("gen-2") + add("gen-3") + } + val effectiveWarningIndex = + if (generalItems.isEmpty()) { + 0 + } else { + (cascadeGeneralWarningIndex.toInt().coerceAtLeast(0)) % generalItems.size } - add("gen-1") - add("gen-2") - add("gen-3") - } - val effectiveWarningIndex = if (generalItems.isEmpty()) 0 else { - (cascadeGeneralWarningIndex.toInt().coerceAtLeast(0)) % generalItems.size - } div({ key = "section.cssCascade" @@ -44,10 +49,13 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> flexDirection = FlexDirection.Column } }) { - text("CSS-like cascade demo: descendant/child/sibling selectors, specificity, source order, !important, inheritance.") + text( + "CSS-like cascade demo: descendant/child/sibling selectors, specificity, " + + "source order, !important, inheritance.", + ) text( "Use the controls to toggle classes, swap siblings, and insert/remove items.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -58,16 +66,13 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> flexDirection = FlexDirection.Row } }) { - button( - if (cascadeParentDark) "Parent class: dark" else "Parent class: light", - { - key = "section.cssCascade.toggleParentClass" - onMouseClick = { event -> - cascadeParentDark = !cascadeParentDark - onLogHook("css.cascade.toggle.parentClass", event, "dark=$cascadeParentDark") - } + button(if (cascadeParentDark) "Parent class: dark" else "Parent class: light", { + key = "section.cssCascade.toggleParentClass" + onMouseClick = { event -> + cascadeParentDark = !cascadeParentDark + onLogHook("css.cascade.toggle.parentClass", event, "dark=$cascadeParentDark") } - ) + }) button(if (cascadeRuleAEnabled) "Rule block: A" else "Rule block: B", { key = "section.cssCascade.toggleRuleBlock" onMouseClick = { event -> @@ -89,7 +94,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> text("Inheritance target: this text should inherit parent color class '$parentThemeClass'.") text( "Descendant vs child: direct item should be green, nested item blue, outside item inherited.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -158,7 +163,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> }) { text( "Adjacent sibling (+): only immediate .adj-target after .adj-source should change.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ key = "section.cssCascade.adj.controls" @@ -168,20 +173,17 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> flexDirection = FlexDirection.Column } }) { - button( - if (cascadeAdjacentSourceEnabled) "Source class: ON" else "Source class: OFF", - { - key = "section.cssCascade.adj.toggleSource" - onMouseClick = { event -> - cascadeAdjacentSourceEnabled = !cascadeAdjacentSourceEnabled - onLogHook( - "css.cascade.adj.toggleSource", - event, - "enabled=$cascadeAdjacentSourceEnabled" - ) - } + button(if (cascadeAdjacentSourceEnabled) "Source class: ON" else "Source class: OFF", { + key = "section.cssCascade.adj.toggleSource" + onMouseClick = { event -> + cascadeAdjacentSourceEnabled = !cascadeAdjacentSourceEnabled + onLogHook( + "css.cascade.adj.toggleSource", + event, + "enabled=$cascadeAdjacentSourceEnabled", + ) } - ) + }) button(if (cascadeAdjacentSwapOrder) "Order: swapped" else "Order: default", { key = "section.cssCascade.adj.swap" onMouseClick = { event -> @@ -200,17 +202,21 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> } }) { adjacentOrder.forEach { item -> - val classNames = buildString { - append("adj-item ") - when (item) { - "adj-source" -> { - if (cascadeAdjacentSourceEnabled) append("adj-source") - else append("adj-neutral") - } + val classNames = + buildString { + append("adj-item ") + when (item) { + "adj-source" -> { + if (cascadeAdjacentSourceEnabled) { + append("adj-source") + } else { + append("adj-neutral") + } + } - else -> append("adj-target") + else -> append("adj-target") + } } - } text(item, { key = "section.cssCascade.$item" className = classNames @@ -220,7 +226,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> text( "General sibling (~): all .gen-target after .warning should change.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ key = "section.cssCascade.gen.controls" @@ -238,7 +244,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> onLogHook( "css.cascade.gen.moveWarning", event, - "index=$cascadeGeneralWarningIndex" + "index=$cascadeGeneralWarningIndex", ) } }) @@ -249,7 +255,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> onLogHook( "css.cascade.gen.toggleExtra", event, - "extra=$cascadeGeneralInsertExtra" + "extra=$cascadeGeneralInsertExtra", ) } }) @@ -274,7 +280,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> text( "Mixed chain: .cascade-mixed > .header + .body .title", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) button(if (cascadeMixedSpacerEnabled) "Spacer: ON (break +)" else "Spacer: OFF (adjacent)", { key = "section.cssCascade.mixed.toggleSpacer" @@ -283,7 +289,7 @@ fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> onLogHook( "css.cascade.mixed.toggleSpacer", event, - "spacer=$cascadeMixedSpacerEnabled" + "spacer=$cascadeMixedSpacerEnabled", ) } }) diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DisplaySection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DisplaySection.kt index fcb00b2..ed22ccf 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DisplaySection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DisplaySection.kt @@ -1,32 +1,31 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event -import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED -private val JUSTIFY_OPTIONS = listOf( - "start" to JustifyContent.Start, - "center" to JustifyContent.Center, - "end" to JustifyContent.End, - "space-between" to JustifyContent.SpaceBetween, - "space-around" to JustifyContent.SpaceAround, - "space-evenly" to JustifyContent.SpaceEvenly -) +private val JUSTIFY_OPTIONS = + listOf( + "start" to JustifyContent.Start, + "center" to JustifyContent.Center, + "end" to JustifyContent.End, + "space-between" to JustifyContent.SpaceBetween, + "space-around" to JustifyContent.SpaceAround, + "space-evenly" to JustifyContent.SpaceEvenly, + ) -private val ALIGN_OPTIONS = listOf( - "start" to AlignItems.Start, - "center" to AlignItems.Center, - "end" to AlignItems.End, - "stretch" to AlignItems.Stretch -) +private val ALIGN_OPTIONS = + listOf( + "start" to AlignItems.Start, + "center" to AlignItems.Center, + "end" to AlignItems.End, + "stretch" to AlignItems.Stretch, + ) -fun UiScope.displaySection( - onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit -) { +fun UiScope.displaySection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) { var displayBlockLargeGap by useState(false) var displayInlineWidth by useState(132L) var displayShowHidden by useState(true) @@ -73,7 +72,10 @@ fun UiScope.displaySection( backgroundColor = 0xFF2B3542.toInt() display = Display.Block gap = (if (displayBlockLargeGap) 6 else 2).px - border { width = 1.px; color = 0xFF657688.toInt() } + border { + width = 1.px + color = 0xFF657688.toInt() + } } }) { repeat(3) { index -> @@ -82,7 +84,10 @@ fun UiScope.displaySection( style = { padding = 2.px backgroundColor = (0xFF3A4B60 + index * 0x000A0A00).toInt() - border { width = 1.px; color = 0xFF8095AA.toInt() } + border { + width = 1.px + color = 0xFF8095AA.toInt() + } } }) { text("Block item ${index + 1}") @@ -96,7 +101,7 @@ fun UiScope.displaySection( value = inlineWidth.toLong(), min = inlineMinWidth.toLong(), max = inlineMaxWidth.toLong(), - step = 4 + step = 4, ), { key = "display.inline.width" @@ -105,11 +110,12 @@ fun UiScope.displaySection( val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: inlineWidth.toLong() displayInlineWidth = next.coerceIn(inlineMinWidth.toLong(), inlineMaxWidth.toLong()) } - } + }, ) dynamicText( { "inline container width=$inlineWidth (drag slider to force wrapping)" }, - { style = { color = DEMO_MUTED } }) + { style = { color = DEMO_MUTED } }, + ) div({ key = "display.inline.container" style = { @@ -117,7 +123,10 @@ fun UiScope.displaySection( padding = 3.px backgroundColor = 0xFF2E3946.toInt() display = Display.Inline - border { width = 1.px; color = 0xFF607181.toInt() } + border { + width = 1.px + color = 0xFF607181.toInt() + } gap = 2.px } }) { @@ -128,8 +137,16 @@ fun UiScope.displaySection( padding = 2.px backgroundColor = 0xFF40556B.toInt() display = Display.Inline - margin { top = 1.px; right = 2.px; bottom = 1.px; left = 1.px } - border { width = 1.px; color = 0xFF90A7BE.toInt() } + margin { + top = 1.px + right = 2.px + bottom = 1.px + left = 1.px + } + border { + width = 1.px + color = 0xFF90A7BE.toInt() + } } }) { text(label) @@ -141,8 +158,16 @@ fun UiScope.displaySection( padding = 2.px backgroundColor = 0xFF3C5D4A.toInt() display = Display.Inline - margin { top = 1.px; right = 2.px; bottom = 1.px; left = 1.px } - border { width = 1.px; color = 0xFF86B197.toInt() } + margin { + top = 1.px + right = 2.px + bottom = 1.px + left = 1.px + } + border { + width = 1.px + color = 0xFF86B197.toInt() + } } }) { text("flex") @@ -153,7 +178,10 @@ fun UiScope.displaySection( backgroundColor = 0xFF2E4739.toInt() display = Display.Flex flexDirection = FlexDirection.Row - border { width = 1.px; color = 0xFF5B8D73.toInt() } + border { + width = 1.px + color = 0xFF5B8D73.toInt() + } } }) { div({ @@ -178,8 +206,16 @@ fun UiScope.displaySection( padding = 2.px backgroundColor = 0xFF5E4B3C.toInt() display = Display.Inline - margin { top = 1.px; right = 2.px; bottom = 1.px; left = 1.px } - border { width = 1.px; color = 0xFFB58E6A.toInt() } + margin { + top = 1.px + right = 2.px + bottom = 1.px + left = 1.px + } + border { + width = 1.px + color = 0xFFB58E6A.toInt() + } } }) { div({ @@ -187,7 +223,10 @@ fun UiScope.displaySection( padding = 1.px backgroundColor = 0xFF4B3B30.toInt() display = Display.Block - border { width = 1.px; color = 0xFF8B6A51.toInt() } + border { + width = 1.px + color = 0xFF8B6A51.toInt() + } gap = 1.px } }) { @@ -206,18 +245,15 @@ fun UiScope.displaySection( flexDirection = FlexDirection.Row } }) { - button( - if (displayShowHidden) "Target visible" else "Target hidden", - { - onMouseClick = { - displayShowHidden = !displayShowHidden - onInfo("Display.none visible=$displayShowHidden") - } + button(if (displayShowHidden) "Target visible" else "Target hidden", { + onMouseClick = { + displayShowHidden = !displayShowHidden + onInfo("Display.none visible=$displayShowHidden") } - ) + }) text( "targetClicks=$displayNoneClicks (should not change while hidden)", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } div({ @@ -229,7 +265,10 @@ fun UiScope.displaySection( padding = 3.px backgroundColor = 0xFF303A46.toInt() gap = 2.px - border { width = 1.px; color = 0xFF64788B.toInt() } + border { + width = 1.px + color = 0xFF64788B.toInt() + } } }) { div({ @@ -242,7 +281,10 @@ fun UiScope.displaySection( padding = 2.px backgroundColor = 0xFF5A3E3E.toInt() display = if (displayShowHidden) Display.Block else Display.None - border { width = 1.px; color = 0xFFB07B7B.toInt() } + border { + width = 1.px + color = 0xFFB07B7B.toInt() + } } }) { text("Toggle target (click me)") @@ -268,12 +310,9 @@ fun UiScope.displaySection( displayFlexAlignIndex = (alignIndex + 1) % ALIGN_OPTIONS.size } }) - button( - if (displayGridLargeGap) "gap: large" else "gap: compact", - { - onMouseClick = { displayGridLargeGap = !displayGridLargeGap } - } - ) + button(if (displayGridLargeGap) "gap: large" else "gap: compact", { + onMouseClick = { displayGridLargeGap = !displayGridLargeGap } + }) } text("Row uses fixed-size items so justify spacing is easier to compare.", { style = { color = DEMO_MUTED } @@ -289,7 +328,10 @@ fun UiScope.displaySection( justifyContent = justify.second alignItems = AlignItems.Center gap = 0.px - border { width = 1.px; color = 0xFF7E93A8.toInt() } + border { + width = 1.px + color = 0xFF7E93A8.toInt() + } } }) { dot("left", "A", 0xFFB3D6FF.toInt(), 0xFFDEEFFF.toInt()) @@ -310,7 +352,10 @@ fun UiScope.displaySection( justifyContent = justify.second alignItems = align.second gap = (if (displayGridLargeGap) 8 else 2).px - border { width = 1.px; color = 0xFF6C7E90.toInt() } + border { + width = 1.px + color = 0xFF6C7E90.toInt() + } } }) { flexRowCell("0", "1", 14, 1, 0xFF46627C.toInt()) @@ -325,7 +370,10 @@ fun UiScope.displaySection( padding = 2.px backgroundColor = 0xFF2A3340.toInt() gap = 2.px - border { width = 1.px; color = 0xFF6B7E92.toInt() } + border { + width = 1.px + color = 0xFF6B7E92.toInt() + } display = Display.Flex flexDirection = FlexDirection.Column } @@ -359,7 +407,7 @@ fun UiScope.displaySection( value = gridColumns.toLong(), min = 2, max = 6, - step = 1 + step = 1, ), { key = "display.grid.columns" @@ -368,11 +416,11 @@ fun UiScope.displaySection( val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: gridColumns.toLong() displayGridColumns = next.coerceIn(2, 6) } - } + }, ) text( "gridColumns=$displayGridColumns (first tile spans 2 columns)", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ key = "display.grid.container" @@ -385,7 +433,10 @@ fun UiScope.displaySection( gap = (if (displayGridLargeGap) 4 else 2).px alignItems = align.second justifyItems = JustifyItems.Stretch - border { width = 1.px; color = 0xFF70849A.toInt() } + border { + width = 1.px + color = 0xFF70849A.toInt() + } } }) { repeat(10) { index -> @@ -397,7 +448,10 @@ fun UiScope.displaySection( if (index == 0) { gridColumnSpan = 2 } - border { width = 1.px; color = 0xFF93AACC.toInt() } + border { + width = 1.px + color = 0xFF93AACC.toInt() + } } }) { text("Cell ${index + 1}") @@ -407,18 +461,32 @@ fun UiScope.displaySection( } } -private fun UiScope.dot(keyPart: String, label: String, fill: Int, borderColor: Int) { +private fun UiScope.dot( + keyPart: String, + label: String, + fill: Int, + borderColor: Int, +) { div({ key = "display.flex.justify.dot.$keyPart" style = { display = Display.Inline backgroundColor = fill - border { width = 1.px; color = borderColor } + border { + width = 1.px + color = borderColor + } } }) { text(label) } } -private fun UiScope.flexRowCell(keyPart: String, label: String, widthPx: Int, paddingPx: Int, color: Int) { +private fun UiScope.flexRowCell( + keyPart: String, + label: String, + @Suppress("UnusedParameter") widthPx: Int, + @Suppress("UnusedParameter") paddingPx: Int, + color: Int, +) { div({ key = "display.flex.row.item.$keyPart" style = { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DragDropSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DragDropSection.kt index 3f8b568..2c61e01 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DragDropSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/DragDropSection.kt @@ -2,16 +2,16 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections import net.minecraft.init.Items import net.minecraft.item.ItemStack -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dnd.* import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event +import org.dreamfinity.dsgl.core.hooks.useEffect +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.AlignItems import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.JustifyItems -import org.dreamfinity.dsgl.core.hooks.useEffect -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.McItemStackRef import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED @@ -20,13 +20,13 @@ private const val HIGHLIGHT_DELTA = 22 private data class DndDemoItem( val id: String, val label: String, - val stack: McItemStackRef + val stack: McItemStackRef, ) private enum class DndLaneIndicator { NONE, BEFORE, - AFTER + AFTER, } private data class DndSectionState( @@ -48,41 +48,44 @@ private data class DndSectionState( val debugCandidatesCount: Int = 0, val debugInsertPosition: String = "none", val debugExcludesActiveCard: Boolean = true, - val boxes: Map> = linkedMapOf( - "box-a" to emptyList(), - "box-b" to emptyList(), - "box-c" to emptyList() - ) + val boxes: Map> = + linkedMapOf( + "box-a" to emptyList(), + "box-b" to emptyList(), + "box-c" to emptyList(), + ), ) private data class LaneHoverIntent( val targetId: String?, val insertAfter: Boolean, - val append: Boolean + val append: Boolean, ) fun UiScope.dragNDropSection( onInfo: (String) -> Unit, onClearLogs: () -> Unit, - onLogHook: (String, Event, String?) -> Unit + onLogHook: (String, Event, String?) -> Unit, ) { var state by useState(DndSectionState()) fun logHook(name: String, event: Event, note: String? = null) = onLogHook(name, event, note) fun clearLaneReorderHover() { - state = state.copy( - reorderHoverTargetId = null, - reorderHoverInsertAfter = false, - reorderHoverLaneAppend = false - ) + state = + state.copy( + reorderHoverTargetId = null, + reorderHoverInsertAfter = false, + reorderHoverLaneAppend = false, + ) } fun resetDndItems(source: String) { - state = DndSectionState( - items = defaultDndItems(), - smoothFactor = state.smoothFactor - ) + state = + DndSectionState( + items = defaultDndItems(), + smoothFactor = state.smoothFactor, + ) onInfo("DnD demo list reset by $source") } @@ -93,13 +96,16 @@ fun UiScope.dragNDropSection( onInfo("DnD smoothing k=${"%.1f".format(next)}") } - fun extractCard(cardId: String): Triple, LinkedHashMap>>? { + fun extractCard( + cardId: String, + ): Triple, LinkedHashMap>>? { val lane = state.items.toMutableList() - val boxes = linkedMapOf>().apply { - state.boxes.forEach { (key, list) -> - this[key] = list.toMutableList() + val boxes = + linkedMapOf>().apply { + state.boxes.forEach { (key, list) -> + this[key] = list.toMutableList() + } } - } val laneIndex = lane.indexOfFirst { it.id == cardId } if (laneIndex >= 0) { val card = lane.removeAt(laneIndex) @@ -119,19 +125,23 @@ fun UiScope.dragNDropSection( draggedId: String, targetId: String?, insertAfter: Boolean?, - dropOnLane: Boolean + dropOnLane: Boolean, ): Boolean { - val laneIds = state.items.map { it.id }.toMutableList() + val laneIds = + state.items + .map { it.id } + .toMutableList() val sourceIndex = laneIds.indexOf(draggedId) if (sourceIndex < 0) return true val removed = laneIds.removeAt(sourceIndex) val targetIndex = targetId?.let { laneIds.indexOf(it) } ?: -1 - val destinationIndex = when { - dropOnLane || targetId == null -> laneIds.size - targetIndex < 0 -> laneIds.size - insertAfter == true -> (targetIndex + 1).coerceAtMost(laneIds.size) - else -> targetIndex.coerceIn(0, laneIds.size) - } + val destinationIndex = + when { + dropOnLane || targetId == null -> laneIds.size + targetIndex < 0 -> laneIds.size + insertAfter == true -> (targetIndex + 1).coerceAtMost(laneIds.size) + else -> targetIndex.coerceIn(0, laneIds.size) + } laneIds.add(destinationIndex, removed) return laneIds != state.items.map { it.id } } @@ -140,26 +150,28 @@ fun UiScope.dragNDropSection( draggedId: String, targetId: String?, insertAfter: Boolean?, - dropOnLane: Boolean + dropOnLane: Boolean, ): Boolean { val extracted = extractCard(draggedId) ?: return false val card = extracted.first val lane = extracted.second.toMutableList() val boxes = extracted.third val targetIndex = targetId?.let { id -> lane.indexOfFirst { it.id == id } } ?: -1 - val insertIndex = when { - dropOnLane || targetId == null || targetIndex < 0 -> lane.size - insertAfter == true -> (targetIndex + 1).coerceAtMost(lane.size) - else -> targetIndex.coerceIn(0, lane.size) - } + val insertIndex = + when { + dropOnLane || targetId == null || targetIndex < 0 -> lane.size + insertAfter == true -> (targetIndex + 1).coerceAtMost(lane.size) + else -> targetIndex.coerceIn(0, lane.size) + } lane.add(insertIndex, card) if (!dropOnLane && !wouldLaneReorderChange(draggedId, targetId, insertAfter, dropOnLane)) { return false } - state = state.copy( - items = lane, - boxes = boxes.mapValuesTo(linkedMapOf()) { (_, value) -> value.toList() } - ) + state = + state.copy( + items = lane, + boxes = boxes.mapValuesTo(linkedMapOf()) { (_, value) -> value.toList() }, + ) return true } @@ -169,25 +181,28 @@ fun UiScope.dragNDropSection( val boxes = extracted.third val target = boxes.getOrPut(boxId) { mutableListOf() } target.add(extracted.first) - state = state.copy( - items = lane, - boxes = boxes.mapValuesTo(linkedMapOf()) { (_, value) -> value.toList() } - ) + state = + state.copy( + items = lane, + boxes = boxes.mapValuesTo(linkedMapOf()) { (_, value) -> value.toList() }, + ) return true } fun handleDndStart(item: DndDemoItem, event: DragStartEvent) { val sourceBounds = event.target?.bounds - val offsetX = if (sourceBounds != null) { - (event.mouseX - sourceBounds.x).coerceIn(0, sourceBounds.width.coerceAtLeast(1)) - } else { - 0 - } - val offsetY = if (sourceBounds != null) { - (event.mouseY - sourceBounds.y).coerceIn(0, sourceBounds.height.coerceAtLeast(1)) - } else { - 0 - } + val offsetX = + if (sourceBounds != null) { + (event.mouseX - sourceBounds.x).coerceIn(0, sourceBounds.width.coerceAtLeast(1)) + } else { + 0 + } + val offsetY = + if (sourceBounds != null) { + (event.mouseY - sourceBounds.y).coerceIn(0, sourceBounds.height.coerceAtLeast(1)) + } else { + 0 + } event.dataTransfer.setData("text/plain", item.label) event.dataTransfer.setData("application/x-dsgl-item-id", item.id) event.dataTransfer.effectAllowed = EffectAllowed.COPY_MOVE @@ -195,33 +210,54 @@ fun UiScope.dragNDropSection( if (!state.ghostEnabled) { event.dataTransfer.hideGhost() } - val sourceKey = event.target?.key?.toString() + val sourceKey = + event.target + ?.key + ?.toString() if (!sourceKey.isNullOrBlank()) { event.dataTransfer.setDragImage(sourceKey, offsetX, offsetY) } clearLaneReorderHover() - state = state.copy( - activeItem = item.label, - transferTypes = event.dataTransfer.types.sorted().joinToString(",").ifBlank { "-" }, - dropEffect = event.dataTransfer.dropEffect.name.lowercase(), - lastAction = "dragstart ${item.label}", - debugOverId = "none", - debugOverContainerId = "none", - debugCandidatesCount = 0, - debugInsertPosition = "none", - debugExcludesActiveCard = true - ) - val mode = event.target?.dragPreviewMode?.name?.lowercase() ?: "unknown" + state = + state.copy( + activeItem = item.label, + transferTypes = + event.dataTransfer.types + .sorted() + .joinToString(",") + .ifBlank { "-" }, + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + lastAction = "dragstart ${item.label}", + debugOverId = "none", + debugOverContainerId = "none", + debugCandidatesCount = 0, + debugInsertPosition = "none", + debugExcludesActiveCard = true, + ) + val mode = + event.target + ?.dragPreviewMode + ?.name + ?.lowercase() ?: "unknown" logHook("dnd.onDragStart", event, "item=${item.id} mode=$mode") } fun handleDndDrag(event: DragEvent) { val tick = state.dragTickCount + 1 - state = state.copy( - dragTickCount = tick, - transferTypes = event.dataTransfer.types.sorted().joinToString(",").ifBlank { "-" }, - dropEffect = event.dataTransfer.dropEffect.name.lowercase() - ) + state = + state.copy( + dragTickCount = tick, + transferTypes = + event.dataTransfer.types + .sorted() + .joinToString(",") + .ifBlank { "-" }, + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) if (tick % 5 == 0) { logHook("dnd.onDrag", event, "tick=$tick") } @@ -234,51 +270,49 @@ fun UiScope.dragNDropSection( val id = extractCardIdFromDragKey(child.key) ?: return@mapNotNull null if (excludedCardId != null && id == excludedCardId) return@mapNotNull null id to child - } - .sortedBy { (_, node) -> node.bounds.y } + }.sortedBy { (_, node) -> node.bounds.y } } - fun resolveLaneIntentFromMouse( - laneNode: DOMNode?, - mouseY: Int, - excludedCardId: String? - ): LaneHoverIntent { + fun resolveLaneIntentFromMouse(laneNode: DOMNode?, mouseY: Int, excludedCardId: String?): LaneHoverIntent { val cards = laneCards(laneNode, excludedCardId) if (cards.isEmpty()) return LaneHoverIntent(targetId = null, insertAfter = false, append = true) val lastCard = cards.last().second if (mouseY >= lastCard.bounds.y + lastCard.bounds.height + 4) { return LaneHoverIntent(targetId = null, insertAfter = false, append = true) } - val target = cards.minByOrNull { (_, node) -> - kotlin.math.abs(mouseY - (node.bounds.y + (node.bounds.height / 2))) - } ?: return LaneHoverIntent(targetId = null, insertAfter = false, append = true) + val target = + cards.minByOrNull { (_, node) -> + kotlin.math.abs(mouseY - (node.bounds.y + (node.bounds.height / 2))) + } ?: return LaneHoverIntent(targetId = null, insertAfter = false, append = true) val splitY = target.second.bounds.y + (target.second.bounds.height / 2) return LaneHoverIntent(targetId = target.first, insertAfter = mouseY >= splitY, append = false) } - fun laneCandidateCount(laneNode: DOMNode?, excludedCardId: String?): Int { - return laneCards(laneNode, excludedCardId).size - } + fun laneCandidateCount(laneNode: DOMNode?, excludedCardId: String?): Int = laneCards(laneNode, excludedCardId).size fun handleDndLaneOver(event: DragOverEvent) { val laneNode = event.target val draggedId = event.dataTransfer.getData("application/x-dsgl-item-id") val intent = resolveLaneIntentFromMouse(laneNode, event.mouseY, draggedId) - state = state.copy( - reorderHoverLaneAppend = intent.append, - reorderHoverTargetId = intent.targetId, - reorderHoverInsertAfter = intent.insertAfter, - debugOverContainerId = "lane", - debugOverId = intent.targetId ?: "append", - debugCandidatesCount = laneCandidateCount(laneNode, draggedId), - debugInsertPosition = when { - intent.append -> "append" - intent.insertAfter -> "after" - else -> "before" - }, - debugExcludesActiveCard = true, - dropEffect = event.dataTransfer.dropEffect.name.lowercase() - ) + state = + state.copy( + reorderHoverLaneAppend = intent.append, + reorderHoverTargetId = intent.targetId, + reorderHoverInsertAfter = intent.insertAfter, + debugOverContainerId = "lane", + debugOverId = intent.targetId ?: "append", + debugCandidatesCount = laneCandidateCount(laneNode, draggedId), + debugInsertPosition = + when { + intent.append -> "append" + intent.insertAfter -> "after" + else -> "before" + }, + debugExcludesActiveCard = true, + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) event.acceptDrop(DropEffect.MOVE) } @@ -286,30 +320,40 @@ fun UiScope.dragNDropSection( val draggedId = event.dataTransfer.getData("application/x-dsgl-item-id") ?: return val laneNode = event.target val intent = resolveLaneIntentFromMouse(laneNode, event.mouseY, draggedId) - val moved = commitLaneReorderDrop( - draggedId = draggedId, - targetId = intent.targetId, - insertAfter = if (intent.append) null else intent.insertAfter, - dropOnLane = intent.append - ) + val moved = + commitLaneReorderDrop( + draggedId = draggedId, + targetId = intent.targetId, + insertAfter = if (intent.append) null else intent.insertAfter, + dropOnLane = intent.append, + ) if (moved) { onInfo( "Lane drop: drag=$draggedId target=${intent.targetId ?: "lane"} pos=${ - if (intent.append) "append" else if (intent.insertAfter) "after" else "before" - }" + if (intent.append) { + "append" + } else if (intent.insertAfter) { + "after" + } else { + "before" + } + }", ) } clearLaneReorderHover() - state = state.copy( - dropEffect = event.dataTransfer.dropEffect.name.lowercase(), - debugOverId = "none", - debugOverContainerId = "none", - debugInsertPosition = "none" - ) + state = + state.copy( + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + debugOverId = "none", + debugOverContainerId = "none", + debugInsertPosition = "none", + ) logHook( "dnd.reorder.lane.onDrop", event, - "dragged=$draggedId target=${intent.targetId ?: "lane"} append=${intent.append}" + "dragged=$draggedId target=${intent.targetId ?: "lane"} append=${intent.append}", ) } @@ -317,17 +361,20 @@ fun UiScope.dragNDropSection( val draggedId = event.dataTransfer.getData("application/x-dsgl-item-id") if (draggedId != null && draggedId == targetCardId) return val laneNode = event.target?.parent - state = state.copy( - reorderHoverTargetId = targetCardId, - reorderHoverInsertAfter = insertAfter, - reorderHoverLaneAppend = false, - debugOverContainerId = "lane", - debugOverId = targetCardId, - debugCandidatesCount = laneCandidateCount(laneNode, draggedId), - debugInsertPosition = if (insertAfter) "after" else "before", - debugExcludesActiveCard = true, - dropEffect = event.dataTransfer.dropEffect.name.lowercase() - ) + state = + state.copy( + reorderHoverTargetId = targetCardId, + reorderHoverInsertAfter = insertAfter, + reorderHoverLaneAppend = false, + debugOverContainerId = "lane", + debugOverId = targetCardId, + debugCandidatesCount = laneCandidateCount(laneNode, draggedId), + debugInsertPosition = if (insertAfter) "after" else "before", + debugExcludesActiveCard = true, + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) event.acceptDrop(DropEffect.MOVE) event.cancelled = true } @@ -340,31 +387,37 @@ fun UiScope.dragNDropSection( onInfo("Card drop: drag=$draggedId target=$targetCardId pos=${if (insertAfter) "after" else "before"}") } clearLaneReorderHover() - state = state.copy( - debugOverId = "none", - debugOverContainerId = "none", - debugInsertPosition = "none", - dropEffect = event.dataTransfer.dropEffect.name.lowercase() - ) + state = + state.copy( + debugOverId = "none", + debugOverContainerId = "none", + debugInsertPosition = "none", + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) event.cancelled = true logHook( "dnd.reorder.card.onDrop", event, - "dragged=$draggedId target=$targetCardId pos=${if (insertAfter) "after" else "before"}" + "dragged=$draggedId target=$targetCardId pos=${if (insertAfter) "after" else "before"}", ) } fun handleDndBoxOver(boxId: String, event: DragOverEvent) { clearLaneReorderHover() - state = state.copy( - hoverZone = boxId, - debugOverId = boxId, - debugOverContainerId = "box:$boxId", - debugInsertPosition = "drop", - debugCandidatesCount = 1, - debugExcludesActiveCard = true, - dropEffect = event.dataTransfer.dropEffect.name.lowercase() - ) + state = + state.copy( + hoverZone = boxId, + debugOverId = boxId, + debugOverContainerId = "box:$boxId", + debugInsertPosition = "drop", + debugCandidatesCount = 1, + debugExcludesActiveCard = true, + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) event.acceptDrop(DropEffect.MOVE) } @@ -374,22 +427,30 @@ fun UiScope.dragNDropSection( if (moved) { state = state.copy(hoverZone = boxId, lastAction = "moved $draggedId to $boxId") } - state = state.copy(dropEffect = event.dataTransfer.dropEffect.name.lowercase()) + state = + state.copy( + dropEffect = + event.dataTransfer.dropEffect.name + .lowercase(), + ) logHook("dnd.$boxId.onDrop", event, "dragged=$draggedId") } fun handleDndEnd(event: DragEndEvent) { clearLaneReorderHover() - state = state.copy( - hoverZone = "none", - dropEffect = event.finalDropEffect.name.lowercase(), - lastAction = "dragend drop=${event.didDrop} effect=${event.finalDropEffect.name.lowercase()}", - activeItem = "none", - debugOverId = "none", - debugOverContainerId = "none", - debugCandidatesCount = 0, - debugInsertPosition = "none" - ) + state = + state.copy( + hoverZone = "none", + dropEffect = + event.finalDropEffect.name + .lowercase(), + lastAction = "dragend drop=${event.didDrop} effect=${event.finalDropEffect.name.lowercase()}", + activeItem = "none", + debugOverId = "none", + debugOverContainerId = "none", + debugCandidatesCount = 0, + debugInsertPosition = "none", + ) logHook("dnd.onDragEnd", event, "drop=${event.didDrop}") } @@ -397,12 +458,13 @@ fun UiScope.dragNDropSection( if (state.reorderHoverLaneAppend) return DndLaneIndicator.NONE if (state.reorderHoverTargetId != cardId) return DndLaneIndicator.NONE val draggedId = extractCardIdFromDragKey(sourceKey) ?: return DndLaneIndicator.NONE - val wouldChange = wouldLaneReorderChange( - draggedId = draggedId, - targetId = cardId, - insertAfter = state.reorderHoverInsertAfter, - dropOnLane = false - ) + val wouldChange = + wouldLaneReorderChange( + draggedId = draggedId, + targetId = cardId, + insertAfter = state.reorderHoverInsertAfter, + dropOnLane = false, + ) if (!wouldChange) return DndLaneIndicator.NONE return if (state.reorderHoverInsertAfter) DndLaneIndicator.AFTER else DndLaneIndicator.BEFORE } @@ -423,32 +485,38 @@ fun UiScope.dragNDropSection( useDragDropMonitor( DragDropMonitorCallbacks( onDragMove = { active, over -> - state = state.copy( - activeItem = active.id ?: active.sourceKey?.toString() ?: "none", - debugOverContainerId = if (over == null) "none" else "target", - debugOverId = over?.toString() ?: "none" - ) + state = + state.copy( + activeItem = active.id ?: active.sourceKey?.toString() ?: "none", + debugOverContainerId = if (over == null) "none" else "target", + debugOverId = over?.toString() ?: "none", + ) }, onDragOver = { active, over -> - state = state.copy( - dropEffect = active.dropEffect.name.lowercase(), - debugOverId = over?.toString() ?: "none" - ) + state = + state.copy( + dropEffect = + active.dropEffect.name + .lowercase(), + debugOverId = over?.toString() ?: "none", + ) }, onDragEnd = { _, _, effect -> - state = state.copy( - dropEffect = effect.name.lowercase(), - debugOverId = "none", - debugOverContainerId = "none" - ) + state = + state.copy( + dropEffect = effect.name.lowercase(), + debugOverId = "none", + debugOverContainerId = "none", + ) }, onDragCancel = { - state = state.copy( - debugOverId = "none", - debugOverContainerId = "none" - ) - } - ) + state = + state.copy( + debugOverId = "none", + debugOverContainerId = "none", + ) + }, + ), ) val monitor = DndSystem.monitor() @@ -461,21 +529,24 @@ fun UiScope.dragNDropSection( flexDirection = FlexDirection.Column } }) { - text("Drag preview modes: ORIGINAL (detached source) and GHOST (overlay preview).") + text("Drag preview modes: ORIGINAL (detached source) and GHOST (portal preview).") text( - "active=${state.activeItem} mode=${monitor.mode?.name ?: "none"} effect=${state.dropEffect} hover=${state.hoverZone}", - { style = { color = DEMO_MUTED } } + "active=${state.activeItem} mode=${monitor.mode?.name ?: "none"} " + + "effect=${state.dropEffect} hover=${state.hoverZone}", + { style = { color = DEMO_MUTED } }, ) text("types=${state.transferTypes} dragTicks=${state.dragTickCount} action=${state.lastAction}", { style = { color = DEMO_MUTED } }) text( - "debug active=${monitor.sourceKey ?: "none"} over=${state.debugOverId} container=${state.debugOverContainerId}", - { style = { color = DEMO_MUTED } } + "debug active=${monitor.sourceKey ?: "none"} over=${state.debugOverId} " + + "container=${state.debugOverContainerId}", + { style = { color = DEMO_MUTED } }, ) text( - "candidates=${state.debugCandidatesCount} insert=${state.debugInsertPosition} excludeActive=${state.debugExcludesActiveCard}", - { style = { color = DEMO_MUTED } } + "candidates=${state.debugCandidatesCount} insert=${state.debugInsertPosition} " + + "excludeActive=${state.debugExcludesActiveCard}", + { style = { color = DEMO_MUTED } }, ) div({ style = { @@ -535,7 +606,7 @@ fun UiScope.dragNDropSection( onCardOver = ::handleDndCardReorderOver, onCardDrop = ::handleDndCardReorderDrop, laneIndicatorForCard = ::laneIndicatorForCard, - shouldShowLaneAppendGap = ::shouldShowLaneAppendGap + shouldShowLaneAppendGap = ::shouldShowLaneAppendGap, ) renderGhostModeBoxes( state = state, @@ -546,7 +617,7 @@ fun UiScope.dragNDropSection( onBoxDrop = ::handleDndBoxDrop, onHoverZone = { boxId -> state = state.copy(hoverZone = boxId) }, onLogHook = ::logHook, - onReset = { resetDndItems("button") } + onReset = { resetDndItems("button") }, ) } } @@ -564,7 +635,7 @@ private fun UiScope.originalModeReorder( onCardOver: (String, Boolean, DragOverEvent) -> Unit, onCardDrop: (String, Boolean, DropEvent) -> Unit, laneIndicatorForCard: (String, Any?) -> DndLaneIndicator, - shouldShowLaneAppendGap: (Any?) -> Boolean + shouldShowLaneAppendGap: (Any?) -> Boolean, ) { val draggedId = extractCardIdFromDragKey(sourceKey) @@ -575,7 +646,10 @@ private fun UiScope.originalModeReorder( gap = 3.px padding = 3.px backgroundColor = 0xFF2D333B.toInt() - border { width = 1.px; color = 0xFF6B7785.toInt() } + border { + width = 1.px + color = 0xFF6B7785.toInt() + } display = Display.Flex flexDirection = FlexDirection.Column flexGrow = 1.0f @@ -583,14 +657,15 @@ private fun UiScope.originalModeReorder( }) { text("ORIGINAL mode: reorder list") text("Detached source follows cursor; slot uses placeholder.", { style = { color = DEMO_MUTED } }) - val laneDroppable = useDroppable( - id = "lane", - nodeKey = "dnd.lane.column", - accepts = { active -> !active.id.isNullOrBlank() }, - onDragOver = { event, _ -> onLaneOver(event) }, - onDragLeave = { _, _ -> onLaneLeave() }, - onDrop = { event, _ -> onLaneDrop(event) } - ) + val laneDroppable = + useDroppable( + id = "lane", + nodeKey = "dnd.lane.column", + accepts = { active -> !active.id.isNullOrBlank() }, + onDragOver = { event, _ -> onLaneOver(event) }, + onDragLeave = { _, _ -> onLaneLeave() }, + onDrop = { event, _ -> onLaneDrop(event) }, + ) div({ style = { display = Display.Flex @@ -605,7 +680,10 @@ private fun UiScope.originalModeReorder( style = { gap = 6.px backgroundColor = if (state.reorderHoverLaneAppend) 0x2A9EC4E3 else 0x00000000 - border { width = 1.px; color = if (state.reorderHoverLaneAppend) 0xFF9EC4E3.toInt() else 0x44405058 } + border { + width = 1.px + color = if (state.reorderHoverLaneAppend) 0xFF9EC4E3.toInt() else 0x44405058 + } display = Display.Flex flexDirection = FlexDirection.Column } @@ -618,15 +696,16 @@ private fun UiScope.originalModeReorder( state.items.forEach { item -> val indicator = laneIndicatorForCard(item.id, sourceKey) val isDraggedItem = draggedId != null && draggedId == item.id - val sortable = useSortable( - id = item.id, - nodeKey = "dnd.lane.card.${item.id}", - containerId = "lane", - items = state.items.map { it.id }, - data = item, - previewMode = DragPreviewMode.ORIGINAL, - hideSourceWhileDragging = true - ) + val sortable = + useSortable( + id = item.id, + nodeKey = "dnd.lane.card.${item.id}", + containerId = "lane", + items = state.items.map { it.id }, + data = item, + previewMode = DragPreviewMode.ORIGINAL, + hideSourceWhileDragging = true, + ) cardWithItem( item = item, @@ -635,17 +714,28 @@ private fun UiScope.originalModeReorder( draggableEnabled = !isDraggedItem, highlighted = indicator != DndLaneIndicator.NONE, insertionIndicator = indicator, - extraListeners = DndListeners( - onDragStart = { event -> onStart(item, event) }, - onDrag = { event -> onDrag(event) }, - onDragEnd = { event -> onEnd(event) }, - onDragOver = if (isDraggedItem) null else { event -> - onCardOver(item.id, resolveInsertAfter(event), event) - }, - onDrop = if (isDraggedItem) null else { event -> - onCardDrop(item.id, resolveInsertAfter(event), event) - } - ) + extraListeners = + DndListeners( + onDragStart = { event -> onStart(item, event) }, + onDrag = { event -> onDrag(event) }, + onDragEnd = { event -> onEnd(event) }, + onDragOver = + if (isDraggedItem) { + null + } else { + { event -> + onCardOver(item.id, resolveInsertAfter(event), event) + } + }, + onDrop = + if (isDraggedItem) { + null + } else { + { event -> + onCardDrop(item.id, resolveInsertAfter(event), event) + } + }, + ), ) } @@ -654,7 +744,10 @@ private fun UiScope.originalModeReorder( key = "dnd.lane.append.gap" style = { backgroundColor = 0x2A9EC4E3 - border { width = 1.px; color = 0xFF9EC4E3.toInt() } + border { + width = 1.px + color = 0xFF9EC4E3.toInt() + } borderRadius = 3.px display = Display.Flex flexDirection = FlexDirection.Column @@ -678,7 +771,7 @@ private fun UiScope.renderGhostModeBoxes( onBoxDrop: (String, DropEvent) -> Unit, onHoverZone: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit, - onReset: () -> Unit + onReset: () -> Unit, ) { div({ key = "dnd.ghost.panel" @@ -688,7 +781,10 @@ private fun UiScope.renderGhostModeBoxes( gap = 4.px padding = 3.px backgroundColor = 0xFF2D333B.toInt() - border { width = 1.px; color = 0xFF6B7785.toInt() } + border { + width = 1.px + color = 0xFF6B7785.toInt() + } display = Display.Flex flexDirection = FlexDirection.Column } @@ -709,7 +805,7 @@ private fun UiScope.renderGhostModeBoxes( onBoxOver = onBoxOver, onBoxDrop = onBoxDrop, onHoverZone = onHoverZone, - onLogHook = onLogHook + onLogHook = onLogHook, ) dropBox( state = state, @@ -724,7 +820,7 @@ private fun UiScope.renderGhostModeBoxes( onBoxOver = onBoxOver, onBoxDrop = onBoxDrop, onHoverZone = onHoverZone, - onLogHook = onLogHook + onLogHook = onLogHook, ) dropBox( state = state, @@ -739,7 +835,7 @@ private fun UiScope.renderGhostModeBoxes( onBoxOver = onBoxOver, onBoxDrop = onBoxDrop, onHoverZone = onHoverZone, - onLogHook = onLogHook + onLogHook = onLogHook, ) button("Reset DnD", { onMouseClick = { onReset() } }) @@ -754,7 +850,7 @@ private fun UiScope.cardWithItem( draggableEnabled: Boolean = true, highlighted: Boolean, insertionIndicator: DndLaneIndicator = DndLaneIndicator.NONE, - extraListeners: DndListeners = DndListeners() + extraListeners: DndListeners = DndListeners(), ) { val draggingThis = sortable?.isDragging ?: draggable?.isDragging ?: DndSystem.monitor(cardKey).isDragging val accent = itemAccentColor(item.id) @@ -773,16 +869,32 @@ private fun UiScope.cardWithItem( alignItems = AlignItems.Center padding = 2.px gap = 1.px - backgroundColor = when { - draggingThis -> lighten(base, HIGHLIGHT_DELTA + 8) - highlighted -> lighten(base, HIGHLIGHT_DELTA) - else -> base + backgroundColor = + when { + draggingThis -> lighten(base, HIGHLIGHT_DELTA + 8) + highlighted -> lighten(base, HIGHLIGHT_DELTA) + else -> base + } + border { + width = 1.px + color = accent } - border { width = 1.px; color = accent } borderRadius = 3.px when (insertionIndicator) { - DndLaneIndicator.BEFORE -> margin { top = insertionGap.px; right = 0.px; bottom = 0.px; left = 0.px } - DndLaneIndicator.AFTER -> margin { top = 0.px; right = 0.px; bottom = insertionGap.px; left = 0.px } + DndLaneIndicator.BEFORE -> + margin { + top = insertionGap.px + right = 0.px + bottom = 0.px + left = 0.px + } + DndLaneIndicator.AFTER -> + margin { + top = 0.px + right = 0.px + bottom = insertionGap.px + left = 0.px + } DndLaneIndicator.NONE -> Unit } } @@ -806,7 +918,10 @@ private fun UiScope.cardWithItem( itemStack(item.stack, { key = "dnd.stack.${item.id}" style = { - border { width = 1.px; color = 0x553A4452 } + border { + width = 1.px + color = 0x553A4452 + } backgroundColor = 0x2219222B } }) @@ -829,33 +944,37 @@ private fun UiScope.dropBox( onBoxOver: (String, DragOverEvent) -> Unit, onBoxDrop: (String, DropEvent) -> Unit, onHoverZone: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit + onLogHook: (String, Event, String?) -> Unit, ) { val highlighted = state.hoverZone == boxId - val dropDescriptor = useDroppable( - id = boxId, - nodeKey = boxKey, - accepts = { active -> !active.id.isNullOrBlank() }, - onDragEnter = { event, _ -> - onHoverZone(boxId) - onLogHook("dnd.$boxId.onDragEnter", event, null) - }, - onDragOver = { event, _ -> onBoxOver(boxId, event) }, - onDragLeave = { event, _ -> - if (highlighted) { - onHoverZone("none") - } - onLogHook("dnd.$boxId.onDragLeave", event, null) - }, - onDrop = { event, _ -> onBoxDrop(boxId, event) } - ) + val dropDescriptor = + useDroppable( + id = boxId, + nodeKey = boxKey, + accepts = { active -> !active.id.isNullOrBlank() }, + onDragEnter = { event, _ -> + onHoverZone(boxId) + onLogHook("dnd.$boxId.onDragEnter", event, null) + }, + onDragOver = { event, _ -> onBoxOver(boxId, event) }, + onDragLeave = { event, _ -> + if (highlighted) { + onHoverZone("none") + } + onLogHook("dnd.$boxId.onDragLeave", event, null) + }, + onDrop = { event, _ -> onBoxDrop(boxId, event) }, + ) div({ key = boxKey style = { padding = 4.px gap = 2.px backgroundColor = if (highlighted) lighten(color, HIGHLIGHT_DELTA) else color - border { width = 1.px; this.color = 0xFF8A94A2.toInt() } + border { + width = 1.px + this.color = 0xFF8A94A2.toInt() + } borderRadius = 3.px } applyDroppable(dropDescriptor) @@ -873,22 +992,23 @@ private fun UiScope.dropBox( } }) { cards.take(5).forEach { item -> - val draggable = useDraggable( - id = item.id, - nodeKey = "dnd.box.$boxId.card.${item.id}", - type = "card", - data = item, - previewMode = DragPreviewMode.GHOST, - hideSourceWhileDragging = state.hideSourceWhileDragging, - onDragStart = { event -> onStart(item, event) }, - onDrag = { event -> onDrag(event) }, - onDragEnd = { event -> onEnd(event) } - ) + val draggable = + useDraggable( + id = item.id, + nodeKey = "dnd.box.$boxId.card.${item.id}", + type = "card", + data = item, + previewMode = DragPreviewMode.GHOST, + hideSourceWhileDragging = state.hideSourceWhileDragging, + onDragStart = { event -> onStart(item, event) }, + onDrag = { event -> onDrag(event) }, + onDragEnd = { event -> onEnd(event) }, + ) cardWithItem( item = item, cardKey = "dnd.box.$boxId.card.${item.id}", draggable = draggable, - highlighted = false + highlighted = false, ) } if (cards.size > 5) { @@ -907,25 +1027,23 @@ private fun extractCardIdFromDragKey(sourceKey: Any?): String? { return key.substring(markerIndex + marker.length).takeIf { it.isNotBlank() } } -private fun itemBaseColor(itemId: String): Int { - return when (itemId) { +private fun itemBaseColor(itemId: String): Int = + when (itemId) { "apple" -> 0xFF355841.toInt() "bread" -> 0xFF68543A.toInt() "carrot" -> 0xFF6A4A2B.toInt() "diamond" -> 0xFF315A70.toInt() else -> 0xFF3C4B5A.toInt() } -} -private fun itemAccentColor(itemId: String): Int { - return when (itemId) { +private fun itemAccentColor(itemId: String): Int = + when (itemId) { "apple" -> 0xFF7BCEA0.toInt() "bread" -> 0xFFE7BE79.toInt() "carrot" -> 0xFFFFB46E.toInt() "diamond" -> 0xFF8ED2FF.toInt() else -> 0xFF9BB0C4.toInt() } -} private fun lighten(color: Int, delta: Int): Int { val a = (color ushr 24) and 0xFF @@ -947,25 +1065,26 @@ private fun resolveInsertAfter(event: DropEvent): Boolean { return event.mouseY >= splitY } -private fun defaultDndItems(): List = listOf( - DndDemoItem( - id = "apple", - label = "Apple", - stack = McItemStackRef(ItemStack(Items.apple, 1, 0)) - ), - DndDemoItem( - id = "bread", - label = "Bread", - stack = McItemStackRef(ItemStack(Items.bread, 1, 0)) - ), - DndDemoItem( - id = "carrot", - label = "Carrot", - stack = McItemStackRef(ItemStack(Items.carrot, 1, 0)) - ), - DndDemoItem( - id = "diamond", - label = "Diamond", - stack = McItemStackRef(ItemStack(Items.diamond, 1, 0)) +private fun defaultDndItems(): List = + listOf( + DndDemoItem( + id = "apple", + label = "Apple", + stack = McItemStackRef(ItemStack(Items.apple, 1, 0)), + ), + DndDemoItem( + id = "bread", + label = "Bread", + stack = McItemStackRef(ItemStack(Items.bread, 1, 0)), + ), + DndDemoItem( + id = "carrot", + label = "Carrot", + stack = McItemStackRef(ItemStack(Items.carrot, 1, 0)), + ), + DndDemoItem( + id = "diamond", + label = "Diamond", + stack = McItemStackRef(ItemStack(Items.diamond, 1, 0)), + ), ) -) diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/FocusRebuildSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/FocusRebuildSection.kt index 7ec04de..1ed63ba 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/FocusRebuildSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/FocusRebuildSection.kt @@ -1,22 +1,22 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.KeyInput import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED fun UiScope.focusRebuildSection( renderPasses: Int, onManualInvalidate: (String) -> Unit, onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit + onLogHook: (String, Event, String?) -> Unit, ) { var focusStableValue by useState("") var focusUnstableValue by useState("") @@ -39,8 +39,7 @@ fun UiScope.focusRebuildSection( display = Display.Flex flexDirection = FlexDirection.Column } - } - ) { + }) { text("Stable key focus test: focus first field, press Enter to rebuild, keep typing.") text("Unstable key field changes key version and demonstrates focus/key instability.", { style = { color = DEMO_MUTED } @@ -48,17 +47,17 @@ fun UiScope.focusRebuildSection( text( "renderPasses=$renderPasses autoState=$autoRebuildCounter manualInvalidates=$manualInvalidateCount", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) text( "stableEnterRebuilds=$focusStableEnterRebuilds unstableKeyVersion=$focusKeyVersion", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) input( InputType.Text( value = focusStableValue, - placeholder = "Stable key input (press Enter to rebuild)" + placeholder = "Stable key input (press Enter to rebuild)", ), { key = "focus.stable.input" @@ -76,13 +75,13 @@ fun UiScope.focusRebuildSection( onKeyUp = { event -> onLogHook("focus.stable.onKeyUp", event, null) } - } + }, ) input( InputType.Text( value = focusUnstableValue, - placeholder = "Unstable key input" + placeholder = "Unstable key input", ), { key = "focus.unstable.input.$focusKeyVersion" @@ -91,7 +90,7 @@ fun UiScope.focusRebuildSection( focusUnstableValue = applyTextMutation(focusUnstableValue, event, maxLength = 28) onLogHook("focus.unstable.onKeyDown", event, null) } - } + }, ) div({ @@ -131,7 +130,7 @@ fun UiScope.focusRebuildSection( }) text( "lastManualReason=$lastManualReason", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -141,7 +140,7 @@ private fun applyTextMutation( current: String, event: KeyboardKeyDownEvent, allowedChars: String? = null, - maxLength: Int? = null + maxLength: Int? = null, ): String { if (event.keyCode == KeyCodes.BACKSPACE) { if (current.isEmpty()) return current @@ -156,4 +155,3 @@ private fun applyTextMutation( if (maxLength != null && next.length > maxLength) return current return next } - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/HooksSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/HooksSection.kt index 71037ef..da0cbdf 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/HooksSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/HooksSection.kt @@ -5,15 +5,15 @@ import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.hooks.createContext import org.dreamfinity.dsgl.core.hooks.provideContext +import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle +import org.dreamfinity.dsgl.core.hooks.ref.RefTarget +import org.dreamfinity.dsgl.core.hooks.ref.useRef import org.dreamfinity.dsgl.core.hooks.useCallback import org.dreamfinity.dsgl.core.hooks.useContext import org.dreamfinity.dsgl.core.hooks.useEffect import org.dreamfinity.dsgl.core.hooks.useMemo import org.dreamfinity.dsgl.core.hooks.useReducer import org.dreamfinity.dsgl.core.hooks.useState -import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle -import org.dreamfinity.dsgl.core.hooks.ref.RefTarget -import org.dreamfinity.dsgl.core.hooks.ref.useRef import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.Overflow @@ -22,10 +22,7 @@ import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_SURFACE_ALT private val hooksThemeContext = createContext(defaultValue = "System", name = "HooksTheme") -fun UiScope.hooksSection( - onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit -) { +fun UiScope.hooksSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) { div({ key = "section.hooks" style = { @@ -49,10 +46,7 @@ fun UiScope.hooksSection( } } -private fun UiScope.overviewUseRef( - onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit -) { +private fun UiScope.overviewUseRef(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) { var refsInputValue by useState("Ref demo input") var refsRebuildCount by useState(0) var refsCallbackMounted by useState(true) @@ -80,7 +74,7 @@ private fun UiScope.overviewUseRef( input( InputType.Text( value = refsInputValue, - placeholder = "Focusable input with stable key" + placeholder = "Focusable input with stable key", ), { key = "refs.input.primary" @@ -89,7 +83,7 @@ private fun UiScope.overviewUseRef( onLogHook("refs.input.onInput", event, "value=${event.value}") } }, - ref = inputRef + ref = inputRef, ) div({ @@ -132,18 +126,19 @@ private fun UiScope.overviewUseRef( backgroundColor = 0xFF313844.toInt() } }, - ref = panelRef + ref = panelRef, ) { text("Bounds target panel", { style = { color = 0xFFE2EAFF.toInt() } }) } text({ val bounds = panelRef.current?.bounds - value = if (bounds == null) { - "panelRef.current: null" - } else { - "panelRef.bounds: x=${bounds.x} y=${bounds.y} w=${bounds.width} h=${bounds.height}" - } + value = + if (bounds == null) { + "panelRef.current: null" + } else { + "panelRef.bounds: x=${bounds.x} y=${bounds.y} w=${bounds.width} h=${bounds.height}" + } style = { color = DEMO_MUTED } }) @@ -156,7 +151,7 @@ private fun UiScope.overviewUseRef( padding = 4.px } }, - ref = refsCallbackRef + ref = refsCallbackRef, ) { text("Callback ref target", { style = { color = 0xFFC5E8C5.toInt() } }) } @@ -164,7 +159,7 @@ private fun UiScope.overviewUseRef( text( "callback attaches=$refsCallbackAttachCount detaches=$refsCallbackDetachCount last=$refsCallbackLast", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -193,12 +188,12 @@ private fun UiScope.overviewUseState() { input( InputType.Text( value = hooksStateLocalText, - placeholder = "Local useState text" + placeholder = "Local useState text", ), { key = "hooks.useState.localText" onInput = { event -> hooksStateLocalText = event.value } - } + }, ) text("mounted state: count=$hooksStateLocalCount text=$hooksStateLocalText", { style = { color = DEMO_MUTED } @@ -265,13 +260,14 @@ private fun UiScope.overviewUseCallback() { val hooksCallbackIdentityRef by useRef() val hooksCallbackIdentity = System.identityHashCode(hooksCallback as Any) val hooksCallbackPreviousIdentity = hooksCallbackIdentityRef.current - val hooksCallbackIdentityStatus = if (hooksCallbackPreviousIdentity == null) { - "first render" - } else if (hooksCallbackIdentity == hooksCallbackPreviousIdentity) { - "stable" - } else { - "changed" - } + val hooksCallbackIdentityStatus = + if (hooksCallbackPreviousIdentity == null) { + "first render" + } else if (hooksCallbackIdentity == hooksCallbackPreviousIdentity) { + "stable" + } else { + "changed" + } hooksCallbackIdentityRef.current = hooksCallbackIdentity hookCard("useCallback", "Function identity is stable until dependency changes") { div({ @@ -323,10 +319,11 @@ private fun UiScope.overviewUseReducer() { } if (hooksReducerMounted) { - val (hooksReducerCount, dispatchReducer) = useReducer( - initialState = 0, - reducer = { old: Int, action: Int -> old + action } - ) + val (hooksReducerCount, dispatchReducer) = + useReducer( + initialState = 0, + reducer = { old: Int, action: Int -> old + action }, + ) div({ style = { gap = 4.px @@ -412,6 +409,7 @@ private fun UiScope.overviewUseEffect() { var hooksEffectNoise by useState(0) var hooksEffectLogRevision by useState(0) val hooksEffectLogBuffer by useRef(mutableListOf()) + fun appendEffectLog(line: String) { val buffer = hooksEffectLogBuffer.current ?: return buffer += line @@ -475,11 +473,7 @@ private fun UiScope.overviewUseEffect() { } } -private fun UiScope.hookCard( - title: String, - subtitle: String, - content: UiScope.() -> Unit -) { +private fun UiScope.hookCard(title: String, subtitle: String, content: UiScope.() -> Unit) { div({ key = "hooks.card.$title" style = { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputEventsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputEventsSection.kt index d799a71..e7a87a9 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputEventsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputEventsSection.kt @@ -1,33 +1,35 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputOption import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.event.FocusGainEvent import org.dreamfinity.dsgl.core.event.FocusLoseEvent import org.dreamfinity.dsgl.core.event.InputEvent import org.dreamfinity.dsgl.core.event.ValueChangedEvent +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.Overflow -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_SURFACE_ALT import java.time.LocalTime import java.time.format.DateTimeFormatter -private val inputEventCheckboxOptions = listOf( - InputOption("alpha", "Alpha"), - InputOption("beta", "Beta"), - InputOption("gamma", "Gamma") -) +private val inputEventCheckboxOptions = + listOf( + InputOption("alpha", "Alpha"), + InputOption("beta", "Beta"), + InputOption("gamma", "Gamma"), + ) -private val inputEventRadioOptions = listOf( - InputOption("north", "North"), - InputOption("center", "Center"), - InputOption("south", "South") -) +private val inputEventRadioOptions = + listOf( + InputOption("north", "North"), + InputOption("center", "Center"), + InputOption("south", "South"), + ) private val inputEventTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") @@ -39,7 +41,12 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { var inputEventRangeValue by useState(35L) var inputEventLogEntries by useState(emptyList()) - fun appendInputEvent(control: String, phase: String, value: String, event: Event) { + fun appendInputEvent( + control: String, + phase: String, + value: String, + event: Event, + ) { val time = LocalTime.now().format(inputEventTimeFormatter) val line = "$time $control.$phase value=$value" inputEventLogEntries = (listOf(line) + inputEventLogEntries).take(8) @@ -62,7 +69,11 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { return emptySet() } - fun checkboxValueString(): String = inputEventCheckboxValue.toList().sorted().joinToString(",") + fun checkboxValueString(): String = + inputEventCheckboxValue + .toList() + .sorted() + .joinToString(",") div({ key = "section.inputEvents" @@ -75,7 +86,7 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { text("HTML-like events demo: onFocus/onBlur/onInput/onChange") text( "Proof case: type in text field, then click elsewhere -> onInput per key, onChange on blur.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -95,27 +106,24 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { } }) { text("Text input") - input( - InputType.Text(value = inputEventTextValue, placeholder = "Type then blur"), - { - key = "inputEvents.text" - style = { width = 100.percent } - onFocusGain = { event: FocusGainEvent -> - appendInputEvent("text", "focus", inputEventTextValue, event) - } - onFocusLose = { event: FocusLoseEvent -> - appendInputEvent("text", "blur", inputEventTextValue, event) - } - onInput = { event: InputEvent -> - inputEventTextValue = event.value - appendInputEvent("text", "input", event.value, event) - } - onValueChange = { event: ValueChangedEvent -> - inputEventTextValue = event.value - appendInputEvent("text", "change", event.value, event) - } + input(InputType.Text(value = inputEventTextValue, placeholder = "Type then blur"), { + key = "inputEvents.text" + style = { width = 100.percent } + onFocusGain = { event: FocusGainEvent -> + appendInputEvent("text", "focus", inputEventTextValue, event) } - ) + onFocusLose = { event: FocusLoseEvent -> + appendInputEvent("text", "blur", inputEventTextValue, event) + } + onInput = { event: InputEvent -> + inputEventTextValue = event.value + appendInputEvent("text", "input", event.value, event) + } + onValueChange = { event: ValueChangedEvent -> + inputEventTextValue = event.value + appendInputEvent("text", "change", event.value, event) + } + }) text("Textarea") textarea({ @@ -158,7 +166,7 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { variants = inputEventCheckboxOptions, selected = inputEventCheckboxValue, minSelected = 0, - maxSelected = 3 + maxSelected = 3, ), { key = "inputEvents.checkbox" @@ -177,14 +185,14 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { inputEventCheckboxValue = parseCheckboxSelection(event.parsedValue) appendInputEvent("checkbox", "change", event.value, event) } - } + }, ) text("Radio") input( InputType.Radio( variants = inputEventRadioOptions, - selected = inputEventRadioValue + selected = inputEventRadioValue, ), { key = "inputEvents.radio" @@ -203,7 +211,7 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { inputEventRadioValue = event.parsedValue as? String appendInputEvent("radio", "change", event.value, event) } - } + }, ) text("Range") @@ -212,7 +220,7 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { value = inputEventRangeValue, min = 0, max = 100, - step = 1 + step = 1, ), { key = "inputEvents.range" @@ -231,11 +239,11 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { inputEventRangeValue = (event.parsedValue as? Long) ?: inputEventRangeValue appendInputEvent("range", "change", event.value, event) } - } + }, ) text( "Range value=$inputEventRangeValue", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -252,7 +260,7 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { }) text( "Entries=${inputEventLogEntries.size}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } @@ -266,7 +274,10 @@ fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { overflowY = Overflow.Auto backgroundColor = DEMO_SURFACE_ALT padding = 3.px - border { width = 1.px; color = 0xFF6A7785.toInt() } + border { + width = 1.px + color = 0xFF6A7785.toInt() + } } }) { if (inputEventLogEntries.isEmpty()) { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputsGallerySection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputsGallerySection.kt index 553d33d..ce748b1 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputsGallerySection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InputsGallerySection.kt @@ -1,11 +1,11 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputOption import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.FocusManager import org.dreamfinity.dsgl.core.hooks.useState -import org.dreamfinity.dsgl.core.select.SelectRuntime +import org.dreamfinity.dsgl.core.portal.DomainPortalServices import org.dreamfinity.dsgl.core.select.SelectStyle import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection @@ -13,22 +13,21 @@ import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import java.time.Instant import java.time.ZoneId -private val inputsCheckboxOptions = listOf( - InputOption("alpha", "Alpha"), - InputOption("beta", "Beta"), - InputOption("gamma", "Gamma") -) +private val inputsCheckboxOptions = + listOf( + InputOption("alpha", "Alpha"), + InputOption("beta", "Beta"), + InputOption("gamma", "Gamma"), + ) -private val inputsRadioOptions = listOf( - InputOption("north", "North"), - InputOption("center", "Center"), - InputOption("south", "South") -) +private val inputsRadioOptions = + listOf( + InputOption("north", "North"), + InputOption("center", "Center"), + InputOption("south", "South"), + ) -fun UiScope.inputsGallerySection( - clippingScrollDemoText: String, - onClippingScrollDemoTextChange: (String) -> Unit -) { +fun UiScope.inputsGallerySection(clippingScrollDemoText: String, onClippingScrollDemoTextChange: (String) -> Unit) { var openedAt by useState(Instant.now()) var timeZoneId by useState(ZoneId.systemDefault()) var sharedRangeValue by useState(35L) @@ -58,9 +57,13 @@ fun UiScope.inputsGallerySection( return emptySet() } - fun checkboxValueString(): String = inputCheckboxValue.toList().sorted().joinToString(",") + fun checkboxValueString(): String = + inputCheckboxValue + .toList() + .sorted() + .joinToString(",") - SelectRuntime.engine.setStyle( + DomainPortalServices.applicationSelectEngine.setStyle( SelectStyle( panelBackgroundColor = 0xFF202A35.toInt(), panelBorderColor = 0xFF607286.toInt(), @@ -69,8 +72,8 @@ fun UiScope.inputsGallerySection( optionSelectedBackgroundColor = 0xFF2A4258.toInt(), groupTextColor = 0xFFB7C6D6.toInt(), openDurationMs = 120L, - closeDurationMs = 90L - ) + closeDurationMs = 90L, + ), ) div({ @@ -84,7 +87,7 @@ fun UiScope.inputsGallerySection( text("All InputType variants are interactive below.") text( "Validation examples: allowed chars, min/max, step, date format.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -109,12 +112,12 @@ fun UiScope.inputsGallerySection( value = "A1", placeholder = "hex", allowedChars = "0123456789ABCDEF", - maxLength = 8 + maxLength = 8, ), { key = "input.text" style = { width = 100.percent } - } + }, ) text("Password (max 12)") @@ -122,12 +125,12 @@ fun UiScope.inputsGallerySection( InputType.Password( value = "", placeholder = "secret", - maxLength = 12 + maxLength = 12, ), { key = "input.password" style = { width = 100.percent } - } + }, ) text("Number (10..20, wheel when focused)") @@ -136,12 +139,12 @@ fun UiScope.inputsGallerySection( value = 15, placeholder = "10..20", min = 10, - max = 20 + max = 20, ), { key = "input.number.basic" style = { width = 100.percent } - } + }, ) text("Number 0..100 wired with slider below") @@ -150,7 +153,7 @@ fun UiScope.inputsGallerySection( value = sharedRangeValue, placeholder = "0..100", min = 0, - max = 100 + max = 100, ), { key = "input.number.shared" @@ -161,7 +164,7 @@ fun UiScope.inputsGallerySection( onValueChange = { event -> sharedRangeValue = event.value.toLongOrNull() ?: sharedRangeValue } - } + }, ) text("Range (step 5, value=$sharedRangeValue)") @@ -170,7 +173,7 @@ fun UiScope.inputsGallerySection( value = sharedRangeValue, min = 0, max = 100, - step = 5 + step = 5, ), { key = "input.range" @@ -181,7 +184,7 @@ fun UiScope.inputsGallerySection( onValueChange = { event -> sharedRangeValue = event.value.toLongOrNull() ?: sharedRangeValue } - } + }, ) } @@ -200,7 +203,7 @@ fun UiScope.inputsGallerySection( variants = inputsCheckboxOptions, selected = inputCheckboxValue, minSelected = 1, - maxSelected = 2 + maxSelected = 2, ), { key = "input.checkbox" @@ -211,7 +214,7 @@ fun UiScope.inputsGallerySection( onValueChange = { event -> inputCheckboxValue = parseCheckboxSelection(event.parsedValue) } - } + }, ) text("Selected: ${checkboxValueString()}", { style = { color = DEMO_MUTED } }) @@ -219,7 +222,7 @@ fun UiScope.inputsGallerySection( input( InputType.Radio( variants = inputsRadioOptions, - selected = inputRadioValue + selected = inputRadioValue, ), { key = "input.radio" @@ -230,7 +233,7 @@ fun UiScope.inputsGallerySection( onValueChange = { event -> inputRadioValue = event.parsedValue as? String } - } + }, ) text("Selected: ${inputRadioValue ?: "-"}", { style = { color = DEMO_MUTED } }) @@ -264,12 +267,12 @@ fun UiScope.inputsGallerySection( input( InputType.Date( value = openedAt, - zoneId = timeZoneId + zoneId = timeZoneId, ), { key = "input.date" style = { width = 100.percent } - } + }, ) text("Opened: $openedAt", { style = { color = DEMO_MUTED } }) @@ -286,10 +289,10 @@ fun UiScope.inputsGallerySection( } }) - text("Select (overlay popup + keyboard + disabled options)") + text("Select (portal popup + keyboard + disabled options)") text( "Use Enter/Space/ArrowDown when focused. Esc closes popup. Wheel scrolls long list.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -374,14 +377,11 @@ fun UiScope.inputsGallerySection( option("alt", "Alternative") } - button( - if (selectDynamicAlt) "Use option set A" else "Use option set B", - { - onMouseClick = { - selectDynamicAlt = !selectDynamicAlt - } + button(if (selectDynamicAlt) "Use option set A" else "Use option set B", { + onMouseClick = { + selectDynamicAlt = !selectDynamicAlt } - ) + }) text("Dynamic options") select({ key = "input.select.dynamic" @@ -405,8 +405,9 @@ fun UiScope.inputsGallerySection( } } text( - "Select state: basic=${selectBasicValue ?: "-"} many=${selectManyValue ?: "-"} dynamic=${selectDynamicValue ?: "-"}", - { style = { color = DEMO_MUTED } } + "Select state: basic=${selectBasicValue ?: "-"} many=${selectManyValue ?: "-"} " + + "dynamic=${selectDynamicValue ?: "-"}", + { style = { color = DEMO_MUTED } }, ) text("Clipping + internal scrolling demo (100 lines prefilled)", { style = { color = DEMO_MUTED } }) @@ -421,7 +422,7 @@ fun UiScope.inputsGallerySection( button("Clear Focus", { onMouseClick = { FocusManager.clearFocus() } }) text( "1) Clear focus 2) wheel-scroll textarea 3) click visible text: caret must land exactly under cursor", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } div({ diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InspectorSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InspectorSection.kt index 487ef2b..5a9b3e4 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InspectorSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InspectorSection.kt @@ -1,10 +1,10 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState private const val INSPECTOR_MUTED_TEXT: Int = 0xFFB0B7C1.toInt() @@ -22,11 +22,14 @@ fun UiScope.inspectorSection(onInfo: (String) -> Unit) { } }) { text("In-game Inspector is global (works on every DSGL screen).") - text("F8: toggle inspector overlay", { style = { color = INSPECTOR_MUTED_TEXT } }) + text("F12: toggle inspector portal", { style = { color = INSPECTOR_MUTED_TEXT } }) text("F9: switch mode (Pick/Locked)", { style = { color = INSPECTOR_MUTED_TEXT } }) text("Expanded panel: click Min to collapse into floating chip.", { style = { color = INSPECTOR_MUTED_TEXT } }) text("Minimized chip: drag to move, click (no drag) to restore.", { style = { color = INSPECTOR_MUTED_TEXT } }) - text("Expanded panel: drag header to move; drag edges/corners to resize.", { style = { color = INSPECTOR_MUTED_TEXT } }) + text("Expanded panel: drag header to move; drag edges/corners to resize.", { + style = + { color = INSPECTOR_MUTED_TEXT } + }) text("Style editor now uses typed controls: dropdowns, text inputs, numeric input + units.", { style = { color = INSPECTOR_MUTED_TEXT } }) @@ -46,11 +49,13 @@ fun UiScope.inspectorSection(onInfo: (String) -> Unit) { gap = 4.px padding = 4.px backgroundColor = 0x3338424F - border { width = 1.px; color = 0xFF5F6E80.toInt() } + border { + width = 1.px + color = 0xFF5F6E80.toInt() + } display = Display.Flex flexDirection = FlexDirection.Column } - }) { text("Sample subtree for inspection (hover/click with inspector ON).") text("Behind-inspector click counter: $inspectorBehindClickCounter", { @@ -69,16 +74,13 @@ fun UiScope.inspectorSection(onInfo: (String) -> Unit) { onInfo("Inspector sample: behind counter=$inspectorBehindClickCounter") } }) - input( - InputType.Text(inspectorInputValue, "Focusable input"), - { - style = { flexGrow = 1f } - key = "inspector.sample.input" - onInput = { event -> - inspectorInputValue = event.value - } + input(InputType.Text(inspectorInputValue, "Focusable input"), { + style = { flexGrow = 1f } + key = "inspector.sample.input" + onInput = { event -> + inspectorInputValue = event.value } - ) + }) } div({ key = "inspector.sample.grid" @@ -94,9 +96,11 @@ fun UiScope.inspectorSection(onInfo: (String) -> Unit) { style = { padding = 2.px backgroundColor = 0x22496699 - border { width = 1.px; color = 0x665A9CE0 } + border { + width = 1.px + color = 0x665A9CE0 + } } - }) { text("cell-$index") } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InteractionsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InteractionsSection.kt index e40891f..037e0c1 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InteractionsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/InteractionsSection.kt @@ -1,20 +1,17 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.hooks.ref.useRef +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_SURFACE_ALT -fun UiScope.interactionsSection( - onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit -) { +fun UiScope.interactionsSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) { var mouseEnterCount by useState(0) var mouseLeaveCount by useState(0) var mouseOverCount by useState(0) @@ -127,13 +124,18 @@ fun UiScope.interactionsSection( height = 52.px padding = 4.px backgroundColor = DEMO_SURFACE_ALT - border { width = 1.px; color = 0xFF6E7A89.toInt() } + border { + width = 1.px + color = 0xFF6E7A89.toInt() + } } }) { text("Move, click, drag and wheel here") text( - "E$mouseEnterCount L$mouseLeaveCount O$mouseOverCount M$mouseMoveCount D$mouseDownCount/$mouseUpCount C$mouseClickCount G$mouseDragCount W$mouseWheelCount", - { style = { color = DEMO_MUTED } } + "E$mouseEnterCount L$mouseLeaveCount O$mouseOverCount M$mouseMoveCount " + + "D$mouseDownCount/$mouseUpCount C$mouseClickCount G$mouseDragCount " + + "W$mouseWheelCount", + { style = { color = DEMO_MUTED } }, ) } @@ -144,44 +146,41 @@ fun UiScope.interactionsSection( flexDirection = FlexDirection.Column } }) { - input( - InputType.Text(placeholder = "onKeyDown/onKeyUp"), { - key = "interactions.key.downUp" - style = { width = 100.percent } - onKeyDown = { event -> - keyDownCount += 1 - if (event.keyCode == KeyCodes.ENTER) { - enterActionCount += 1 - onLogHook("onKeyDown", event, "enterAction") - } else { - onLogHook("onKeyDown", event, null) - } - } - onKeyUp = { event -> - keyUpCount += 1 - onLogHook("onKeyUp", event, null) + input(InputType.Text(placeholder = "onKeyDown/onKeyUp"), { + key = "interactions.key.downUp" + style = { width = 100.percent } + onKeyDown = { event -> + keyDownCount += 1 + if (event.keyCode == KeyCodes.ENTER) { + enterActionCount += 1 + onLogHook("onKeyDown", event, "enterAction") + } else { + onLogHook("onKeyDown", event, null) } } - ) - input( - InputType.Text(placeholder = "onKeyPressed/onKeyReleased"), { - key = "interactions.key.aliases" - style = { width = 100.percent } - onKeyPressed = { event -> - keyPressedCount += 1 - onLogHook("onKeyPressed", event, null) - } - onKeyReleased = { event -> - keyReleasedCount += 1 - onLogHook("onKeyReleased", event, null) - } + onKeyUp = { event -> + keyUpCount += 1 + onLogHook("onKeyUp", event, null) } - ) + }) + input(InputType.Text(placeholder = "onKeyPressed/onKeyReleased"), { + key = "interactions.key.aliases" + style = { width = 100.percent } + onKeyPressed = { event -> + keyPressedCount += 1 + onLogHook("onKeyPressed", event, null) + } + onKeyReleased = { event -> + keyReleasedCount += 1 + onLogHook("onKeyReleased", event, null) + } + }) } text( - "Key counters: down=$keyDownCount up=$keyUpCount pressed=$keyPressedCount released=$keyReleasedCount enter=$enterActionCount", - { style = { color = DEMO_MUTED } } + "Key counters: down=$keyDownCount up=$keyUpCount pressed=$keyPressedCount " + + "released=$keyReleasedCount enter=$enterActionCount", + { style = { color = DEMO_MUTED } }, ) div({ @@ -191,18 +190,15 @@ fun UiScope.interactionsSection( flexDirection = FlexDirection.Row } }) { - button( - if (cancellationEnabled) "Cancel child click: ON" else "Cancel child click: OFF", - { - onMouseClick = { - cancellationEnabled = !cancellationEnabled - onInfo("Interactions: cancellation=$cancellationEnabled") - } + button(if (cancellationEnabled) "Cancel child click: ON" else "Cancel child click: OFF", { + onMouseClick = { + cancellationEnabled = !cancellationEnabled + onInfo("Interactions: cancellation=$cancellationEnabled") } - ) + }) text( "Parent=$cancellationParentHits Child=$cancellationChildHits", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } @@ -216,7 +212,10 @@ fun UiScope.interactionsSection( width = 100.percent padding = 3.px backgroundColor = 0xFF353D46.toInt() - border { width = 1.px; color = 0xFF708090.toInt() } + border { + width = 1.px + color = 0xFF708090.toInt() + } } }) { text("Parent click area") @@ -230,13 +229,16 @@ fun UiScope.interactionsSection( onLogHook( "child.onMouseClick", event, - if (cancellationEnabled) "cancelled=true" else "cancelled=false" + if (cancellationEnabled) "cancelled=true" else "cancelled=false", ) } style = { padding = 3.px backgroundColor = 0xFF4D5560.toInt() - border { width = 1.px; color = 0xFF9AA5B1.toInt() } + border { + width = 1.px + color = 0xFF9AA5B1.toInt() + } } }) { text("Child area") @@ -244,4 +246,3 @@ fun UiScope.interactionsSection( } } } - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutDebugSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutDebugSection.kt index 8811bdb..65fff7c 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutDebugSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutDebugSection.kt @@ -1,12 +1,12 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.debug.LayoutDebug import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.TextWrap -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED private const val WRAP_DEBUG_TEXT_A = @@ -14,10 +14,7 @@ private const val WRAP_DEBUG_TEXT_A = private const val WRAP_DEBUG_TEXT_B = "Wrapped text B: this block should always appear below text A with no overlap." -fun UiScope.layoutDebugSection( - onClearLogs: () -> Unit, - onInfo: (String) -> Unit -) { +fun UiScope.layoutDebugSection(onClearLogs: () -> Unit, onInfo: (String) -> Unit) { val minWidth = 96 val maxWidth = 320 var layoutDebugStrict by useState(LayoutDebug.strictBounds) @@ -46,33 +43,28 @@ fun UiScope.layoutDebugSection( flexDirection = FlexDirection.Row } }) { - button( - if (layoutDebugStrict) "strict: on" else "strict: off", - { - onMouseClick = { - layoutDebugStrict = !layoutDebugStrict - LayoutDebug.strictBounds = layoutDebugStrict - onInfo("LayoutDebug.strict=$layoutDebugStrict") - } + button(if (layoutDebugStrict) "strict: on" else "strict: off", { + onMouseClick = { + layoutDebugStrict = !layoutDebugStrict + LayoutDebug.strictBounds = layoutDebugStrict + onInfo("LayoutDebug.strict=$layoutDebugStrict") } - ) - button( - if (layoutDebugDraw) "draw bounds: on" else "draw bounds: off", - { - onMouseClick = { - layoutDebugDraw = !layoutDebugDraw - LayoutDebug.drawBounds = layoutDebugDraw - onInfo("LayoutDebug.drawBounds=$layoutDebugDraw") - } + }) + button(if (layoutDebugDraw) "draw bounds: on" else "draw bounds: off", { + onMouseClick = { + layoutDebugDraw = !layoutDebugDraw + LayoutDebug.drawBounds = layoutDebugDraw + onInfo("LayoutDebug.drawBounds=$layoutDebugDraw") } - ) + }) button("clear logs", { onMouseClick = { onClearLogs() } }) } text( - "validatorViolations=${LayoutDebug.lastViolationCount} strict=${LayoutDebug.strictBounds} draw=${LayoutDebug.drawBounds}", - { style = { color = DEMO_MUTED } } + "validatorViolations=${LayoutDebug.lastViolationCount} " + + "strict=${LayoutDebug.strictBounds} draw=${LayoutDebug.drawBounds}", + { style = { color = DEMO_MUTED } }, ) input( @@ -80,7 +72,7 @@ fun UiScope.layoutDebugSection( value = wrapWidth.toLong(), min = minWidth.toLong(), max = maxWidth.toLong(), - step = 2 + step = 2, ), { key = "layoutDebug.wrapWidth" @@ -89,7 +81,7 @@ fun UiScope.layoutDebugSection( val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: wrapWidth.toLong() layoutDebugWrapWidth = next.coerceIn(minWidth.toLong(), maxWidth.toLong()) } - } + }, ) text("wrap test width=$wrapWidth", { style = { color = DEMO_MUTED } }) @@ -102,9 +94,11 @@ fun UiScope.layoutDebugSection( backgroundColor = 0xFF2D3745.toInt() display = Display.Flex flexDirection = FlexDirection.Column - border { width = 1.px; color = 0xFF70859C.toInt() } + border { + width = 1.px + color = 0xFF70859C.toInt() + } } - }) { text("Case: wrapped text stack", { style = { textWrap = TextWrap.Wrap } }) text(WRAP_DEBUG_TEXT_A, { style = { textWrap = TextWrap.Wrap } }) @@ -118,5 +112,3 @@ fun UiScope.layoutDebugSection( } } } - - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutStyleSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutStyleSection.kt index adfcffd..242f357 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutStyleSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/LayoutStyleSection.kt @@ -1,44 +1,42 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseDragEvent import org.dreamfinity.dsgl.core.hooks.ref.useRef +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_SURFACE_ALT import kotlin.math.abs -fun UiScope.layoutStyleSection( - onInfo: (String) -> Unit, - onLogHook: (String, Event, String?) -> Unit -) { +fun UiScope.layoutStyleSection(onInfo: (String) -> Unit, onLogHook: (String, Event, String?) -> Unit) { var styleUseMargin by useState(true) var styleUsePadding by useState(true) var styleUseBorder by useState(true) var styleLargeGap by useState(false) var styleFixedSize by useState(false) - var stackOverlayEnabled by useState(true) - var layoutOverlayX by useState(8) - var layoutOverlayY by useState(92) - var layoutOverlayDragging by useState(false) - var overlayClicks by useState(0) + var stackLayerEnabled by useState(true) + var stackLayerX by useState(8) + var stackLayerY by useState(92) + var stackLayerDragging by useState(false) + var stackLayerClicks by useState(0) - val overlayDragAnchorXRef by useRef(0) - val overlayDragAnchorYRef by useRef(0) - val overlayDragMovedRef by useRef(false) + val stackLayerDragAnchorXRef by useRef(0) + val stackLayerDragAnchorYRef by useRef(0) + val stackLayerDragMovedRef by useRef(false) val demoGap = if (styleLargeGap) 10 else 3 val fixedSize = if (styleFixedSize) 24 else null - val overlayWidth = 148 - val overlayHeight = 26 + val stackLayerWidth = 148 + val stackLayerHeight = 26 - overlay({ + div({ key = "section.layoutStyle.stack" + overlapChildren = true style = { width = 100.percent gap = 0.px @@ -54,7 +52,7 @@ fun UiScope.layoutStyleSection( }) { text( "Toggle values and click boxes to verify row/column behavior.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -71,16 +69,14 @@ fun UiScope.layoutStyleSection( styleLargeGap = !styleLargeGap onInfo("Layout: gap=${if (styleLargeGap) "large" else "compact"}") } - }) - button( - if (styleFixedSize) "Size: Fixed" else "Size: Auto", - { - onMouseClick = { - styleFixedSize = !styleFixedSize - onInfo("Layout: fixedSize=$styleFixedSize") - } - } + }, ) + button(if (styleFixedSize) "Size: Fixed" else "Size: Auto", { + onMouseClick = { + styleFixedSize = !styleFixedSize + onInfo("Layout: fixedSize=$styleFixedSize") + } + }) } div({ @@ -102,7 +98,10 @@ fun UiScope.layoutStyleSection( height = fixedSize?.px padding = 2.px backgroundColor = 0xFF3A4A5A.toInt() - border { width = 1.px; color = 0xFF5E89B5.toInt() } + border { + width = 1.px + color = 0xFF5E89B5.toInt() + } } }) { text("R${index + 1}") @@ -128,7 +127,10 @@ fun UiScope.layoutStyleSection( width = if (styleFixedSize) 72.px else null padding = 2.px backgroundColor = 0xFF43404F.toInt() - border { width = 1.px; color = 0xFF786AA6.toInt() } + border { + width = 1.px + color = 0xFF786AA6.toInt() + } } }) { text("Column box ${index + 1}") @@ -143,24 +145,15 @@ fun UiScope.layoutStyleSection( flexDirection = FlexDirection.Row } }) { - button( - if (styleUseMargin) "Margin ON" else "Margin OFF", - { - onMouseClick = { styleUseMargin = !styleUseMargin } - } - ) - button( - if (styleUsePadding) "Padding ON" else "Padding OFF", - { - onMouseClick = { styleUsePadding = !styleUsePadding } - } - ) - button( - if (styleUseBorder) "Border ON" else "Border OFF", - { - onMouseClick = { styleUseBorder = !styleUseBorder } - } - ) + button(if (styleUseMargin) "Margin ON" else "Margin OFF", { + onMouseClick = { styleUseMargin = !styleUseMargin } + }) + button(if (styleUsePadding) "Padding ON" else "Padding OFF", { + onMouseClick = { styleUsePadding = !styleUsePadding } + }) + button(if (styleUseBorder) "Border ON" else "Border OFF", { + onMouseClick = { styleUseBorder = !styleUseBorder } + }) } div({ @@ -171,15 +164,27 @@ fun UiScope.layoutStyleSection( style = { width = 100.percent backgroundColor = DEMO_SURFACE_ALT - if (styleUseMargin) margin { top = 4.px; right = 0.px; bottom = 0.px; left = 8.px } + if (styleUseMargin) { + margin { + top = 4.px + right = 0.px + bottom = 0.px + left = 8.px + } + } if (styleUsePadding) padding { all(4.px) } - if (styleUseBorder) border { width = 1.px; color = 0xFF90A4AE.toInt() } + if (styleUseBorder) { + border { + width = 1.px + color = 0xFF90A4AE.toInt() + } + } } }) { text("Style target (margin/padding/border)") text( "margin=$styleUseMargin padding=$styleUsePadding border=$styleUseBorder", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } @@ -190,110 +195,123 @@ fun UiScope.layoutStyleSection( flexDirection = FlexDirection.Row } }) { - button( - if (stackOverlayEnabled) "Stack Overlay ON" else "Stack Overlay OFF", - { - onMouseClick = { - stackOverlayEnabled = !stackOverlayEnabled - onInfo("Layout: stackOverlay=$stackOverlayEnabled") - } + button(if (stackLayerEnabled) "Stack Layer ON" else "Stack Layer OFF", { + onMouseClick = { + stackLayerEnabled = !stackLayerEnabled + onInfo("Layout: stackLayer=$stackLayerEnabled") } - ) - button("Reset Overlay", { + }) + button("Reset Stack Layer", { onMouseClick = { - layoutOverlayX = 8 - layoutOverlayY = 92 - layoutOverlayDragging = false - overlayDragMovedRef.current = false - onInfo("Layout: overlay reset") + stackLayerX = 8 + stackLayerY = 92 + stackLayerDragging = false + stackLayerDragMovedRef.current = false + onInfo("Layout: stack layer reset") } }) text( - "Overlay: $layoutOverlayX,$layoutOverlayY clicks=$overlayClicks", - { style = { color = DEMO_MUTED } } + "Stack layer: $stackLayerX,$stackLayerY clicks=$stackLayerClicks", + { style = { color = DEMO_MUTED } }, ) } } - if (stackOverlayEnabled) { + if (stackLayerEnabled) { div({ - key = "layout.stack.overlay" + key = "layout.stack.layer" onMouseDown = onMouseDown@{ event -> if (event.mouseButton != MouseButton.LEFT) return@onMouseDown - val overlayNode = findNodeInPath(event.target, "layout.stack.overlay") ?: return@onMouseDown - layoutOverlayDragging = true - overlayDragAnchorXRef.current = - (event.mouseX - overlayNode.bounds.x).coerceIn(0, overlayNode.bounds.width.coerceAtLeast(1)) - overlayDragAnchorYRef.current = - (event.mouseY - overlayNode.bounds.y).coerceIn(0, overlayNode.bounds.height.coerceAtLeast(1)) - overlayDragMovedRef.current = false + val stackLayerNode = findNodeInPath(event.target, "layout.stack.layer") ?: return@onMouseDown + stackLayerDragging = true + stackLayerDragAnchorXRef.current = + (event.mouseX - stackLayerNode.bounds.x).coerceIn( + 0, + stackLayerNode.bounds.width + .coerceAtLeast(1), + ) + stackLayerDragAnchorYRef.current = + (event.mouseY - stackLayerNode.bounds.y).coerceIn( + 0, + stackLayerNode.bounds.height + .coerceAtLeast(1), + ) + stackLayerDragMovedRef.current = false } onMouseDrag = { event -> - updateOverlayDrag( + updateStackLayerDrag( event = event, - overlayWidth = overlayWidth, - overlayHeight = overlayHeight, - isDragging = layoutOverlayDragging, - currentX = layoutOverlayX, - currentY = layoutOverlayY, - anchorX = overlayDragAnchorXRef.current ?: 0, - anchorY = overlayDragAnchorYRef.current ?: 0 + stackLayerWidth = stackLayerWidth, + stackLayerHeight = stackLayerHeight, + isDragging = stackLayerDragging, + currentX = stackLayerX, + currentY = stackLayerY, + anchorX = stackLayerDragAnchorXRef.current ?: 0, + anchorY = stackLayerDragAnchorYRef.current ?: 0, ) { nextX, nextY, moved -> if (moved) { - overlayDragMovedRef.current = true + stackLayerDragMovedRef.current = true } - if (nextX != layoutOverlayX) { - layoutOverlayX = nextX + if (nextX != stackLayerX) { + stackLayerX = nextX } - if (nextY != layoutOverlayY) { - layoutOverlayY = nextY + if (nextY != stackLayerY) { + stackLayerY = nextY } } } onMouseUp = onMouseUp@{ event -> - if (!layoutOverlayDragging) return@onMouseUp - if (event.mouseButton == MouseButton.LEFT && !(overlayDragMovedRef.current ?: false)) { - overlayClicks += 1 - onLogHook("overlay.onMouseClick", event, "overlayClicks=$overlayClicks") + if (!stackLayerDragging) return@onMouseUp + if (event.mouseButton == MouseButton.LEFT && !(stackLayerDragMovedRef.current ?: false)) { + stackLayerClicks += 1 + onLogHook("stackLayer.onMouseClick", event, "stackLayerClicks=$stackLayerClicks") } - layoutOverlayDragging = false - overlayDragMovedRef.current = false + stackLayerDragging = false + stackLayerDragMovedRef.current = false } style = { - width = overlayWidth.px - height = overlayHeight.px + width = stackLayerWidth.px + height = stackLayerHeight.px backgroundColor = 0xCC5A3131.toInt() - margin { top = layoutOverlayY.px; right = 0.px; bottom = 0.px; left = layoutOverlayX.px } + margin { + top = stackLayerY.px + right = 0.px + bottom = 0.px + left = stackLayerX.px + } padding { all(4.px) } - border { width = 1.px; color = 0xFF8D4848.toInt() } + border { + width = 1.px + color = 0xFF8D4848.toInt() + } } }) { text( - if (layoutOverlayDragging) "Overlay (dragging...)" else "Overlay (drag me)", - { style = { color = 0xFFF5F7FA.toInt() } } + if (stackLayerDragging) "Stack layer (dragging...)" else "Stack layer (drag me)", + { style = { color = 0xFFF5F7FA.toInt() } }, ) } } } } -private fun updateOverlayDrag( +private fun updateStackLayerDrag( event: MouseDragEvent, - overlayWidth: Int, - overlayHeight: Int, + stackLayerWidth: Int, + stackLayerHeight: Int, isDragging: Boolean, currentX: Int, currentY: Int, anchorX: Int, anchorY: Int, - onUpdate: (nextX: Int, nextY: Int, moved: Boolean) -> Unit + onUpdate: (nextX: Int, nextY: Int, moved: Boolean) -> Unit, ) { if (!isDragging) return val stackNode = findNodeInPath(event.target, "section.layoutStyle.stack") ?: return val currentMouseX = event.lastMouseX + event.dx val currentMouseY = event.lastMouseY + event.dy - val maxX = (stackNode.bounds.width - overlayWidth - 2).coerceAtLeast(0) - val maxY = (stackNode.bounds.height - overlayHeight - 2).coerceAtLeast(0) + val maxX = (stackNode.bounds.width - stackLayerWidth - 2).coerceAtLeast(0) + val maxY = (stackNode.bounds.height - stackLayerHeight - 2).coerceAtLeast(0) val nextX = (currentMouseX - stackNode.bounds.x - anchorX).coerceIn(0, maxX) val nextY = (currentMouseY - stackNode.bounds.y - anchorY).coerceIn(0, maxY) val moved = abs(nextX - currentX) > 0 || abs(nextY - currentY) > 0 diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/McFeaturesSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/McFeaturesSection.kt index 1b8b17d..d051077 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/McFeaturesSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/McFeaturesSection.kt @@ -1,19 +1,16 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections import org.dreamfinity.dsgl.core.DsglColors +import org.dreamfinity.dsgl.core.dom.elements.* import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.core.dom.elements.InputType -import org.dreamfinity.dsgl.core.event.Event -import org.dreamfinity.dsgl.core.style.AlignItems -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.style.JustifyContent +import org.dreamfinity.dsgl.core.event.* import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.mcForge1710.McItemStackRef import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import kotlin.math.roundToLong -data class McFeaturesShellProps( +data class McFeaturesSection( val viewportWidthPx: Int, val viewportHeightPx: Int, val mediaReady: Boolean, @@ -28,15 +25,17 @@ data class McFeaturesShellProps( val guiScaleLabel: (Int) -> String, val setGuiScale: (Int) -> Unit, val cycleGuiScale: (Int) -> Unit, - val onLogHook: (String, Event, String?) -> Unit + val onLogHook: (String, Event, String?) -> Unit, ) -fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { +fun UiScope.mcFeaturesSection(props: McFeaturesSection) { var itemRotY by useState(160.0) var itemRotX by useState(-11.0) fun itemRotYLong(): Long = itemRotY.roundToLong().coerceIn(0L, 360L) + fun itemRotXLong(): Long = itemRotX.roundToLong().coerceIn(-89L, 89L) + fun adjustItemRotation(deltaY: Double = 0.0, deltaX: Double = 0.0) { var normalized = (itemRotY + deltaY) % 360.0 if (normalized < 0.0) normalized += 360.0 @@ -55,12 +54,16 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { } }) { text( - "DSGL viewport=${props.viewportWidthPx}x${props.viewportHeightPx}px, guiScale=${props.guiScaleLabel(guiScaleValue)}", - { style = { color = DsglColors.WHITE } } + "DSGL viewport=${props.viewportWidthPx}x${props.viewportHeightPx}px, guiScale=${ + props.guiScaleLabel( + guiScaleValue, + ) + }", + { style = { color = DsglColors.WHITE } }, ) text( "Change guiScale below: vanilla UI changes, DSGL layout should stay pixel-stable.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -116,7 +119,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { width = 100.percent maxWidth = 360.px padding = 4.px - border { width = 1.px; color = 0xFF5D6A76.toInt() } + border { + width = 1.px + color = 0xFF5D6A76.toInt() + } backgroundColor = 0xFF1A222A.toInt() gap = 4.px display = Display.Flex @@ -136,8 +142,12 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { style = { width = 18.px height = 10.px - border { width = 1.px; color = 0xFF3F4B56.toInt() } - backgroundColor = if ((row + col) % 2 == 0) 0xFF1F2D38.toInt() else 0xFF243544.toInt() + border { + width = 1.px + color = 0xFF3F4B56.toInt() + } + backgroundColor = + if ((row + col) % 2 == 0) 0xFF1F2D38.toInt() else 0xFF243544.toInt() } }) {} } @@ -158,7 +168,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { width = 160.px height = 102.px padding = 4.px - border { width = 1.px; color = 0xFF6A7784.toInt() } + border { + width = 1.px + color = 0xFF6A7784.toInt() + } backgroundColor = 0xFF111922.toInt() gap = 3.px display = Display.Flex @@ -247,7 +260,7 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { text("Image sources: resource + file:// + http(s):// cached path.") text( "mediaReady=${props.mediaReady} file=${if (props.mediaReady) "prepared" else "failed"}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -271,7 +284,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { style = { width = 36.px height = 36.px - border { width = 1.px; color = 0xFF66737F.toInt() } + border { + width = 1.px + color = 0xFF66737F.toInt() + } } }) } @@ -289,7 +305,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { style = { width = 36.px height = 36.px - border { width = 1.px; color = 0xFF66737F.toInt() } + border { + width = 1.px + color = 0xFF66737F.toInt() + } } }) } @@ -307,7 +326,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { style = { width = 36.px height = 36.px - border { width = 1.px; color = 0xFF66737F.toInt() } + border { + width = 1.px + color = 0xFF66737F.toInt() + } } }) } @@ -327,7 +349,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { key = "mc.item.2d" style = { width = 64.px - border { width = 1.px; color = 0xFF586A7A.toInt() } + border { + width = 1.px + color = 0xFF586A7A.toInt() + } } }) itemStack(props.blockItemRef, { @@ -337,7 +362,10 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { key = "mc.item.3d" style = { width = 70.px - border { width = 1.px; color = 0xFF586A7A.toInt() } + border { + width = 1.px + color = 0xFF586A7A.toInt() + } } }) } @@ -345,14 +373,14 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { text("Rotation controls (drag sliders or use step buttons)") text( "Drag outside slider bounds: value should keep updating until mouse up.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) input( InputType.Range( value = itemRotYLong(), min = 0, max = 360, - step = 5 + step = 5, ), { key = "mc.rotation.slider.yaw" @@ -373,7 +401,7 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { itemRotY = next.toDouble() props.onLogHook("mc.rotY.onChange", event, "rotY=${itemRotYLong()}") } - } + }, ) input( @@ -381,7 +409,7 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { value = itemRotXLong(), min = -89, max = 89, - step = 1 + step = 1, ), { key = "mc.rotation.slider.pitch" @@ -402,7 +430,7 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { itemRotX = next.toDouble() props.onLogHook("mc.rotX.onChange", event, "rotX=${itemRotXLong()}") } - } + }, ) div({ @@ -434,7 +462,7 @@ fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { text( "rotY=${itemRotYLong()} rotX=${itemRotXLong()}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ModalsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ModalsSection.kt index ee9032c..2015786 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ModalsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/ModalsSection.kt @@ -1,10 +1,16 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections +import org.dreamfinity.dsgl.core.components.modal.BackdropMode +import org.dreamfinity.dsgl.core.components.modal.ModalSize +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.components.modal.modalBody +import org.dreamfinity.dsgl.core.components.modal.modalFooter +import org.dreamfinity.dsgl.core.components.modal.modalHeader +import org.dreamfinity.dsgl.core.components.modal.modalTitle import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.core.components.modal.* +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED fun UiScope.modalsSection( @@ -13,7 +19,7 @@ fun UiScope.modalsSection( onRemoveModal: (String) -> Unit, onPopTopModal: () -> Unit, onClearModals: () -> Unit, - onInfo: (String) -> Unit + onInfo: (String) -> Unit, ) { var modalBackgroundCounter by useState(0) @@ -76,26 +82,31 @@ fun UiScope.modalsSection( } text({ - val stack = if (modals.isEmpty()) "[]" else modals.joinToString( - prefix = "[", - postfix = "]" - ) { it.key } + val stack = + if (modals.isEmpty()) { + "[]" + } else { + modals.joinToString( + prefix = "[", + postfix = "]", + ) { it.key } + } value = "Stack=$stack" style = { color = DEMO_MUTED } }) text( "Background counter=$modalBackgroundCounter", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } -private fun basicModal(onRemoveModal: (String) -> Unit): ModalSpec { - return ModalSpec( +private fun basicModal(onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( key = "modal.basic", backdrop = BackdropMode.True, keyboard = true, - onHide = { onRemoveModal("modal.basic") } + onHide = { onRemoveModal("modal.basic") }, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle("Basic Modal") @@ -109,14 +120,13 @@ private fun basicModal(onRemoveModal: (String) -> Unit): ModalSpec { }) } } -} -private fun staticModal(onRemoveModal: (String) -> Unit): ModalSpec { - return ModalSpec( +private fun staticModal(onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( key = "modal.static", backdrop = BackdropMode.Static, keyboard = false, - onHide = { onRemoveModal("modal.static") } + onHide = { onRemoveModal("modal.static") }, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle("Static Backdrop") @@ -131,21 +141,20 @@ private fun staticModal(onRemoveModal: (String) -> Unit): ModalSpec { }) } } -} -private fun largeCenteredModal(onRemoveModal: (String) -> Unit): ModalSpec { - return ModalSpec( +private fun largeCenteredModal(onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( key = "modal.large", size = ModalSize.Lg, centered = true, - onHide = { onRemoveModal("modal.large") } + onHide = { onRemoveModal("modal.large") }, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle("Large Centered") } modalBody { text("Preset size: Lg; centered=true") - text("ModalHost keeps background inert while open.", { style = { color = DEMO_MUTED } }) + text("Modal portal keeps background inert while open.", { style = { color = DEMO_MUTED } }) } modalFooter { button("Done", { @@ -153,15 +162,11 @@ private fun largeCenteredModal(onRemoveModal: (String) -> Unit): ModalSpec { }) } } -} -private fun flowStep1Modal( - onPushModal: (ModalSpec) -> Unit, - onRemoveModal: (String) -> Unit -): ModalSpec { - return ModalSpec( +private fun flowStep1Modal(onPushModal: (ModalSpec) -> Unit, onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( key = "modal.flow.1", - onHide = { onRemoveModal("modal.flow.1") } + onHide = { onRemoveModal("modal.flow.1") }, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle("Flow Step 1") @@ -180,13 +185,12 @@ private fun flowStep1Modal( }) } } -} -private fun flowStep2Modal(onRemoveModal: (String) -> Unit): ModalSpec { - return ModalSpec( +private fun flowStep2Modal(onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( key = "modal.flow.2", centered = true, - onHide = { onRemoveModal("modal.flow.2") } + onHide = { onRemoveModal("modal.flow.2") }, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle("Flow Step 2") @@ -200,4 +204,3 @@ private fun flowStep2Modal(onRemoveModal: (String) -> Unit): ModalSpec { }) } } -} diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/MsdfFontsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/MsdfFontsSection.kt index bba9971..49ace92 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/MsdfFontsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/MsdfFontsSection.kt @@ -1,35 +1,37 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections +import org.dreamfinity.dsgl.core.dom.elements.* import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.core.dom.elements.InputType -import org.dreamfinity.dsgl.core.font.FontRegistry -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.style.FontStyle -import org.dreamfinity.dsgl.core.style.FontWeight -import org.dreamfinity.dsgl.core.style.TextDecoration -import org.dreamfinity.dsgl.core.style.TextFormatting -import org.dreamfinity.dsgl.core.style.TextWrap +import org.dreamfinity.dsgl.core.font.* import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.text.MsdfRuntimeDebugSettings -private val COLOR_PRESETS = listOf( - 0xFFFFFFFF.toInt(), - 0xFFFFC857.toInt(), - 0xFF8EE3F5.toInt(), - 0xFFFF7E67.toInt() -) +private val COLOR_PRESETS = + listOf( + 0xFFFFFFFF.toInt(), + 0xFFFFC857.toInt(), + 0xFF8EE3F5.toInt(), + 0xFFFF7E67.toInt(), + ) private const val SAMPLE_PARAGRAPH = - "MSDF/MTSDF text rendering demo in DSGL. This paragraph should wrap cleanly in a fixed-width panel and respect font switches, opacity, and size." + "MSDF/MTSDF text rendering demo in DSGL. This paragraph should wrap cleanly " + + "in a fixed-width panel and respect font switches, opacity, and size." private const val SAMPLE_WORD = "\u4ED6\u65B9\u3001\u6210\u7E3E\u8A55long_unbroken_word_to_force_hard_break_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789" private const val SAMPLE_SPACES_A = "Hello world" + +@Suppress("MaxLineLength") private const val LONG_CH_SENTENCE = "\u592A\u9633\u6652\u5F97\u58A8\u9ED1\u7684\u6E05\u7626\u7684\u8138\u4E0A\uFF0C\u6709\u4E00\u5BF9\u7A0D\u7A0D\u6D3C\u8FDB\u53BB\u7684\u5927\u5927\u7684\u53CC\u773C\u76AE\u513F\u773C\u775B\uFF0C\u7709\u6BDB\u7EC6\u800C\u659C\uFF0C\u9ED1\u91CC\u5E26\u9EC4\u7684\u5934\u53D1\u7528\u82B1\u5E03\u6761\u5B50\u624E\u4E24\u6761\u77ED\u8FAB\u5B50\uFF0C\u8863\u670D\u90FD\u5F88\u65E7\uFF0C\u53F3\u88E4\u811A\u4E0A\u7684\u4E00\u4E2A\u7834\u6D1E\u522B\u4E00\u652F\u522B\u9488\uFF0C\u6625\u590F\u79CB\u4E09\u5B63\u90FD\u6253\u8D64\u811A\uFF0C\u53EA\u6709\u4E0A\u5C71\u6293\u67F4\u79BE\u7684\u65F6\u8282\uFF0C\u6015\u523A\u7834\u811A\u677F\uFF0C\u624D\u7A7F\u53CC\u978B\u5B50\uFF0C\u4F46\u4E00\u4E0B\u5C71\u5C31\u8131\u4E86\u3002" + +@Suppress("MaxLineLength") private const val LONG_JP_SENTENCE = "\u4ED6\u65B9\u3001\u6210\u7E3E\u8A55\u4FA1\u306E\u7518\u3044\u6388\u696D\u304C\u9AD8\u304F\u8A55\u4FA1\u3055\u308C\u305F\u308A\u3001\u4EBA\u6C17\u53D6\u308A\u306B\u8D70\u308B\u6559\u5E2B\u304C\u51FA\u305F\u308A\u3057\u3001\u6210\u7E3E\u306E\u5B89\u58F2\u308A\u3084\u5927\u5B66\u6559\u5E2B\u306E\u30EC\u30D9\u30EB\u30C0\u30A6\u30F3\u3068\u3044\u3046\u5F0A\u5BB3\u3092\u3082\u305F\u3089\u3059\u6050\u308C\u304C\u3042\u308B\u3001\u306A\u3069\u306E\u53CD\u7701\u610F\u898B\u3082\u3042\u308B." + +@Suppress("MaxLineLength") private const val LONG_KR_SENTENCE = "\uC800\uB294 \uC624\uB298 \uC544\uCE68\uC5D0 \uCE74\uD398\uC5D0\uC11C \uCE5C\uAD6C\uB791 \uD55C\uAD6D\uC5B4 \uACF5\uBD80\uB97C \uD558\uACE0 \uB098\uC11C \uB3C4\uC11C\uAD00\uC5D0 \uAC08 \uAC70\uC608\uC694." @@ -65,8 +67,9 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { }) { text("MSDF Fonts") text( - "All DSGL DrawText commands go through MSDF/MTSDF rendering. Switch font/size/color/opacity and verify wrapping.", - { style = { color = DEMO_MUTED } } + "All DSGL DrawText commands go through MSDF/MTSDF rendering. Switch " + + "font/size/color/opacity and verify wrapping.", + { style = { color = DEMO_MUTED } }, ) text("DREAMFINITY", { style = { fontId = "telegrafico" } }) @@ -104,7 +107,7 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { msdfParseMinecraftFormatting = !msdfParseMinecraftFormatting onInfo("MSDF formatting=$msdfParseMinecraftFormatting") } - } + }, ) button( if (msdfShowBaselineGuides) { @@ -118,11 +121,11 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { MsdfRuntimeDebugSettings.decorationGuidesEnabled = msdfShowBaselineGuides System.setProperty( "dsgl.msdf.debug.decorations", - msdfShowBaselineGuides.toString() + msdfShowBaselineGuides.toString(), ) onInfo("MSDF guides=$msdfShowBaselineGuides") } - } + }, ) } @@ -131,7 +134,7 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { value = msdfOpacityPercent, min = 0, max = 100, - step = 1 + step = 1, ), { key = "msdf.opacity" @@ -140,14 +143,14 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: msdfOpacityPercent msdfOpacityPercent = next.coerceIn(0, 100) } - } + }, ) input( InputType.Range( value = msdfFontSizePx, min = 6, max = 48, - step = 1 + step = 1, ), { key = "msdf.fontSize" @@ -156,14 +159,14 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: msdfFontSizePx msdfFontSizePx = next.coerceIn(6, 48) } - } + }, ) input( InputType.Range( value = panelWidthPercent, min = 0L, max = 100L, - step = 2 + step = 2, ), { key = "msdf.wrapWidth" @@ -172,31 +175,39 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: panelWidthPercent msdfWrapWidthPercent = next.coerceIn(0L, 100L) } - } + }, ) text( - "fontId=${selectedFont.fontId} source=${selectedFont.source.name.lowercase()} fontSize=$fontSize opacity=$textOpacity panelWidth=$panelWidthPercent% formatting=${formattingMode.name.lowercase()} guides=$msdfShowBaselineGuides", - { style = { this.color = DEMO_MUTED } } + "fontId=${selectedFont.fontId} " + + "source=${selectedFont.source.name.lowercase()} " + + "fontSize=$fontSize " + + "opacity=$textOpacity " + + "panelWidth=$panelWidthPercent% " + + "formatting=${formattingMode.name.lowercase()} guides=$msdfShowBaselineGuides", + { style = { this.color = DEMO_MUTED } }, ) text( - "Drop external font packages into /dsgl/fonts//.ttf + -meta.json + -mtsdf.png and restart.", + "Drop external font packages into /dsgl/fonts//.ttf + " + + "-meta.json + -mtsdf.png and restart.", { style = { color = DEMO_MUTED textWrap = TextWrap.Wrap } - } + }, ) text({ - val preview = selectableFonts - .take(8) - .joinToString(", ") { "${it.fontId}[${it.source.name.lowercase()}]" } - value = if (selectableFonts.size > 8) { - "Registered fonts (${selectableFonts.size}): $preview ..." - } else { - "Registered fonts (${selectableFonts.size}): $preview" - } + val preview = + selectableFonts + .take(8) + .joinToString(", ") { "${it.fontId}[${it.source.name.lowercase()}]" } + value = + if (selectableFonts.size > 8) { + "Registered fonts (${selectableFonts.size}): $preview ..." + } else { + "Registered fonts (${selectableFonts.size}): $preview" + } style = { color = DEMO_MUTED @@ -213,7 +224,10 @@ fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { backgroundColor = 0xFF233040.toInt() display = Display.Flex flexDirection = FlexDirection.Column - border { width = 1.px; color = 0xFF5F7288.toInt() } + border { + width = 1.px + color = 0xFF5F7288.toInt() + } } }) { text("Header text", { diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverflowScrollSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverflowScrollSection.kt index ae26573..8bcc1e1 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverflowScrollSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverflowScrollSection.kt @@ -1,26 +1,28 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.Overflow -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED -private val OVERFLOW_MODES = listOf( - Overflow.Visible, - Overflow.Hidden, - Overflow.Scroll, - Overflow.Auto -) +private val OVERFLOW_MODES = + listOf( + Overflow.Visible, + Overflow.Hidden, + Overflow.Scroll, + Overflow.Auto, + ) -private fun Overflow.label(): String = when (this) { - Overflow.Visible -> "visible" - Overflow.Hidden -> "hidden" - Overflow.Scroll -> "scroll" - Overflow.Auto -> "auto" -} +private fun Overflow.label(): String = + when (this) { + Overflow.Visible -> "visible" + Overflow.Hidden -> "hidden" + Overflow.Scroll -> "scroll" + Overflow.Auto -> "auto" + } private fun nextOverflow(current: Overflow): Overflow { val idx = OVERFLOW_MODES.indexOf(current) @@ -64,7 +66,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { text("Overflow/scroll viewport playground") text( "Scrollbar presence is state-only for now, but gutters already reduce viewport size.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -107,7 +109,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { value = viewportWidth.toLong(), min = viewportMinWidth.toLong(), max = viewportMaxWidth.toLong(), - step = 2 + step = 2, ), { key = "section.overflowScroll.viewportWidth" @@ -116,7 +118,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: viewportWidth.toLong() overflowDemoViewportWidth = next.coerceIn(viewportMinWidth.toLong(), viewportMaxWidth.toLong()) } - } + }, ) text("Viewport width = $viewportWidth", { style = { color = DEMO_MUTED } }) @@ -125,7 +127,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { value = viewportHeight.toLong(), min = viewportMinHeight.toLong(), max = viewportMaxHeight.toLong(), - step = 2 + step = 2, ), { key = "section.overflowScroll.viewportHeight" @@ -134,7 +136,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: viewportHeight.toLong() overflowDemoViewportHeight = next.coerceIn(viewportMinHeight.toLong(), viewportMaxHeight.toLong()) } - } + }, ) text("Viewport height = $viewportHeight", { style = { color = DEMO_MUTED } }) @@ -143,7 +145,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { value = demoContentWidth.toLong(), min = contentMinWidth.toLong(), max = contentMaxWidth.toLong(), - step = 2 + step = 2, ), { key = "section.overflowScroll.contentWidth" @@ -152,7 +154,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: demoContentWidth.toLong() overflowDemoContentWidth = next.coerceIn(contentMinWidth.toLong(), contentMaxWidth.toLong()) } - } + }, ) text("Content width = $demoContentWidth", { style = { color = DEMO_MUTED } }) @@ -161,7 +163,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { value = demoContentHeight.toLong(), min = contentMinHeight.toLong(), max = contentMaxHeight.toLong(), - step = 2 + step = 2, ), { key = "section.overflowScroll.contentHeight" @@ -170,13 +172,13 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: demoContentHeight.toLong() overflowDemoContentHeight = next.coerceIn(contentMinHeight.toLong(), contentMaxHeight.toLong()) } - } + }, ) text("Content height = $demoContentHeight", { style = { color = DEMO_MUTED } }) text( "Clicks: visible=$overflowDemoVisibleClicks edge=$overflowDemoEdgeClicks (edge click only when visible)", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) overflowDemoCard( @@ -196,7 +198,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { onEdgeClick = { overflowDemoEdgeClicks += 1 onInfo("Overflow demo edge click") - } + }, ) overflowDemoCard( @@ -210,7 +212,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { overflowY = Overflow.Scroll, keyPrefix = "section.overflowScroll.preset.scroll", onVisibleClick = {}, - onEdgeClick = {} + onEdgeClick = {}, ) overflowDemoCard( @@ -224,7 +226,7 @@ fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { overflowY = Overflow.Auto, keyPrefix = "section.overflowScroll.preset.cross", onVisibleClick = {}, - onEdgeClick = {} + onEdgeClick = {}, ) } } @@ -240,7 +242,7 @@ private fun UiScope.overflowDemoCard( overflowY: Overflow, keyPrefix: String, onVisibleClick: () -> Unit, - onEdgeClick: () -> Unit + onEdgeClick: () -> Unit, ) { div({ key = "$keyPrefix.card" @@ -251,14 +253,20 @@ private fun UiScope.overflowDemoCard( gap = 2.px padding = 2.px backgroundColor = 0xFF2B3440.toInt() - border { width = 1.px; color = 0xFF5E7286.toInt() } + border { + width = 1.px + color = 0xFF5E7286.toInt() + } } }) { text(title) text(note, { style = { color = DEMO_MUTED } }) text( - "viewport=${viewportWidth}x$viewportHeight content=${contentWidth}x$contentHeight overflow-x=${overflowX.label()} overflow-y=${overflowY.label()}", - { style = { color = DEMO_MUTED } } + "viewport=${viewportWidth}x$viewportHeight " + + "content=${contentWidth}x$contentHeight " + + "overflow-x=${overflowX.label()} " + + "overflow-y=${overflowY.label()}", + { style = { color = DEMO_MUTED } }, ) div({ @@ -268,7 +276,10 @@ private fun UiScope.overflowDemoCard( height = viewportHeight.px this.overflowX = overflowX this.overflowY = overflowY - border { width = 1.px; color = 0xFF8AA0B5.toInt() } + border { + width = 1.px + color = 0xFF8AA0B5.toInt() + } backgroundColor = 0xFF23303D.toInt() padding = 2.px } @@ -281,7 +292,10 @@ private fun UiScope.overflowDemoCard( display = Display.Flex flexDirection = FlexDirection.Column gap = 1.px - border { width = 1.px; color = 0xFF7992AA.toInt() } + border { + width = 1.px + color = 0xFF7992AA.toInt() + } backgroundColor = 0xFF384C60.toInt() padding = 2.px } diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverviewSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverviewSection.kt index d4ef336..f73d918 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverviewSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/OverviewSection.kt @@ -1,19 +1,19 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState -import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityId import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityChecklistCatalog import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityGroup +import org.dreamfinity.dsgl.mcForge1710.demo.support.CapabilityId import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_OK fun UiScope.overviewSection( implementedCapabilities: Set, onManualInvalidate: (String) -> Unit, - onInfo: (String) -> Unit + onInfo: (String) -> Unit, ) { var manualInvalidateCount by useState(0) var lastManualReason by useState("none") @@ -40,7 +40,7 @@ fun UiScope.overviewSection( text("Press F6 to force stylesheet reload and rebuild after file edits.", { style = { color = DEMO_MUTED } }) - text("Press F10 to toggle the draggable overlay panel panel demo (text + button + image).", { + text("Press F10 to toggle the draggable Application portal window demo (text + button + image).", { style = { color = DEMO_MUTED } }) @@ -77,14 +77,16 @@ fun UiScope.overviewSection( text("Checklist groups", { style = { color = DEMO_OK } }) CapabilityGroup.entries.forEach { group -> - val required = CapabilityChecklistCatalog.required.filter { it.group == group }.size + val required = + CapabilityChecklistCatalog.required + .filter { it.group == group } + .size val implemented = implementedCapabilities.count { it.group == group } val ok = implemented == required text( "${group.title}: $implemented/$required", - { style = { color = if (ok) DEMO_OK else 0xFFE06A6A.toInt() } } + { style = { color = if (ok) DEMO_OK else 0xFFE06A6A.toInt() } }, ) } } } - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutSection.kt index 30264f6..708eb17 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutSection.kt @@ -1,19 +1,20 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputOption import org.dreamfinity.dsgl.core.dom.elements.InputType -import org.dreamfinity.dsgl.core.style.* +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED -private val POSITION_MODE_OPTIONS = listOf( - PositionMode.Static, - PositionMode.Relative, - PositionMode.Absolute, - PositionMode.Fixed, - PositionMode.Sticky -) +private val POSITION_MODE_OPTIONS = + listOf( + PositionMode.Static, + PositionMode.Relative, + PositionMode.Absolute, + PositionMode.Fixed, + PositionMode.Sticky, + ) private const val OFFSET_MIN = -72 private const val OFFSET_MAX = 120 @@ -65,7 +66,6 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { val fixedBaseLeft = (viewportWidthPx - 236).coerceAtLeast(104) val fixedBaseTop = 72 - div({ key = "section.positionedLayout" style = { @@ -80,7 +80,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { text("Positioned layout verification surface: static/relative/absolute/fixed + z-index + scroll + hit-testing") text( "Top-level fixed and root-anchored absolute badges are intentional: they prove root-space anchoring.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) controls( @@ -132,13 +132,16 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { positionedDemoScrollClicks = 0 positionedDemoStickyTopClicks = 0 positionedDemoStickyCombinedClicks = 0 - } - ) + }, + ), ) div({ style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px } }) { @@ -147,7 +150,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { key = "positioned.mode.playground" style = { position = PositionMode.Relative - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } backgroundColor = 0xFF2A3440.toInt() padding = 5.px } @@ -158,22 +164,27 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onMouseClick = { positionedDemoLastClick = "mode-$demoMode" } style = { position = demoMode - left = if (positionedDemoUseLeft) { - leftOffset.px - } else { - null - } + left = + if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } right = rightOffset.px - top = if (positionedDemoUseTop) { - topOffset.px - } else { - null - } + top = + if (positionedDemoUseTop) { + topOffset.px + } else { + null + } bottom = bottomOffset.px padding = 5.px zIndex = zBlue backgroundColor = 0xCC476487.toInt() - border { width = 1.px; color = 0xFFBFD8EE.toInt() } + border { + width = 1.px + color = 0xFFBFD8EE.toInt() + } } }) { text("mode=$demoMode") @@ -184,7 +195,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { div({ style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px } }) { @@ -196,7 +210,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { flexDirection = FlexDirection.Column gap = 2.px padding = 5.px - border { width = 1.px; color = 0xFF667A8D.toInt() } + border { + width = 1.px + color = 0xFF667A8D.toInt() + } backgroundColor = 0xFF2A313A.toInt() } }) { @@ -204,7 +221,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { style = { padding = 5.px backgroundColor = 0xFF364352.toInt() - border { width = 1.px; color = 0xFF7E97AD.toInt() } + border { + width = 1.px + color = 0xFF7E97AD.toInt() + } } }) { text("Flow item before") } div({ @@ -221,21 +241,30 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { bottom = bottomOffset.px padding = 5.px backgroundColor = 0xFF3F5A73.toInt() - border { width = 1.px; color = 0xFF9DB7CF.toInt() } + border { + width = 1.px + color = 0xFF9DB7CF.toInt() + } } }) { text("position: static + offsets (still in normal slot)") } div({ style = { padding = 5.px backgroundColor = 0xFF364352.toInt() - border { width = 1.px; color = 0xFF7E97AD.toInt() } + border { + width = 1.px + color = 0xFF7E97AD.toInt() + } } }) { text("Flow item after") } } } div({ style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px } }) { @@ -247,7 +276,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { flexDirection = FlexDirection.Row gap = 3.px padding = 5.px - border { width = 1.px; color = 0xFF6D7C8A.toInt() } + border { + width = 1.px + color = 0xFF6D7C8A.toInt() + } backgroundColor = 0xFF29323D.toInt() } }) { @@ -255,7 +287,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { style = { padding = 5.px backgroundColor = 0xFF455B70.toInt() - border { width = 1.px; color = 0xFF91A9BF.toInt() } + border { + width = 1.px + color = 0xFF91A9BF.toInt() + } } }) { text("left") } div({ @@ -264,21 +299,26 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onMouseClick = { positionedDemoLastClick = "relative-target" } style = { position = PositionMode.Relative - left = if (positionedDemoUseLeft) { - leftOffset.px - } else { - null - } + left = + if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } right = rightOffset.px - top = if (positionedDemoUseTop) { - topOffset.px - } else { - null - } + top = + if (positionedDemoUseTop) { + topOffset.px + } else { + null + } bottom = bottomOffset.px padding = 5.px backgroundColor = 0xFF4A6A87.toInt() - border { width = 1.px; color = 0xFFB5CFE6.toInt() } + border { + width = 1.px + color = 0xFFB5CFE6.toInt() + } zIndex = zBlue } }) { text("relative target") } @@ -286,7 +326,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { style = { padding = 5.px backgroundColor = 0xFF455B70.toInt() - border { width = 1.px; color = 0xFF91A9BF.toInt() } + border { + width = 1.px + color = 0xFF91A9BF.toInt() + } } }) { text("right") } } @@ -294,7 +337,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { div({ style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px } }) { @@ -306,20 +352,27 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { flexDirection = FlexDirection.Column gap = 2.px padding = 5.px - border { width = 1.px; color = 0xFF6A7E8C.toInt() } + border { + width = 1.px + color = 0xFF6A7E8C.toInt() + } backgroundColor = 0xFF2B333E.toInt() } }) { text( "Root-anchored absolute badge appears outside this card (near top-middle).", - { style = { color = DEMO_MUTED } }) + { style = { color = DEMO_MUTED } }, + ) div({ key = "positioned.absolute.container" style = { position = PositionMode.Relative overflowY = Overflow.Auto padding = 5.px - border { width = 1.px; color = 0xFF8AA0B4.toInt() } + border { + width = 1.px + color = 0xFF8AA0B4.toInt() + } backgroundColor = 0xFF344252.toInt() } }) { @@ -331,21 +384,26 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { } style = { position = PositionMode.Absolute - left = if (positionedDemoUseLeft) { - leftOffset.px - } else { - null - } + left = + if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } right = rightOffset.px - top = if (positionedDemoUseTop) { - topOffset.px - } else { - null - } + top = + if (positionedDemoUseTop) { + topOffset.px + } else { + null + } bottom = bottomOffset.px padding = 5.px backgroundColor = 0xAA2B4E73.toInt() - border { width = 1.px; color = 0xFFD2E8FF.toInt() } + border { + width = 1.px + color = 0xFFD2E8FF.toInt() + } zIndex = zGreen } }) { text("absolute in relative ancestor") } @@ -358,7 +416,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { div({ style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px } }) { @@ -372,7 +433,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { top = rootAnchoredTop.px padding = 5.px backgroundColor = 0xCC2C5A89.toInt() - border { width = 1.px; color = 0xFFB9D9FA.toInt() } + border { + width = 1.px + color = 0xFFB9D9FA.toInt() + } zIndex = 18 } }) { @@ -386,7 +450,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { position = PositionMode.Relative overflowY = Overflow.Auto padding = 5.px - border { width = 1.px; color = 0xFF73879A.toInt() } + border { + width = 1.px + color = 0xFF73879A.toInt() + } backgroundColor = 0xFF27323E.toInt() display = Display.Flex flexDirection = FlexDirection.Column @@ -402,7 +469,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { top = (topOffset / 2).px padding = 5.px backgroundColor = 0xFF496B8A.toInt() - border { width = 1.px; color = 0xFFB6D5EE.toInt() } + border { + width = 1.px + color = 0xFFB6D5EE.toInt() + } } }) { text("relative in scroller") @@ -415,7 +485,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { top = 34.px padding = 5.px backgroundColor = 0xAA345469.toInt() - border { width = 1.px; color = 0xFFCFE6F4.toInt() } + border { + width = 1.px + color = 0xFFCFE6F4.toInt() + } zIndex = 7 } }) { text("absolute in scroller") } @@ -429,7 +502,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { positionedDemoLastClick = "scroll-action" } }) - text("scroll action clicks=${positionedDemoScrollClicks}", { style = { color = DEMO_MUTED } }) + text("scroll action clicks=$positionedDemoScrollClicks", { style = { color = DEMO_MUTED } }) } } @@ -439,21 +512,26 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onMouseClick = { positionedDemoLastClick = "fixed-badge" } style = { position = PositionMode.Fixed - left = if (positionedDemoUseLeft) { - (fixedBaseLeft + leftOffset).coerceAtLeast(0).px - } else { - null - } + left = + if (positionedDemoUseLeft) { + (fixedBaseLeft + leftOffset).coerceAtLeast(0).px + } else { + null + } right = rightOffset.px - top = if (positionedDemoUseTop) { - (fixedBaseTop + topOffset).coerceAtLeast(0).px - } else { - null - } + top = + if (positionedDemoUseTop) { + (fixedBaseTop + topOffset).coerceAtLeast(0).px + } else { + null + } bottom = bottomOffset.px padding = 5.px backgroundColor = 0xCC4F3C73.toInt() - border { width = 1.px; color = 0xFFE3D5F6.toInt() } + border { + width = 1.px + color = 0xFFE3D5F6.toInt() + } zIndex = 28 } }) { @@ -463,31 +541,37 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { div({ key = "positioned.sticky.surface" style = { - border { width = 1.px; color = 0xFF6A7D8F.toInt() } + border { + width = 1.px + color = 0xFF6A7D8F.toInt() + } padding = 5.px display = Display.Flex flexDirection = FlexDirection.Column gap = 3.px } }) { - text("H. Sticky: in-flow slot + visual stick with per-axis nearest scroll container and direct-parent clamp.") + text( + "H. Sticky: in-flow slot + visual stick with per-axis nearest scroll " + + "container and direct-parent clamp.", + ) text( "Inspector target key: positioned.sticky.xy.target", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) stickyVerticalGroup( onSetLastHover = { positionedDemoLastHover = it }, onSetLastClick = { positionedDemoLastClick = it }, stickyTopClicks = positionedDemoStickyTopClicks, - onStickyTopClick = { positionedDemoStickyTopClicks += 1 } + onStickyTopClick = { positionedDemoStickyTopClicks += 1 }, ) stickyHorizontalGroup() stickyXYGroup( onSetLastHover = { positionedDemoLastHover = it }, onSetLastClick = { positionedDemoLastClick = it }, stickyCombinedClicks = positionedDemoStickyCombinedClicks, - onStickyCombinedClick = { positionedDemoStickyCombinedClicks += 1 } + onStickyCombinedClick = { positionedDemoStickyCombinedClicks += 1 }, ) stickyNoInsets() stickyClamp() @@ -498,7 +582,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { key = "positioned.z.overlap" style = { position = PositionMode.Relative - border { width = 1.px; color = 0xFF6F8498.toInt() } + border { + width = 1.px + color = 0xFF6F8498.toInt() + } backgroundColor = 0xFF283340.toInt() } }) { @@ -513,7 +600,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onClick = { positionedDemoBlueClicks += 1 positionedDemoLastClick = "blue" - } + }, ) positionedOverlapCard( key = "positioned.z.green", @@ -526,7 +613,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onClick = { positionedDemoGreenClicks += 1 positionedDemoLastClick = "green" - } + }, ) positionedOverlapCard( key = "positioned.z.red", @@ -539,12 +626,12 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onClick = { positionedDemoRedClicks += 1 positionedDemoLastClick = "red" - } + }, ) } text( - "clicks blue=${positionedDemoBlueClicks}, green=${positionedDemoGreenClicks}, red=${positionedDemoRedClicks}", - { style = { color = DEMO_MUTED } } + "clicks blue=$positionedDemoBlueClicks, green=$positionedDemoGreenClicks, red=$positionedDemoRedClicks", + { style = { color = DEMO_MUTED } }, ) div({ @@ -554,15 +641,12 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { gap = 3.px } }) { - button( - if (positionedDemoTieSwap) "tie order: second->first" else "tie order: first->second", - { - onMouseClick = { positionedDemoTieSwap = !positionedDemoTieSwap } - } - ) + button(if (positionedDemoTieSwap) "tie order: second->first" else "tie order: first->second", { + onMouseClick = { positionedDemoTieSwap = !positionedDemoTieSwap } + }) text( "same z, later DOM child should win overlap hit", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } @@ -570,7 +654,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { key = "positioned.tie.sample" style = { position = PositionMode.Relative - border { width = 1.px; color = 0xFF6C7F91.toInt() } + border { + width = 1.px + color = 0xFF6C7F91.toInt() + } backgroundColor = 0xFF283440.toInt() } }) { @@ -585,7 +672,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onTieClick = { positionedDemoTieSecondClicks += 1 positionedDemoLastClick = it - } + }, ) positionedTieCard( label = "first", @@ -597,7 +684,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onTieClick = { positionedDemoTieFirstClicks += 1 positionedDemoLastClick = it - } + }, ) } else { positionedTieCard( @@ -610,7 +697,7 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onTieClick = { positionedDemoTieFirstClicks += 1 positionedDemoLastClick = it - } + }, ) positionedTieCard( label = "second", @@ -622,13 +709,13 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { onTieClick = { positionedDemoTieSecondClicks += 1 positionedDemoLastClick = it - } + }, ) } } text( - "tie clicks first=${positionedDemoTieFirstClicks}, second=${positionedDemoTieSecondClicks}", - { style = { color = DEMO_MUTED } } + "tie clicks first=$positionedDemoTieFirstClicks, second=$positionedDemoTieSecondClicks", + { style = { color = DEMO_MUTED } }, ) text("Mixed static vs positioned overlap (stage rule).") @@ -636,7 +723,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { key = "positioned.mixed.sample" style = { position = PositionMode.Relative - border { width = 1.px; color = 0xFF6E8196.toInt() } + border { + width = 1.px + color = 0xFF6E8196.toInt() + } backgroundColor = 0xFF293541.toInt() } }) { @@ -652,7 +742,10 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { zIndex = 999 padding = 5.px backgroundColor = 0xFF667F9A.toInt() - border { width = 1.px; color = 0xFFC4D8EB.toInt() } + border { + width = 1.px + color = 0xFFC4D8EB.toInt() + } } }) { text("static z=999") @@ -671,21 +764,29 @@ fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { zIndex = -100 padding = 5.px backgroundColor = 0xCC2F536F.toInt() - border { width = 1.px; color = 0xFFB8D7EE.toInt() } + border { + width = 1.px + color = 0xFFB8D7EE.toInt() + } } }) { text("positioned z=-100") } } - text( - "mixed clicks static=${positionedDemoMixedStaticClicks}, positioned=${positionedDemoMixedPositionedClicks}", - { style = { color = DEMO_MUTED; minHeight = 1.em } } - ) + text("mixed clicks static=$positionedDemoMixedStaticClicks, positioned=$positionedDemoMixedPositionedClicks", { + style = { + color = DEMO_MUTED + minHeight = 1.em + } + }) repeat(40) { div({ style = { padding = 1.px - border { width = 1.px; color = 0xFF617A90.toInt() } + border { + width = 1.px + color = 0xFF617A90.toInt() + } } }) { text("Hi there, #$it pyj", { style { fontSize = it.px } }) @@ -698,12 +799,15 @@ private fun UiScope.stickyVerticalGroup( onSetLastHover: (String) -> Unit, onSetLastClick: (String) -> Unit, stickyTopClicks: Int, - onStickyTopClick: () -> Unit + onStickyTopClick: () -> Unit, ) { div({ key = "positioned.sticky.vertical.group" style = { - border { width = 1.px; color = 0xFF6C8096.toInt() } + border { + width = 1.px + color = 0xFF6C8096.toInt() + } padding = 4.px display = Display.Flex flexDirection = FlexDirection.Column @@ -721,7 +825,10 @@ private fun UiScope.stickyVerticalGroup( div({ style = { width = 50.percent - border { width = 1.px; color = 0xFF6A7E90.toInt() } + border { + width = 1.px + color = 0xFF6A7E90.toInt() + } padding = 3.px } }) { @@ -730,7 +837,10 @@ private fun UiScope.stickyVerticalGroup( key = "positioned.sticky.vertical.top.scroller" style = { overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 7.em display = Display.Flex flexDirection = FlexDirection.Column @@ -759,7 +869,10 @@ private fun UiScope.stickyVerticalGroup( div({ style = { width = 50.percent - border { width = 1.px; color = 0xFF6A7E90.toInt() } + border { + width = 1.px + color = 0xFF6A7E90.toInt() + } padding = 3.px } }) { @@ -768,7 +881,10 @@ private fun UiScope.stickyVerticalGroup( key = "positioned.sticky.vertical.bottom.scroller" style = { overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 7.em display = Display.Flex flexDirection = FlexDirection.Column @@ -786,7 +902,10 @@ private fun UiScope.stickyVerticalGroup( bottom = 0.px zIndex = 5 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC446181.toInt() } }) { @@ -802,7 +921,10 @@ private fun UiScope.stickyVerticalGroup( key = "positioned.sticky.vertical.precedence.scroller" style = { overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 6.em display = Display.Flex flexDirection = FlexDirection.Column @@ -818,7 +940,10 @@ private fun UiScope.stickyVerticalGroup( bottom = 0.px zIndex = 5 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC3F5871.toInt() } }) { text("top+bottom set -> top wins (top=6)") } @@ -826,7 +951,7 @@ private fun UiScope.stickyVerticalGroup( } text( "sticky top clicks=$stickyTopClicks", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -835,7 +960,10 @@ private fun UiScope.stickyHorizontalGroup() { div({ key = "positioned.sticky.horizontal.group" style = { - border { width = 1.px; color = 0xFF6C8096.toInt() } + border { + width = 1.px + color = 0xFF6C8096.toInt() + } padding = 4.px display = Display.Flex flexDirection = FlexDirection.Column @@ -847,7 +975,10 @@ private fun UiScope.stickyHorizontalGroup() { key = "positioned.sticky.horizontal.left.scroller" style = { overflowX = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } display = Display.Flex flexDirection = FlexDirection.Row gap = 2.px @@ -861,7 +992,10 @@ private fun UiScope.stickyHorizontalGroup() { left = 0.px zIndex = 5 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC3F617B.toInt() } }) { text("left=0") } @@ -873,7 +1007,10 @@ private fun UiScope.stickyHorizontalGroup() { key = "positioned.sticky.horizontal.right.scroller" style = { overflowX = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } display = Display.Flex flexDirection = FlexDirection.Row gap = 2.px @@ -890,7 +1027,10 @@ private fun UiScope.stickyHorizontalGroup() { right = 0.px zIndex = 5 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC45637F.toInt() } }) { text("right=0") } @@ -902,7 +1042,10 @@ private fun UiScope.stickyHorizontalGroup() { key = "positioned.sticky.horizontal.precedence.scroller" style = { overflowX = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } display = Display.Flex flexDirection = FlexDirection.Row gap = 2.px @@ -917,7 +1060,10 @@ private fun UiScope.stickyHorizontalGroup() { right = 0.px zIndex = 5 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC415A74.toInt() } }) { text("left+right set -> left wins (left=8)") } @@ -926,19 +1072,21 @@ private fun UiScope.stickyHorizontalGroup() { } } } - } private fun UiScope.stickyXYGroup( onSetLastHover: (String) -> Unit, onSetLastClick: (String) -> Unit, stickyCombinedClicks: Int, - onStickyCombinedClick: () -> Unit + onStickyCombinedClick: () -> Unit, ) { div({ key = "positioned.sticky.xy.group" style = { - border { width = 1.px; color = 0xFF6C8096.toInt() } + border { + width = 1.px + color = 0xFF6C8096.toInt() + } padding = 4.px display = Display.Flex flexDirection = FlexDirection.Column @@ -951,7 +1099,10 @@ private fun UiScope.stickyXYGroup( style = { overflowX = Overflow.Auto overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 7.em display = Display.Flex flexDirection = FlexDirection.Column @@ -979,7 +1130,7 @@ private fun UiScope.stickyXYGroup( } text( "sticky x+y clicks=$stickyCombinedClicks", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -988,7 +1139,10 @@ private fun UiScope.stickyNoInsets() { div({ key = "positioned.sticky.inactive.group" style = { - border { width = 1.px; color = 0xFF6C8096.toInt() } + border { + width = 1.px + color = 0xFF6C8096.toInt() + } padding = 4.px display = Display.Flex flexDirection = FlexDirection.Column @@ -1001,7 +1155,10 @@ private fun UiScope.stickyNoInsets() { style = { overflowX = Overflow.Auto overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 6.em display = Display.Flex flexDirection = FlexDirection.Column @@ -1014,7 +1171,10 @@ private fun UiScope.stickyNoInsets() { style = { position = PositionMode.Sticky padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC455F78.toInt() } }) { text("sticky without insets") } @@ -1029,7 +1189,10 @@ private fun UiScope.stickyClamp() { div({ key = "positioned.sticky.clamp.group" style = { - border { width = 1.px; color = 0xFF6C8096.toInt() } + border { + width = 1.px + color = 0xFF6C8096.toInt() + } padding = 4.px display = Display.Flex flexDirection = FlexDirection.Column @@ -1041,7 +1204,10 @@ private fun UiScope.stickyClamp() { key = "positioned.sticky.clamp.scroller" style = { overflowY = Overflow.Auto - border { width = 1.px; color = 0xFF8097AB.toInt() } + border { + width = 1.px + color = 0xFF8097AB.toInt() + } maxHeight = 7.em display = Display.Flex flexDirection = FlexDirection.Column @@ -1053,7 +1219,10 @@ private fun UiScope.stickyClamp() { div({ key = "positioned.sticky.clamp.parent" style = { - border { width = 1.px; color = 0xFF8FA5B9.toInt() } + border { + width = 1.px + color = 0xFF8FA5B9.toInt() + } backgroundColor = 0xFF2F3D4C.toInt() maxHeight = 6.em overflowY = Overflow.Auto @@ -1070,7 +1239,10 @@ private fun UiScope.stickyClamp() { top = 0.px zIndex = 6 padding = 3.px - border { width = 1.px; color = 0xFF9FC2DF.toInt() } + border { + width = 1.px + color = 0xFF9FC2DF.toInt() + } backgroundColor = 0xCC3F5A74.toInt() } }) { text("clamped sticky top") } @@ -1105,7 +1277,7 @@ data class ControlsProps( val onSetZBlue: (Long) -> Unit, val onSetZGreen: (Long) -> Unit, val onSetZRed: (Long) -> Unit, - val onReset: () -> Unit + val onReset: () -> Unit, ) private fun UiScope.controls(props: ControlsProps) { @@ -1116,7 +1288,10 @@ private fun UiScope.controls(props: ControlsProps) { flexDirection = FlexDirection.Column gap = 3.px padding = 5.px - border { width = 1.px; color = 0xFF617A90.toInt() } + border { + width = 1.px + color = 0xFF617A90.toInt() + } backgroundColor = 0xFF2A3541.toInt() position = PositionMode.Sticky top = 0.px @@ -1133,18 +1308,12 @@ private fun UiScope.controls(props: ControlsProps) { button("mode=${props.demoMode.name.lowercase()}", { onMouseClick = { props.onModeCycle() } }) - button( - if (props.useLeft) "h: left first" else "h: right fallback", - { - onMouseClick = { props.onToggleUseLeft() } - } - ) - button( - if (props.useTop) "v: top first" else "v: bottom fallback", - { - onMouseClick = { props.onToggleUseTop() } - } - ) + button(if (props.useLeft) "h: left first" else "h: right fallback", { + onMouseClick = { props.onToggleUseLeft() } + }) + button(if (props.useTop) "v: top first" else "v: bottom fallback", { + onMouseClick = { props.onToggleUseTop() } + }) button("Reset", { onMouseClick = { props.onReset() } }) @@ -1165,7 +1334,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.leftOffset.toLong(), min = OFFSET_MIN.toLong(), max = OFFSET_MAX.toLong(), - onChange = props.onSetLeft + onChange = props.onSetLeft, ) positionedRangeControl( label = "right", @@ -1173,7 +1342,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.rightOffset.toLong(), min = 0, max = OFFSET_MAX.toLong(), - onChange = props.onSetRight + onChange = props.onSetRight, ) positionedRangeControl( label = "top", @@ -1181,7 +1350,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.topOffset.toLong(), min = OFFSET_MIN.toLong(), max = OFFSET_MAX.toLong(), - onChange = props.onSetTop + onChange = props.onSetTop, ) positionedRangeControl( label = "bottom", @@ -1189,7 +1358,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.bottomOffset.toLong(), min = 0, max = OFFSET_MAX.toLong(), - onChange = props.onSetBottom + onChange = props.onSetBottom, ) positionedRangeControl( label = "z blue", @@ -1197,7 +1366,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.zBlue.toLong(), min = Z_MIN.toLong(), max = Z_MAX.toLong(), - onChange = props.onSetZBlue + onChange = props.onSetZBlue, ) positionedRangeControl( label = "z green", @@ -1205,7 +1374,7 @@ private fun UiScope.controls(props: ControlsProps) { value = props.zGreen.toLong(), min = Z_MIN.toLong(), max = Z_MAX.toLong(), - onChange = props.onSetZGreen + onChange = props.onSetZGreen, ) positionedRangeControl( label = "z red", @@ -1213,11 +1382,11 @@ private fun UiScope.controls(props: ControlsProps) { value = props.zRed.toLong(), min = Z_MIN.toLong(), max = Z_MAX.toLong(), - onChange = props.onSetZRed + onChange = props.onSetZRed, ) text( "hover=${props.lastHover} click=${props.lastClick}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } } @@ -1228,7 +1397,7 @@ private fun UiScope.positionedRangeControl( value: Long, min: Long, max: Long, - onChange: (Long) -> Unit + onChange: (Long) -> Unit, ) { div({ this.key = "$key-container" @@ -1250,7 +1419,7 @@ private fun UiScope.positionedRangeControl( value = value, min = min, max = max, - step = 1 + step = 1, ), { this.key = key @@ -1261,7 +1430,7 @@ private fun UiScope.positionedRangeControl( style = { width = 90.percent } - } + }, ) } } @@ -1274,7 +1443,7 @@ private fun UiScope.positionedOverlapCard( zIndex: Int, color: Int, onHover: () -> Unit, - onClick: () -> Unit + onClick: () -> Unit, ) { div({ this.key = key @@ -1286,7 +1455,10 @@ private fun UiScope.positionedOverlapCard( this.top = top.px padding = 5.px backgroundColor = color - border { width = 1.px; this.color = 0xFFE6F1FD.toInt() } + border { + width = 1.px + this.color = 0xFFE6F1FD.toInt() + } this.zIndex = zIndex } }) { @@ -1301,7 +1473,7 @@ private fun UiScope.positionedTieCard( zIndex: Int, color: Int, onSetLastHover: (String) -> Unit, - onTieClick: (String) -> Unit + onTieClick: (String) -> Unit, ) { div({ key = "positioned.tie.$label" @@ -1313,13 +1485,13 @@ private fun UiScope.positionedTieCard( this.top = top.px padding = 5.px backgroundColor = color - border { width = 1.px; this.color = 0xFFDDEDFD.toInt() } + border { + width = 1.px + this.color = 0xFFDDEDFD.toInt() + } this.zIndex = zIndex } }) { text("$label z=$zIndex") } } - - - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/StylesheetsSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/StylesheetsSection.kt index 7623206..338669e 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/StylesheetsSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/StylesheetsSection.kt @@ -1,12 +1,12 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.hooks.useMemo import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED fun UiScope.stylesheetsSection( @@ -14,7 +14,7 @@ fun UiScope.stylesheetsSection( onInfo: (String) -> Unit, loadStylesheetText: () -> String, saveStylesheetText: (String) -> Unit, - onReloadStylesheets: () -> Unit + onReloadStylesheets: () -> Unit, ) { val initialLoad by useMemo { runCatching { loadStylesheetText() } @@ -27,8 +27,11 @@ fun UiScope.stylesheetsSection( if (initialLoad.isSuccess) { "loaded" } else { - "load failed: ${initialLoad.exceptionOrNull()?.javaClass?.simpleName ?: "unknown"}" - } + "load failed: ${initialLoad + .exceptionOrNull() + ?.javaClass + ?.simpleName ?: "unknown"}" + }, ) div({ @@ -51,7 +54,10 @@ fun UiScope.stylesheetsSection( style = { padding = 4.px gap = 3.px - border { width = 1.px; color = 0xFF5E6A77.toInt() } + border { + width = 1.px + color = 0xFF5E6A77.toInt() + } } }) { text("Demo stylesheet editor: showcase_styles.dss") @@ -121,7 +127,7 @@ fun UiScope.stylesheetsSection( } text( "status=$stylesheetEditorStatus; reloads=$stylesheetReloadCount; clicks=$stylesheetDemoClickCount", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) } @@ -132,7 +138,10 @@ fun UiScope.stylesheetsSection( style = { padding = 4.px gap = 3.px - border { width = 1.px; color = 0xFF5E6A77.toInt() } + border { + width = 1.px + color = 0xFF5E6A77.toInt() + } } }) { text("Selector matrix", { @@ -213,7 +222,10 @@ fun UiScope.stylesheetsSection( style = { padding = 4.px gap = 3.px - border { width = 1.px; color = 0xFF5E6A77.toInt() } + border { + width = 1.px + color = 0xFF5E6A77.toInt() + } } }) { text("Pseudo-states: :hover, :active, :focus, :disabled") @@ -236,7 +248,7 @@ fun UiScope.stylesheetsSection( input( InputType.Text( value = stylesheetDemoTextValue, - placeholder = "Focus target" + placeholder = "Focus target", ), { key = "styles.state.focusInput" @@ -247,7 +259,7 @@ fun UiScope.stylesheetsSection( stylesheetDemoTextValue = event.value onLogHook("styles.state.focusInput.onInput", event, "value=${event.value}") } - } + }, ) button("Disabled", { key = "styles.state.disabled" @@ -265,7 +277,10 @@ fun UiScope.stylesheetsSection( style = { padding = 4.px gap = 2.px - border { width = 1.px; color = 0xFF5E6A77.toInt() } + border { + width = 1.px + color = 0xFF5E6A77.toInt() + } } }) { text("Variable demo uses :root { --primary: ... } and var(--primary)") @@ -284,7 +299,10 @@ fun UiScope.stylesheetsSection( style = { padding = 4.px gap = 3.px - border { width = 1.px; color = 0xFF5E6A77.toInt() } + border { + width = 1.px + color = 0xFF5E6A77.toInt() + } } }) { text("CSS units demo: px, em, %, vw, vh") diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextEditingSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextEditingSection.kt index a27f3cf..4cb4ae7 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextEditingSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextEditingSection.kt @@ -1,13 +1,13 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections -import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.Event import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.KeyModifiers +import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.hooks.useState import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_OK @@ -20,7 +20,7 @@ fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) { var textEditingSingleValue by useState("Edit this line") var textEditingPasswordValue by useState("secret42") var textEditingAreaValue by useState( - "Line 1: drag-select me\nLine 2: use Shift+Arrows\nLine 3: Ctrl/Cmd+C/V/X" + "Line 1: drag-select me\nLine 2: use Shift+Arrows\nLine 3: Ctrl/Cmd+C/V/X", ) var textEditingSawSelectionDrag by useState(false) var textEditingSawShiftSelection by useState(false) @@ -44,7 +44,7 @@ fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) { input( InputType.Text( value = textEditingSingleValue, - placeholder = "Type and select text" + placeholder = "Type and select text", ), { key = SINGLE_KEY @@ -70,7 +70,7 @@ fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) { onLogHook("textEditing.clipboard", event, "key=$SINGLE_KEY code=${event.keyCode}") } } - } + }, ) text("Single-line: caret + selection visible in control", { style = { color = DEMO_MUTED } @@ -80,7 +80,7 @@ fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) { input( InputType.Password( value = textEditingPasswordValue, - placeholder = "password" + placeholder = "password", ), { key = PASSWORD_KEY @@ -106,7 +106,7 @@ fun UiScope.textEditingSection(onLogHook: (String, Event, String?) -> Unit) { onLogHook("textEditing.clipboard", event, "key=$PASSWORD_KEY code=${event.keyCode}") } } - } + }, ) text("Password: masked selection/caret behavior", { style = { color = DEMO_MUTED } @@ -176,19 +176,17 @@ private fun UiScope.checklistLine(textValue: String, done: Boolean) { }) } -private fun isArrowLike(keyCode: Int): Boolean { - return keyCode == KeyCodes.LEFT || - keyCode == KeyCodes.RIGHT || - keyCode == KeyCodes.UP || - keyCode == KeyCodes.DOWN || - keyCode == KeyCodes.HOME || - keyCode == KeyCodes.END -} +private fun isArrowLike(keyCode: Int): Boolean = + keyCode == KeyCodes.LEFT || + keyCode == KeyCodes.RIGHT || + keyCode == KeyCodes.UP || + keyCode == KeyCodes.DOWN || + keyCode == KeyCodes.HOME || + keyCode == KeyCodes.END -private fun isClipboardShortcut(keyCode: Int): Boolean { - return keyCode == KeyCodes.C || - keyCode == KeyCodes.X || - keyCode == KeyCodes.V || - keyCode == KeyCodes.A || - keyCode == KeyCodes.Z -} +private fun isClipboardShortcut(keyCode: Int): Boolean = + keyCode == KeyCodes.C || + keyCode == KeyCodes.X || + keyCode == KeyCodes.V || + keyCode == KeyCodes.A || + keyCode == KeyCodes.Z diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextWrapSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextWrapSection.kt index 41e4731..8e3562c 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextWrapSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/TextWrapSection.kt @@ -1,18 +1,17 @@ package org.dreamfinity.dsgl.mcForge1710.demo.sections +import org.dreamfinity.dsgl.core.dom.elements.* import org.dreamfinity.dsgl.core.dsl.* -import org.dreamfinity.dsgl.core.dom.elements.InputType -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.style.TextWrap import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.* import org.dreamfinity.dsgl.mcForge1710.demo.support.DEMO_MUTED private const val WRAP_SAMPLE_TEXT = "This sentence demonstrates style.textWrap on text and button labels inside a fixed-width panel." private const val WRAP_SAMPLE_WORD = "long_unbroken_word_to_force_hard_break_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" private const val WRAP_TEXTAREA_SAMPLE = - "Textarea sample: long_unbroken_word_to_force_hard_break_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ\nSecond line with spaces for normal wrapping." + "Textarea sample: long_unbroken_word_to_force_hard_break_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ\n" + + "Second line with spaces for normal wrapping." fun UiScope.textWrapSection(onInfo: (String) -> Unit) { val minWidth = 96 @@ -33,7 +32,7 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { text("Text Wrap: wrap / nowrap") text( "Wrap keeps text inside panel width; NoWrap keeps one line and may overflow or clip.", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -50,7 +49,7 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { textWrapNoWrap = !textWrapNoWrap onInfo("TextWrap mode=${if (textWrapNoWrap) "nowrap" else "wrap"}") } - } + }, ) button("Reset width", { onMouseClick = { @@ -64,7 +63,7 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { value = panelWidth.toLong(), min = minWidth.toLong(), max = maxWidth.toLong(), - step = 2 + step = 2, ), { key = "textWrap.width" @@ -73,11 +72,11 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: panelWidth.toLong() textWrapWidth = next.coerceIn(minWidth.toLong(), maxWidth.toLong()) } - } + }, ) text( "panelWidth=$panelWidth mode=${if (mode == TextWrap.Wrap) "wrap" else "nowrap"}", - { style = { color = DEMO_MUTED } } + { style = { color = DEMO_MUTED } }, ) div({ @@ -89,9 +88,11 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { padding = 3.px backgroundColor = 0xFF2B3542.toInt() gap = 2.px - border { width = 1.px; color = 0xFF6F8298.toInt() } + border { + width = 1.px + color = 0xFF6F8298.toInt() + } } - }) { text("Text node (static)", { style = { textWrap = mode } }) text(WRAP_SAMPLE_TEXT, { style = { textWrap = mode } }) @@ -116,5 +117,3 @@ fun UiScope.textWrapSection(onInfo: (String) -> Unit) { } } } - - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/CapabilityChecklistCatalog.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/CapabilityChecklistCatalog.kt index 4b57750..a75b7c1 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/CapabilityChecklistCatalog.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/CapabilityChecklistCatalog.kt @@ -1,19 +1,21 @@ package org.dreamfinity.dsgl.mcForge1710.demo.support -enum class CapabilityGroup(val title: String) { +enum class CapabilityGroup( + val title: String, +) { DSL_BUILDERS("DSL Builders"), INPUT_TYPES("Input Types"), EVENT_HOOKS("Event Hooks"), SHOWCASE_FEATURES("Showcase Features"), - MC_ADAPTER_FEATURES("MC Adapter Features") + MC_ADAPTER_FEATURES("MC Adapter Features"), } enum class CapabilityId( val label: String, - val group: CapabilityGroup + val group: CapabilityGroup, ) { BUILDER_DIV("builder: div", CapabilityGroup.DSL_BUILDERS), - BUILDER_OVERLAY("builder: overlay", CapabilityGroup.DSL_BUILDERS), + BUILDER_STACK_LAYOUT("builder: stack-layout DSL", CapabilityGroup.DSL_BUILDERS), BUILDER_TEXT("builder: text", CapabilityGroup.DSL_BUILDERS), BUILDER_TEXT_LAMBDA("builder: text(lambda)", CapabilityGroup.DSL_BUILDERS), BUILDER_BUTTON("builder: button", CapabilityGroup.DSL_BUILDERS), @@ -71,18 +73,18 @@ enum class CapabilityId( ANIMATION_TRANSFORM("Transform animation demo", CapabilityGroup.SHOWCASE_FEATURES), ANIMATION_OPACITY("Opacity animation demo", CapabilityGroup.SHOWCASE_FEATURES), ANIMATION_KEYFRAMES("Keyframes animation demo", CapabilityGroup.SHOWCASE_FEATURES), - MODAL_HOST("Modal host overlay", CapabilityGroup.SHOWCASE_FEATURES), + MODAL_HOST("Modal portal host", CapabilityGroup.SHOWCASE_FEATURES), MODAL_STACKING("Modal deterministic stacking", CapabilityGroup.SHOWCASE_FEATURES), MODAL_BACKDROP("Modal backdrop behaviors", CapabilityGroup.SHOWCASE_FEATURES), MODAL_ESCAPE("Modal ESC close behavior", CapabilityGroup.SHOWCASE_FEATURES), MODAL_FOCUS_TRAP("Modal focus trap + restore", CapabilityGroup.SHOWCASE_FEATURES), - CONTEXT_MENU_OVERLAY("Context menu overlay rendering", CapabilityGroup.SHOWCASE_FEATURES), + CONTEXT_MENU_PORTAL("Context menu portal rendering", CapabilityGroup.SHOWCASE_FEATURES), CONTEXT_MENU_NESTED("Context menu nested submenus", CapabilityGroup.SHOWCASE_FEATURES), CONTEXT_MENU_ANCHORED("Context menu anchored + cursor open", CapabilityGroup.SHOWCASE_FEATURES), CONTEXT_MENU_SCROLL("Context menu overflow + wheel scroll", CapabilityGroup.SHOWCASE_FEATURES), LAYOUT_GAP_FIXED("Gap + fixed-size demo", CapabilityGroup.SHOWCASE_FEATURES), STYLE_MARGIN_PADDING_BORDER("Style margin/padding/border toggles", CapabilityGroup.SHOWCASE_FEATURES), - OVERLAY_BEHAVIOR("Overlay behavior demo", CapabilityGroup.SHOWCASE_FEATURES), + PORTAL_BEHAVIOR("Portal behavior demo", CapabilityGroup.SHOWCASE_FEATURES), STYLESHEET_SELECTORS("Stylesheet selectors demo", CapabilityGroup.SHOWCASE_FEATURES), STYLESHEET_COMBINATORS("Stylesheet descendant/child/sibling combinators demo", CapabilityGroup.SHOWCASE_FEATURES), STYLESHEET_CASCADE("Stylesheet cascade/specificity/important/inheritance demo", CapabilityGroup.SHOWCASE_FEATURES), @@ -105,259 +107,281 @@ enum class CapabilityId( IMAGE_HTTP("Image: http(s):// path", CapabilityGroup.MC_ADAPTER_FEATURES), ITEMSTACK_2D("Item stack: 2D item", CapabilityGroup.MC_ADAPTER_FEATURES), ITEMSTACK_3D("Item stack: 3D block", CapabilityGroup.MC_ADAPTER_FEATURES), - ITEMSTACK_ROTATION("Item stack rotation controls", CapabilityGroup.MC_ADAPTER_FEATURES) + ITEMSTACK_ROTATION("Item stack rotation controls", CapabilityGroup.MC_ADAPTER_FEATURES), } object CapabilityChecklistCatalog { val required: List = CapabilityId.entries - fun capabilitiesForSection(section: DemoSection): Set = when (section) { - DemoSection.OVERVIEW -> setOf( - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_BUTTON, - CapabilityId.EVENT_INSPECTOR, - CapabilityId.CAPABILITY_CHECKLIST - ) - - DemoSection.INSPECTOR -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT - ) - - DemoSection.LAYOUT_STYLE -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_OVERLAY, - CapabilityId.HOOK_MOUSE_CLICK, - CapabilityId.LAYOUT_GAP_FIXED, - CapabilityId.STYLE_MARGIN_PADDING_BORDER, - CapabilityId.OVERLAY_BEHAVIOR - ) - - DemoSection.LAYOUT_DEBUG -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.LAYOUT_VALIDATOR - ) - - DemoSection.POSITIONED_LAYOUT -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.HOOK_MOUSE_ENTER, - CapabilityId.HOOK_MOUSE_LEAVE, - CapabilityId.HOOK_MOUSE_CLICK, - CapabilityId.HOOK_MOUSE_WHEEL, - CapabilityId.OVERLAY_BEHAVIOR - ) - - DemoSection.OVERFLOW_SCROLL -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.HOOK_MOUSE_CLICK, - ) - - DemoSection.DISPLAY -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.HOOK_MOUSE_CLICK, - CapabilityId.DISPLAY_BLOCK, - CapabilityId.DISPLAY_INLINE, - CapabilityId.DISPLAY_NONE, - CapabilityId.DISPLAY_FLEX, - CapabilityId.DISPLAY_GRID - ) - - DemoSection.TEXT_WRAP -> setOf( - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.TEXT_WRAP - ) - - DemoSection.MSDF_FONTS -> setOf( - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.MSDF_FONT_SWITCH, - CapabilityId.MSDF_OPACITY, - CapabilityId.MSDF_WRAP - ) - - DemoSection.ANIMATIONS -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.HOOK_MOUSE_ENTER, - CapabilityId.HOOK_MOUSE_LEAVE, - CapabilityId.ANIMATION_TRANSFORM, - CapabilityId.ANIMATION_OPACITY, - CapabilityId.ANIMATION_KEYFRAMES - ) - - DemoSection.MODALS -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.MODAL_HOST, - CapabilityId.MODAL_STACKING, - CapabilityId.MODAL_BACKDROP, - CapabilityId.MODAL_ESCAPE, - CapabilityId.MODAL_FOCUS_TRAP - ) - - DemoSection.CONTEXT_MENU -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.HOOK_MOUSE_DOWN, - CapabilityId.CONTEXT_MENU_OVERLAY, - CapabilityId.CONTEXT_MENU_NESTED, - CapabilityId.CONTEXT_MENU_ANCHORED, - CapabilityId.CONTEXT_MENU_SCROLL - ) - - DemoSection.INPUTS -> setOf( - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_TEXTAREA, - CapabilityId.INPUT_TEXT, - CapabilityId.INPUT_PASSWORD, - CapabilityId.INPUT_NUMBER, - CapabilityId.INPUT_RANGE, - CapabilityId.INPUT_CHECKBOX, - CapabilityId.INPUT_RADIO, - CapabilityId.INPUT_DATE - ) - - DemoSection.INPUT_EVENTS -> setOf( - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_TEXTAREA, - CapabilityId.HOOK_FOCUS, - CapabilityId.HOOK_BLUR, - CapabilityId.HOOK_INPUT, - CapabilityId.HOOK_CHANGE - ) - - DemoSection.COLOR_PICKER -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.HOOK_MOUSE_DOWN, - CapabilityId.HOOK_MOUSE_CLICK - ) - - DemoSection.TEXT_EDITING -> setOf( - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_TEXTAREA, - CapabilityId.HOOK_MOUSE_DOWN, - CapabilityId.HOOK_MOUSE_DRAG, - CapabilityId.HOOK_KEY_DOWN, - CapabilityId.HOOK_INPUT - ) - - DemoSection.REFS -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_INPUT, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.REFS_OBJECT, - CapabilityId.REFS_CALLBACK, - CapabilityId.REFS_IMPERATIVE_FOCUS - ) - - DemoSection.DRAG_DROP -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.HOOK_DRAG_START, - CapabilityId.HOOK_DRAG, - CapabilityId.HOOK_DRAG_END, - CapabilityId.HOOK_DRAG_ENTER, - CapabilityId.HOOK_DRAG_OVER, - CapabilityId.HOOK_DRAG_LEAVE, - CapabilityId.HOOK_DROP, - CapabilityId.DND_SMOOTH_GHOST, - CapabilityId.DND_DATA_TRANSFER, - CapabilityId.DND_DROP_EFFECT - ) - - DemoSection.INTERACTIONS -> setOf( - CapabilityId.HOOK_MOUSE_ENTER, - CapabilityId.HOOK_MOUSE_LEAVE, - CapabilityId.HOOK_MOUSE_OVER, - CapabilityId.HOOK_MOUSE_MOVE, - CapabilityId.HOOK_MOUSE_DOWN, - CapabilityId.HOOK_MOUSE_UP, - CapabilityId.HOOK_MOUSE_DRAG, - CapabilityId.HOOK_MOUSE_WHEEL, - CapabilityId.HOOK_KEY_DOWN, - CapabilityId.HOOK_KEY_UP, - CapabilityId.HOOK_KEY_PRESSED, - CapabilityId.HOOK_KEY_RELEASED, - CapabilityId.EVENT_CANCELLATION - ) - - DemoSection.FOCUS_REBUILD -> setOf( - CapabilityId.HOOK_KEY_DOWN, - CapabilityId.HOOK_KEY_UP, - CapabilityId.FOCUS_RETENTION, - CapabilityId.STATE_REBUILD, - CapabilityId.MANUAL_INVALIDATE - ) - - DemoSection.STYLESHEETS -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_BUTTON, - CapabilityId.BUILDER_INPUT, - CapabilityId.STYLESHEET_SELECTORS, - CapabilityId.STYLESHEET_PSEUDO_STATES, - CapabilityId.STYLESHEET_VARIABLES, - CapabilityId.STYLESHEET_INLINE_OVERRIDE, - CapabilityId.STYLESHEET_PROGRAMMATIC_RELOAD - ) - - DemoSection.CSS_CASCADE -> setOf( - CapabilityId.BUILDER_DIV, - CapabilityId.BUILDER_TEXT, - CapabilityId.BUILDER_TEXT_LAMBDA, - CapabilityId.BUILDER_BUTTON, - CapabilityId.HOOK_MOUSE_CLICK, - CapabilityId.STYLESHEET_SELECTORS, - CapabilityId.STYLESHEET_COMBINATORS, - CapabilityId.STYLESHEET_CASCADE - ) - - DemoSection.MC_FEATURES -> setOf( - CapabilityId.BUILDER_IMG, - CapabilityId.BUILDER_ITEM_STACK, - CapabilityId.IMAGE_RESOURCE, - CapabilityId.IMAGE_FILE, - CapabilityId.IMAGE_HTTP, - CapabilityId.ITEMSTACK_2D, - CapabilityId.ITEMSTACK_3D, - CapabilityId.ITEMSTACK_ROTATION - ) - } - - fun implementedByAllSections(): Set { - return DemoSection.entries + fun capabilitiesForSection(section: DemoSection): Set = + when (section) { + DemoSection.OVERVIEW -> + setOf( + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_BUTTON, + CapabilityId.EVENT_INSPECTOR, + CapabilityId.CAPABILITY_CHECKLIST, + ) + + DemoSection.INSPECTOR -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + ) + + DemoSection.LAYOUT_STYLE -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_STACK_LAYOUT, + CapabilityId.HOOK_MOUSE_CLICK, + CapabilityId.LAYOUT_GAP_FIXED, + CapabilityId.STYLE_MARGIN_PADDING_BORDER, + CapabilityId.PORTAL_BEHAVIOR, + ) + + DemoSection.LAYOUT_DEBUG -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.LAYOUT_VALIDATOR, + ) + + DemoSection.POSITIONED_LAYOUT -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.HOOK_MOUSE_ENTER, + CapabilityId.HOOK_MOUSE_LEAVE, + CapabilityId.HOOK_MOUSE_CLICK, + CapabilityId.HOOK_MOUSE_WHEEL, + CapabilityId.PORTAL_BEHAVIOR, + ) + + DemoSection.OVERFLOW_SCROLL -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.HOOK_MOUSE_CLICK, + ) + + DemoSection.DISPLAY -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.HOOK_MOUSE_CLICK, + CapabilityId.DISPLAY_BLOCK, + CapabilityId.DISPLAY_INLINE, + CapabilityId.DISPLAY_NONE, + CapabilityId.DISPLAY_FLEX, + CapabilityId.DISPLAY_GRID, + ) + + DemoSection.TEXT_WRAP -> + setOf( + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.TEXT_WRAP, + ) + + DemoSection.MSDF_FONTS -> + setOf( + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.MSDF_FONT_SWITCH, + CapabilityId.MSDF_OPACITY, + CapabilityId.MSDF_WRAP, + ) + + DemoSection.ANIMATIONS -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.HOOK_MOUSE_ENTER, + CapabilityId.HOOK_MOUSE_LEAVE, + CapabilityId.ANIMATION_TRANSFORM, + CapabilityId.ANIMATION_OPACITY, + CapabilityId.ANIMATION_KEYFRAMES, + ) + + DemoSection.MODALS -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.MODAL_HOST, + CapabilityId.MODAL_STACKING, + CapabilityId.MODAL_BACKDROP, + CapabilityId.MODAL_ESCAPE, + CapabilityId.MODAL_FOCUS_TRAP, + ) + + DemoSection.CONTEXT_MENU -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.HOOK_MOUSE_DOWN, + CapabilityId.CONTEXT_MENU_PORTAL, + CapabilityId.CONTEXT_MENU_NESTED, + CapabilityId.CONTEXT_MENU_ANCHORED, + CapabilityId.CONTEXT_MENU_SCROLL, + ) + + DemoSection.INPUTS -> + setOf( + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_TEXTAREA, + CapabilityId.INPUT_TEXT, + CapabilityId.INPUT_PASSWORD, + CapabilityId.INPUT_NUMBER, + CapabilityId.INPUT_RANGE, + CapabilityId.INPUT_CHECKBOX, + CapabilityId.INPUT_RADIO, + CapabilityId.INPUT_DATE, + ) + + DemoSection.INPUT_EVENTS -> + setOf( + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_TEXTAREA, + CapabilityId.HOOK_FOCUS, + CapabilityId.HOOK_BLUR, + CapabilityId.HOOK_INPUT, + CapabilityId.HOOK_CHANGE, + ) + + DemoSection.COLOR_PICKER -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.HOOK_MOUSE_DOWN, + CapabilityId.HOOK_MOUSE_CLICK, + ) + + DemoSection.TEXT_EDITING -> + setOf( + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_TEXTAREA, + CapabilityId.HOOK_MOUSE_DOWN, + CapabilityId.HOOK_MOUSE_DRAG, + CapabilityId.HOOK_KEY_DOWN, + CapabilityId.HOOK_INPUT, + ) + + DemoSection.REFS -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_INPUT, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.REFS_OBJECT, + CapabilityId.REFS_CALLBACK, + CapabilityId.REFS_IMPERATIVE_FOCUS, + ) + + DemoSection.DRAG_DROP -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.HOOK_DRAG_START, + CapabilityId.HOOK_DRAG, + CapabilityId.HOOK_DRAG_END, + CapabilityId.HOOK_DRAG_ENTER, + CapabilityId.HOOK_DRAG_OVER, + CapabilityId.HOOK_DRAG_LEAVE, + CapabilityId.HOOK_DROP, + CapabilityId.DND_SMOOTH_GHOST, + CapabilityId.DND_DATA_TRANSFER, + CapabilityId.DND_DROP_EFFECT, + ) + + DemoSection.INTERACTIONS -> + setOf( + CapabilityId.HOOK_MOUSE_ENTER, + CapabilityId.HOOK_MOUSE_LEAVE, + CapabilityId.HOOK_MOUSE_OVER, + CapabilityId.HOOK_MOUSE_MOVE, + CapabilityId.HOOK_MOUSE_DOWN, + CapabilityId.HOOK_MOUSE_UP, + CapabilityId.HOOK_MOUSE_DRAG, + CapabilityId.HOOK_MOUSE_WHEEL, + CapabilityId.HOOK_KEY_DOWN, + CapabilityId.HOOK_KEY_UP, + CapabilityId.HOOK_KEY_PRESSED, + CapabilityId.HOOK_KEY_RELEASED, + CapabilityId.EVENT_CANCELLATION, + ) + + DemoSection.FOCUS_REBUILD -> + setOf( + CapabilityId.HOOK_KEY_DOWN, + CapabilityId.HOOK_KEY_UP, + CapabilityId.FOCUS_RETENTION, + CapabilityId.STATE_REBUILD, + CapabilityId.MANUAL_INVALIDATE, + ) + + DemoSection.STYLESHEETS -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_BUTTON, + CapabilityId.BUILDER_INPUT, + CapabilityId.STYLESHEET_SELECTORS, + CapabilityId.STYLESHEET_PSEUDO_STATES, + CapabilityId.STYLESHEET_VARIABLES, + CapabilityId.STYLESHEET_INLINE_OVERRIDE, + CapabilityId.STYLESHEET_PROGRAMMATIC_RELOAD, + ) + + DemoSection.CSS_CASCADE -> + setOf( + CapabilityId.BUILDER_DIV, + CapabilityId.BUILDER_TEXT, + CapabilityId.BUILDER_TEXT_LAMBDA, + CapabilityId.BUILDER_BUTTON, + CapabilityId.HOOK_MOUSE_CLICK, + CapabilityId.STYLESHEET_SELECTORS, + CapabilityId.STYLESHEET_COMBINATORS, + CapabilityId.STYLESHEET_CASCADE, + ) + + DemoSection.MC_FEATURES -> + setOf( + CapabilityId.BUILDER_IMG, + CapabilityId.BUILDER_ITEM_STACK, + CapabilityId.IMAGE_RESOURCE, + CapabilityId.IMAGE_FILE, + CapabilityId.IMAGE_HTTP, + CapabilityId.ITEMSTACK_2D, + CapabilityId.ITEMSTACK_3D, + CapabilityId.ITEMSTACK_ROTATION, + ) + } + + fun implementedByAllSections(): Set = + DemoSection.entries .flatMap { capabilitiesForSection(it) } .toSet() - } } - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/DemoSection.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/DemoSection.kt index 54e2c5d..e8c24ea 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/DemoSection.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/DemoSection.kt @@ -2,22 +2,28 @@ package org.dreamfinity.dsgl.mcForge1710.demo.support enum class DemoSection( val title: String, - val subtitle: String + val subtitle: String, ) { OVERVIEW("Overview", "How to use the showcase"), - INSPECTOR("Inspector", "Global in-game element/style/layout inspector (F8/F9)"), + INSPECTOR("Inspector", "Global in-game element/style/layout inspector (F12/F9)"), LAYOUT_STYLE("Layout & Style", "Containers, gaps, fixed sizes, style DSL"), LAYOUT_DEBUG("Layout Debug", "Strict bounds validator and diagnostics"), - POSITIONED_LAYOUT("Positioned Layout", "static/relative/absolute/fixed/sticky + z-index overlap, scroll and hit-testing"), + POSITIONED_LAYOUT( + "Positioned Layout", + "static/relative/absolute/fixed/sticky + z-index overlap, scroll and hit-testing", + ), OVERFLOW_SCROLL("Overflow & Scroll", "Viewport clipping, gutters, and cross-axis overflow forcing"), DISPLAY("Display", "block/inline/none/flex/grid layout behaviors"), TEXT_WRAP("Text Wrap", "wrap/nowrap behavior for text rendering"), MSDF_FONTS("MSDF Fonts", "MTSDF atlas fonts: switch font, size, color, opacity, wrapping"), ANIMATIONS("Animations & Transforms", "Transform hit-testing, transitions, keyframes, easing"), STYLESHEETS("Stylesheets", "Selectors, variables, pseudo-states, inline override"), - CSS_CASCADE("CSS Cascade & Combinators", "Descendant/child/sibling selectors, specificity, source order, !important, inheritance"), - MODALS("Modals", "Declarative stacked modal host (RB-inspired)"), - CONTEXT_MENU("Context Menu", "Right-click nested menus with overlay-first hit testing"), + CSS_CASCADE( + "CSS Cascade & Combinators", + "Descendant/child/sibling selectors, specificity, source order, !important, inheritance", + ), + MODALS("Modals", "Declarative stacked modal portal (RB-inspired)"), + CONTEXT_MENU("Context Menu", "Right-click nested menus with portal-first hit testing"), INPUTS("Inputs Gallery", "All input factory variants and textarea"), INPUT_EVENTS("Input Events", "HTML-like onFocus/onBlur/onInput/onChange"), COLOR_PICKER("Color Picker", "Reusable inline + popup pane color picker with eyedropper/history"), @@ -26,7 +32,5 @@ enum class DemoSection( DRAG_DROP("Drag & Drop", "HTML-like drag events, DataTransfer and smooth ghost"), INTERACTIONS("Interactions", "Mouse/key hooks, bubbling, cancellation"), FOCUS_REBUILD("Focus & Rebuild", "Focus retention and invalidation"), - MC_FEATURES("MC Features", "Pixel viewport rendering, clipping and item stacks") + MC_FEATURES("MC Features", "Pixel viewport rendering, clipping and item stacks"), } - - diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventFormatting.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventFormatting.kt index 962b809..90e466a 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventFormatting.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventFormatting.kt @@ -3,40 +3,71 @@ package org.dreamfinity.dsgl.mcForge1710.demo.support import org.dreamfinity.dsgl.core.dnd.* import org.dreamfinity.dsgl.core.event.* -fun formatEventLine( - hookName: String, - event: Event, - note: String? = null -): String { - val targetKey = event.target?.key?.toString() ?: "none" - val coords = when (event) { - is MouseEvent -> "xy=${event.mouseX},${event.mouseY}" - else -> "xy=-" - } - val payload = when (event) { - is MouseDownEvent -> "btn=${event.mouseButton.name.lowercase()}" - is MouseUpEvent -> "btn=${event.mouseButton.name.lowercase()}" - is MouseClickEvent -> "btn=${event.mouseButton.name.lowercase()}" - is MouseDragEvent -> "drag=${event.dx},${event.dy} btn=${event.mouseButton.name.lowercase()}" - is MouseWheelEvent -> "wheel=${event.dWheel}" - is KeyboardKeyDownEvent -> "key=${event.keyCode} char=${safeChar(event.keyChar)}" - is KeyboardKeyUpEvent -> "key=${event.keyCode} char=${safeChar(event.keyChar)}" - is FocusGainEvent -> "prev=${event.previousTargetKey ?: "none"}" - is FocusLoseEvent -> "next=${event.nextTargetKey ?: "none"}" - is InputEvent -> "value=${event.value} parsed=${event.parsedValue ?: "null"}" - is ValueChangedEvent -> "value=${event.value} parsed=${event.parsedValue ?: "null"}" - is DragStartEvent -> "source=${event.sourceKey ?: "none"} types=${event.dataTransfer.types.joinToString(",")}" - is DragEvent -> "source=${event.sourceKey ?: "none"} effect=${event.dataTransfer.dropEffect.name.lowercase()}" - is DragEndEvent -> "drop=${event.didDrop} effect=${event.finalDropEffect.name.lowercase()} target=${event.dropTargetKey ?: "none"}" - is DragEnterEvent -> "source=${event.sourceKey ?: "none"}" - is DragOverEvent -> "source=${event.sourceKey ?: "none"} effect=${event.dataTransfer.dropEffect.name.lowercase()} accepted=${event.dropAccepted || event.cancelled}" - is DragLeaveEvent -> "source=${event.sourceKey ?: "none"}" - is DropEvent -> "source=${event.sourceKey ?: "none"} types=${event.dataTransfer.types.joinToString(",")}" - else -> "" - } +fun formatEventLine(hookName: String, event: Event, note: String? = null): String { + val targetKey = + event.target + ?.key + ?.toString() ?: "none" + val coords = + when (event) { + is MouseEvent -> "xy=${event.mouseX},${event.mouseY}" + else -> "xy=-" + } + val payload = + when (event) { + is MouseDownEvent -> "btn=${event.mouseButton.name.lowercase()}" + is MouseUpEvent -> "btn=${event.mouseButton.name.lowercase()}" + is MouseClickEvent -> "btn=${event.mouseButton.name.lowercase()}" + is MouseDragEvent -> "drag=${event.dx},${event.dy} btn=${event.mouseButton.name.lowercase()}" + is MouseWheelEvent -> "wheel=${event.dWheel}" + is KeyboardKeyDownEvent -> "key=${event.keyCode} char=${safeChar(event.keyChar)}" + is KeyboardKeyUpEvent -> "key=${event.keyCode} char=${safeChar(event.keyChar)}" + is FocusGainEvent -> "prev=${event.previousTargetKey ?: "none"}" + is FocusLoseEvent -> "next=${event.nextTargetKey ?: "none"}" + is InputEvent -> "value=${event.value} parsed=${event.parsedValue ?: "null"}" + is ValueChangedEvent -> "value=${event.value} parsed=${event.parsedValue ?: "null"}" + is DragStartEvent -> + "source=${event.sourceKey ?: "none"} types=${ + event.dataTransfer.types.joinToString( + ",", + ) + }" + + is DragEvent -> { + val effect = + event.dataTransfer.dropEffect.name + .lowercase() + "source=${event.sourceKey ?: "none"} effect=$effect" + } + + is DragEndEvent -> { + val effect = + event.finalDropEffect.name + .lowercase() + "drop=${event.didDrop} effect=$effect target=${event.dropTargetKey ?: "none"}" + } + + is DragEnterEvent -> "source=${event.sourceKey ?: "none"}" + is DragOverEvent -> { + val effect = + event.dataTransfer.dropEffect.name + .lowercase() + val accepted = event.dropAccepted || event.cancelled + "source=${event.sourceKey ?: "none"} effect=$effect accepted=$accepted" + } + + is DragLeaveEvent -> "source=${event.sourceKey ?: "none"}" + is DropEvent -> "source=${event.sourceKey ?: "none"} types=${event.dataTransfer.types.joinToString(",")}" + else -> "" + } val notePart = if (note.isNullOrBlank()) "" else " note=$note" val raw = - "$hookName ${event.type.name} target=$targetKey $coords $payload shift=${KeyModifiers.shiftDown} ctrl=${KeyModifiers.controlDown} meta=${KeyModifiers.metaDown} shortcut=${KeyModifiers.shortcutDown}$notePart" + "$hookName ${event.type.name} " + + "target=$targetKey $coords $payload " + + "shift=${KeyModifiers.shiftDown} " + + "ctrl=${KeyModifiers.controlDown} " + + "meta=${KeyModifiers.metaDown} " + + "shortcut=${KeyModifiers.shortcutDown}$notePart" return truncateForPanel(raw, 118) } @@ -53,4 +84,4 @@ private fun safeChar(ch: Char): String { if (ch == '\t') return "\\t" if (ch.code < 32) return "\\u%04x".format(ch.code) return ch.toString() -} \ No newline at end of file +} diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventLogEntry.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventLogEntry.kt index 2764ab2..b4f0667 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventLogEntry.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/EventLogEntry.kt @@ -3,5 +3,5 @@ package org.dreamfinity.dsgl.mcForge1710.demo.support data class EventLogEntry( val sequence: Int, val line: String, - val color: Int + val color: Int, ) diff --git a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/PersistentPanels.kt b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/PersistentPanels.kt index 6b63a0d..3714b4a 100644 --- a/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/PersistentPanels.kt +++ b/adapters/mc-forge-1-7-10/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/support/PersistentPanels.kt @@ -12,7 +12,7 @@ fun UiScope.renderEventInspectorPanel( eventLogs: List, maxEventLogs: Int, visibleEventLines: Int, - onClearLogs: () -> Unit + onClearLogs: () -> Unit, ) { div({ key = "panel.eventInspector" @@ -24,9 +24,11 @@ fun UiScope.renderEventInspectorPanel( padding = 20.px backgroundColor = DEMO_SURFACE_ALT color = DsglColors.TEXT - border { width = 1.px; color = DsglColors.BORDER } + border { + width = 1.px + color = DsglColors.BORDER + } } - }) { div({ style = { @@ -58,7 +60,7 @@ fun UiScope.renderChecklistPanel( checklistPage: Int, checklistPageSize: Int, onSetChecklistPage: (Int) -> Unit, - onMoveChecklistPage: (Int) -> Unit + onMoveChecklistPage: (Int) -> Unit, ) { val required = CapabilityChecklistCatalog.required val pageSize = checklistPageSize @@ -81,9 +83,11 @@ fun UiScope.renderChecklistPanel( gap = 4.px backgroundColor = DEMO_SURFACE_ALT color = DsglColors.TEXT - border { width = 1.px; color = DsglColors.BORDER } + border { + width = 1.px + color = DsglColors.BORDER + } } - }) { text("Capability Checklist") div({ @@ -110,4 +114,3 @@ fun UiScope.renderChecklistPanel( text("Missing: $missing / ${required.size}", { style = { color = if (missing == 0) DEMO_OK else DEMO_ERR } }) } } - diff --git a/adapters/mc-forge-1-7-10/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt b/adapters/mc-forge-1-7-10/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt index 0f477d4..109a4f7 100644 --- a/adapters/mc-forge-1-7-10/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt +++ b/adapters/mc-forge-1-7-10/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt @@ -1,4 +1,4 @@ -package org.dreamfinity.dsgl.mcForge1710.demo.sections +package org.dreamfinity.dsgl.mcForge1710.demo.sections import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.DOMNode @@ -26,11 +26,14 @@ class PositionedLayoutStickyDemoIntegrationTests { private val width = 1024 private val height = 720 - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -94,7 +97,7 @@ class PositionedLayoutStickyDemoIntegrationTests { val topViewport = topScroller.scrollContainerState().viewportRect assertTrue( intersects(topRect, topViewport), - "Sticky top target must remain visible in its scroller viewport; targetRect=$topRect viewport=$topViewport" + "Sticky top target must remain visible in its scroller viewport; targetRect=$topRect viewport=$topViewport", ) val topPoint = findPointInsideTarget(fixture.tree.root, topTarget, topRect) @@ -104,7 +107,7 @@ class PositionedLayoutStickyDemoIntegrationTests { assertNotNull( topPoint, "Expected a hover-resolvable point inside sticky top target. " + - "targetRect=$topRect viewport=$topViewport centerWinner=$topCenterWinner" + "targetRect=$topRect viewport=$topViewport centerWinner=$topCenterWinner", ) assertEquals(topTarget, collectHoverChain(fixture.tree.root, topPoint.first, topPoint.second).lastOrNull()) @@ -133,7 +136,7 @@ class PositionedLayoutStickyDemoIntegrationTests { "onNativeDomExpandedPanelRect", Rect(700, 30, 280, 260), width, - height + height, ) inspector.onCursorMoved(xyPoint.first, xyPoint.second) invokeInspectorInternalByName(inspector, "buildDomSnapshot", width, height) @@ -146,23 +149,24 @@ class PositionedLayoutStickyDemoIntegrationTests { private data class Fixture( val window: ShowcaseWindow, - val tree: DomTree + val tree: DomTree, ) + private fun renderFixture(): Fixture { val window = ShowcaseWindow() window.onResize(width, height) window.selectedSection = DemoSection.POSITIONED_LAYOUT window.beginRenderBuild() - val tree = try { - window.render() - } finally { - window.endRenderBuild() - } + val tree = + try { + window.render() + } finally { + window.endRenderBuild() + } tree.render(ctx, width, height) return Fixture(window = window, tree = tree) } - private fun scrollMainSectionToSticky(fixture: Fixture) { val sectionScroller = requireContainer(fixture.tree.root, "section.positionedLayout") val stickySurface = requireNode(fixture.tree.root, "positioned.sticky.surface") @@ -176,14 +180,11 @@ class PositionedLayoutStickyDemoIntegrationTests { fixture.tree.render(ctx, width, height) } - private fun requireContainer(root: DOMNode, key: String): ContainerNode { - return requireNode(root, key) as? ContainerNode + private fun requireContainer(root: DOMNode, key: String): ContainerNode = + requireNode(root, key) as? ContainerNode ?: error("Expected container with key '$key'") - } - private fun requireNode(root: DOMNode, key: String): DOMNode { - return findByKey(root, key) ?: error("Node with key '$key' not found") - } + private fun requireNode(root: DOMNode, key: String): DOMNode = findByKey(root, key) ?: error("Node with key '$key' not found") private fun findByKey(root: DOMNode, key: String): DOMNode? { if (root.key?.toString() == key) return root @@ -236,9 +237,11 @@ class PositionedLayoutStickyDemoIntegrationTests { } } - private fun hoverWinnerKey(root: DOMNode, x: Int, y: Int): String? { - return collectHoverChain(root, x, y).lastOrNull()?.key?.toString() - } + private fun hoverWinnerKey(root: DOMNode, x: Int, y: Int): String? = + collectHoverChain(root, x, y) + .lastOrNull() + ?.key + ?.toString() private fun intersects(a: Rect, b: Rect): Boolean { val noOverlapX = a.x + a.width <= b.x || b.x + b.width <= a.x @@ -247,19 +250,15 @@ class PositionedLayoutStickyDemoIntegrationTests { } private fun inspectorHoveredBorderRect(inspector: InspectorController): Rect? { - val debugHoveredHighlight = findMethodByNameAndArity(inspector.javaClass, "debugHoveredHighlight", 0) - debugHoveredHighlight.isAccessible = true - val snapshot = debugHoveredHighlight.invoke(inspector) ?: return null + val highlightMethod = findMethodByNameAndArity(inspector.javaClass, "portalHoveredHighlight", 0) + highlightMethod.isAccessible = true + val snapshot = highlightMethod.invoke(inspector) ?: return null val borderRectField = findField(snapshot.javaClass, "borderRect") borderRectField.isAccessible = true return borderRectField.get(snapshot) as? Rect } - private fun invokeInspectorInternalByName( - inspector: InspectorController, - methodName: String, - vararg args: Any? - ): Any? { + private fun invokeInspectorInternalByName(inspector: InspectorController, methodName: String, vararg args: Any?): Any? { val method = findMethodByNameAndArity(inspector.javaClass, methodName, args.size) method.isAccessible = true return method.invoke(inspector, *args) @@ -275,32 +274,13 @@ class PositionedLayoutStickyDemoIntegrationTests { error("Field '$fieldName' not found on ${clazz.name}") } - private fun findMethod( - clazz: Class<*>, - methodName: String, - parameterTypes: Array> - ): Method { + private fun findMethodByNameAndArity(clazz: Class<*>, methodName: String, arity: Int): Method { var current: Class<*>? = clazz while (current != null) { - val method = current.declaredMethods.firstOrNull { - it.name == methodName && it.parameterTypes.contentEquals(parameterTypes) - } - if (method != null) return method - current = current.superclass - } - error("Method '$methodName' not found on ${clazz.name}") - } - - private fun findMethodByNameAndArity( - clazz: Class<*>, - methodName: String, - arity: Int - ): Method { - var current: Class<*>? = clazz - while (current != null) { - val method = current.declaredMethods.firstOrNull { - (it.name == methodName || it.name.startsWith("$methodName$")) && it.parameterCount == arity - } + val method = + current.declaredMethods.firstOrNull { + (it.name == methodName || it.name.startsWith("$methodName$")) && it.parameterCount == arity + } if (method != null) return method current = current.superclass } diff --git a/adapters/mc-forge-1-7-10/detekt-baseline.xml b/adapters/mc-forge-1-7-10/detekt-baseline.xml new file mode 100644 index 0000000..0370594 --- /dev/null +++ b/adapters/mc-forge-1-7-10/detekt-baseline.xml @@ -0,0 +1,70 @@ + + + + + ComplexCondition:DsglScreenHost.kt$DsglScreenHost$!inspectorBlocks && !contextMenuBlocks && !selectBlocks && !systemSelectBlocks && !colorPickerBlocks + ComplexCondition:DsglScreenHost.kt$DsglScreenHost$inspectorBlocks || contextMenuBlocks || selectBlocks || systemSelectBlocks || colorPickerBlocks + ComplexCondition:DsglScreenHost.kt$DsglScreenHost$lastWidth > 0 && lastHeight > 0 && (rootBounds.width <= 0 || rootBounds.height <= 0) + ComplexCondition:Mc1710UiAdapter.kt$Mc1710UiAdapter$x < 0 || y < 0 || x >= viewport.width || y >= viewport.height + ComplexCondition:MsdfTextRenderer.kt$MsdfTextRenderer.SegmentBuffer$last >= 0 && kind[last] == segmentKind && color[last] == segmentColor && kotlin.math.abs(endX[last] - segmentStartX) <= 0.51f && kotlin.math.abs(y[last] - segmentY) <= 0.51f && kotlin.math.abs(thickness[last] - segmentThickness) <= 0.1f + CyclomaticComplexMethod:DsglScreenHost.kt$DsglScreenHost$private fun consumeApplicationPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, buttonPressed: Boolean, ): Boolean + CyclomaticComplexMethod:DsglScreenHost.kt$DsglScreenHost$private fun consumeSystemPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, buttonPressed: Boolean, ): Boolean + CyclomaticComplexMethod:DsglScreenHost.kt$DsglScreenHost$private fun handleKeyboardKeyDown( keyCode: Int, keyChar: Char, inspectorMouseX: Int, inspectorMouseY: Int, ): Boolean + CyclomaticComplexMethod:DsglScreenHost.kt$DsglScreenHost$private fun updateFrameInteractionState( tree: DomTree, dtSeconds: Double, dsglMouseX: Int, dsglMouseY: Int, applicationPortalInputEnabled: Boolean, systemPortalInputEnabled: Boolean, inspectorBlocks: Boolean, ) + CyclomaticComplexMethod:Mc1710UiAdapter.kt$Mc1710UiAdapter$@Suppress("LoopWithTooManyJumpStatements") override fun paint(commands: List<RenderCommand>) + CyclomaticComplexMethod:MsdfTextRenderer.kt$MsdfTextRenderer$fun draw(command: RenderCommand.DrawText, opacityMultiplier: Float) + CyclomaticComplexMethod:MsdfTextRenderer.kt$MsdfTextRenderer$private fun drawDecorationSegments(segments: SegmentBuffer, includeDebug: Boolean) + LargeClass:DsglScreenHost.kt$DsglScreenHost : GuiScreenDsglWindowHost + LargeClass:Mc1710UiAdapter.kt$Mc1710UiAdapter : UiMeasureContext + LargeClass:MsdfTextRenderer.kt$MsdfTextRenderer + LongMethod:Mc1710UiAdapter.kt$Mc1710UiAdapter$@Suppress("LoopWithTooManyJumpStatements") override fun paint(commands: List<RenderCommand>) + LongMethod:Mc1710UiAdapter.kt$Mc1710UiAdapter$private fun captureScreenRegion(command: RenderCommand.CaptureScreenRegion, viewport: Viewport) + LongMethod:MsdfTextRenderer.kt$MsdfTextRenderer$fun draw(command: RenderCommand.DrawText, opacityMultiplier: Float) + MagicNumber:DsglScreenHost.kt$DsglScreenHost$0.25 + MagicNumber:DsglScreenHost.kt$DsglScreenHost$1_000_000_000.0 + MagicNumber:DsglScreenHost.kt$DsglScreenHost$2_000L + MagicNumber:DsglScreenHost.kt$DsglScreenHost$60.0 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0.1f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0.5f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0.999f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0x00FF_FFFF + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0xFF + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$0xFFFF_FFFFL + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$1000.0 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$120f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$15 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$180f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$24 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$240f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$300f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$31 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$32 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$360f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$3_000L + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$4096 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$6 + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$60f + MagicNumber:Mc1710UiAdapter.kt$Mc1710UiAdapter$8.0 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$0.5f + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$0.999f + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$0x00FF_FFFF + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$0xFF + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$1_000L + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$1_000_000_000.0 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$24 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$31 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$32 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$3_000L + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$4096 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer$64 + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer.SegmentBuffer$0.1f + MagicNumber:MsdfTextRenderer.kt$MsdfTextRenderer.SegmentBuffer$0.51f + NestedBlockDepth:Mc1710UiAdapter.kt$Mc1710UiAdapter$@Suppress("LoopWithTooManyJumpStatements") override fun paint(commands: List<RenderCommand>) + NestedBlockDepth:MsdfTextRenderer.kt$MsdfTextRenderer$fun draw(command: RenderCommand.DrawText, opacityMultiplier: Float) + ReturnCount:DsglScreenHost.kt$DsglScreenHost$private fun consumeApplicationPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, buttonPressed: Boolean, ): Boolean + ReturnCount:DsglScreenHost.kt$DsglScreenHost$private fun consumeSystemPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, buttonPressed: Boolean, ): Boolean + TooManyFunctions:DsglScreenHost.kt$DsglScreenHost : GuiScreenDsglWindowHost + TooManyFunctions:Mc1710UiAdapter.kt$Mc1710UiAdapter : UiMeasureContext + TooManyFunctions:MsdfTextRenderer.kt$MsdfTextRenderer + + diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglFonts.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglFonts.kt index 8a0a7d5..44307ee 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglFonts.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglFonts.kt @@ -13,32 +13,36 @@ object DsglFonts { @Volatile private var lastSummary: FontPreloadSummary? = null - private val warmFontIds: Set = linkedSetOf( - FontRegistry.DEFAULT_FONT_ID, - FontRegistry.FONT_UBUNTU, - FontRegistry.FONT_JB_MONO, - FontRegistry.TELEGRAFICO, - FontRegistry.FALLBACK_FONT_ID - ) + private val warmFontIds: Set = + linkedSetOf( + FontRegistry.DEFAULT_FONT_ID, + FontRegistry.FONT_UBUNTU, + FontRegistry.FONT_JB_MONO, + FontRegistry.TELEGRAFICO, + FontRegistry.FALLBACK_FONT_ID, + ) fun ensureInitialized(gameDir: File, classLoader: ClassLoader = javaClass.classLoader): FontPreloadSummary { if (initialized) { - return lastSummary ?: FontRegistry.discoverAndPreloadFonts( - externalFontsDir = File(gameDir, "dsgl/fonts"), - classLoader = classLoader - ).also { lastSummary = it } + return lastSummary ?: FontRegistry + .discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader, + ).also { lastSummary = it } } synchronized(lock) { if (initialized) { - return lastSummary ?: FontRegistry.discoverAndPreloadFonts( - externalFontsDir = File(gameDir, "dsgl/fonts"), - classLoader = classLoader - ).also { lastSummary = it } + return lastSummary ?: FontRegistry + .discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader, + ).also { lastSummary = it } } - val summary = FontRegistry.discoverAndPreloadFonts( - externalFontsDir = File(gameDir, "dsgl/fonts"), - classLoader = classLoader - ) + val summary = + FontRegistry.discoverAndPreloadFonts( + externalFontsDir = File(gameDir, "dsgl/fonts"), + classLoader = classLoader, + ) val warmed = FontRegistry.predecodeAtlases(warmFontIds) if (warmed > 0) { println("[DSGL-MSDF] predecoded atlases for $warmed warm fonts") diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHost.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHost.kt index f1384cc..02bdf4a 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHost.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHost.kt @@ -7,17 +7,15 @@ import net.minecraft.client.gui.GuiScreen import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.DsglWindow import org.dreamfinity.dsgl.core.HotReloadBridge -import org.dreamfinity.dsgl.core.animation.StyleAnimationEngine +import org.dreamfinity.dsgl.core.animation.* import org.dreamfinity.dsgl.core.colorpicker.* -import org.dreamfinity.dsgl.core.contextmenu.ContextMenuRuntime -import org.dreamfinity.dsgl.core.debug.OverlayDebugControlHost -import org.dreamfinity.dsgl.core.debug.OverlayLayerDebugState -import org.dreamfinity.dsgl.core.dnd.DndRuntime +import org.dreamfinity.dsgl.core.debug.DebugDomainPortalHost +import org.dreamfinity.dsgl.core.debug.DebugDomainRootHost +import org.dreamfinity.dsgl.core.debug.DomainSurfaceDebugState +import org.dreamfinity.dsgl.core.dnd.* import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.elements.ColorPickerInlineNode -import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode -import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode -import org.dreamfinity.dsgl.core.dom.elements.TextAreaNode +import org.dreamfinity.dsgl.core.dom.elements.* +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.* import org.dreamfinity.dsgl.core.hooks.HookHotReloadRemountException import org.dreamfinity.dsgl.core.hooks.HookRenderSessionMode @@ -27,23 +25,41 @@ import org.dreamfinity.dsgl.core.host.rawMouseToDsglX import org.dreamfinity.dsgl.core.host.rawMouseToDsglY import org.dreamfinity.dsgl.core.input.ClipboardAccess import org.dreamfinity.dsgl.core.input.ClipboardBridge -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorMode -import org.dreamfinity.dsgl.core.overlay.ApplicationOverlayHost -import org.dreamfinity.dsgl.core.overlay.OverlayLayerContracts -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope -import org.dreamfinity.dsgl.core.overlay.UiLayerId -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayHost +import org.dreamfinity.dsgl.core.inspector.* +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.portal.DomainSurfaceHost +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.appendDndGhostPortalCommands +import org.dreamfinity.dsgl.core.portal.appendFloatingPortalCommands +import org.dreamfinity.dsgl.core.portal.captureColorPickerEyedropperSample +import org.dreamfinity.dsgl.core.portal.closeFloatingPortals +import org.dreamfinity.dsgl.core.portal.handlePortalKeyDownAfterDom +import org.dreamfinity.dsgl.core.portal.handlePortalKeyDownBeforeDom +import org.dreamfinity.dsgl.core.portal.handlePortalKeyUpAfterDom +import org.dreamfinity.dsgl.core.portal.handlePortalKeyUpBeforeDom +import org.dreamfinity.dsgl.core.portal.handlePortalPointerAfterDom +import org.dreamfinity.dsgl.core.portal.handlePortalPointerBeforeDom +import org.dreamfinity.dsgl.core.portal.hasActiveColorPickerEyedropper +import org.dreamfinity.dsgl.core.portal.hasActiveModalPortal +import org.dreamfinity.dsgl.core.portal.hasDomPointerTargetAt +import org.dreamfinity.dsgl.core.portal.hasOpenColorPickerPortal +import org.dreamfinity.dsgl.core.portal.hasOpenContextMenuPortal +import org.dreamfinity.dsgl.core.portal.hasOpenSelectPortal +import org.dreamfinity.dsgl.core.portal.input.PointerCaptureSession +import org.dreamfinity.dsgl.core.portal.syncPortalFrame +import org.dreamfinity.dsgl.core.portal.system.SystemPortalHost +import org.dreamfinity.dsgl.core.portal.toggleFloatingWindowDemo import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.select.SelectRuntime -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.* import org.lwjgl.input.Keyboard import org.lwjgl.input.Mouse import java.io.File import java.time.Instant import java.time.ZoneId -import java.util.* +import java.util.Collections +import java.util.IdentityHashMap /** * Minecraft 1.7.10 host that owns UI lifecycle and boilerplate. @@ -54,8 +70,9 @@ import java.util.* @SideOnly(Side.CLIENT) abstract class DsglScreenHost( private val windowFactory: () -> DsglWindow, - var rendersCount: Long = 0 -) : GuiScreen(), DsglWindowHost { + var rendersCount: Long = 0, +) : GuiScreen(), + DsglWindowHost { companion object { @Volatile private var stylesPreloadedOnce: Boolean = false @@ -73,6 +90,7 @@ abstract class DsglScreenHost( private var needsLayout: Boolean = true private var lastMouseEvent: Long = 0 private var eventButton: Int = -1 + private var higherSurfacePointerButton: Int = -1 private var lastMouseX: Int = 0 private var lastMouseY: Int = 0 private var lastMoveX: Int = Int.MIN_VALUE @@ -80,49 +98,53 @@ abstract class DsglScreenHost( private val pressedKeys: MutableSet = HashSet() private val hoverChain: MutableList = mutableListOf() private var hoverTarget: DOMNode? = null - private var dragCaptureTarget: DOMNode? = null - private var dragCaptureKey: Any? = null - private var dragCaptureClass: Class? = null - private var dragCaptureFocusKey: Any? = null + private val pointerCapture: PointerCaptureSession = PointerCaptureSession() private var inspectorPointerCaptured: Boolean = false private var layoutRevision: Long = 0L private val pendingCleanupRoots: MutableSet = Collections.newSetFromMap(IdentityHashMap()) private val composedCommandsBuffer: MutableList = ArrayList(512) private val stagingCommandsBuffer: MutableList = ArrayList(512) - private val applicationOverlayCommandsBuffer: MutableList = ArrayList(256) + private val applicationPortalCommandsBuffer: MutableList = ArrayList(256) + private val systemPortalCommandsBuffer: MutableList = ArrayList(256) private var activeTarget: DOMNode? = null private var lastFrameNanos: Long = 0L private val inspector: InspectorController = InspectorController() - private val applicationOverlayHost: ApplicationOverlayHost = ApplicationOverlayHost() - private val systemOverlayHost: SystemOverlayHost = SystemOverlayHost(inspector) - private val debugOverlayHost: OverlayDebugControlHost = OverlayDebugControlHost() + private val applicationPortalHost: ApplicationPortalHost = ApplicationPortalHost() + private val systemPortalHost: SystemPortalHost = SystemPortalHost(inspector) + private val debugDomainRootHost: DebugDomainRootHost = DebugDomainRootHost() + private val debugDomainPortalHost: DebugDomainPortalHost = DebugDomainPortalHost() + private val domainOrchestrator: ScreenDomainSurfaceOrchestrator = ScreenDomainSurfaceOrchestrator() private val colorSamplerOwnershipRouter: ActiveColorSamplerOwnershipRouter = ActiveColorSamplerOwnershipRouter() private var activeColorSamplerOwner: ActiveColorSamplerOwner = ActiveColorSamplerOwner.None private var activeInlineColorSamplerNode: ColorPickerInlineNode? = null private val inspectorInputDebug: Boolean = false - private val perfDebug: Boolean = java.lang.Boolean.getBoolean("dsgl.perf.debug") - private val phaseTraceDebug: Boolean = java.lang.Boolean.getBoolean("dsgl.rebuild.trace") + private val perfDebug: Boolean = + java.lang.Boolean + .getBoolean("dsgl.perf.debug") + private val phaseTraceDebug: Boolean = + java.lang.Boolean + .getBoolean("dsgl.rebuild.trace") private var lastPerfLogMs: Long = 0L private var frameIndex: Long = 0L private var blankFrameGuardSkips: Long = 0L private val pipelineErrorLogTimes: MutableMap = linkedMapOf() - private val clipboardAccess: ClipboardAccess = object : ClipboardAccess { - override fun readText(): String { - return try { - getClipboardString() ?: "" - } catch (_: Exception) { - "" - } - } + private val clipboardAccess: ClipboardAccess = + object : ClipboardAccess { + override fun readText(): String = + try { + getClipboardString() ?: "" + } catch (_: Exception) { + "" + } - override fun writeText(value: String) { - try { - setClipboardString(value) - } catch (_: Exception) { + override fun writeText(value: String) { + try { + setClipboardString(value) + } catch (_: Exception) { + } } } - } override fun initGui() { DsglFonts.ensureInitialized(mc.mcDataDir, javaClass.classLoader) @@ -132,13 +154,18 @@ abstract class DsglScreenHost( object : ScreenColorSampler { override fun sampleColorAt(x: Int, y: Int): Int? = adapter.sampleScreenColor(x, y) - override fun sampleArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { - return adapter.sampleScreenArea(x, y, width, height, outArgb) - } - } + override fun sampleArea( + x: Int, + y: Int, + width: Int, + height: Int, + outArgb: IntArray, + ): Boolean = adapter.sampleScreenArea(x, y, width, height, outArgb) + }, ) inspector.deactivate() inspectorPointerCaptured = false + higherSurfacePointerButton = -1 colorSamplerOwnershipRouter.reset() activeColorSamplerOwner = ActiveColorSamplerOwner.None activeInlineColorSamplerNode = null @@ -163,25 +190,138 @@ abstract class DsglScreenHost( if (!::adapter.isInitialized) return frameIndex += 1 tracePhase("draw.start") + val frameCursor = prepareFrameCursor() + val rebuiltThisFrame = rebuildIfNeeded() + val tree = domTree ?: return + val dtSeconds = tickFrameAndAnimations(tree, partialTicks) + val layoutPhase = + commitLayoutPhaseOrFallback( + tree = tree, + mouseX = mouseX, + mouseY = mouseY, + partialTicks = partialTicks, + ) ?: return + val domainDebugState = + syncInspectorAndResolveSurfaceState( + tree = tree, + dsglMouseX = frameCursor.mouseX, + dsglMouseY = frameCursor.mouseY, + ) + syncFeatureRuntimeFrame( + tree = tree, + dsglMouseX = frameCursor.mouseX, + dsglMouseY = frameCursor.mouseY, + ) + syncApplicationPortalSurface( + appPortalEnabled = domainDebugState.appPortalRenderEnabled || domainDebugState.appPortalInputEnabled, + ) + val systemPortalCommands = + syncSystemPortalAndCollectCommands( + tree = tree, + dsglMouseX = frameCursor.mouseX, + dsglMouseY = frameCursor.mouseY, + systemPortalRenderEnabled = domainDebugState.systemPortalRenderEnabled, + ) + stageSystemPortalCommands( + systemPortalCommands = systemPortalCommands, + systemPortalRenderEnabled = domainDebugState.systemPortalRenderEnabled, + ) + val debugDomainCommands = collectDebugDomainCommands() + updateFrameInteractionState( + tree = tree, + dtSeconds = dtSeconds, + dsglMouseX = frameCursor.mouseX, + dsglMouseY = frameCursor.mouseY, + appPortalInputEnabled = domainDebugState.appPortalInputEnabled, + systemPortalInputEnabled = domainDebugState.systemPortalInputEnabled, + inspectorBlocks = domainDebugState.inspectorBlocks, + ) + val commands = + paintApplicationRootOrFallback( + tree = tree, + stylesAlreadyApplied = layoutPhase.stylesAlreadyApplied, + mouseX = mouseX, + mouseY = mouseY, + partialTicks = partialTicks, + ) ?: return + syncCollectAndStageApplicationPortalAfterRootPaint( + tree = tree, + appPortalRenderEnabled = domainDebugState.appPortalRenderEnabled, + appPortalInputEnabled = domainDebugState.appPortalInputEnabled, + ) + composeAndPresentFrame( + tree = tree, + commands = commands, + debugDomainCommands = debugDomainCommands, + rebuiltThisFrame = rebuiltThisFrame, + layoutCommittedThisFrame = layoutPhase.layoutCommittedThisFrame, + ) + finishDrawScreenFrame( + tree = tree, + mouseX = mouseX, + mouseY = mouseY, + partialTicks = partialTicks, + ) + } + + private data class FrameCursorPosition( + val mouseX: Int, + val mouseY: Int, + ) + + private data class LayoutPhaseResult( + val stylesAlreadyApplied: Boolean, + val layoutCommittedThisFrame: Boolean, + ) + + private data class DomainSurfaceFrameState( + val appPortalRenderEnabled: Boolean, + val systemPortalRenderEnabled: Boolean, + val appPortalInputEnabled: Boolean, + val systemPortalInputEnabled: Boolean, + val inspectorBlocks: Boolean, + ) + + private data class DebugDomainCommands( + val root: List, + val portal: List, + ) + + private fun prepareFrameCursor(): FrameCursorPosition { updateSize(force = false) val dsglMouseX = lastViewport.rawMouseToDsglX(Mouse.getX()) val dsglMouseY = lastViewport.rawMouseToDsglY(Mouse.getY()) window.onFrame(System.currentTimeMillis()) - val rebuiltThisFrame = rebuildIfNeeded() - val tree = domTree ?: return + return FrameCursorPosition( + mouseX = dsglMouseX, + mouseY = dsglMouseY, + ) + } + + private fun tickFrameAndAnimations(tree: DomTree, partialTicks: Float): Double { val nowNanos = System.nanoTime() - val dtSeconds = if (lastFrameNanos == 0L) { - 1.0 / 60.0 - } else { - ((nowNanos - lastFrameNanos).toDouble() / 1_000_000_000.0).coerceIn(0.0, 0.25) - } + val dtSeconds = + if (lastFrameNanos == 0L) { + 1.0 / 60.0 + } else { + ((nowNanos - lastFrameNanos).toDouble() / 1_000_000_000.0).coerceIn(0.0, 0.25) + } lastFrameNanos = nowNanos - OverlayLayerDebugState.updateFrameTiming(dtSeconds) + DomainSurfaceDebugState.updateFrameTiming(dtSeconds) window.tick(dtSeconds.toFloat(), partialTicks) val animationVisualsChanged = StyleAnimationEngine.tickAndApply(tree.root, dtSeconds, partialTicks) if (animationVisualsChanged) { tree.markVisualDirty() } + return dtSeconds + } + + private fun commitLayoutPhaseOrFallback( + tree: DomTree, + mouseX: Int, + mouseY: Int, + partialTicks: Float, + ): LayoutPhaseResult? { var stylesAlreadyApplied = false var layoutCommittedThisFrame = false if (needsLayout) { @@ -197,155 +337,379 @@ abstract class DsglScreenHost( flushPendingCleanup() super.drawScreen(mouseX, mouseY, partialTicks) captureColorPickerEyedropperSamples() - return + return null } } + return LayoutPhaseResult( + stylesAlreadyApplied = stylesAlreadyApplied, + layoutCommittedThisFrame = layoutCommittedThisFrame, + ) + } + + private fun syncInspectorAndResolveSurfaceState( + tree: DomTree, + dsglMouseX: Int, + dsglMouseY: Int, + ): DomainSurfaceFrameState { inspector.onLayoutCommitted(tree.root, layoutRevision) inspector.onCursorMoved(dsglMouseX, dsglMouseY) inspectorPointerCaptured = inspector.isPointerCaptured if (inspectorPointerCaptured) { inspector.onCapturedPointerMove(dsglMouseX, dsglMouseY, lastWidth, lastHeight) } - val appOverlayRenderEnabled = OverlayLayerDebugState.isRenderEnabled(UiLayerId.ApplicationOverlay) - val systemOverlayRenderEnabled = OverlayLayerDebugState.isRenderEnabled(UiLayerId.SystemOverlay) - val appOverlayInputEnabled = OverlayLayerDebugState.isInputEnabled(UiLayerId.ApplicationOverlay) - val systemOverlayInputEnabled = OverlayLayerDebugState.isInputEnabled(UiLayerId.SystemOverlay) - val inspectorBlocks = systemOverlayInputEnabled && ( - inspectorPointerCaptured || inspector.shouldConsumePointer(dsglMouseX, dsglMouseY) + val appPortalRenderEnabled = DomainSurfaceDebugState.isRenderEnabled(ScreenDomainSurfaces.ApplicationPortal) + val systemPortalRenderEnabled = DomainSurfaceDebugState.isRenderEnabled(ScreenDomainSurfaces.SystemPortal) + val appPortalInputEnabled = DomainSurfaceDebugState.isInputEnabled(ScreenDomainSurfaces.ApplicationPortal) + val systemPortalInputEnabled = DomainSurfaceDebugState.isInputEnabled(ScreenDomainSurfaces.SystemPortal) + val inspectorBlocks = + systemPortalInputEnabled && + ( + inspectorPointerCaptured || inspector.shouldConsumePointer(dsglMouseX, dsglMouseY) ) + return DomainSurfaceFrameState( + appPortalRenderEnabled = appPortalRenderEnabled, + systemPortalRenderEnabled = systemPortalRenderEnabled, + appPortalInputEnabled = appPortalInputEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + inspectorBlocks = inspectorBlocks, + ) + } + + private fun paintApplicationRootOrFallback( + tree: DomTree, + stylesAlreadyApplied: Boolean, + mouseX: Int, + mouseY: Int, + partialTicks: Float, + ): List? { tracePhase("commands.start") if (!stylesAlreadyApplied) { tracePhase("style.start") } - val commands = try { - tree.paint(adapter, applyStyles = !stylesAlreadyApplied) - } catch (error: Throwable) { - logPipelineError( - key = "draw.paint", - message = "[DSGL] Paint pipeline failed; rendering previous committed frame: ${error.message}" - ) - adapter.paint(composedCommandsBuffer) - flushPendingCleanup() - super.drawScreen(mouseX, mouseY, partialTicks) - captureColorPickerEyedropperSamples() - return - } + val commands = + try { + tree.paint(adapter, applyStyles = !stylesAlreadyApplied) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { + logPipelineError( + key = "draw.paint", + message = "[DSGL] Paint pipeline failed; rendering previous committed frame: ${error.message}", + ) + adapter.paint(composedCommandsBuffer) + flushPendingCleanup() + super.drawScreen(mouseX, mouseY, partialTicks) + captureColorPickerEyedropperSamples() + return null + } if (!stylesAlreadyApplied) { tracePhase("style.end") } - ContextMenuRuntime.engine.onFrame(adapter, lastWidth, lastHeight, 1f) - SelectRuntime.engine.onFrame(adapter, lastWidth, lastHeight, 1f) - ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) - ColorPickerRuntime.engine.onCursorPosition(dsglMouseX, dsglMouseY) + return commands + } + + private fun syncFeatureRuntimeFrame(tree: DomTree, dsglMouseX: Int, dsglMouseY: Int) { + applicationPortalHost.syncPortalFrame( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f, + mouseX = dsglMouseX, + mouseY = dsglMouseY, + ) + systemPortalHost.syncPortalFrame(adapter, lastWidth, lastHeight, 1f) refreshActiveColorSamplerOwner(tree.root) - val applicationOverlayCommands = if (!appOverlayRenderEnabled) { + } + + private fun syncApplicationPortalSurface(appPortalEnabled: Boolean) { + if (!appPortalEnabled) return + try { + applicationPortalHost.render(adapter, lastWidth, lastHeight) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { + logPipelineError( + key = "draw.applicationPortal.sync", + message = + "[DSGL] Application portal sync failed; " + + "skipping application portal sync frame: ${error.message}", + ) + } + } + + private fun syncCollectAndStageApplicationPortalAfterRootPaint( + tree: DomTree, + appPortalRenderEnabled: Boolean, + appPortalInputEnabled: Boolean, + ) { + syncApplicationPortalSurface( + appPortalEnabled = appPortalRenderEnabled || appPortalInputEnabled, + ) + val applicationPortalCommands = collectApplicationPortalCommands(appPortalRenderEnabled) + stageApplicationPortalCommands( + tree = tree, + applicationPortalCommands = applicationPortalCommands, + appPortalRenderEnabled = appPortalRenderEnabled, + ) + } + + private fun collectApplicationPortalCommands(appPortalRenderEnabled: Boolean): List { + if (!appPortalRenderEnabled) { + return emptyList() + } + return try { + applicationPortalHost.paint(adapter) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { + logPipelineError( + key = "draw.applicationPortal", + message = "[DSGL] Application portal paint failed; skipping application portal frame: ${error.message}", + ) emptyList() - } else { - try { - applicationOverlayHost.render(adapter, lastWidth, lastHeight) - applicationOverlayHost.paint(adapter) - } catch (error: Throwable) { - logPipelineError( - key = "draw.applicationOverlay", - message = "[DSGL] Application overlay paint failed; skipping app overlay frame: ${error.message}" - ) - emptyList() - } } - systemOverlayHost.syncFrame( + } + + private fun syncSystemPortalAndCollectCommands( + tree: DomTree, + dsglMouseX: Int, + dsglMouseY: Int, + systemPortalRenderEnabled: Boolean, + ): List { + systemPortalHost.syncFrame( inspectedRoot = tree.root, inspectedLayoutRevision = layoutRevision, cursorX = dsglMouseX, cursorY = dsglMouseY, - inspectorPointerCaptured = inspectorPointerCaptured + inspectorPointerCaptured = inspectorPointerCaptured, ) - val systemOverlayCommands = if (!systemOverlayRenderEnabled) { - emptyList() - } else { - try { - systemOverlayHost.render(adapter, lastWidth, lastHeight) - systemOverlayHost.paint(adapter) - } catch (error: Throwable) { - logPipelineError( - key = "draw.systemOverlay", - message = "[DSGL] System overlay paint failed; skipping system overlay frame: ${error.message}" - ) - emptyList() - } + if (!systemPortalRenderEnabled) { + return emptyList() } - val debugOverlayCommands = runCatching { - debugOverlayHost.render(lastWidth, lastHeight) - debugOverlayHost.paint(adapter) - }.getOrElse { + return try { + systemPortalHost.render(adapter, lastWidth, lastHeight) + systemPortalHost.paint(adapter) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { + logPipelineError( + key = "draw.systemPortal", + message = "[DSGL] System portal paint failed; skipping system portal frame: ${error.message}", + ) emptyList() } - val contextMenuBlocks = appOverlayInputEnabled && !inspectorBlocks && ContextMenuRuntime.engine.isOpen() - val selectBlocks = appOverlayInputEnabled && !inspectorBlocks && SelectRuntime.engine.isOpen() - val inlineSamplerOwnsSession = activeColorSamplerOwner is ActiveColorSamplerOwner.Inline - val colorPickerBlocks = !inspectorBlocks && ( - (systemOverlayInputEnabled && systemOverlayHost.isSystemColorPickerOpen()) || - (appOverlayInputEnabled && ColorPickerRuntime.engine.isOpen() && !inlineSamplerOwnsSession) - ) - if (!inspectorBlocks && !contextMenuBlocks && !selectBlocks && !colorPickerBlocks) { - DndRuntime.engine.onMouseMove(tree.root, dsglMouseX, dsglMouseY) + } + + private fun stageSystemPortalCommands( + systemPortalCommands: List, + systemPortalRenderEnabled: Boolean, + ) { + systemPortalCommandsBuffer.clear() + systemPortalCommandsBuffer.addAll(systemPortalCommands) + if (systemPortalRenderEnabled) { + systemPortalHost.appendFloatingPortalCommands( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + out = systemPortalCommandsBuffer, + ) } - DndRuntime.engine.onFrame(tree.root, dtSeconds) + } + + private fun collectDebugDomainCommands(): DebugDomainCommands { + val root = + runCatching { + debugDomainRootHost.render(adapter, lastWidth, lastHeight) + debugDomainRootHost.paint(adapter) + }.getOrElse { + emptyList() + } + val portal = + runCatching { + debugDomainPortalHost.render(adapter, lastWidth, lastHeight) + debugDomainPortalHost.paint(adapter) + }.getOrElse { + emptyList() + } + return DebugDomainCommands(root = root, portal = portal) + } + + private fun updateFrameInteractionState( + tree: DomTree, + dtSeconds: Double, + dsglMouseX: Int, + dsglMouseY: Int, + appPortalInputEnabled: Boolean, + systemPortalInputEnabled: Boolean, + inspectorBlocks: Boolean, + ) { + val applicationRootFrameBlocked = + isApplicationRootFrameBlocked( + dsglMouseX = dsglMouseX, + dsglMouseY = dsglMouseY, + appPortalInputEnabled = appPortalInputEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + inspectorBlocks = inspectorBlocks, + ) val prevX = if (lastMoveX == Int.MIN_VALUE) dsglMouseX else lastMoveX val prevY = if (lastMoveY == Int.MIN_VALUE) dsglMouseY else lastMoveY val dx = dsglMouseX - prevX val dy = dsglMouseY - prevY - if (inspectorBlocks || contextMenuBlocks || selectBlocks || colorPickerBlocks) { - clearHoverChainStates() + val applicationModalBlocks = applicationPortalHost.hasActiveModalPortal() + val applicationPortalBlocks = + isApplicationPortalFrameBlocking( + dsglMouseX = dsglMouseX, + dsglMouseY = dsglMouseY, + appPortalInputEnabled = appPortalInputEnabled, + ) + if (applicationRootFrameBlocked) { + if (applicationModalBlocks) { + DndRuntime.engine.cancelActiveDrag() + } + if (applicationModalBlocks || applicationPortalBlocks) { + applicationPortalHost.handleMouseMove(dsglMouseX, dsglMouseY) + } + clearHoverChainStates(postLeaveEvents = true, mouseX = dsglMouseX, mouseY = dsglMouseY) hoverTarget = null } else { - updateHover(tree.root, hoverChain, dsglMouseX, dsglMouseY, dx, dy) - hoverTarget = hoverChain.lastOrNull() - if (dragCaptureTarget != null && hasFocusChangedSinceCapture()) { - releaseDragCapture() - } - if (dx != 0 || dy != 0) { + updateFrameApplicationRootInteraction(tree, dsglMouseX, dsglMouseY, prevX, prevY, dx, dy) + } + if (!applicationModalBlocks) { + DndRuntime.engine.onFrame(tree.root, dtSeconds) + } + lastMoveX = dsglMouseX + lastMoveY = dsglMouseY + } + + private fun isApplicationRootFrameBlocked( + dsglMouseX: Int, + dsglMouseY: Int, + appPortalInputEnabled: Boolean, + systemPortalInputEnabled: Boolean, + inspectorBlocks: Boolean, + ): Boolean { + if (appPortalInputEnabled && applicationPortalHost.hasActiveModalPortal()) return true + if (isApplicationRootPointerDragActive() && pointerCapture.target != null) return false + if (inspectorBlocks || higherSurfacePointerButton != -1) return true + if (isApplicationPortalFrameBlocking(dsglMouseX, dsglMouseY, appPortalInputEnabled)) return true + if (systemPortalInputEnabled && systemPortalHost.hasOpenPortal()) return true + return isColorPickerFrameBlocking( + appPortalInputEnabled = appPortalInputEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + ) + } + + private fun isApplicationPortalFrameBlocking( + dsglMouseX: Int, + dsglMouseY: Int, + appPortalInputEnabled: Boolean, + ): Boolean { + if (!appPortalInputEnabled) return false + return applicationPortalHost.hasOpenContextMenuPortal() || + applicationPortalHost.hasOpenSelectPortal() || + applicationPortalHost.hasDomPointerTargetAt(dsglMouseX, dsglMouseY) + } + + private fun isColorPickerFrameBlocking( + appPortalInputEnabled: Boolean, + systemPortalInputEnabled: Boolean, + ): Boolean { + val inlineSamplerOwnsSession = activeColorSamplerOwner is ActiveColorSamplerOwner.Inline + val systemPickerBlocks = systemPortalInputEnabled && systemPortalHost.isSystemColorPickerOpen() + val applicationPickerBlocks = + appPortalInputEnabled && + applicationPortalHost.hasOpenColorPickerPortal() && + !inlineSamplerOwnsSession + return systemPickerBlocks || applicationPickerBlocks + } + + private fun updateFrameApplicationRootInteraction( + tree: DomTree, + dsglMouseX: Int, + dsglMouseY: Int, + prevX: Int, + prevY: Int, + dx: Int, + dy: Int, + ) { + updateHover(tree.root, hoverChain, dsglMouseX, dsglMouseY, dx, dy) + hoverTarget = hoverChain.lastOrNull() + if (pointerCapture.target != null && pointerCapture.hasFocusChanged()) { + releaseDragCapture() + } + if (dx != 0 || dy != 0) { + if (isApplicationRootPointerDragActive()) { + dispatchApplicationRootPointerDragDelta( + tree = tree, + mouseX = dsglMouseX, + mouseY = dsglMouseY, + previousMouseX = prevX, + previousMouseY = prevY, + dx = dx, + dy = dy, + ) + lastMouseX = dsglMouseX + lastMouseY = dsglMouseY + } else { + DndRuntime.engine.onMouseMove(tree.root, dsglMouseX, dsglMouseY) val moveEvent = MouseMoveEvent(dsglMouseX, dsglMouseY, prevX, prevY) - moveEvent.target = resolveForcedPointerTarget() ?: dragCaptureTarget ?: hoverTarget + moveEvent.target = resolveForcedPointerTarget() ?: pointerCapture.target ?: hoverTarget EventBus.post(moveEvent) } } - lastMoveX = dsglMouseX - lastMoveY = dsglMouseY - applicationOverlayCommandsBuffer.clear() - if (appOverlayRenderEnabled) { - applicationOverlayCommandsBuffer.addAll(applicationOverlayCommands) - DndRuntime.engine.appendPlaceholderCommands(applicationOverlayCommandsBuffer) - DndRuntime.engine.appendOverlayCommands( - tree.root, - adapter, - lastWidth, - lastHeight, - applicationOverlayCommandsBuffer - ) - SelectRuntime.engine.appendOverlayCommands(adapter, lastWidth, lastHeight, applicationOverlayCommandsBuffer) - ContextMenuRuntime.engine.appendOverlayCommands( - adapter, - lastWidth, - lastHeight, - applicationOverlayCommandsBuffer + } + + private fun stageApplicationPortalCommands( + tree: DomTree, + applicationPortalCommands: List, + appPortalRenderEnabled: Boolean, + measureContext: UiMeasureContext = adapter, + ) { + applicationPortalCommandsBuffer.clear() + if (appPortalRenderEnabled) { + applicationPortalCommandsBuffer.addAll(applicationPortalCommands) + if (!applicationPortalHost.hasActiveModalPortal()) { + applicationPortalHost.appendDndGhostPortalCommands( + root = tree.root, + measureContext = measureContext, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + out = applicationPortalCommandsBuffer, + ) + } + applicationPortalHost.appendFloatingPortalCommands( + measureContext = measureContext, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + out = applicationPortalCommandsBuffer, ) - ColorPickerRuntime.engine.appendOverlayCommands(applicationOverlayCommandsBuffer) - appendInlineColorPickerOverlayCommands(applicationOverlayCommandsBuffer) + appendInlineColorPickerPortalCommands(applicationPortalCommandsBuffer) } - OverlayLayerContracts.composePaintCommands( + } + + private fun composeAndPresentFrame( + tree: DomTree, + commands: List, + debugDomainCommands: DebugDomainCommands, + rebuiltThisFrame: Boolean, + layoutCommittedThisFrame: Boolean, + ) { + domainOrchestrator.composePaintCommands( applicationRoot = commands, - applicationOverlay = applicationOverlayCommandsBuffer, - systemOverlay = systemOverlayCommands, - debug = debugOverlayCommands, + applicationPortal = applicationPortalCommandsBuffer, + systemPortal = systemPortalCommandsBuffer, + debugRoot = debugDomainCommands.root, + debugPortal = debugDomainCommands.portal, out = stagingCommandsBuffer, - shouldRenderLayer = OverlayLayerDebugState::isRenderEnabled - ) - val keepPrevious = shouldKeepPreviousFrameCommands( - tree = tree, - rebuiltThisFrame = rebuiltThisFrame, - layoutCommittedThisFrame = layoutCommittedThisFrame, - candidate = stagingCommandsBuffer + shouldRenderSurface = DomainSurfaceDebugState::isRenderEnabled, ) + val keepPrevious = + shouldKeepPreviousFrameCommands( + tree = tree, + rebuiltThisFrame = rebuiltThisFrame, + layoutCommittedThisFrame = layoutCommittedThisFrame, + candidate = stagingCommandsBuffer, + ) if (!keepPrevious) { composedCommandsBuffer.clear() composedCommandsBuffer.addAll(stagingCommandsBuffer) @@ -355,6 +719,14 @@ abstract class DsglScreenHost( } tracePhase("commands.end") adapter.paint(composedCommandsBuffer) + } + + private fun finishDrawScreenFrame( + tree: DomTree, + mouseX: Int, + mouseY: Int, + partialTicks: Float, + ) { tracePhase("draw.end") maybeLogPerf(tree) flushPendingCleanup() @@ -371,9 +743,8 @@ abstract class DsglScreenHost( ScreenColorSamplerBridge.install(null) FocusManager.clearFocus() DndRuntime.engine.cancelActiveDrag() - ColorPickerRuntime.engine.closeAll() - SelectRuntime.engine.closeAll() - ContextMenuRuntime.engine.closeAll() + applicationPortalHost.closeFloatingPortals() + systemPortalHost.clearRefs() clearActiveTarget() flushPendingCleanup() clearHoverChainStates() @@ -386,9 +757,10 @@ abstract class DsglScreenHost( StyleEngine.clearAllInspectorOverrides() StyleAnimationEngine.clear() domTree?.clearRefs() - applicationOverlayHost.clearRefs() - systemOverlayHost.clearRefs() - debugOverlayHost.clearRefs() + applicationPortalHost.clearRefs() + systemPortalHost.clearRefs() + debugDomainRootHost.clearRefs() + debugDomainPortalHost.clearRefs() domTree?.root?.let { root -> EventBus.run { root.clearListenersDeep() } } @@ -407,12 +779,11 @@ abstract class DsglScreenHost( needsRender = true } + @Suppress("EmptyFunctionBlock") override fun requestRedraw() { } - override fun getViewport(): Viewport { - return lastViewport - } + override fun getViewport(): Viewport = lastViewport private fun updateSize(force: Boolean) { val viewport = adapter.viewport() @@ -420,7 +791,7 @@ abstract class DsglScreenHost( val height = viewport.height lastViewport = viewport if (force || width != lastWidth || height != lastHeight) { - ContextMenuRuntime.engine.closeAll() + applicationPortalHost.closeFloatingPortals() lastWidth = width lastHeight = height needsLayout = true @@ -467,11 +838,13 @@ abstract class DsglScreenHost( window.commitRenderBuild() tracePhase("rebuild.end") true - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { window.discardRenderBuild() logPipelineError( key = "rebuild", - message = "[DSGL] Rebuild failed; keeping previous committed frame/tree: ${error.message}" + message = "[DSGL] Rebuild failed; keeping previous committed frame/tree: ${error.message}", ) false } @@ -504,8 +877,8 @@ abstract class DsglScreenHost( } } - throw IllegalStateException( - "Hot-reload hook remount recovery exceeded $maxAttempts attempts: ${lastRemountRequest?.message}" + error( + "Hot-reload hook remount recovery exceeded $maxAttempts attempts: ${lastRemountRequest?.message}", ) } @@ -514,258 +887,647 @@ abstract class DsglScreenHost( KeyModifiers.sync( shift = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) || Keyboard.isKeyDown(Keyboard.KEY_RSHIFT), control = Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) || Keyboard.isKeyDown(Keyboard.KEY_RCONTROL), - meta = Keyboard.isKeyDown(Keyboard.KEY_LMETA) || Keyboard.isKeyDown(Keyboard.KEY_RMETA) + meta = Keyboard.isKeyDown(Keyboard.KEY_LMETA) || Keyboard.isKeyDown(Keyboard.KEY_RMETA), + ) + runSurfaceInputFrame(applicationPortalHost) + runSurfaceInputFrame(systemPortalHost) + applicationPortalHost.syncPortalFrame( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f, + mouseX = if (lastMoveX == Int.MIN_VALUE) lastMouseX else lastMoveX, + mouseY = if (lastMoveY == Int.MIN_VALUE) lastMouseY else lastMoveY, ) - systemOverlayHost.onInputFrame(lastWidth, lastHeight) - ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) val keyCode = Keyboard.getEventKey() val keyChar = Keyboard.getEventCharacter() val inspectorMouseX = if (lastMoveX == Int.MIN_VALUE) lastMouseX else lastMoveX val inspectorMouseY = if (lastMoveY == Int.MIN_VALUE) lastMouseY else lastMoveY if (Keyboard.getEventKeyState()) { - if (!Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12) { - inspector.toggle() - inspectorPointerCaptured = false - if (inspector.active) { - DndRuntime.engine.cancelActiveDrag() - releaseDragCapture() - clearActiveTarget() - clearHoverChainStates() - } - mc.dispatchKeypresses() - return - } - if (Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12 && inspector.active) { - inspector.toggleMode() - mc.dispatchKeypresses() - return - } - if (keyCode == Keyboard.KEY_F10) { - val demoAnchorX = if (lastMoveX == Int.MIN_VALUE) inspectorMouseX else lastMoveX - val demoAnchorY = if (lastMoveY == Int.MIN_VALUE) inspectorMouseY else lastMoveY - systemOverlayHost.togglePanelDemo(demoAnchorX, demoAnchorY) - mc.dispatchKeypresses() - return - } - if (keyCode == Keyboard.KEY_ESCAPE && inspector.cancelPickMode()) { - logInspectorInput("escape cancelled inspector pick mode") - mc.dispatchKeypresses() - return - } - if (consumeOverlayKeyDown( + if (handleKeyboardKeyDown( keyCode = keyCode, keyChar = keyChar, inspectorMouseX = inspectorMouseX, - inspectorMouseY = inspectorMouseY + inspectorMouseY = inspectorMouseY, ) ) { - mc.dispatchKeypresses() return } - if (keyCode == Keyboard.KEY_F6) { - StyleEngine.forceReloadStylesheets() - requestRebuild("style reload") + } else { + if (handleKeyboardKeyUp(keyCode, keyChar, inspectorMouseX, inspectorMouseY)) return + } + + mc.dispatchKeypresses() + } + + private fun handleKeyboardKeyDown( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int, + ): Boolean { + if (!Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12) { + inspector.toggle() + inspectorPointerCaptured = false + if (inspector.active) { + DndRuntime.engine.cancelActiveDrag() + releaseDragCapture() + clearActiveTarget() + clearHoverChainStates() } - if (pressedKeys.add(keyCode)) { - val downEvent = KeyboardKeyDownEvent(keyChar, keyCode) - EventBus.post(downEvent) - if (downEvent.cancelled) { - pressedKeys.remove(keyCode) - } else { - window.onKeyTyped(keyChar, keyCode) - if (keyCode == Keyboard.KEY_ESCAPE) { - mc.displayGuiScreen(null) - } - } + mc.dispatchKeypresses() + return true + } + if (Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) && keyCode == Keyboard.KEY_F12 && inspector.active) { + inspector.toggleMode() + mc.dispatchKeypresses() + return true + } + if (keyCode == Keyboard.KEY_F10) { + val demoAnchorX = if (lastMoveX == Int.MIN_VALUE) inspectorMouseX else lastMoveX + val demoAnchorY = if (lastMoveY == Int.MIN_VALUE) inspectorMouseY else lastMoveY + applicationPortalHost.toggleFloatingWindowDemo(demoAnchorX, demoAnchorY) + mc.dispatchKeypresses() + return true + } + if (keyCode == Keyboard.KEY_ESCAPE && inspector.cancelPickMode()) { + logInspectorInput("escape cancelled inspector pick mode") + mc.dispatchKeypresses() + return true + } + when ( + dispatchDomainKeyDown( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY, + ) + ) { + DomainKeyDispatchResult.HigherSurfaceConsumed -> { + mc.dispatchKeypresses() + return true } - } else { - val keyboardBlocked = inspector.active && ( + + DomainKeyDispatchResult.ApplicationRootHandled, DomainKeyDispatchResult.None -> return false + } + } + + private enum class DomainKeyDispatchResult { + None, + HigherSurfaceConsumed, + ApplicationRootHandled, + } + + private fun handleKeyboardKeyUp( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int, + ): Boolean { + val keyboardBlocked = + inspector.active && + ( inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || - inspector.mode == InspectorMode.Locked - ) - if (keyboardBlocked) { - pressedKeys.remove(keyCode) - logInspectorInput("keyboard up consumed keyCode=$keyCode") + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + pressedKeys.remove(keyCode) + logInspectorInput("keyboard up consumed keyCode=$keyCode") + mc.dispatchKeypresses() + return true + } + when ( + dispatchDomainKeyUp( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY, + ) + ) { + DomainKeyDispatchResult.HigherSurfaceConsumed -> { mc.dispatchKeypresses() - return - } - if (pressedKeys.remove(keyCode)) { - EventBus.post(KeyboardKeyUpEvent(keyChar, keyCode)) + return true } - } - mc.dispatchKeypresses() + DomainKeyDispatchResult.ApplicationRootHandled, DomainKeyDispatchResult.None -> return false + } } override fun handleMouseInput() { updateSize(force = false) rebuildIfNeeded() - val tree = domTree ?: return + val tree = prepareMouseInputTree() ?: return + val inputEvent = readMouseInputEvent() + syncMouseInputFrame(tree, inputEvent) + when (dispatchDomainPointerPhase(tree, inputEvent)) { + DomainPointerDispatchResult.HigherSurfaceConsumed -> return + DomainPointerDispatchResult.ApplicationRootHandled, DomainPointerDispatchResult.None -> + finishMouseInputEvent(inputEvent) + } + } + + private enum class DomainPointerDispatchResult { + None, + HigherSurfaceConsumed, + ApplicationRootHandled, + } + + private data class MouseInputEvent( + val mouseX: Int, + val mouseY: Int, + val dWheel: Int, + val mouseButton: Int, + ) + + private data class DomainPointerDispatchContext( + val inputEvent: MouseInputEvent, + val mappedButton: MouseButton?, + val buttonPressed: Boolean, + val applicationRootPressMove: Boolean, + ) + + private fun prepareMouseInputTree(): DomTree? { + val tree = domTree ?: return null if (needsLayout) { if (tryCommitLayout(tree, "handleMouseInput")) { needsLayout = false } else { - return + return null } } + return tree + } + + private fun readMouseInputEvent(): MouseInputEvent = + MouseInputEvent( + mouseX = lastViewport.rawMouseToDsglX(Mouse.getEventX()), + mouseY = lastViewport.rawMouseToDsglY(Mouse.getEventY()), + dWheel = Mouse.getDWheel(), + mouseButton = Mouse.getEventButton(), + ) - val mouseX = lastViewport.rawMouseToDsglX(Mouse.getEventX()) - val mouseY = lastViewport.rawMouseToDsglY(Mouse.getEventY()) - val dWheel = Mouse.getDWheel() - val mouseButton = Mouse.getEventButton() - inspector.onCursorMoved(mouseX, mouseY) - ContextMenuRuntime.engine.onFrame( + private fun syncMouseInputFrame(tree: DomTree, inputEvent: MouseInputEvent) { + inspector.onCursorMoved(inputEvent.mouseX, inputEvent.mouseY) + applicationPortalHost.syncPortalFrame( measureContext = adapter, viewportWidth = lastWidth, viewportHeight = lastHeight, - viewportScale = 1f + viewportScale = 1f, + mouseX = inputEvent.mouseX, + mouseY = inputEvent.mouseY, ) - SelectRuntime.engine.onFrame( + systemPortalHost.syncPortalFrame( measureContext = adapter, viewportWidth = lastWidth, viewportHeight = lastHeight, - viewportScale = 1f + viewportScale = 1f, ) - systemOverlayHost.onInputFrame(lastWidth, lastHeight) + runSurfaceInputFrame(applicationPortalHost) + runSurfaceInputFrame(systemPortalHost) + runSurfaceInputFrame(debugDomainRootHost) + runSurfaceInputFrame(debugDomainPortalHost) inspectorPointerCaptured = inspector.isPointerCaptured - systemOverlayHost.syncFrame( + systemPortalHost.syncFrame( inspectedRoot = tree.root, inspectedLayoutRevision = layoutRevision, - cursorX = mouseX, - cursorY = mouseY, - inspectorPointerCaptured = inspectorPointerCaptured + cursorX = inputEvent.mouseX, + cursorY = inputEvent.mouseY, + inspectorPointerCaptured = inspectorPointerCaptured, ) - ColorPickerRuntime.engine.onFrame(lastWidth, lastHeight) refreshActiveColorSamplerOwner(tree.root) - val appPressMove = mouseButton == -1 && eventButton != -1 - if (!appPressMove && consumeOverlayPointerEvent(mouseX, mouseY, dWheel, mouseButton)) { - consumeOverlayPointerState(mouseX, mouseY) - return + } + + private fun runSurfaceInputFrame(host: DomainSurfaceHost) { + host.onInputFrame(lastWidth, lastHeight) + } + + private fun dispatchDomainPointerPhase(tree: DomTree, inputEvent: MouseInputEvent): DomainPointerDispatchResult { + val context = + DomainPointerDispatchContext( + inputEvent = inputEvent, + mappedButton = mapButton(inputEvent.mouseButton), + buttonPressed = Mouse.getEventButtonState(), + applicationRootPressMove = inputEvent.mouseButton == -1 && eventButton != -1, + ) + val consumedBy = + domainOrchestrator.firstInputConsumer( + canConsume = { surface -> + consumeDomainPointerSurface(surface = surface, tree = tree, context = context) + }, + isSurfaceInputEnabled = DomainSurfaceDebugState::isInputEnabled, + ) + return when (consumedBy) { + null -> + if (isHigherSurfaceOwnedPointerRelease(context)) { + higherSurfacePointerButton = -1 + consumePortalPointerState( + mouseX = inputEvent.mouseX, + mouseY = inputEvent.mouseY, + cancelRootDnd = context.inputEvent.mouseButton != -1, + ) + DomainPointerDispatchResult.HigherSurfaceConsumed + } else { + DomainPointerDispatchResult.None + } + ScreenDomainSurfaces.ApplicationRoot -> DomainPointerDispatchResult.ApplicationRootHandled + else -> { + updateHigherSurfacePointerOwnership(context) + consumePortalPointerState( + mouseX = inputEvent.mouseX, + mouseY = inputEvent.mouseY, + cancelRootDnd = context.inputEvent.mouseButton != -1, + ) + DomainPointerDispatchResult.HigherSurfaceConsumed + } } + } + private fun consumeDomainPointerSurface( + surface: ScreenDomainSurface, + tree: DomTree, + context: DomainPointerDispatchContext, + ): Boolean = + when (surface) { + ScreenDomainSurfaces.DebugPortal -> consumeDebugPortalPointerSurface(context) + ScreenDomainSurfaces.DebugRoot -> consumeDebugRootPointerSurface(context) + ScreenDomainSurfaces.SystemPortal -> consumeSystemPortalPointerSurface(context) + ScreenDomainSurfaces.SystemRoot -> false + ScreenDomainSurfaces.ApplicationPortal -> consumeApplicationPortalPointerSurface(context) + ScreenDomainSurfaces.ApplicationRoot -> + if (isHigherSurfaceOwnedPointerRelease(context)) { + false + } else { + dispatchApplicationRootPointerSurface( + tree = tree, + inputEvent = context.inputEvent, + ) + } - refreshHoverTarget(mouseX, mouseY) + else -> false + } - if (mouseButton > 2) return + private fun isHigherSurfaceOwnedPointerRelease(context: DomainPointerDispatchContext): Boolean = + context.inputEvent.mouseButton != -1 && + !context.buttonPressed && + context.inputEvent.mouseButton == higherSurfacePointerButton - if (Mouse.getEventButtonState()) { - eventButton = mouseButton - lastMouseEvent = Minecraft.getSystemTime() - mapButton(mouseButton)?.let { mappedButton -> - val event = MouseDownEvent(mouseX, mouseY, mappedButton) - event.target = resolvePointerDownTarget() - EventBus.post(event) - DndRuntime.engine.onMouseDown(tree.root, event.target ?: hoverTarget, event) - if (mappedButton == MouseButton.LEFT) { - setActiveTarget(event.target ?: hoverTarget) - val captureTarget = resolveDragCaptureTarget(event.target ?: hoverTarget, mouseX, mouseY) - if (captureTarget != null) { - setDragCapture(captureTarget) - captureTarget.beginPointerCapture(mouseX, mouseY, mappedButton) - } else if (dragCaptureTarget != null) { - releaseDragCapture() - } + private fun updateHigherSurfacePointerOwnership(context: DomainPointerDispatchContext) { + if (context.inputEvent.mouseButton == -1 || context.mappedButton == null) return + if (context.buttonPressed) { + higherSurfacePointerButton = context.inputEvent.mouseButton + } else if (higherSurfacePointerButton == context.inputEvent.mouseButton) { + higherSurfacePointerButton = -1 + } + } + + private fun consumeDebugRootPointerSurface(context: DomainPointerDispatchContext): Boolean { + if (context.applicationRootPressMove) { + return false + } + return consumeDebugPointerEvent( + host = debugDomainRootHost, + mouseX = context.inputEvent.mouseX, + mouseY = context.inputEvent.mouseY, + dWheel = context.inputEvent.dWheel, + mappedButton = context.mappedButton, + mouseButton = context.inputEvent.mouseButton, + buttonPressed = context.buttonPressed, + ) + } + + private fun consumeDebugPortalPointerSurface(context: DomainPointerDispatchContext): Boolean { + if (context.applicationRootPressMove) { + return false + } + return consumeDebugPointerEvent( + host = debugDomainPortalHost, + mouseX = context.inputEvent.mouseX, + mouseY = context.inputEvent.mouseY, + dWheel = context.inputEvent.dWheel, + mappedButton = context.mappedButton, + mouseButton = context.inputEvent.mouseButton, + buttonPressed = context.buttonPressed, + ) + } + + private fun consumeSystemPortalPointerSurface(context: DomainPointerDispatchContext): Boolean { + if (context.applicationRootPressMove) { + return false + } + return consumeSystemPortalPointerEvent( + mouseX = context.inputEvent.mouseX, + mouseY = context.inputEvent.mouseY, + dWheel = context.inputEvent.dWheel, + mouseButton = context.inputEvent.mouseButton, + mappedButton = context.mappedButton, + buttonPressed = context.buttonPressed, + ) + } + + private fun consumeApplicationPortalPointerSurface(context: DomainPointerDispatchContext): Boolean { + if (context.applicationRootPressMove) { + return false + } + return consumeApplicationPortalPointerEvent( + mouseX = context.inputEvent.mouseX, + mouseY = context.inputEvent.mouseY, + dWheel = context.inputEvent.dWheel, + mouseButton = context.inputEvent.mouseButton, + mappedButton = context.mappedButton, + buttonPressed = context.buttonPressed, + ) + } + + private fun dispatchApplicationRootPointerSurface(tree: DomTree, inputEvent: MouseInputEvent): Boolean { + refreshHoverTarget(inputEvent.mouseX, inputEvent.mouseY) + if (inputEvent.mouseButton > 2) { + return false + } + val pointerHandled = dispatchApplicationRootPointerPhase(tree, inputEvent) + val wheelHandled = dispatchApplicationRootWheelPhase(inputEvent) + return pointerHandled || wheelHandled + } + + private fun dispatchApplicationRootPointerPhase(tree: DomTree, inputEvent: MouseInputEvent): Boolean { + if (inputEvent.mouseButton != -1 && Mouse.getEventButtonState()) { + dispatchApplicationRootPointerDown(tree, inputEvent, Minecraft.getSystemTime()) + return true + } else if (inputEvent.mouseButton != -1 && eventButton == inputEvent.mouseButton) { + dispatchApplicationRootPointerUp(tree, inputEvent) + return true + } else if (eventButton != -1 && lastMouseEvent > 0L) { + dispatchApplicationRootPointerDrag(tree, inputEvent) + return true + } + return false + } + + private fun dispatchApplicationRootPointerDown(tree: DomTree, inputEvent: MouseInputEvent, eventTimeMs: Long) { + eventButton = inputEvent.mouseButton + lastMouseEvent = eventTimeMs + mapButton(inputEvent.mouseButton)?.let { mappedButton -> + val event = MouseDownEvent(inputEvent.mouseX, inputEvent.mouseY, mappedButton) + event.target = resolvePointerDownTarget() + EventBus.post(event) + if (mappedButton == MouseButton.LEFT) { + setActiveTarget(event.target ?: hoverTarget) + val captureTarget = + PointerCaptureSession.resolveCaptureTarget( + start = event.target ?: hoverTarget, + mouseX = inputEvent.mouseX, + mouseY = inputEvent.mouseY, + ) + if (captureTarget != null) { + setDragCapture(captureTarget) + captureTarget.beginPointerCapture(inputEvent.mouseX, inputEvent.mouseY, mappedButton) + } else if (pointerCapture.target != null) { + releaseDragCapture() } - } - } else if (mouseButton != -1 && eventButton == mouseButton) { - val releaseTarget = resolvePointerUpTarget() - val hadDragCapture = dragCaptureTarget != null - eventButton = -1 - mapButton(mouseButton)?.let { mappedButton -> - val upEvent = MouseUpEvent(mouseX, mouseY, mappedButton) - upEvent.target = releaseTarget - EventBus.post(upEvent) - dragCaptureTarget?.endPointerCapture(mouseX, mouseY, mappedButton) - val dndConsumed = DndRuntime.engine.onMouseUp(tree.root, upEvent) - if (!hadDragCapture && !dndConsumed) { - val clickEvent = MouseClickEvent(mouseX, mouseY, mappedButton) - clickEvent.target = resolveClickTarget() - EventBus.post(clickEvent) + if (captureTarget == null && !event.cancelled) { + DndRuntime.engine.onMouseDown(tree.root, event.target ?: hoverTarget, event) } + } else if (!event.cancelled) { + DndRuntime.engine.onMouseDown(tree.root, event.target ?: hoverTarget, event) } - clearActiveTarget() - releaseDragCapture() - } else if (eventButton != -1 && lastMouseEvent > 0L) { - mapButton(eventButton)?.let { mappedButton -> - val dx = mouseX - lastMouseX - val dy = mouseY - lastMouseY - if (dx != 0 || dy != 0) { - DndRuntime.engine.onMouseMove(tree.root, mouseX, mouseY) - val dragEvent = MouseDragEvent( - lastMouseX, - lastMouseY, - dx, - dy, - mappedButton - ) - if (!DndRuntime.engine.isDragging) { - dragEvent.target = dragCaptureTarget ?: hoverTarget - EventBus.post(dragEvent) - } - dragCaptureTarget?.continuePointerCapture( - mouseX = mouseX, - mouseY = mouseY, - mouseDX = dx, - mouseDY = dy, - button = mappedButton - ) - } + } + } + + private fun dispatchApplicationRootPointerUp(tree: DomTree, inputEvent: MouseInputEvent) { + val releaseTarget = resolvePointerUpTarget() + val hadDragCapture = pointerCapture.target != null + eventButton = -1 + mapButton(inputEvent.mouseButton)?.let { mappedButton -> + val upEvent = MouseUpEvent(inputEvent.mouseX, inputEvent.mouseY, mappedButton) + upEvent.target = releaseTarget + EventBus.post(upEvent) + pointerCapture.target?.endPointerCapture(inputEvent.mouseX, inputEvent.mouseY, mappedButton) + val dndConsumed = DndRuntime.engine.onMouseUp(tree.root, upEvent) + if (!hadDragCapture && !dndConsumed) { + val clickEvent = MouseClickEvent(inputEvent.mouseX, inputEvent.mouseY, mappedButton) + clickEvent.target = resolveClickTarget() + EventBus.post(clickEvent) } } + clearActiveTarget() + releaseDragCapture() + } + + private fun dispatchApplicationRootPointerDrag(tree: DomTree, inputEvent: MouseInputEvent) { + mapButton(eventButton)?.let { mappedButton -> + val dx = inputEvent.mouseX - lastMouseX + val dy = inputEvent.mouseY - lastMouseY + if (dx != 0 || dy != 0) { + dispatchApplicationRootPointerDragDelta( + tree = tree, + mouseX = inputEvent.mouseX, + mouseY = inputEvent.mouseY, + previousMouseX = lastMouseX, + previousMouseY = lastMouseY, + dx = dx, + dy = dy, + mappedButton = mappedButton, + ) + } + } + } + + private fun dispatchApplicationRootPointerDragDelta( + tree: DomTree, + mouseX: Int, + mouseY: Int, + previousMouseX: Int, + previousMouseY: Int, + dx: Int, + dy: Int, + mappedButton: MouseButton? = mapButton(eventButton), + ) { + val button = mappedButton ?: return + val captured = pointerCapture.target + if (captured == null) { + DndRuntime.engine.onMouseMove(tree.root, mouseX, mouseY) + } + val dragEvent = + MouseDragEvent( + previousMouseX, + previousMouseY, + dx, + dy, + button, + ) + if (captured != null || !DndRuntime.engine.isDragging) { + dragEvent.target = captured ?: hoverTarget + EventBus.post(dragEvent) + } + captured?.continuePointerCapture( + mouseX = mouseX, + mouseY = mouseY, + mouseDX = dx, + mouseDY = dy, + button = button, + ) + } - if (dWheel != 0) { + private fun dispatchApplicationRootWheelPhase(inputEvent: MouseInputEvent): Boolean { + if (inputEvent.dWheel != 0) { val wheelTarget = resolveWheelTarget() if (wheelTarget != null) { - val wheelEvent = MouseWheelEvent(mouseX, mouseY, dWheel) + val wheelEvent = MouseWheelEvent(inputEvent.mouseX, inputEvent.mouseY, inputEvent.dWheel) wheelEvent.target = wheelTarget EventBus.post(wheelEvent) if (!wheelEvent.cancelled) { - bubbleGenericWheel(wheelTarget, mouseX, mouseY, dWheel) + bubbleGenericWheel(wheelTarget, inputEvent.mouseX, inputEvent.mouseY, inputEvent.dWheel) } + return true } } + return false + } - lastMouseX = mouseX - lastMouseY = mouseY + private fun finishMouseInputEvent(inputEvent: MouseInputEvent) { + lastMouseX = inputEvent.mouseX + lastMouseY = inputEvent.mouseY } - private fun consumeOverlayKeyDown( + private fun dispatchDomainKeyDown( keyCode: Int, keyChar: Char, inspectorMouseX: Int, - inspectorMouseY: Int - ): Boolean { - val consumedBy = OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - when (layer) { - UiLayerId.Debug -> debugOverlayHost.handleKeyDown(keyCode, keyChar) - UiLayerId.SystemOverlay -> consumeSystemOverlayKeyDown( - keyCode = keyCode, - keyChar = keyChar, - inspectorMouseX = inspectorMouseX, - inspectorMouseY = inspectorMouseY - ) + inspectorMouseY: Int, + ): DomainKeyDispatchResult { + val consumedBy = + domainOrchestrator.firstInputConsumer( + canConsume = { surface -> + when (surface) { + ScreenDomainSurfaces.DebugPortal -> debugDomainPortalHost.handleKeyDown(keyCode, keyChar) + ScreenDomainSurfaces.DebugRoot -> debugDomainRootHost.handleKeyDown(keyCode, keyChar) + + ScreenDomainSurfaces.SystemPortal -> + consumeSystemPortalKeyDown( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY, + ) + + ScreenDomainSurfaces.ApplicationPortal -> consumeApplicationPortalKeyDown(keyCode, keyChar) + ScreenDomainSurfaces.ApplicationRoot -> { + dispatchApplicationRootKeyDown(keyCode, keyChar) + true + } + ScreenDomainSurfaces.SystemRoot -> false + else -> false + } + }, + isSurfaceInputEnabled = DomainSurfaceDebugState::isInputEnabled, + ) + return when (consumedBy) { + null -> DomainKeyDispatchResult.None + ScreenDomainSurfaces.ApplicationRoot -> DomainKeyDispatchResult.ApplicationRootHandled + else -> DomainKeyDispatchResult.HigherSurfaceConsumed + } + } + + private fun dispatchDomainKeyUp( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int, + ): DomainKeyDispatchResult { + val consumedBy = + domainOrchestrator.firstInputConsumer( + canConsume = { surface -> + when (surface) { + ScreenDomainSurfaces.DebugPortal -> debugDomainPortalHost.handleKeyUp(keyCode, keyChar) + ScreenDomainSurfaces.DebugRoot -> debugDomainRootHost.handleKeyUp(keyCode, keyChar) + + ScreenDomainSurfaces.SystemPortal -> + consumeSystemPortalKeyUp( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY, + ) + + ScreenDomainSurfaces.ApplicationPortal -> consumeApplicationPortalKeyUp(keyCode, keyChar) + ScreenDomainSurfaces.ApplicationRoot -> dispatchApplicationRootKeyUp(keyCode, keyChar) + ScreenDomainSurfaces.SystemRoot -> false + else -> false + } + }, + isSurfaceInputEnabled = DomainSurfaceDebugState::isInputEnabled, + ) + return when (consumedBy) { + null -> DomainKeyDispatchResult.None + ScreenDomainSurfaces.ApplicationRoot -> DomainKeyDispatchResult.ApplicationRootHandled + else -> DomainKeyDispatchResult.HigherSurfaceConsumed + } + } - UiLayerId.ApplicationOverlay -> consumeApplicationOverlayKeyDown(keyCode, keyChar) - UiLayerId.ApplicationRoot -> false + private fun dispatchApplicationRootKeyDown(keyCode: Int, keyChar: Char) { + // TODO(Veritaris): remove this handling from production build + if (keyCode == Keyboard.KEY_F6) { + StyleEngine.forceReloadStylesheets() + requestRebuild("style reload") + } + if (pressedKeys.add(keyCode)) { + val downEvent = dispatchFocusedApplicationRootKeyDown(keyCode, keyChar) + if (!downEvent.cancelled && keyCode == Keyboard.KEY_ESCAPE && downEvent.target != null) { + FocusManager.clearFocus() + downEvent.cancelled = true + } + if (downEvent.cancelled) { + pressedKeys.remove(keyCode) + } else { + window.onKeyTyped(keyChar, keyCode) + if (keyCode == Keyboard.KEY_ESCAPE) { + mc.displayGuiScreen(null) } - }, - isLayerInputEnabled = OverlayLayerDebugState::isInputEnabled - ) - return consumedBy != null + } + } + } + + private fun dispatchApplicationRootKeyUp(keyCode: Int, keyChar: Char): Boolean { + if (!pressedKeys.remove(keyCode)) return false + dispatchFocusedApplicationRootKeyUp(keyCode, keyChar) + return true } - private fun consumeSystemOverlayKeyDown( + private fun dispatchFocusedApplicationRootKeyDown(keyCode: Int, keyChar: Char): KeyboardKeyDownEvent { + val downEvent = KeyboardKeyDownEvent(keyChar, keyCode) + val root = domTree?.root + if (root != null) { + downEvent.target = FocusManager.focusedNodeWithin(root) + if (downEvent.target != null) { + EventBus.post(downEvent) + } + } + return downEvent + } + + private fun dispatchFocusedApplicationRootKeyUp(keyCode: Int, keyChar: Char) { + val root = domTree?.root ?: return + val focused = FocusManager.focusedNodeWithin(root) ?: return + val upEvent = KeyboardKeyUpEvent(keyChar, keyCode) + upEvent.target = focused + EventBus.post(upEvent) + } + + private fun consumeSystemPortalKeyDown( keyCode: Int, keyChar: Char, inspectorMouseX: Int, - inspectorMouseY: Int + inspectorMouseY: Int, ): Boolean { - if (systemOverlayHost.handleKeyDown(keyCode, keyChar)) { + if (systemPortalHost.handlePortalKeyDown(keyCode, keyChar)) { return true } - val keyboardBlocked = inspector.active && ( - inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + if (systemPortalHost.handleKeyDown(keyCode, keyChar)) { + return true + } + val keyboardBlocked = + inspector.active && + ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || inspector.mode == InspectorMode.Locked ) if (keyboardBlocked) { @@ -775,113 +1537,118 @@ abstract class DsglScreenHost( return false } - private fun consumeApplicationOverlayKeyDown(keyCode: Int, keyChar: Char): Boolean { - if (ColorPickerRuntime.engine.handleKeyDown(keyCode, keyChar)) { + private fun consumeSystemPortalKeyUp( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int, + ): Boolean { + if (systemPortalHost.handlePortalKeyUp(keyCode, keyChar)) { + return true + } + if (systemPortalHost.handleKeyUp(keyCode, keyChar)) { + return true + } + val keyboardBlocked = + inspector.active && + ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + logInspectorInput("keyboard up consumed keyCode=$keyCode") return true } - if (applicationOverlayHost.handleKeyDown(keyCode, keyChar)) { + return false + } + + private fun consumeApplicationPortalKeyDown(keyCode: Int, keyChar: Char): Boolean { + if (applicationPortalHost.handlePortalKeyDownBeforeDom(keyCode, keyChar)) { return true } - if (SelectRuntime.engine.handleKeyDown(keyCode, keyChar)) { + if (applicationPortalHost.handleKeyDown(keyCode, keyChar)) { return true } - if (ContextMenuRuntime.engine.handleKeyDown(keyCode)) { + if (applicationPortalHost.handlePortalKeyDownAfterDom(keyCode, keyChar)) { return true } return false } - private fun consumeOverlayPointerEvent( - mouseX: Int, - mouseY: Int, - dWheel: Int, - mouseButton: Int - ): Boolean { - val mappedButton = mapButton(mouseButton) - val buttonPressed = Mouse.getEventButtonState() - val consumedBy = OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - when (layer) { - UiLayerId.Debug -> consumeDebugPointerEvent( - mouseX = mouseX, - mouseY = mouseY, - dWheel = dWheel, - mappedButton = mappedButton, - mouseButton = mouseButton, - buttonPressed = buttonPressed - ) - - UiLayerId.SystemOverlay -> consumeSystemOverlayPointerEvent( - mouseX = mouseX, - mouseY = mouseY, - dWheel = dWheel, - mouseButton = mouseButton, - mappedButton = mappedButton, - buttonPressed = buttonPressed - ) - - UiLayerId.ApplicationOverlay -> consumeApplicationOverlayPointerEvent( - mouseX = mouseX, - mouseY = mouseY, - dWheel = dWheel, - mouseButton = mouseButton, - mappedButton = mappedButton, - buttonPressed = buttonPressed - ) - - UiLayerId.ApplicationRoot -> false - } - }, - isLayerInputEnabled = OverlayLayerDebugState::isInputEnabled - ) - return consumedBy != null + private fun consumeApplicationPortalKeyUp(keyCode: Int, keyChar: Char): Boolean { + if (applicationPortalHost.handlePortalKeyUpBeforeDom(keyCode, keyChar)) { + return true + } + if (applicationPortalHost.handleKeyUp(keyCode, keyChar)) { + return true + } + if (applicationPortalHost.handlePortalKeyUpAfterDom(keyCode, keyChar)) { + return true + } + return false } private fun consumeDebugPointerEvent( + host: DomainSurfaceHost, mouseX: Int, mouseY: Int, dWheel: Int, mappedButton: MouseButton?, mouseButton: Int, - buttonPressed: Boolean + buttonPressed: Boolean, ): Boolean { - if (dWheel != 0 && debugOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + if (dWheel != 0 && host.handleMouseWheel(mouseX, mouseY, dWheel)) { return true } if (mouseButton != -1 && mappedButton != null) { return if (buttonPressed) { - debugOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) + host.handleMouseDown(mouseX, mouseY, mappedButton) } else { - debugOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + host.handleMouseUp(mouseX, mouseY, mappedButton) } } - if (mouseButton == -1 && debugOverlayHost.handleMouseMove(mouseX, mouseY)) { + if (mouseButton == -1 && host.handleMouseMove(mouseX, mouseY)) { return true } return false } - private fun consumeSystemOverlayPointerEvent( + private fun consumeSystemPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, - buttonPressed: Boolean + buttonPressed: Boolean, ): Boolean { - if (dWheel != 0 && systemOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + if (dWheel != 0 && systemPortalHost.handlePortalMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (dWheel != 0 && systemPortalHost.handleMouseWheel(mouseX, mouseY, dWheel)) { return true } if (mouseButton != -1 && mappedButton != null) { - val consumedBySystemOverlay = if (buttonPressed) { - systemOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) - } else { - systemOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + val consumedBySystemSelect = + if (buttonPressed) { + systemPortalHost.handlePortalMouseDown(mouseX, mouseY, mappedButton) + } else { + systemPortalHost.handlePortalMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedBySystemSelect) { + return true } - if (consumedBySystemOverlay) { + val consumedBySystemPortal = + if (buttonPressed) { + systemPortalHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + systemPortalHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedBySystemPortal) { return true } - } else if (mouseButton == -1 && systemOverlayHost.handleMouseMove(mouseX, mouseY)) { + } else if (mouseButton == -1 && systemPortalHost.handlePortalMouseMove(mouseX, mouseY)) { + return true + } else if (mouseButton == -1 && systemPortalHost.handleMouseMove(mouseX, mouseY)) { return true } @@ -894,102 +1661,80 @@ abstract class DsglScreenHost( return true } - private fun consumeApplicationOverlayPointerEvent( + private fun consumeApplicationPortalPointerEvent( mouseX: Int, mouseY: Int, dWheel: Int, mouseButton: Int, mappedButton: MouseButton?, - buttonPressed: Boolean + buttonPressed: Boolean, ): Boolean { val inlineSamplerOwnsSession = activeColorSamplerOwner is ActiveColorSamplerOwner.Inline if (!inlineSamplerOwnsSession) { - if (dWheel != 0 && ColorPickerRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { - return true - } - if (mouseButton != -1 && mappedButton != null) { - val consumedByColorPicker = if (buttonPressed) { - ColorPickerRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) - } else { - ColorPickerRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) - } - if (consumedByColorPicker) { - return true - } - } else if (mouseButton == -1 && ColorPickerRuntime.engine.handleMouseMove(mouseX, mouseY)) { + if ( + applicationPortalHost.handlePortalPointerBeforeDom( + mouseX = mouseX, + mouseY = mouseY, + dWheel = dWheel, + button = mappedButton, + pressed = buttonPressed, + ) + ) { return true } } - if (dWheel != 0 && applicationOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + if (dWheel != 0 && applicationPortalHost.handleMouseWheel(mouseX, mouseY, dWheel)) { return true } if (mouseButton != -1 && mappedButton != null) { - val consumedByAppOverlay = if (buttonPressed) { - applicationOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) - } else { - applicationOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) - } - if (consumedByAppOverlay) { + val consumedByApplicationPortal = + if (buttonPressed) { + applicationPortalHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + applicationPortalHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + if (consumedByApplicationPortal) { return true } - } else if (mouseButton == -1 && applicationOverlayHost.handleMouseMove(mouseX, mouseY)) { + } else if (mouseButton == -1 && applicationPortalHost.handleMouseMove(mouseX, mouseY)) { return true } - if (dWheel != 0 && ContextMenuRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { - return true - } - if (dWheel != 0 && SelectRuntime.engine.handleMouseWheel(mouseX, mouseY, dWheel)) { - return true - } - if (mouseButton != -1 && mappedButton != null) { - val consumedByContextMenu = if (buttonPressed) { - ContextMenuRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) - } else { - ContextMenuRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) - } - if (consumedByContextMenu) { - return true - } - val consumedBySelect = if (buttonPressed) { - SelectRuntime.engine.handleMouseDown(mouseX, mouseY, mappedButton) - } else { - SelectRuntime.engine.handleMouseUp(mouseX, mouseY, mappedButton) - } - if (consumedBySelect) { - return true - } - return false - } - if (mouseButton == -1 && ContextMenuRuntime.engine.handleMouseMove(mouseX, mouseY)) { - return true - } - if (mouseButton == -1 && SelectRuntime.engine.handleMouseMove(mouseX, mouseY)) { - return true - } - return false + return applicationPortalHost.handlePortalPointerAfterDom( + mouseX = mouseX, + mouseY = mouseY, + dWheel = dWheel, + button = mappedButton, + pressed = buttonPressed, + ) } - private fun consumeOverlayPointerState(mouseX: Int, mouseY: Int) { + private fun consumePortalPointerState(mouseX: Int, mouseY: Int, cancelRootDnd: Boolean = false) { + if (cancelRootDnd) { + DndRuntime.engine.cancelActiveDrag() + } eventButton = -1 clearActiveTarget() releaseDragCapture() + clearHoverChainStates(postLeaveEvents = true, mouseX = mouseX, mouseY = mouseY) + hoverTarget = null lastMouseX = mouseX lastMouseY = mouseY } - private fun mapButton(button: Int): MouseButton? { - return when (button) { + private fun mapButton(button: Int): MouseButton? = + when (button) { 0 -> MouseButton.LEFT 1 -> MouseButton.RIGHT 2 -> MouseButton.MIDDLE else -> null } - } + + private fun isApplicationRootPointerDragActive(): Boolean = eventButton != -1 && lastMouseEvent > 0L init { - inspector.installColorPickerHost(systemOverlayHost.systemInspectorColorPickerPopupHost()) + inspector.installColorPickerPortalService(systemPortalHost.systemInspectorColorPickerService()) } private fun refreshActiveColorSamplerOwner(root: DOMNode?) { @@ -1001,20 +1746,19 @@ abstract class DsglScreenHost( if (focusedInline != null && focusedInline.wantsGlobalPointerInput()) { inlineByToken.putIfAbsent(colorSamplerToken(focusedInline), focusedInline) } - activeColorSamplerOwner = colorSamplerOwnershipRouter.update( - popupEyedropperActive = ColorPickerRuntime.engine.hasActiveEyedropper(), - inlineActiveTokens = inlineByToken.keys.toSet() - ) - activeInlineColorSamplerNode = when (val owner = activeColorSamplerOwner) { - is ActiveColorSamplerOwner.Inline -> inlineByToken[owner.token] - else -> null - } + activeColorSamplerOwner = + colorSamplerOwnershipRouter.update( + popupEyedropperActive = applicationPortalHost.hasActiveColorPickerEyedropper(), + inlineActiveTokens = inlineByToken.keys.toSet(), + ) + activeInlineColorSamplerNode = + when (val owner = activeColorSamplerOwner) { + is ActiveColorSamplerOwner.Inline -> inlineByToken[owner.token] + else -> null + } } - private fun collectActiveInlineColorSamplers( - node: DOMNode, - out: MutableMap - ) { + private fun collectActiveInlineColorSamplers(node: DOMNode, out: MutableMap) { if (node is ColorPickerInlineNode && node.wantsGlobalPointerInput()) { out.putIfAbsent(colorSamplerToken(node), node) } @@ -1023,9 +1767,7 @@ abstract class DsglScreenHost( } } - private fun colorSamplerToken(node: ColorPickerInlineNode): Any { - return node.key ?: node - } + private fun colorSamplerToken(node: ColorPickerInlineNode): Any = node.key ?: node private fun resolveForcedPointerTarget(): DOMNode? { if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { @@ -1037,30 +1779,32 @@ abstract class DsglScreenHost( return null } - private fun appendInlineColorPickerOverlayCommands(out: MutableList) { - val layer = OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) - if (layer != UiLayerId.ApplicationOverlay) return + private fun appendInlineColorPickerPortalCommands(out: MutableList) { + val surface = ScreenDomainSurfaces.portalSurfaceForDomain(ScreenDomainId.Application) + if (surface != ScreenDomainSurfaces.ApplicationPortal) return if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { val inline = activeInlineColorSamplerNode ?: return if (!inline.wantsGlobalPointerInput()) return - inline.appendEyedropperOverlayCommands( + inline.appendEyedropperPortalCommands( viewportWidth = lastWidth.coerceAtLeast(1), viewportHeight = lastHeight.coerceAtLeast(1), - out = out + out = out, ) } } private fun captureColorPickerEyedropperSamples() { refreshActiveColorSamplerOwner(domTree?.root) - if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.System) == UiLayerId.SystemOverlay) { - systemOverlayHost.captureSystemColorPickerEyedropperSample() + if (ScreenDomainSurfaces.portalSurfaceForDomain(ScreenDomainId.System) == ScreenDomainSurfaces.SystemPortal) { + systemPortalHost.captureSystemColorPickerEyedropperSample() } - if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) != UiLayerId.ApplicationOverlay) { + if (ScreenDomainSurfaces.portalSurfaceForDomain(ScreenDomainId.Application) != + ScreenDomainSurfaces.ApplicationPortal + ) { return } when (activeColorSamplerOwner) { - ActiveColorSamplerOwner.Popup -> ColorPickerRuntime.engine.captureEyedropperSample() + ActiveColorSamplerOwner.Popup -> applicationPortalHost.captureColorPickerEyedropperSample() is ActiveColorSamplerOwner.Inline -> { val inline = activeInlineColorSamplerNode if (inline != null && inline.wantsGlobalPointerInput()) { @@ -1069,8 +1813,8 @@ abstract class DsglScreenHost( } ActiveColorSamplerOwner.None -> { - if (ColorPickerRuntime.engine.hasActiveEyedropper()) { - ColorPickerRuntime.engine.captureEyedropperSample() + if (applicationPortalHost.hasActiveColorPickerEyedropper()) { + applicationPortalHost.captureColorPickerEyedropperSample() } } } @@ -1108,22 +1852,177 @@ abstract class DsglScreenHost( internal fun debugRebuildIfNeededForTests(): Boolean = rebuildIfNeeded() + internal fun debugComposeDomainPaintCommandsForTests( + applicationRoot: List, + applicationPortal: List, + systemRoot: List = emptyList(), + systemPortal: List, + debugRoot: List, + debugPortal: List = emptyList(), + shouldRenderSurface: (ScreenDomainSurface) -> Boolean = { true }, + ): List { + val out = ArrayList() + domainOrchestrator.composePaintCommands( + applicationRoot = applicationRoot, + applicationPortal = applicationPortal, + systemRoot = systemRoot, + systemPortal = systemPortal, + debugRoot = debugRoot, + debugPortal = debugPortal, + out = out, + shouldRenderSurface = shouldRenderSurface, + ) + return out + } + + internal fun debugStageApplicationPortalCommandsForTests( + tree: DomTree, + applicationPortalCommands: List, + appPortalRenderEnabled: Boolean = true, + measureContext: UiMeasureContext, + ): List { + stageApplicationPortalCommands( + tree = tree, + applicationPortalCommands = applicationPortalCommands, + appPortalRenderEnabled = appPortalRenderEnabled, + measureContext = measureContext, + ) + return applicationPortalCommandsBuffer.toList() + } + + internal fun debugSyncApplicationPortalSurfaceForTests( + measureContext: UiMeasureContext, + width: Int, + height: Int, + appPortalEnabled: Boolean = true, + ) { + if (appPortalEnabled) { + applicationPortalHost.render(measureContext, width, height) + } + } + + internal fun debugCollectApplicationPortalCommandsForTests( + measureContext: UiMeasureContext, + appPortalRenderEnabled: Boolean = true, + ): List = + if (appPortalRenderEnabled) { + applicationPortalHost.paint(measureContext) + } else { + emptyList() + } + + internal fun debugFirstDomainInputConsumerForTests( + canConsume: (ScreenDomainSurface) -> Boolean, + isSurfaceInputEnabled: (ScreenDomainSurface) -> Boolean = { true }, + ): ScreenDomainSurface? = + domainOrchestrator.firstInputConsumer( + canConsume = canConsume, + isSurfaceInputEnabled = isSurfaceInputEnabled, + ) + + internal fun debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton: Int, + buttonPressed: Boolean, + mouseX: Int = 0, + mouseY: Int = 0, + applicationPortalConsumes: () -> Boolean, + applicationRootConsumes: () -> Boolean, + ): ScreenDomainSurface? { + val context = + DomainPointerDispatchContext( + inputEvent = + MouseInputEvent( + mouseX = mouseX, + mouseY = mouseY, + dWheel = 0, + mouseButton = mouseButton, + ), + mappedButton = mapButton(mouseButton), + buttonPressed = buttonPressed, + applicationRootPressMove = false, + ) + val consumedBy = + domainOrchestrator.firstInputConsumer( + canConsume = { surface -> + when (surface) { + ScreenDomainSurfaces.ApplicationPortal -> applicationPortalConsumes() + ScreenDomainSurfaces.ApplicationRoot -> + if (isHigherSurfaceOwnedPointerRelease(context)) { + false + } else { + applicationRootConsumes() + } + + else -> false + } + }, + ) + if (consumedBy != null && consumedBy != ScreenDomainSurfaces.ApplicationRoot) { + updateHigherSurfacePointerOwnership(context) + consumePortalPointerState( + mouseX = mouseX, + mouseY = mouseY, + cancelRootDnd = context.inputEvent.mouseButton != -1, + ) + } else if (consumedBy == null && isHigherSurfaceOwnedPointerRelease(context)) { + higherSurfacePointerButton = -1 + consumePortalPointerState( + mouseX = mouseX, + mouseY = mouseY, + cancelRootDnd = context.inputEvent.mouseButton != -1, + ) + return ScreenDomainSurfaces.ApplicationPortal + } + return consumedBy + } + + internal fun debugApplicationPortalHostForTests(): ApplicationPortalHost = applicationPortalHost + + internal fun debugUpdateFrameInteractionStateForTests( + tree: DomTree, + mouseX: Int, + mouseY: Int, + appPortalInputEnabled: Boolean = true, + systemPortalInputEnabled: Boolean = true, + inspectorBlocks: Boolean = false, + ) { + updateFrameInteractionState( + tree = tree, + dtSeconds = 1.0 / 60.0, + dsglMouseX = mouseX, + dsglMouseY = mouseY, + appPortalInputEnabled = appPortalInputEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + inspectorBlocks = inspectorBlocks, + ) + } + + internal fun debugDispatchApplicationRootPointerDownForTests( + tree: DomTree, + mouseX: Int, + mouseY: Int, + mouseButton: Int = 0, + ) { + val inputEvent = + MouseInputEvent( + mouseX = mouseX, + mouseY = mouseY, + dWheel = 0, + mouseButton = mouseButton, + ) + refreshHoverTarget(mouseX, mouseY) + dispatchApplicationRootPointerDown(tree, inputEvent, eventTimeMs = 1L) + finishMouseInputEvent(inputEvent) + lastMoveX = mouseX + lastMoveY = mouseY + } + private fun setDragCapture(target: DOMNode) { - dragCaptureTarget = target - dragCaptureKey = target.key - dragCaptureClass = target.javaClass - dragCaptureFocusKey = FocusManager.focusedNode()?.key + pointerCapture.capture(target) } private fun releaseDragCapture() { - dragCaptureTarget?.cancelPointerCapture() - RangeInputNode.clearActiveDrag() - SingleLineInputNode.clearActiveDrag() - TextAreaNode.clearActiveDrag() - dragCaptureTarget = null - dragCaptureKey = null - dragCaptureClass = null - dragCaptureFocusKey = null + pointerCapture.release() } private fun setActiveTarget(target: DOMNode?) { @@ -1139,71 +2038,8 @@ abstract class DsglScreenHost( activeTarget = null } - private fun resolveDragCaptureTarget(start: DOMNode?, mouseX: Int, mouseY: Int): DOMNode? { - var current = start - while (current != null) { - when (current) { - is RangeInputNode -> return current - is SingleLineInputNode -> if (current.shouldCaptureTextSelectionDrag(mouseX, mouseY)) return current - is TextAreaNode -> if (current.shouldCaptureAnyDrag(mouseX, mouseY)) return current - } - if (current.shouldCapturePointerDrag(mouseX, mouseY)) { - return current - } - current = current.parent - } - return null - } - private fun restoreDragCapture(root: DOMNode) { - if (dragCaptureTarget == null) return - val key = dragCaptureKey - val cls = dragCaptureClass - if (cls == null) { - releaseDragCapture() - return - } - if (key == null) { - val captured = dragCaptureTarget - if (captured != null && captured.javaClass == cls) { - if (eventButton != -1) { - return - } - if (isSameOrAncestor(root, captured)) { - return - } - } - releaseDragCapture() - return - } - - val restored = findByKeyAndClass(root, key, cls) - if (restored != null) { - dragCaptureTarget = restored - } else { - releaseDragCapture() - } - } - - private fun findByKeyAndClass( - node: DOMNode, - key: Any, - cls: Class - ): DOMNode? { - if (node.key == key && node.javaClass == cls) return node - for (child in node.children) { - val found = findByKeyAndClass(child, key, cls) - if (found != null) { - return found - } - } - return null - } - - private fun hasFocusChangedSinceCapture(): Boolean { - if (dragCaptureFocusKey == null) return false - val currentFocusKey = FocusManager.focusedNode()?.key - return currentFocusKey != dragCaptureFocusKey + pointerCapture.restore(root, pointerPressed = eventButton != -1) } private fun refreshHoverTarget(mouseX: Int, mouseY: Int) { @@ -1219,17 +2055,12 @@ abstract class DsglScreenHost( hoverTarget = chain.lastOrNull() } - private fun resolvePointerDownTarget(): DOMNode? { - return resolveForcedPointerTarget() ?: hoverTarget - } + private fun resolvePointerDownTarget(): DOMNode? = resolveForcedPointerTarget() ?: hoverTarget - private fun resolvePointerUpTarget(): DOMNode? { - return dragCaptureTarget ?: resolveForcedPointerTarget() ?: hoverTarget - } + private fun resolvePointerUpTarget(): DOMNode? = + pointerCapture.target ?: resolveForcedPointerTarget() ?: hoverTarget - private fun resolveClickTarget(): DOMNode? { - return hoverTarget - } + private fun resolveClickTarget(): DOMNode? = hoverTarget private fun resolveWheelTarget(): DOMNode? { val focused = FocusManager.focusedNode() @@ -1242,7 +2073,12 @@ abstract class DsglScreenHost( return hoverTarget } - private fun bubbleGenericWheel(target: DOMNode, mouseX: Int, mouseY: Int, delta: Int): Boolean { + private fun bubbleGenericWheel( + target: DOMNode, + mouseX: Int, + mouseY: Int, + delta: Int, + ): Boolean { var current: DOMNode? = target while (current != null) { if (current.handleGenericWheel(mouseX, mouseY, delta)) { @@ -1262,9 +2098,19 @@ abstract class DsglScreenHost( return false } - private fun clearHoverChainStates() { + private fun clearHoverChainStates( + postLeaveEvents: Boolean = false, + mouseX: Int = lastMoveX, + mouseY: Int = lastMoveY, + ) { hoverChain.forEach { node -> node.setHoveredState(false) + if (postLeaveEvents) { + val leave = MouseLeaveEvent(mouseX, mouseY) + leave.target = node + EventBus.post(leave) + node.onmouseleave?.invoke(leave) + } } hoverChain.clear() } @@ -1281,17 +2127,21 @@ abstract class DsglScreenHost( if (lastWidth > 0 && lastHeight > 0 && (rootBounds.width <= 0 || rootBounds.height <= 0)) { logPipelineError( key = "layout.$phase.invalidBounds", - message = "[DSGL] Layout commit produced invalid root bounds ${rootBounds.width}x${rootBounds.height} in $phase." + message = + "[DSGL] Layout commit produced invalid root bounds " + + "${rootBounds.width}x${rootBounds.height} in $phase.", ) return false } layoutRevision++ inspector.onLayoutCommitted(tree.root, layoutRevision) true - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { logPipelineError( key = "layout.$phase", - message = "[DSGL] Layout commit failed in $phase; keeping previous frame: ${error.message}" + message = "[DSGL] Layout commit failed in $phase; keeping previous frame: ${error.message}", ) false } @@ -1301,13 +2151,16 @@ abstract class DsglScreenHost( tree: DomTree, rebuiltThisFrame: Boolean, layoutCommittedThisFrame: Boolean, - candidate: List + candidate: List, ): Boolean { val shape = validateCommandShape(candidate) if (!shape.valid) { logPipelineError( key = "shape.guard", - message = "[DSGL] Guarded invalid command shape (clip=${shape.clipDepth}, transform=${shape.transformDepth}, opacity=${shape.opacityDepth}); keeping previous frame." + message = + "[DSGL] Guarded invalid command shape " + + "(clip=${shape.clipDepth}, transform=${shape.transformDepth}, " + + "opacity=${shape.opacityDepth}); keeping previous frame.", ) return composedCommandsBuffer.isNotEmpty() } @@ -1317,7 +2170,7 @@ abstract class DsglScreenHost( if (!hasRenderableNodes(tree.root)) return false logPipelineError( key = "blank.guard", - message = "[DSGL] Guarded against blank rebuild frame; keeping previous commands." + message = "[DSGL] Guarded against blank rebuild frame; keeping previous commands.", ) return true } @@ -1326,7 +2179,7 @@ abstract class DsglScreenHost( val valid: Boolean, val clipDepth: Int, val transformDepth: Int, - val opacityDepth: Int + val opacityDepth: Int, ) private fun validateCommandShape(commands: List): CommandShape { @@ -1360,7 +2213,7 @@ abstract class DsglScreenHost( valid = clipDepth == 0 && transformDepth == 0 && opacityDepth == 0, clipDepth = clipDepth, transformDepth = transformDepth, - opacityDepth = opacityDepth + opacityDepth = opacityDepth, ) } @@ -1396,9 +2249,10 @@ abstract class DsglScreenHost( val styleStats = StyleEngine.lastStyleApplyReport() println( "[DSGL-PERF] frames=${paintStats.frames} commandRebuilds=${paintStats.commandRebuilds} " + - "chunkVisited=${paintStats.chunkNodesVisitedLastFrame} chunkRebuilt=${paintStats.chunkNodesRebuiltLastFrame} " + - "styled=${styleStats.visitedNodes} styleCacheHit=${styleStats.cacheHits} " + - "styleRecomputed=${styleStats.recomputedNodes} blankGuardSkips=$blankFrameGuardSkips" + "chunkVisited=${paintStats.chunkNodesVisitedLastFrame} " + + "chunkRebuilt=${paintStats.chunkNodesRebuiltLastFrame} " + + "styled=${styleStats.visitedNodes} styleCacheHit=${styleStats.cacheHits} " + + "styleRecomputed=${styleStats.recomputedNodes} blankGuardSkips=$blankFrameGuardSkips", ) } } diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/GlUtils.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/GlUtils.kt index d9ab9bf..151e317 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/GlUtils.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/GlUtils.kt @@ -6,10 +6,7 @@ import org.lwjgl.opengl.GL11 /** * Executes a block with an OpenGL matrix/attribute stack push/pop. */ -inline fun withStack( - attributes: List, - block: () -> Unit -) { +inline fun withStack(attributes: List, block: () -> Unit) { val attributesBitMask = attributes.reduce { a, b -> a or b } withStack(attributesBitMask) { block() } } @@ -17,10 +14,7 @@ inline fun withStack( /** * Executes a block with an OpenGL matrix/attribute stack push/pop. */ -inline fun withStack( - attributesBitMask: Int = 0, - block: () -> Unit -) { +inline fun withStack(attributesBitMask: Int = 0, block: () -> Unit) { if (attributesBitMask > 0) { GL11.glPushAttrib(attributesBitMask) } @@ -38,11 +32,7 @@ inline fun withStack( /** * Enables and disables GL capabilities for the duration of [block]. */ -inline fun withAttributes( - enable: List = emptyList(), - disable: List = emptyList(), - block: () -> Unit -) { +inline fun withAttributes(enable: List = emptyList(), disable: List = emptyList(), block: () -> Unit) { for (capability in enable) { GL11.glEnable(capability) } @@ -64,11 +54,7 @@ inline fun withAttributes( /** * Executes a block with an OpenGL matrix/attribute stack push/pop. */ -inline fun withAttributes( - enableBitMask: Int? = null, - disableBitMask: Int? = null, - block: () -> Unit -) { +inline fun withAttributes(enableBitMask: Int? = null, disableBitMask: Int? = null, block: () -> Unit) { enableBitMask?.let { GL11.glEnable(it) } disableBitMask?.let { GL11.glDisable(it) } try { diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/Mc1710UiAdapter.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/Mc1710UiAdapter.kt index e5401e9..947feac 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/Mc1710UiAdapter.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/Mc1710UiAdapter.kt @@ -7,9 +7,9 @@ import net.minecraft.client.renderer.texture.DynamicTexture import net.minecraft.item.ItemBlock import net.minecraft.item.ItemStack import net.minecraft.util.ResourceLocation -import org.dreamfinity.dsgl.core.dom.layout.FontLineMetrics -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.font.FontRegistry +import org.dreamfinity.dsgl.core.colorpicker.* +import org.dreamfinity.dsgl.core.dom.layout.* +import org.dreamfinity.dsgl.core.font.* import org.dreamfinity.dsgl.core.host.Viewport import org.dreamfinity.dsgl.core.host.dsglRectToGlScissor import org.dreamfinity.dsgl.core.render.RenderCommand @@ -24,19 +24,22 @@ import javax.imageio.ImageIO /** * Minecraft 1.7.10 adapter that turns DSGL render commands into Minecraft calls. */ -class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : UiMeasureContext { +class Mc1710UiAdapter( + private val mc: Minecraft, + var paintsCount: Long = 0L, +) : UiMeasureContext { private enum class ReadbackApi { OpenGl30, ArbFramebufferObject, ExtFramebufferObject, - Legacy + Legacy, } private data class ReadbackBindingState( val readFramebufferBinding: Int, val drawFramebufferBinding: Int, val framebufferBinding: Int, - val currentReadBuffer: Int + val currentReadBuffer: Int, ) { val usingFramebufferObject: Boolean get() = readFramebufferBinding != 0 @@ -45,19 +48,19 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U private data class ReadbackSetup( val previousReadBuffer: Int, val appliedReadBuffer: Int, - val shouldRestore: Boolean + val shouldRestore: Boolean, ) private data class FramebufferBindingSnapshot( val readFramebufferBinding: Int, val drawFramebufferBinding: Int, - val framebufferBinding: Int + val framebufferBinding: Int, ) private data class SceneTextureSource( val textureId: Int, val textureWidth: Int, - val textureHeight: Int + val textureHeight: Int, ) private data class MagnifierCaptureShader( @@ -67,21 +70,24 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U val sourceSizeUniform: Int, val viewportSizeUniform: Int, val sourceTextureSizeUniform: Int, - val fallbackColorUniform: Int + val fallbackColorUniform: Int, ) companion object { private val imageCache: MutableMap = HashMap() private val dynamicTexturesCache: MutableMap = HashMap() - private val MAGNIFIER_CAPTURE_VERTEX_SHADER: String = """ + private val HUE_STOPS: FloatArray = floatArrayOf(0f, 60f, 120f, 180f, 240f, 300f, 360f) + private val MAGNIFIER_CAPTURE_VERTEX_SHADER: String = + """ #version 120 varying vec2 vUv; void main() { gl_Position = gl_Vertex; vUv = gl_MultiTexCoord0.xy; } - """.trimIndent() - private val MAGNIFIER_CAPTURE_FRAGMENT_SHADER: String = """ + """.trimIndent() + private val MAGNIFIER_CAPTURE_FRAGMENT_SHADER: String = + """ #version 120 uniform sampler2D uSourceTexture; uniform vec2 uSourceOriginTopLeft; @@ -111,15 +117,21 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U vec4 sampled = texture2D(uSourceTexture, sourceUv); gl_FragColor = vec4(sampled.rgb, 1.0); } - """.trimIndent() + """.trimIndent() } private val itemRenderer: RenderItem = RenderItem() private val textRenderer: MsdfTextRenderer = MsdfTextRenderer() - private val opacityStack: MutableList = ArrayList(8) + + // Plain float stack: a MutableList would box on every push/pop in the paint loop. + private var opacityStackValues = FloatArray(16) + private var opacityStackSize = 0 private var opacityMultiplier: Float = 1f + private val transformStack = RenderCommandTransformStack() private val errorLogTimes: MutableMap = linkedMapOf() - private val readbackDiagnosticsVerbose: Boolean = java.lang.Boolean.getBoolean("dsgl.readback.diagnostics.verbose") + private val readbackDiagnosticsVerbose: Boolean = + java.lang.Boolean + .getBoolean("dsgl.readback.diagnostics.verbose") private val readbackApi: ReadbackApi by lazy(LazyThreadSafetyMode.NONE) { resolveReadbackApi() } private val samplePixelBuffer = BufferUtils.createByteBuffer(4) @@ -145,29 +157,25 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U private var cachedDisplayHeight: Int = -1 override fun measureText(text: String): Int = textRenderer.measureText(text, null, null) - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { - return textRenderer.measureText(text, fontId, fontSize) - } + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = + textRenderer.measureText(text, fontId, fontSize) + override fun measureTextRange( text: String, startIndex: Int, endIndexExclusive: Int, fontId: String?, - fontSize: Int? - ): Int { - return textRenderer.measureTextRange(text, startIndex, endIndexExclusive, fontId, fontSize) - } + fontSize: Int?, + ): Int = textRenderer.measureTextRange(text, startIndex, endIndexExclusive, fontId, fontSize) override val fontHeight: Int get() = textRenderer.lineHeight(FontRegistry.DEFAULT_FONT_ID, null) - override fun fontHeight(fontId: String?, fontSize: Int?): Int { - return textRenderer.lineHeight(fontId, fontSize) - } + override fun fontHeight(fontId: String?, fontSize: Int?): Int = textRenderer.lineHeight(fontId, fontSize) - override fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { - return textRenderer.fontLineMetrics(fontId, fontSize) - } + override fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? = + textRenderer.fontLineMetrics(fontId, fontSize) fun viewport(): Viewport { val displayWidth = mc.displayWidth.coerceAtLeast(1) @@ -175,13 +183,14 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U if (displayWidth != cachedDisplayWidth || displayHeight != cachedDisplayHeight) { cachedDisplayWidth = displayWidth cachedDisplayHeight = displayHeight - cachedViewport = Viewport( - width = displayWidth, - height = displayHeight, - scale = 1f, - x = 0, - y = 0 - ) + cachedViewport = + Viewport( + width = displayWidth, + height = displayHeight, + scale = 1f, + x = 0, + y = 0, + ) } return cachedViewport } @@ -200,7 +209,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U sourceY = y, sourceWidth = 1, sourceHeight = 1, - setup = setup + setup = setup, ) } try { @@ -218,7 +227,13 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } } - fun sampleScreenArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { + fun sampleScreenArea( + x: Int, + y: Int, + width: Int, + height: Int, + outArgb: IntArray, + ): Boolean { if (width <= 0 || height <= 0) return false val required = width * height if (outArgb.size < required) return false @@ -253,7 +268,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U sourceY = srcY, sourceWidth = srcW, sourceHeight = srcH, - setup = setup + setup = setup, ) } try { @@ -302,7 +317,9 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U if (readbackDiagnosticsVerbose) { logRateLimited( key = "magnifier:capture:fallback", - message = "[DSGL-Magnifier] Falling back to solid fill preview. sourceTexture=${sceneTextureSource?.textureId ?: 0} shaderReady=${shader != null}" + message = + "[DSGL-Magnifier] Falling back to solid fill preview. " + + "sourceTexture=${sceneTextureSource?.textureId ?: 0} shaderReady=${shader != null}", ) } return @@ -342,18 +359,18 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U ARBShaderObjects.glUniform2fARB( shader.sourceOriginUniform, command.sourceX.toFloat(), - command.sourceY.toFloat() + command.sourceY.toFloat(), ) ARBShaderObjects.glUniform2fARB(shader.sourceSizeUniform, sourceWidth.toFloat(), sourceHeight.toFloat()) ARBShaderObjects.glUniform2fARB( shader.viewportSizeUniform, viewport.width.toFloat(), - viewport.height.toFloat() + viewport.height.toFloat(), ) ARBShaderObjects.glUniform2fARB( shader.sourceTextureSizeUniform, sceneTextureSource.textureWidth.toFloat(), - sceneTextureSource.textureHeight.toFloat() + sceneTextureSource.textureHeight.toFloat(), ) val fallbackAlpha = ((command.fallbackColor ushr 24) and 0xFF) / 255f val fallbackRed = ((command.fallbackColor ushr 16) and 0xFF) / 255f @@ -364,7 +381,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U fallbackRed, fallbackGreen, fallbackBlue, - fallbackAlpha + fallbackAlpha, ) GL11.glColor4f(1f, 1f, 1f, 1f) GL11.glBegin(GL11.GL_QUADS) @@ -378,11 +395,13 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.glVertex2f(-1f, 1f) GL11.glEnd() renderingSucceeded = true - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { if (readbackDiagnosticsVerbose) { logRateLimited( key = "magnifier:capture:error", - message = "[DSGL-Magnifier] GPU capture failed: ${error.message ?: error::class.java.simpleName}" + message = "[DSGL-Magnifier] GPU capture failed: ${error.message ?: error::class.java.simpleName}", ) } } finally { @@ -395,11 +414,12 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U previousViewportX, previousViewportY, previousViewportWidth, - previousViewportHeight + previousViewportHeight, ) } capturedRegionValid = - renderingSucceeded || fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) + renderingSucceeded || + fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) } private fun drawCapturedScreenRegion(command: RenderCommand.DrawCapturedScreenRegion) { @@ -410,7 +430,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U command.y, command.x + command.width, command.y + command.height, - applyOpacity(capturedRegionFallbackColor) + applyOpacity(capturedRegionFallbackColor), ) return } @@ -430,6 +450,37 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.glTexCoord2f(0f, 0f) GL11.glVertex2f(command.x.toFloat(), (command.y + command.height).toFloat()) GL11.glEnd() + drawCapturedRegionGrid(command) + } + + private fun drawCapturedRegionGrid(command: RenderCommand.DrawCapturedScreenRegion) { + val grid = command.grid ?: return + val columns = grid.columns.coerceAtLeast(1) + val rows = grid.rows.coerceAtLeast(1) + val magnification = grid.magnification.coerceAtLeast(1) + + GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + glColor(applyOpacity(grid.color)) + GL11.glBegin(GL11.GL_LINES) + for (column in 1 until columns) { + val lineX = command.x + column * magnification + if (lineX <= command.x || lineX >= command.x + command.width) continue + val x = lineX + 0.5f + GL11.glVertex2f(x, command.y.toFloat()) + GL11.glVertex2f(x, (command.y + command.height).toFloat()) + } + for (row in 1 until rows) { + val lineY = command.y + row * magnification + if (lineY <= command.y || lineY >= command.y + command.height) continue + val y = lineY + 0.5f + GL11.glVertex2f(command.x.toFloat(), y) + GL11.glVertex2f((command.x + command.width).toFloat(), y) + } + GL11.glEnd() + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) } private fun drawCheckerboard(command: RenderCommand.DrawCheckerboard) { @@ -442,7 +493,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U command.y, command.x + command.width, command.y + command.height, - applyOpacity(command.lightColor) + applyOpacity(command.lightColor), ) return } @@ -485,10 +536,26 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U val light = argbToRgbaBytes(lightColor) val dark = argbToRgbaBytes(darkColor) checkerTextureUploadBuffer.clear() - checkerTextureUploadBuffer.put(light[0]).put(light[1]).put(light[2]).put(light[3]) - checkerTextureUploadBuffer.put(dark[0]).put(dark[1]).put(dark[2]).put(dark[3]) - checkerTextureUploadBuffer.put(dark[0]).put(dark[1]).put(dark[2]).put(dark[3]) - checkerTextureUploadBuffer.put(light[0]).put(light[1]).put(light[2]).put(light[3]) + checkerTextureUploadBuffer + .put(light[0]) + .put(light[1]) + .put(light[2]) + .put(light[3]) + checkerTextureUploadBuffer + .put(dark[0]) + .put(dark[1]) + .put(dark[2]) + .put(dark[3]) + checkerTextureUploadBuffer + .put(dark[0]) + .put(dark[1]) + .put(dark[2]) + .put(dark[3]) + checkerTextureUploadBuffer + .put(light[0]) + .put(light[1]) + .put(light[2]) + .put(light[3]) checkerTextureUploadBuffer.flip() GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1) @@ -501,21 +568,23 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, - checkerTextureUploadBuffer + checkerTextureUploadBuffer, ) checkerTextureCache[key] = textureId while (checkerTextureCache.size > maxCheckerTextures) { - val eldest = checkerTextureCache.entries.iterator().next() + val eldest = + checkerTextureCache.entries + .iterator() + .next() GL11.glDeleteTextures(eldest.value) checkerTextureCache.remove(eldest.key) } return textureId } - private fun checkerTextureKey(lightColor: Int, darkColor: Int): Long { - return (lightColor.toLong() shl 32) xor (darkColor.toLong() and 0xFFFF_FFFFL) - } + private fun checkerTextureKey(lightColor: Int, darkColor: Int): Long = + (lightColor.toLong() shl 32) xor (darkColor.toLong() and 0xFFFF_FFFFL) private fun ensureCapturedRegionTexture(width: Int, height: Int) { if (capturedRegionTextureId == 0) { @@ -543,7 +612,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, - null as java.nio.ByteBuffer? + null as java.nio.ByteBuffer?, ) } @@ -556,11 +625,12 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U private fun resolveActiveSceneTextureSource(): SceneTextureSource? { val state = detectReadbackBindingState() if (!state.usingFramebufferObject) return null - val colorAttachment = if (isColorAttachmentReadBuffer(state.currentReadBuffer)) { - state.currentReadBuffer - } else { - defaultColorAttachmentReadBuffer() - } + val colorAttachment = + if (isColorAttachmentReadBuffer(state.currentReadBuffer)) { + state.currentReadBuffer + } else { + defaultColorAttachmentReadBuffer() + } val objectType = getFramebufferAttachmentObjectType(colorAttachment) if (objectType != GL11.GL_TEXTURE) return null val textureId = getFramebufferAttachmentObjectName(colorAttachment) @@ -577,95 +647,107 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } } - private fun getFramebufferAttachmentObjectType(colorAttachment: Int): Int { - return when (readbackApi) { - ReadbackApi.OpenGl30 -> GL30.glGetFramebufferAttachmentParameteri( - GL30.GL_READ_FRAMEBUFFER, - colorAttachment, - GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE - ) + private fun getFramebufferAttachmentObjectType(colorAttachment: Int): Int = + when (readbackApi) { + ReadbackApi.OpenGl30 -> + GL30.glGetFramebufferAttachmentParameteri( + GL30.GL_READ_FRAMEBUFFER, + colorAttachment, + GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, + ) - ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGetFramebufferAttachmentParameteri( - ARBFramebufferObject.GL_READ_FRAMEBUFFER, - colorAttachment, - ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE - ) + ReadbackApi.ArbFramebufferObject -> + ARBFramebufferObject.glGetFramebufferAttachmentParameteri( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + colorAttachment, + ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, + ) - ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( - EXTFramebufferObject.GL_FRAMEBUFFER_EXT, - colorAttachment, - EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE_EXT - ) + ReadbackApi.ExtFramebufferObject -> + EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + colorAttachment, + EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE_EXT, + ) ReadbackApi.Legacy -> GL11.GL_NONE } - } - private fun getFramebufferAttachmentObjectName(colorAttachment: Int): Int { - return when (readbackApi) { - ReadbackApi.OpenGl30 -> GL30.glGetFramebufferAttachmentParameteri( - GL30.GL_READ_FRAMEBUFFER, - colorAttachment, - GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME - ) + private fun getFramebufferAttachmentObjectName(colorAttachment: Int): Int = + when (readbackApi) { + ReadbackApi.OpenGl30 -> + GL30.glGetFramebufferAttachmentParameteri( + GL30.GL_READ_FRAMEBUFFER, + colorAttachment, + GL30.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, + ) - ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGetFramebufferAttachmentParameteri( - ARBFramebufferObject.GL_READ_FRAMEBUFFER, - colorAttachment, - ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME - ) + ReadbackApi.ArbFramebufferObject -> + ARBFramebufferObject.glGetFramebufferAttachmentParameteri( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + colorAttachment, + ARBFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, + ) - ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( - EXTFramebufferObject.GL_FRAMEBUFFER_EXT, - colorAttachment, - EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME_EXT - ) + ReadbackApi.ExtFramebufferObject -> + EXTFramebufferObject.glGetFramebufferAttachmentParameteriEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + colorAttachment, + EXTFramebufferObject.GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME_EXT, + ) ReadbackApi.Legacy -> 0 } - } private fun ensureMagnifierCaptureShader(): MagnifierCaptureShader? { magnifierCaptureShader?.let { return it } if (magnifierCaptureShaderInitFailed) return null return try { - val vertexShader = compileShaderObject( - type = ARBVertexShader.GL_VERTEX_SHADER_ARB, - source = MAGNIFIER_CAPTURE_VERTEX_SHADER - ) - val fragmentShader = compileShaderObject( - type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, - source = MAGNIFIER_CAPTURE_FRAGMENT_SHADER - ) + val vertexShader = + compileShaderObject( + type = ARBVertexShader.GL_VERTEX_SHADER_ARB, + source = MAGNIFIER_CAPTURE_VERTEX_SHADER, + ) + val fragmentShader = + compileShaderObject( + type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, + source = MAGNIFIER_CAPTURE_FRAGMENT_SHADER, + ) val program = ARBShaderObjects.glCreateProgramObjectARB() ARBShaderObjects.glAttachObjectARB(program, vertexShader) ARBShaderObjects.glAttachObjectARB(program, fragmentShader) ARBShaderObjects.glLinkProgramARB(program) - val linkStatus = ARBShaderObjects.glGetObjectParameteriARB( - program, - ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB - ) + val linkStatus = + ARBShaderObjects.glGetObjectParameteriARB( + program, + ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB, + ) if (linkStatus == GL11.GL_FALSE) { val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) - throw IllegalStateException("Magnifier shader link failed: $info") + error("Magnifier shader link failed: $info") } - val shader = MagnifierCaptureShader( - programId = program, - sourceTextureUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTexture"), - sourceOriginUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceOriginTopLeft"), - sourceSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceSize"), - viewportSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uViewportSize"), - sourceTextureSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTextureSize"), - fallbackColorUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uFallbackColor") - ) + val shader = + MagnifierCaptureShader( + programId = program, + sourceTextureUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTexture"), + sourceOriginUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceOriginTopLeft"), + sourceSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceSize"), + viewportSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uViewportSize"), + sourceTextureSizeUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uSourceTextureSize"), + fallbackColorUniform = ARBShaderObjects.glGetUniformLocationARB(program, "uFallbackColor"), + ) magnifierCaptureShader = shader shader - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { magnifierCaptureShaderInitFailed = true if (readbackDiagnosticsVerbose) { logRateLimited( key = "magnifier:shader:init", - message = "[DSGL-Magnifier] Failed to initialize capture shader: ${error.message ?: error::class.java.simpleName}" + message = + "[DSGL-Magnifier] Failed to initialize capture shader: " + + "${error.message ?: error::class.java.simpleName}", ) } null @@ -676,33 +758,32 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U val shader = ARBShaderObjects.glCreateShaderObjectARB(type) ARBShaderObjects.glShaderSourceARB(shader, source) ARBShaderObjects.glCompileShaderARB(shader) - val compileStatus = ARBShaderObjects.glGetObjectParameteriARB( - shader, - ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB - ) + val compileStatus = + ARBShaderObjects.glGetObjectParameteriARB( + shader, + ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB, + ) if (compileStatus == GL11.GL_FALSE) { val info = ARBShaderObjects.glGetInfoLogARB(shader, 4096) - throw IllegalStateException("Magnifier shader compile failed: $info") + error("Magnifier shader compile failed: $info") } return shader } - private fun generateFramebufferObject(): Int { - return when (readbackApi) { + private fun generateFramebufferObject(): Int = + when (readbackApi) { ReadbackApi.OpenGl30 -> GL30.glGenFramebuffers() ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGenFramebuffers() ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGenFramebuffersEXT() ReadbackApi.Legacy -> 0 } - } - private fun snapshotFramebufferBindings(): FramebufferBindingSnapshot { - return FramebufferBindingSnapshot( + private fun snapshotFramebufferBindings(): FramebufferBindingSnapshot = + FramebufferBindingSnapshot( readFramebufferBinding = currentReadFramebufferBinding(), drawFramebufferBinding = currentDrawFramebufferBinding(), - framebufferBinding = currentFramebufferBinding() + framebufferBinding = currentFramebufferBinding(), ) - } private fun restoreFramebufferBindings(snapshot: FramebufferBindingSnapshot) { when (readbackApi) { @@ -714,18 +795,18 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U ReadbackApi.ArbFramebufferObject -> { ARBFramebufferObject.glBindFramebuffer( ARBFramebufferObject.GL_READ_FRAMEBUFFER, - snapshot.readFramebufferBinding + snapshot.readFramebufferBinding, ) ARBFramebufferObject.glBindFramebuffer( ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, - snapshot.drawFramebufferBinding + snapshot.drawFramebufferBinding, ) } ReadbackApi.ExtFramebufferObject -> { EXTFramebufferObject.glBindFramebufferEXT( EXTFramebufferObject.GL_FRAMEBUFFER_EXT, - snapshot.framebufferBinding + snapshot.framebufferBinding, ) } @@ -736,15 +817,17 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U private fun bindDrawFramebuffer(framebufferId: Int) { when (readbackApi) { ReadbackApi.OpenGl30 -> GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, framebufferId) - ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glBindFramebuffer( - ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, - framebufferId - ) + ReadbackApi.ArbFramebufferObject -> + ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + framebufferId, + ) - ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glBindFramebufferEXT( - EXTFramebufferObject.GL_FRAMEBUFFER_EXT, - framebufferId - ) + ReadbackApi.ExtFramebufferObject -> + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + framebufferId, + ) ReadbackApi.Legacy -> Unit } @@ -752,54 +835,57 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U private fun attachCapturedRegionTextureToFramebuffer() { when (readbackApi) { - ReadbackApi.OpenGl30 -> GL30.glFramebufferTexture2D( - GL30.GL_DRAW_FRAMEBUFFER, - GL30.GL_COLOR_ATTACHMENT0, - GL11.GL_TEXTURE_2D, - capturedRegionTextureId, - 0 - ) + ReadbackApi.OpenGl30 -> + GL30.glFramebufferTexture2D( + GL30.GL_DRAW_FRAMEBUFFER, + GL30.GL_COLOR_ATTACHMENT0, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0, + ) - ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glFramebufferTexture2D( - ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, - ARBFramebufferObject.GL_COLOR_ATTACHMENT0, - GL11.GL_TEXTURE_2D, - capturedRegionTextureId, - 0 - ) + ReadbackApi.ArbFramebufferObject -> + ARBFramebufferObject.glFramebufferTexture2D( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + ARBFramebufferObject.GL_COLOR_ATTACHMENT0, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0, + ) - ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glFramebufferTexture2DEXT( - EXTFramebufferObject.GL_FRAMEBUFFER_EXT, - EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, - GL11.GL_TEXTURE_2D, - capturedRegionTextureId, - 0 - ) + ReadbackApi.ExtFramebufferObject -> + EXTFramebufferObject.glFramebufferTexture2DEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, + GL11.GL_TEXTURE_2D, + capturedRegionTextureId, + 0, + ) ReadbackApi.Legacy -> Unit } } - private fun isCurrentFramebufferComplete(): Boolean { - return when (readbackApi) { - ReadbackApi.OpenGl30 -> GL30.glCheckFramebufferStatus(GL30.GL_DRAW_FRAMEBUFFER) == GL30.GL_FRAMEBUFFER_COMPLETE - ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glCheckFramebufferStatus( - ARBFramebufferObject.GL_DRAW_FRAMEBUFFER - ) == ARBFramebufferObject.GL_FRAMEBUFFER_COMPLETE + private fun isCurrentFramebufferComplete(): Boolean = + when (readbackApi) { + ReadbackApi.OpenGl30 -> + GL30.glCheckFramebufferStatus(GL30.GL_DRAW_FRAMEBUFFER) == + GL30.GL_FRAMEBUFFER_COMPLETE + + ReadbackApi.ArbFramebufferObject -> + ARBFramebufferObject.glCheckFramebufferStatus( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + ) == ARBFramebufferObject.GL_FRAMEBUFFER_COMPLETE - ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glCheckFramebufferStatusEXT( - EXTFramebufferObject.GL_FRAMEBUFFER_EXT - ) == EXTFramebufferObject.GL_FRAMEBUFFER_COMPLETE_EXT + ReadbackApi.ExtFramebufferObject -> + EXTFramebufferObject.glCheckFramebufferStatusEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + ) == EXTFramebufferObject.GL_FRAMEBUFFER_COMPLETE_EXT ReadbackApi.Legacy -> false } - } - private fun fillCapturedRegionFallbackTexture( - fallbackColor: Int, - width: Int, - height: Int - ): Boolean { + private fun fillCapturedRegionFallbackTexture(fallbackColor: Int, width: Int, height: Int): Boolean { val snapshot = snapshotFramebufferBindings() val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) val previousDrawBuffer = GL11.glGetInteger(GL11.GL_DRAW_BUFFER) @@ -835,7 +921,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U previousClearRed, previousClearGreen, previousClearBlue, - previousClearAlpha + previousClearAlpha, ) GL11.glReadBuffer(previousReadBuffer) GL11.glDrawBuffer(previousDrawBuffer) @@ -844,7 +930,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U previousViewportX, previousViewportY, previousViewportWidth, - previousViewportHeight + previousViewportHeight, ) } } @@ -855,8 +941,11 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } private fun viewportXFromSnapshot(): Int = glIntStateQueryBuffer.get(0) + private fun viewportYFromSnapshot(): Int = glIntStateQueryBuffer.get(1) + private fun viewportWidthFromSnapshot(): Int = glIntStateQueryBuffer.get(2) + private fun viewportHeightFromSnapshot(): Int = glIntStateQueryBuffer.get(3) private fun snapshotClearColorState() { @@ -865,8 +954,11 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } private fun clearRedFromSnapshot(): Float = glFloatStateQueryBuffer.get(0) + private fun clearGreenFromSnapshot(): Float = glFloatStateQueryBuffer.get(1) + private fun clearBlueFromSnapshot(): Float = glFloatStateQueryBuffer.get(2) + private fun clearAlphaFromSnapshot(): Float = glFloatStateQueryBuffer.get(3) private fun argbToRgbaBytes(argb: Int, forceOpaqueAlpha: Boolean = false): ByteArray { @@ -878,11 +970,11 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } /** Executes DSGL render commands using Minecraft rendering APIs. */ + @Suppress("LoopWithTooManyJumpStatements") override fun paint(commands: List) { paintsCount++ - opacityStack.clear() + opacityStackSize = 0 opacityMultiplier = 1f - val transformStack = RenderCommandTransformStack() transformStack.reset() val viewport = viewport() GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS) @@ -907,7 +999,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U command.y, command.x + command.width, command.y + command.height, - applyOpacity(command.color) + applyOpacity(command.color), ) } @@ -917,7 +1009,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U y = command.y, width = command.width, height = command.height, - hueDeg = command.hueDeg + hueDeg = command.hueDeg, ) } @@ -926,7 +1018,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U x = command.x, y = command.y, width = command.width, - height = command.height + height = command.height, ) } @@ -936,7 +1028,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U y = command.y, width = command.width, height = command.height, - rgbColor = command.rgbColor + rgbColor = command.rgbColor, ) } @@ -948,17 +1040,21 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U try { textRenderer.draw( command = command, - opacityMultiplier = opacityMultiplier + opacityMultiplier = opacityMultiplier, ) } catch (error: LinkageError) { logRateLimited( key = "drawText:linkage", - message = "[DSGL] Skipping DrawText due linkage error in text renderer: ${error.message}" + message = + "[DSGL] Skipping DrawText due linkage error in text renderer: " + + "${error.message}", ) - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { logRateLimited( key = "drawText:runtime", - message = "[DSGL] Skipping DrawText due renderer error: ${error.message}" + message = "[DSGL] Skipping DrawText due renderer error: ${error.message}", ) } } @@ -976,7 +1072,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U command.width, command.height, command.width.toFloat(), - command.height.toFloat() + command.height.toFloat(), ) } @@ -997,23 +1093,24 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U size = command.size, width = command.width, rotY = command.rotYDeg, - rotX = command.rotXDeg + rotX = command.rotXDeg, ) } is RenderCommand.PushClip -> { - val transformedClip = transformStack.resolveClipRect( - x = command.x, - y = command.y, - width = command.width, - height = command.height - ) + val transformedClip = + transformStack.resolveClipRect( + x = command.x, + y = command.y, + width = command.width, + height = command.height, + ) pushClip( viewport = viewport, guiX = transformedClip.x, guiY = transformedClip.y, guiWidth = transformedClip.width, - guiHeight = transformedClip.height + guiHeight = transformedClip.height, ) } @@ -1037,13 +1134,12 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } is RenderCommand.PushOpacity -> { - opacityStack.add(opacityMultiplier) + pushOpacity(opacityMultiplier) opacityMultiplier = (opacityMultiplier * command.opacity).coerceIn(0f, 1f) } is RenderCommand.PopOpacity -> { - opacityMultiplier = - if (opacityStack.isEmpty()) 1f else opacityStack.removeAt(opacityStack.lastIndex) + opacityMultiplier = popOpacityOrDefault() } } } @@ -1058,12 +1154,26 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } finally { ScissorContext.clear() transformStack.reset() - opacityStack.clear() + opacityStackSize = 0 opacityMultiplier = 1f GL11.glPopAttrib() } } + private fun pushOpacity(value: Float) { + if (opacityStackSize == opacityStackValues.size) { + opacityStackValues = opacityStackValues.copyOf(opacityStackValues.size * 2) + } + opacityStackValues[opacityStackSize] = value + opacityStackSize += 1 + } + + private fun popOpacityOrDefault(): Float { + if (opacityStackSize == 0) return 1f + opacityStackSize -= 1 + return opacityStackValues[opacityStackSize] + } + private fun applyOpacity(color: Int): Int { if (opacityMultiplier >= 0.999f) return color val alpha = ((color ushr 24) and 0xFF) @@ -1071,7 +1181,13 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U return (color and 0x00FF_FFFF) or (scaled shl 24) } - private fun drawColorField(x: Int, y: Int, width: Int, height: Int, hueDeg: Float) { + private fun drawColorField( + x: Int, + y: Int, + width: Int, + height: Int, + hueDeg: Float, + ) { if (width <= 0 || height <= 0) return val normalizedHue = ((hueDeg % 360f) + 360f) % 360f @@ -1079,35 +1195,51 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U drawGradientBlock { drawHorizontalGradientRectRaw( - x, y, width, height, + x, + y, + width, + height, applyOpacity(0xFFFFFFFF.toInt()), - applyOpacity(hueColor) + applyOpacity(hueColor), ) drawVerticalGradientRectRaw( - x, y, width, height, + x, + y, + width, + height, applyOpacity(0x00000000), - applyOpacity(0xFF000000.toInt()) + applyOpacity(0xFF000000.toInt()), ) } } - private fun drawHueBar(x: Int, y: Int, width: Int, height: Int) { + private fun drawHueBar( + x: Int, + y: Int, + width: Int, + height: Int, + ) { if (width <= 0 || height <= 0) return val segments = 6 - val hueStops = floatArrayOf(0f, 60f, 120f, 180f, 240f, 300f, 360f) var index = 0 while (index < segments) { val startX = x + (width * index) / segments val endX = if (index == segments - 1) x + width else x + (width * (index + 1)) / segments val segmentWidth = (endX - startX).coerceAtLeast(1) - val startColor = applyOpacity(hsvToArgbInt(hueStops[index], 1f, 1f)) - val endColor = applyOpacity(hsvToArgbInt(hueStops[index + 1], 1f, 1f)) + val startColor = applyOpacity(hsvToArgbInt(HUE_STOPS[index], 1f, 1f)) + val endColor = applyOpacity(hsvToArgbInt(HUE_STOPS[index + 1], 1f, 1f)) drawHorizontalGradientRect(startX, y, segmentWidth, height, startColor, endColor) index += 1 } } - private fun drawAlphaBar(x: Int, y: Int, width: Int, height: Int, rgbColor: Int) { + private fun drawAlphaBar( + x: Int, + y: Int, + width: Int, + height: Int, + rgbColor: Int, + ) { if (width <= 0 || height <= 0) return val rgbOnly = rgbColor and 0x00FF_FFFF val leftColor = applyOpacity(rgbOnly) @@ -1115,7 +1247,14 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U drawHorizontalGradientRect(x, y, width, height, leftColor, rightColor) } - private fun drawHorizontalGradientRect(x: Int, y: Int, width: Int, height: Int, leftColor: Int, rightColor: Int) { + private fun drawHorizontalGradientRect( + x: Int, + y: Int, + width: Int, + height: Int, + leftColor: Int, + rightColor: Int, + ) { if (width <= 0 || height <= 0) return GL11.glDisable(GL11.GL_TEXTURE_2D) GL11.glEnable(GL11.GL_BLEND) @@ -1127,20 +1266,6 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.glColor4f(1f, 1f, 1f, 1f) } - private fun drawVerticalGradientRect(x: Int, y: Int, width: Int, height: Int, topColor: Int, bottomColor: Int) { - if (width <= 0 || height <= 0) return - GL11.glDisable(GL11.GL_TEXTURE_2D) - GL11.glDisable(GL11.GL_ALPHA) - GL11.glEnable(GL11.GL_BLEND) - GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) - GL11.glShadeModel(GL11.GL_SMOOTH) - drawVerticalGradientRectRaw(x, y, width, height, topColor, bottomColor) - GL11.glEnable(GL11.GL_ALPHA) - GL11.glShadeModel(GL11.GL_FLAT) - GL11.glEnable(GL11.GL_TEXTURE_2D) - GL11.glColor4f(1f, 1f, 1f, 1f) - } - private inline fun drawGradientBlock(block: () -> Unit) { GL11.glDisable(GL11.GL_TEXTURE_2D) GL11.glDisable(GL11.GL_DEPTH_TEST) @@ -1167,7 +1292,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U width: Int, height: Int, leftColor: Int, - rightColor: Int + rightColor: Int, ) { GL11.glBegin(GL11.GL_QUADS) glColor(leftColor) @@ -1185,7 +1310,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U width: Int, height: Int, topColor: Int, - bottomColor: Int + bottomColor: Int, ) { GL11.glBegin(GL11.GL_QUADS) glColor(topColor) @@ -1197,42 +1322,9 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.glEnd() } - private fun drawBilinearGradientRect( - x: Int, - y: Int, - width: Int, - height: Int, - topLeftColor: Int, - topRightColor: Int, - bottomRightColor: Int, - bottomLeftColor: Int - ) { - if (width <= 0 || height <= 0) return - GL11.glDisable(GL11.GL_TEXTURE_2D) - GL11.glEnable(GL11.GL_BLEND) - GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) - GL11.glShadeModel(GL11.GL_SMOOTH) - GL11.glBegin(GL11.GL_QUADS) - glColor(topLeftColor) - GL11.glVertex2f(x.toFloat(), y.toFloat()) - glColor(bottomLeftColor) - GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) - glColor(bottomRightColor) - GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) - glColor(topRightColor) - GL11.glVertex2f((x + width).toFloat(), y.toFloat()) - GL11.glEnd() - GL11.glShadeModel(GL11.GL_FLAT) - GL11.glEnable(GL11.GL_TEXTURE_2D) - GL11.glColor4f(1f, 1f, 1f, 1f) - } - private fun glColor(argb: Int) { - val a = ((argb ushr 24) and 0xFF) / 255f - val r = ((argb ushr 16) and 0xFF) / 255f - val g = ((argb ushr 8) and 0xFF) / 255f - val b = (argb and 0xFF) / 255f - GL11.glColor4f(r, g, b, a) + val color = RgbaColor.fromArgbInt(argb) + GL11.glColor4f(color.r, color.g, color.b, color.a) } private fun hsvToArgbInt(hueDeg: Float, saturation: Float, value: Float): Int { @@ -1242,17 +1334,44 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U val c = v * s val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)) val m = v - c - val (r1, g1, b1) = when { - h < 60f -> Triple(c, x, 0f) - h < 120f -> Triple(x, c, 0f) - h < 180f -> Triple(0f, c, x) - h < 240f -> Triple(0f, x, c) - h < 300f -> Triple(x, 0f, c) - else -> Triple(c, 0f, x) + val rFromHue: Float + val gFromHue: Float + val bFromHue: Float + when { + h < 60f -> { + rFromHue = c + gFromHue = x + bFromHue = 0f + } + h < 120f -> { + rFromHue = x + gFromHue = c + bFromHue = 0f + } + h < 180f -> { + rFromHue = 0f + gFromHue = c + bFromHue = x + } + h < 240f -> { + rFromHue = 0f + gFromHue = x + bFromHue = c + } + h < 300f -> { + rFromHue = x + gFromHue = 0f + bFromHue = c + } + else -> { + rFromHue = c + gFromHue = 0f + bFromHue = x + } } - val r = ((r1 + m) * 255f).toInt().coerceIn(0, 255) - val g = ((g1 + m) * 255f).toInt().coerceIn(0, 255) - val b = ((b1 + m) * 255f).toInt().coerceIn(0, 255) + val r = ((rFromHue + m) * 255f).toInt().coerceIn(0, 255) + val g = ((gFromHue + m) * 255f).toInt().coerceIn(0, 255) + val b = ((bFromHue + m) * 255f).toInt().coerceIn(0, 255) return (0xFF shl 24) or (r shl 16) or (g shl 8) or b } @@ -1263,14 +1382,14 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U return ReadbackSetup( previousReadBuffer = previousReadBuffer, appliedReadBuffer = desiredReadBuffer, - shouldRestore = false + shouldRestore = false, ) } GL11.glReadBuffer(desiredReadBuffer) return ReadbackSetup( previousReadBuffer = previousReadBuffer, appliedReadBuffer = desiredReadBuffer, - shouldRestore = true + shouldRestore = true, ) } @@ -1301,41 +1420,37 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } } - private fun currentReadFramebufferBinding(): Int { - return when (readbackApi) { + private fun currentReadFramebufferBinding(): Int = + when (readbackApi) { ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_READ_FRAMEBUFFER_BINDING) ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_READ_FRAMEBUFFER_BINDING) ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) ReadbackApi.Legacy -> 0 } - } - private fun currentDrawFramebufferBinding(): Int { - return when (readbackApi) { + private fun currentDrawFramebufferBinding(): Int = + when (readbackApi) { ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_DRAW_FRAMEBUFFER_BINDING) ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_DRAW_FRAMEBUFFER_BINDING) ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) ReadbackApi.Legacy -> 0 } - } - private fun currentFramebufferBinding(): Int { - return when (readbackApi) { + private fun currentFramebufferBinding(): Int = + when (readbackApi) { ReadbackApi.OpenGl30 -> GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING) ReadbackApi.ArbFramebufferObject -> GL11.glGetInteger(ARBFramebufferObject.GL_FRAMEBUFFER_BINDING) ReadbackApi.ExtFramebufferObject -> GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT) ReadbackApi.Legacy -> 0 } - } - private fun defaultColorAttachmentReadBuffer(): Int { - return when (readbackApi) { + private fun defaultColorAttachmentReadBuffer(): Int = + when (readbackApi) { ReadbackApi.OpenGl30 -> GL30.GL_COLOR_ATTACHMENT0 ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.GL_COLOR_ATTACHMENT0 ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT ReadbackApi.Legacy -> GL11.GL_BACK } - } private fun detectReadbackBindingState(): ReadbackBindingState { val readFramebufferBinding = currentReadFramebufferBinding() @@ -1343,7 +1458,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U readFramebufferBinding = readFramebufferBinding, drawFramebufferBinding = currentDrawFramebufferBinding(), framebufferBinding = currentFramebufferBinding(), - currentReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + currentReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER), ) } @@ -1353,36 +1468,44 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U sourceY: Int, sourceWidth: Int, sourceHeight: Int, - setup: ReadbackSetup + setup: ReadbackSetup, ) { val state = detectReadbackBindingState() val recommended = selectReadBufferForActiveTarget(state.currentReadBuffer) val appliedCompatible = isReadBufferCompatibleWithActiveTarget(setup.appliedReadBuffer, state) val previousCompatible = isReadBufferCompatibleWithActiveTarget(setup.previousReadBuffer, state) - val message = buildString { - append("[DSGL-Readback] path=").append(path) - append(" src=(").append(sourceX).append(',').append(sourceY).append(' ') - append(sourceWidth).append('x').append(sourceHeight).append(')') - append(" readFbo=").append(state.readFramebufferBinding) - append(" drawFbo=").append(state.drawFramebufferBinding) - append(" fbo=").append(state.framebufferBinding) - append(" previousReadBuffer=").append(glEnumName(setup.previousReadBuffer)) - append(" appliedReadBuffer=").append(glEnumName(setup.appliedReadBuffer)) - append(" currentReadBuffer=").append(glEnumName(state.currentReadBuffer)) - append(" changed=").append(setup.shouldRestore) - append(" previousCompatible=").append(previousCompatible) - append(" appliedCompatible=").append(appliedCompatible) - append(" recommendedReadBuffer=").append(glEnumName(recommended)) - append(" api=").append(readbackApi.name) - } + val message = + buildString { + append("[DSGL-Readback] path=").append(path) + append(" src=(") + .append(sourceX) + .append(',') + .append(sourceY) + .append(' ') + append(sourceWidth) + .append('x') + .append(sourceHeight) + .append(')') + append(" readFbo=").append(state.readFramebufferBinding) + append(" drawFbo=").append(state.drawFramebufferBinding) + append(" fbo=").append(state.framebufferBinding) + append(" previousReadBuffer=").append(glEnumName(setup.previousReadBuffer)) + append(" appliedReadBuffer=").append(glEnumName(setup.appliedReadBuffer)) + append(" currentReadBuffer=").append(glEnumName(state.currentReadBuffer)) + append(" changed=").append(setup.shouldRestore) + append(" previousCompatible=").append(previousCompatible) + append(" appliedCompatible=").append(appliedCompatible) + append(" recommendedReadBuffer=").append(glEnumName(recommended)) + append(" api=").append(readbackApi.name) + } logRateLimited( key = "readback:$path:${state.readFramebufferBinding}:${setup.appliedReadBuffer}", - message = message + message = message, ) } - private fun isReadBufferCompatibleWithActiveTarget(readBuffer: Int, state: ReadbackBindingState): Boolean { - return if (state.usingFramebufferObject) { + private fun isReadBufferCompatibleWithActiveTarget(readBuffer: Int, state: ReadbackBindingState): Boolean = + if (state.usingFramebufferObject) { readBuffer == GL11.GL_NONE || isColorAttachmentReadBuffer(readBuffer) } else { when (readBuffer) { @@ -1393,24 +1516,29 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.GL_FRONT_LEFT, GL11.GL_FRONT_RIGHT, GL11.GL_BACK_LEFT, - GL11.GL_BACK_RIGHT -> true + GL11.GL_BACK_RIGHT, + -> true else -> false } } - } - private fun isColorAttachmentReadBuffer(readBuffer: Int): Boolean { - return when (readbackApi) { + private fun isColorAttachmentReadBuffer(readBuffer: Int): Boolean = + when (readbackApi) { ReadbackApi.OpenGl30 -> readBuffer in GL30.GL_COLOR_ATTACHMENT0..(GL30.GL_COLOR_ATTACHMENT0 + 31) - ReadbackApi.ArbFramebufferObject -> readBuffer in ARBFramebufferObject.GL_COLOR_ATTACHMENT0..(ARBFramebufferObject.GL_COLOR_ATTACHMENT0 + 15) - ReadbackApi.ExtFramebufferObject -> readBuffer in EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT..(EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT + 15) + ReadbackApi.ArbFramebufferObject -> + readBuffer in + ARBFramebufferObject.GL_COLOR_ATTACHMENT0..(ARBFramebufferObject.GL_COLOR_ATTACHMENT0 + 15) + + ReadbackApi.ExtFramebufferObject -> + readBuffer in + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT..(EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT + 15) + ReadbackApi.Legacy -> false } - } - private fun glEnumName(value: Int): String { - return when (value) { + private fun glEnumName(value: Int): String = + when (value) { GL11.GL_NONE -> "GL_NONE" GL11.GL_FRONT -> "GL_FRONT" GL11.GL_BACK -> "GL_BACK" @@ -1422,13 +1550,14 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U GL11.GL_BACK_RIGHT -> "GL_BACK_RIGHT" GL30.GL_COLOR_ATTACHMENT0, ARBFramebufferObject.GL_COLOR_ATTACHMENT0, - EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT -> "GL_COLOR_ATTACHMENT0" + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, + -> "GL_COLOR_ATTACHMENT0" + else -> { val hex = Integer.toHexString(value).uppercase() "0x$hex" } } - } private fun logRateLimited(key: String, message: String) { val now = System.currentTimeMillis() @@ -1438,16 +1567,26 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U println(message) } - private fun pushClip(viewport: Viewport, guiX: Int, guiY: Int, guiWidth: Int, guiHeight: Int) { + private fun pushClip( + viewport: Viewport, + guiX: Int, + guiY: Int, + guiWidth: Int, + guiHeight: Int, + ) { val scissor = viewport.dsglRectToGlScissor(guiX, guiY, guiWidth, guiHeight) ScissorContext.push(scissor.x, scissor.y, scissor.width, scissor.height) } - private fun isBlockStack(stack: ItemStack): Boolean { - return stack.item is ItemBlock - } + private fun isBlockStack(stack: ItemStack): Boolean = stack.item is ItemBlock - private fun draw2DItem(stack: ItemStack, x: Int, y: Int, size: Int, maxWidth: Int) { + private fun draw2DItem( + stack: ItemStack, + x: Int, + y: Int, + size: Int, + maxWidth: Int, + ) { val drawX = x + ((maxWidth - size) / 2).coerceAtLeast(0) withStack { withAttributes(enable = listOf(GL11.GL_DEPTH_TEST)) { @@ -1459,7 +1598,15 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } } - private fun draw3DItem(stack: ItemStack, x: Int, y: Int, size: Int, width: Int, rotY: Double, rotX: Double) { + private fun draw3DItem( + stack: ItemStack, + x: Int, + y: Int, + size: Int, + width: Int, + rotY: Double, + rotX: Double, + ) { val scale = size / 16.0f val drawX = x + ((width - size) / 2).coerceAtLeast(0) @@ -1485,7 +1632,7 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U size: Int, width: Int, rotY: Double, - rotX: Double + rotX: Double, ) { withStack(attributesBitMask = GL11.GL_ALL_ATTRIB_BITS) { val previousZ = itemRenderer.zLevel @@ -1548,8 +1695,8 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U return File(baseDir, host + File.separator + path) } - private fun downloadToFile(url: URL, file: File): Boolean { - return try { + private fun downloadToFile(url: URL, file: File): Boolean = + try { file.parentFile?.mkdirs() url.openStream().use { input -> file.outputStream().use { output -> @@ -1557,10 +1704,9 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U } } true - } catch (ex: Exception) { + } catch (_: java.io.IOException) { false } - } private fun loadDynamicTexture(file: File, cacheKey: String): ResourceLocation? { if (!file.exists()) return null @@ -1574,7 +1720,9 @@ class Mc1710UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : U val location = mc.textureManager.getDynamicTextureLocation(name, texture) imageCache[cacheKey] = location location - } catch (ex: Exception) { + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception, + ) { null } } diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/McItemStackRef.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/McItemStackRef.kt index b14c9c8..bc1b1cc 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/McItemStackRef.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/McItemStackRef.kt @@ -6,4 +6,6 @@ import org.dreamfinity.dsgl.core.ItemStackRef /** * Wrapper for Minecraft 1.7.10 [ItemStack] to satisfy [ItemStackRef]. */ -class McItemStackRef(val stack: ItemStack) : ItemStackRef +class McItemStackRef( + val stack: ItemStack, +) : ItemStackRef diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStack.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStack.kt index 85eaf1f..24a52bf 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStack.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStack.kt @@ -1,15 +1,17 @@ package org.dreamfinity.dsgl.mcForge1710 -import kotlin.math.ceil -import kotlin.math.floor import org.dreamfinity.dsgl.core.dom.layout.AffineTransform2D import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.sin internal data class GuiClipRect( val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, ) internal class RenderCommandTransformStack { @@ -32,11 +34,14 @@ internal class RenderCommandTransformStack { fun currentTransform(): AffineTransform2D = current - fun transformPoint(x: Float, y: Float): Pair { - return current.transform(x, y) - } + fun transformPoint(x: Float, y: Float): Pair = current.transform(x, y) - fun resolveClipRect(x: Int, y: Int, width: Int, height: Int): GuiClipRect { + fun resolveClipRect( + x: Int, + y: Int, + width: Int, + height: Int, + ): GuiClipRect { val safeWidth = width.coerceAtLeast(0) val safeHeight = height.coerceAtLeast(0) if (safeWidth == 0 || safeHeight == 0) { @@ -46,15 +51,24 @@ internal class RenderCommandTransformStack { return GuiClipRect(x, y, safeWidth, safeHeight) } - val topLeft = current.transform(x.toFloat(), y.toFloat()) - val topRight = current.transform((x + safeWidth).toFloat(), y.toFloat()) - val bottomLeft = current.transform(x.toFloat(), (y + safeHeight).toFloat()) - val bottomRight = current.transform((x + safeWidth).toFloat(), (y + safeHeight).toFloat()) + val transform = current + val left = x.toFloat() + val top = y.toFloat() + val right = (x + safeWidth).toFloat() + val bottom = (y + safeHeight).toFloat() + val topLeftX = transform.a * left + transform.c * top + transform.tx + val topLeftY = transform.b * left + transform.d * top + transform.ty + val topRightX = transform.a * right + transform.c * top + transform.tx + val topRightY = transform.b * right + transform.d * top + transform.ty + val bottomLeftX = transform.a * left + transform.c * bottom + transform.tx + val bottomLeftY = transform.b * left + transform.d * bottom + transform.ty + val bottomRightX = transform.a * right + transform.c * bottom + transform.tx + val bottomRightY = transform.b * right + transform.d * bottom + transform.ty - val minX = minOf(topLeft.first, topRight.first, bottomLeft.first, bottomRight.first) - val maxX = maxOf(topLeft.first, topRight.first, bottomLeft.first, bottomRight.first) - val minY = minOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) - val maxY = maxOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) + val minX = minOf(minOf(topLeftX, topRightX), minOf(bottomLeftX, bottomRightX)) + val maxX = maxOf(maxOf(topLeftX, topRightX), maxOf(bottomLeftX, bottomRightX)) + val minY = minOf(minOf(topLeftY, topRightY), minOf(bottomLeftY, bottomRightY)) + val maxY = maxOf(maxOf(topLeftY, topRightY), maxOf(bottomLeftY, bottomRightY)) val resolvedX = floor(minX.toDouble()).toInt() val resolvedY = floor(minY.toDouble()).toInt() @@ -64,16 +78,22 @@ internal class RenderCommandTransformStack { } private fun RenderCommand.PushTransform.toAffineTransform(): AffineTransform2D { - val toOrigin = AffineTransform2D.translation(originX, originY) - val translate = AffineTransform2D.translation(translateX, translateY) - val rotate = AffineTransform2D.rotation(rotateDeg) - val scale = AffineTransform2D.scale(scaleX, scaleY) - val fromOrigin = AffineTransform2D.translation(-originX, -originY) - return toOrigin - .times(translate) - .times(rotate) - .times(scale) - .times(fromOrigin) + // Closed form of T(origin) * T(translate) * R(rotateDeg) * S(scaleX, scaleY) * T(-origin): + // chaining times() would allocate nine intermediate matrices per push in the paint loop. + val rad = Math.toRadians(rotateDeg.toDouble()) + val cos = cos(rad).toFloat() + val sin = sin(rad).toFloat() + val a = cos * scaleX + val b = sin * scaleX + val c = -sin * scaleY + val d = cos * scaleY + return AffineTransform2D( + a = a, + b = b, + c = c, + d = d, + tx = originX + translateX - a * originX - c * originY, + ty = originY + translateY - b * originX - d * originY, + ) } } - diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/ScreenDomainSurfaceOrchestrator.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/ScreenDomainSurfaceOrchestrator.kt new file mode 100644 index 0000000..d079665 --- /dev/null +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/ScreenDomainSurfaceOrchestrator.kt @@ -0,0 +1,45 @@ +package org.dreamfinity.dsgl.mcForge1710 + +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.render.RenderCommand + +internal class ScreenDomainSurfaceOrchestrator { + private val paintSurfaces: List = ScreenDomainSurfaces.paintOrder + private val inputSurfaces: List = ScreenDomainSurfaces.inputPriority + + fun composePaintCommands( + applicationRoot: List, + applicationPortal: List, + systemRoot: List = emptyList(), + systemPortal: List, + debugRoot: List, + debugPortal: List = emptyList(), + out: MutableList, + shouldRenderSurface: (ScreenDomainSurface) -> Boolean = { true }, + ) { + out.clear() + paintSurfaces.forEach { surface -> + if (!shouldRenderSurface(surface)) return@forEach + when (surface) { + ScreenDomainSurfaces.ApplicationRoot -> out.addAll(applicationRoot) + ScreenDomainSurfaces.ApplicationPortal -> out.addAll(applicationPortal) + ScreenDomainSurfaces.SystemRoot -> out.addAll(systemRoot) + ScreenDomainSurfaces.SystemPortal -> out.addAll(systemPortal) + ScreenDomainSurfaces.DebugRoot -> out.addAll(debugRoot) + ScreenDomainSurfaces.DebugPortal -> out.addAll(debugPortal) + } + } + } + + fun firstInputConsumer( + canConsume: (ScreenDomainSurface) -> Boolean, + isSurfaceInputEnabled: (ScreenDomainSurface) -> Boolean = { true }, + ): ScreenDomainSurface? { + inputSurfaces.forEach { surface -> + if (!isSurfaceInputEnabled(surface)) return@forEach + if (canConsume(surface)) return surface + } + return null + } +} diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorContext.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorContext.kt index 1f0ffe8..8477b58 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorContext.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorContext.kt @@ -1,14 +1,20 @@ package org.dreamfinity.dsgl.mcForge1710.scissorsHelper import org.lwjgl.opengl.GL11 -import java.util.* +import java.util.ArrayDeque +import java.util.Deque object ScissorContext { val instance = ScissorContext val stack: Deque = ArrayDeque() var scissorsEnabledByContext = false - fun push(x: Number, y: Number, width: Number, height: Number): ScissorsArea { + fun push( + x: Number, + y: Number, + width: Number, + height: Number, + ): ScissorsArea { if (stack.isEmpty()) { scissorsEnabledByContext = !GL11.glIsEnabled(GL11.GL_SCISSOR_TEST) if (scissorsEnabledByContext) GL11.glEnable(GL11.GL_SCISSOR_TEST) diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorsArea.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorsArea.kt index dc36566..346a9d2 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorsArea.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/scissorsHelper/ScissorsArea.kt @@ -3,10 +3,15 @@ package org.dreamfinity.dsgl.mcForge1710.scissorsHelper import kotlin.math.max import kotlin.math.min -data class ScissorsArea(val x: Int, val y: Int, val width: Int, val height: Int) +data class ScissorsArea( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) -infix fun ScissorsArea.intersectionWith(another: ScissorsArea?): ScissorsArea { - return another?.let { +infix fun ScissorsArea.intersectionWith(another: ScissorsArea?): ScissorsArea = + another?.let { val x1 = max(this.x, another.x) val x2 = min(this.x + this.width, another.x + another.width) val y1 = max(this.y, another.y) @@ -15,7 +20,6 @@ infix fun ScissorsArea.intersectionWith(another: ScissorsArea?): ScissorsArea { x1, y1, max(0, x2 - x1), - max(0, y2 - y1) + max(0, y2 - y1), ) } ?: this -} diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfRuntimeDebugSettings.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfRuntimeDebugSettings.kt index 652aecf..072ab80 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfRuntimeDebugSettings.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfRuntimeDebugSettings.kt @@ -2,5 +2,7 @@ package org.dreamfinity.dsgl.mcForge1710.text object MsdfRuntimeDebugSettings { @Volatile - var decorationGuidesEnabled: Boolean = java.lang.Boolean.getBoolean("dsgl.msdf.debug.decorations") + var decorationGuidesEnabled: Boolean = + java.lang.Boolean + .getBoolean("dsgl.msdf.debug.decorations") } diff --git a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfTextRenderer.kt b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfTextRenderer.kt index 343ffc8..bca16e0 100644 --- a/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfTextRenderer.kt +++ b/adapters/mc-forge-1-7-10/src/main/kotlin/org/dreamfinity/dsgl/mcForge1710/text/MsdfTextRenderer.kt @@ -1,20 +1,22 @@ package org.dreamfinity.dsgl.mcForge1710.text -import org.dreamfinity.dsgl.core.font.* import org.dreamfinity.dsgl.core.dom.layout.FontLineMetrics +import org.dreamfinity.dsgl.core.font.* import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.TextFormatting import org.dreamfinity.dsgl.core.text.* import org.lwjgl.BufferUtils import org.lwjgl.opengl.* import java.nio.ByteBuffer -import java.util.* +import java.util.ArrayList +import java.util.Collections +import java.util.LinkedHashMap import java.util.concurrent.ConcurrentHashMap internal class MsdfTextRenderer { private data class PreparedText( val text: String, - val styleSpans: List + val styleSpans: List, ) private data class LayoutCacheKey( @@ -23,17 +25,21 @@ internal class MsdfTextRenderer { val fontSize: Int, val textFormatting: TextFormatting, val baseFlagsMask: Int, - val styleSpansHash: Int + val styleSpansHash: Int, ) private data class CachedLineLayout( val start: Int, - val shaped: ShapedText + val shaped: ShapedText, ) - private data class LayoutCacheEntry(val lines: List) + private data class LayoutCacheEntry( + val lines: List, + ) - private class SegmentBuffer(initialCapacity: Int = 64) { + private class SegmentBuffer( + initialCapacity: Int = 64, + ) { private var startX = FloatArray(initialCapacity) private var endX = FloatArray(initialCapacity) private var y = FloatArray(initialCapacity) @@ -54,7 +60,7 @@ internal class MsdfTextRenderer { segmentEndX: Float, segmentY: Float, segmentThickness: Float, - segmentColor: Int + segmentColor: Int, ) { if (segmentEndX <= segmentStartX) return val last = size - 1 @@ -79,10 +85,15 @@ internal class MsdfTextRenderer { } fun kindAt(index: Int): Int = kind[index] + fun startXAt(index: Int): Float = startX[index] + fun endXAt(index: Int): Float = endX[index] + fun yAt(index: Int): Float = y[index] + fun thicknessAt(index: Int): Float = thickness[index] + fun colorAt(index: Int): Int = color[index] private fun ensureCapacity(required: Int) { @@ -107,35 +118,27 @@ internal class MsdfTextRenderer { var glyphVectorRequests: Long = 0L, var glyphResolutionRequests: Long = 0L, var textureUploads: Long = 0L, - var textureUploadBytes: Long = 0L - ) - - private data class DecorationSegment( - var startX: Float, - var endX: Float, - val y: Float, - val thickness: Float, - val color: Int + var textureUploadBytes: Long = 0L, ) private data class ObfuscationBuckets( val byAdvanceBucket: Map>, val expandedByAdvanceBucket: Map>, val sortedKeys: List, - val allGlyphs: List + val allGlyphs: List, ) private data class LineSlice( val start: Int, - val endExclusive: Int + val endExclusive: Int, ) private val textures: MutableMap = linkedMapOf() private val layoutCache: MutableMap = object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MAX_LAYOUT_CACHE_ENTRIES - } + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry?, + ): Boolean = size > MAX_LAYOUT_CACHE_ENTRIES } private var programId: Int = 0 private var uniformAtlas: Int = -1 @@ -143,7 +146,8 @@ internal class MsdfTextRenderer { private val errorLogTimes: MutableMap = linkedMapOf() private val debugLogKeys: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val debugGlyphResolutionEnabled: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - java.lang.Boolean.getBoolean("dsgl.msdf.debug") + java.lang.Boolean + .getBoolean("dsgl.msdf.debug") } private val obfuscationBuckets: MutableMap = linkedMapOf() private var obfuscationLastNano: Long = System.nanoTime() @@ -153,35 +157,38 @@ internal class MsdfTextRenderer { private val segmentBuffer = SegmentBuffer(96) private val debugCounters = RendererDebugCounters() private val debugPerformanceEnabled: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - java.lang.Boolean.getBoolean("dsgl.msdf.debug.performance") + java.lang.Boolean + .getBoolean("dsgl.msdf.debug.performance") } private var debugLastLogMs: Long = 0L - fun measureText(text: String, fontId: String?, fontSize: Int?): Int { - return FontRegistry.measureText(text, fontId, fontSize) - } + + fun measureText(text: String, fontId: String?, fontSize: Int?): Int = + FontRegistry.measureText(text, fontId, fontSize) fun measureTextRange( text: String, startIndex: Int, endIndexExclusive: Int, fontId: String?, - fontSize: Int? + fontSize: Int?, ): Int { - val shaped = FontRegistry.shapeTextRange( - text = text, - startIndex = startIndex, - endIndexExclusive = endIndexExclusive, - fontId = fontId, - fontSize = fontSize, - formattingMode = "plain" - ) - return shaped.width.toInt().coerceAtLeast(0) + val shaped = + FontRegistry.shapeTextRange( + text = text, + startIndex = startIndex, + endIndexExclusive = endIndexExclusive, + fontId = fontId, + fontSize = fontSize, + formattingMode = "plain", + ) + return shaped.width + .toInt() + .coerceAtLeast(0) } - fun lineHeight(fontId: String?, fontSize: Int?): Int { - return FontRegistry.lineHeight(fontId, fontSize) - } + fun lineHeight(fontId: String?, fontSize: Int?): Int = FontRegistry.lineHeight(fontId, fontSize) + @Suppress("UnusedParameter") fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { val font = FontRegistry.get(fontId) ?: return null val metrics = font.meta.metrics @@ -190,25 +197,29 @@ internal class MsdfTextRenderer { emSize = metrics.emSize, lineHeightEm = metrics.lineHeight, ascenderEm = metrics.ascender, - descenderEm = metrics.descender + descenderEm = metrics.descender, ) } fun draw(command: RenderCommand.DrawText, opacityMultiplier: Float) { debugCounters.drawCalls += 1 val primaryFont = FontRegistry.get(command.fontId) ?: return - val runtimeFallbackFont = FontRegistry.get(FontRegistry.FALLBACK_FONT_ID) - ?.takeIf { it.descriptor.fontId != primaryFont.descriptor.fontId } - val missingGlyphFont = FontRegistry.get(FontRegistry.FALLBACK_FONT_ID) - ?: FontRegistry.get(FontRegistry.DEFAULT_FONT_ID) + val runtimeFallbackFont = + FontRegistry + .get(FontRegistry.FALLBACK_FONT_ID) + ?.takeIf { it.descriptor.fontId != primaryFont.descriptor.fontId } + val missingGlyphFont = + FontRegistry.get(FontRegistry.FALLBACK_FONT_ID) + ?: FontRegistry.get(FontRegistry.DEFAULT_FONT_ID) val fontSize = FontRegistry.resolveFontSize(command.fontSize) val prepared = prepareText(command) - val layoutEntry = getOrBuildLayoutCacheEntry( - command = command, - prepared = prepared, - primaryFont = primaryFont, - fontSize = fontSize - ) + val layoutEntry = + getOrBuildLayoutCacheEntry( + command = command, + prepared = prepared, + primaryFont = primaryFont, + fontSize = fontSize, + ) if (prepared.text.isEmpty() || layoutEntry.lines.isEmpty()) return val debugDecorationGuidesEnabled = MsdfRuntimeDebugSettings.decorationGuidesEnabled @@ -222,15 +233,20 @@ internal class MsdfTextRenderer { try { val primaryScalePx = TextDecorationLayout.scalePx(fontSize, primaryFont.meta.metrics.emSize) - val lineHeight = primaryFont.meta.lineHeightPx(fontSize).toFloat().coerceAtLeast(1f) - val fontDecorationMetrics = DecorationFontMetrics( - emSize = primaryFont.meta.metrics.emSize, - lineHeightEm = primaryFont.meta.metrics.lineHeight, - ascenderEm = primaryFont.meta.metrics.ascender, - descenderEm = primaryFont.meta.metrics.descender, - underlineYEm = primaryFont.meta.metrics.underlineY, - underlineThicknessEm = primaryFont.meta.metrics.underlineThickness - ) + val lineHeight = + primaryFont.meta + .lineHeightPx(fontSize) + .toFloat() + .coerceAtLeast(1f) + val fontDecorationMetrics = + DecorationFontMetrics( + emSize = primaryFont.meta.metrics.emSize, + lineHeightEm = primaryFont.meta.metrics.lineHeight, + ascenderEm = primaryFont.meta.metrics.ascender, + descenderEm = primaryFont.meta.metrics.descender, + underlineYEm = primaryFont.meta.metrics.underlineY, + underlineThicknessEm = primaryFont.meta.metrics.underlineThickness, + ) debugGlyphResolution(prepared.text, primaryFont) if (!useProgram()) return @@ -273,25 +289,28 @@ internal class MsdfTextRenderer { try { layoutEntry.lines.forEach { line -> - val baselineY = TextDecorationLayout.baselineY( - lineTopY = lineTop, - ascenderEm = primaryFont.meta.metrics.ascender, - scalePx = primaryScalePx - ) + val baselineY = + TextDecorationLayout.baselineY( + lineTopY = lineTop, + ascenderEm = primaryFont.meta.metrics.ascender, + scalePx = primaryScalePx, + ) val shaped = line.shaped - val lineRecord = TextVisualLine( - lineIndex = lineIndex, - lineTopY = lineTop, - baselineY = baselineY, - lineHeightPx = lineHeight, - glyphStartIndex = globalGlyphIndex, - glyphEndIndexExclusive = globalGlyphIndex + shaped.glyphs.size - ) - val lineMetrics = TextDecorationLayout.resolveLineMetrics( - line = lineRecord, - fontMetrics = fontDecorationMetrics, - fontPx = fontSize - ) + val lineRecord = + TextVisualLine( + lineIndex = lineIndex, + lineTopY = lineTop, + baselineY = baselineY, + lineHeightPx = lineHeight, + glyphStartIndex = globalGlyphIndex, + glyphEndIndexExclusive = globalGlyphIndex + shaped.glyphs.size, + ) + val lineMetrics = + TextDecorationLayout.resolveLineMetrics( + line = lineRecord, + fontMetrics = fontDecorationMetrics, + fontPx = fontSize, + ) var lineStartX = command.x.toFloat() var lineEndX = lineStartX var glyphIndexInLine = 0 @@ -301,59 +320,66 @@ internal class MsdfTextRenderer { shaped.glyphs.forEach { shapedGlyph -> val globalCharStart = line.start + shapedGlyph.charStart - while (spanIndex < prepared.styleSpans.size && globalCharStart >= prepared.styleSpans[spanIndex].end) { - spanIndex += 1 - } - val resolvedSpanIndex = if ( - spanIndex < prepared.styleSpans.size && - globalCharStart >= prepared.styleSpans[spanIndex].start && - globalCharStart < prepared.styleSpans[spanIndex].end + while (spanIndex < prepared.styleSpans.size && + globalCharStart >= prepared.styleSpans[spanIndex].end ) { - spanIndex - } else { - -1 + spanIndex += 1 } + val resolvedSpanIndex = + if ( + spanIndex < prepared.styleSpans.size && + globalCharStart >= prepared.styleSpans[spanIndex].start && + globalCharStart < prepared.styleSpans[spanIndex].end + ) { + spanIndex + } else { + -1 + } if (resolvedSpanIndex != activeResolvedSpanIndex) { activeResolvedSpanIndex = resolvedSpanIndex if (resolvedSpanIndex >= 0) { val span = prepared.styleSpans[resolvedSpanIndex] activeStyleColor = withOpacity(span.color, opacityMultiplier) - activeStyleFlags = flagsMask( - bold = span.bold, - italic = span.italic, - underline = span.underline, - strikethrough = span.strikethrough, - obfuscated = span.obfuscated - ) + activeStyleFlags = + flagsMask( + bold = span.bold, + italic = span.italic, + underline = span.underline, + strikethrough = span.strikethrough, + obfuscated = span.obfuscated, + ) } else { activeStyleColor = withOpacity(command.color, opacityMultiplier) activeStyleFlags = baseFlagsMask(command) } } - val shapedFont = if (shapedGlyph.fontId == cachedShapedFontId) { - cachedShapedFont - } else { - (FontRegistry.get(shapedGlyph.fontId) ?: primaryFont).also { resolved -> - cachedShapedFontId = shapedGlyph.fontId - cachedShapedFont = resolved + val shapedFont = + if (shapedGlyph.fontId == cachedShapedFontId) { + cachedShapedFont + } else { + (FontRegistry.get(shapedGlyph.fontId) ?: primaryFont).also { resolved -> + cachedShapedFontId = shapedGlyph.fontId + cachedShapedFont = resolved + } + } + val forceMissingGlyphFont = + if ( + shapedGlyph.sourceCodepoint == REPLACEMENT_CODEPOINT || + isShapedGlyphMissingInFont(shapedFont, shapedGlyph) + ) { + missingGlyphFont ?: runtimeFallbackFont ?: shapedFont + } else { + null } - } - val forceMissingGlyphFont = if ( - shapedGlyph.sourceCodepoint == REPLACEMENT_CODEPOINT || - isShapedGlyphMissingInFont(shapedFont, shapedGlyph) - ) { - missingGlyphFont ?: runtimeFallbackFont ?: shapedFont - } else { - null - } var glyphFont = forceMissingGlyphFont ?: shapedFont - var glyph = if (forceMissingGlyphFont != null) { - preferredMissingGlyph(glyphFont) - } else { - resolveGlyphForShapedInput(shapedFont, shapedGlyph) - } + var glyph = + if (forceMissingGlyphFont != null) { + preferredMissingGlyph(glyphFont) + } else { + resolveGlyphForShapedInput(shapedFont, shapedGlyph) + } if (glyph == null && runtimeFallbackFont != null) { val fallbackByCodepoint = resolveGlyphForShapedInput(runtimeFallbackFont, shapedGlyph) if (fallbackByCodepoint != null) { @@ -405,14 +431,16 @@ internal class MsdfTextRenderer { } val drawGlyph = - if (styleObfuscated && ObfuscationTextSelector.shouldObfuscateCodepoint(shapedGlyph.sourceCodepoint)) { + if (styleObfuscated && + ObfuscationTextSelector.shouldObfuscateCodepoint(shapedGlyph.sourceCodepoint) + ) { resolveObfuscatedGlyph( font = glyphFont, sourceKey = command.sourceKey ?: command.text, original = resolvedGlyph, lineIndex = lineIndex, glyphIndexInLine = glyphIndexInLine, - avoidGlyphIndex = lastObfuscatedGlyphIndex + avoidGlyphIndex = lastObfuscatedGlyphIndex, ) } else { resolvedGlyph @@ -428,7 +456,7 @@ internal class MsdfTextRenderer { atlasHeight = texture.height, fontScalePx = glyphScale, italic = styleItalic, - italicSkewPx = glyphScale * 0.2f + italicSkewPx = glyphScale * 0.2f, ) if (styleBold) { emitGlyphQuad( @@ -439,7 +467,7 @@ internal class MsdfTextRenderer { atlasHeight = texture.height, fontScalePx = glyphScale, italic = styleItalic, - italicSkewPx = glyphScale * 0.2f + italicSkewPx = glyphScale * 0.2f, ) } lastObfuscatedGlyphIndex = if (styleObfuscated) effectiveGlyph.glyphIndex else null @@ -455,7 +483,7 @@ internal class MsdfTextRenderer { segmentEndX = glyphEndX, segmentY = lineMetrics.underlineY, segmentThickness = lineMetrics.underlineThickness, - segmentColor = activeStyleColor + segmentColor = activeStyleColor, ) } if (styleStrikethrough) { @@ -465,7 +493,7 @@ internal class MsdfTextRenderer { segmentEndX = glyphEndX, segmentY = lineMetrics.strikethroughY, segmentThickness = lineMetrics.strikethroughThickness, - segmentColor = activeStyleColor + segmentColor = activeStyleColor, ) } } @@ -479,12 +507,13 @@ internal class MsdfTextRenderer { segmentKind = SEGMENT_DEBUG_BASELINE, segmentStartX = lineStartX, segmentEndX = lineEndX, - segmentY = lineRecord.baselineY.coerceIn( - lineRecord.lineTopY, - lineRecord.lineTopY + lineRecord.lineHeightPx - ), + segmentY = + lineRecord.baselineY.coerceIn( + lineRecord.lineTopY, + lineRecord.lineTopY + lineRecord.lineHeightPx, + ), segmentThickness = 1f, - segmentColor = 0x66FFAA00 + segmentColor = 0x66FFAA00, ) segmentBuffer.appendMerged( segmentKind = SEGMENT_DEBUG_UNDERLINE, @@ -492,7 +521,7 @@ internal class MsdfTextRenderer { segmentEndX = lineEndX, segmentY = lineMetrics.underlineY, segmentThickness = lineMetrics.underlineThickness, - segmentColor = 0x6600FF00 + segmentColor = 0x6600FF00, ) segmentBuffer.appendMerged( segmentKind = SEGMENT_DEBUG_STRIKE, @@ -500,7 +529,7 @@ internal class MsdfTextRenderer { segmentEndX = lineEndX, segmentY = lineMetrics.strikethroughY, segmentThickness = lineMetrics.strikethroughThickness, - segmentColor = 0x66FF00FF + segmentColor = 0x66FF00FF, ) } @@ -527,43 +556,46 @@ internal class MsdfTextRenderer { if (command.textFormatting != TextFormatting.Minecraft) { return PreparedText( text = command.text, - styleSpans = command.textStyleSpans + styleSpans = command.textStyleSpans, ) } if (command.textStyleSpans.isNotEmpty()) { return PreparedText( text = command.text, - styleSpans = command.textStyleSpans + styleSpans = command.textStyleSpans, ) } val parsed = MinecraftFormattingParser.parse(command.text, TextFormatting.Minecraft) - val spans = MinecraftFormattingParser.resolveStyleSpans( - parsed = parsed, - baseColor = command.color, - baseFlags = TextStyleFlags( - bold = command.bold, - italic = command.italic, - underline = command.underline, - strikethrough = command.strikethrough, - obfuscated = command.obfuscated - ) - ).map { span -> - RenderCommand.TextStyleSpan( - start = span.start, - end = span.end, - color = span.color, - bold = span.flags.bold, - italic = span.flags.italic, - underline = span.flags.underline, - strikethrough = span.flags.strikethrough, - obfuscated = span.flags.obfuscated - ) - } + val spans = + MinecraftFormattingParser + .resolveStyleSpans( + parsed = parsed, + baseColor = command.color, + baseFlags = + TextStyleFlags( + bold = command.bold, + italic = command.italic, + underline = command.underline, + strikethrough = command.strikethrough, + obfuscated = command.obfuscated, + ), + ).map { span -> + RenderCommand.TextStyleSpan( + start = span.start, + end = span.end, + color = span.color, + bold = span.flags.bold, + italic = span.flags.italic, + underline = span.flags.underline, + strikethrough = span.flags.strikethrough, + obfuscated = span.flags.obfuscated, + ) + } return PreparedText( text = parsed.plainText, - styleSpans = spans + styleSpans = spans, ) } @@ -586,21 +618,24 @@ internal class MsdfTextRenderer { private fun resolveGlyphForShapedInput(font: LoadedMsdfFont, shapedGlyph: ShapedGlyph): MsdfGlyph? { val sourceCodepoint = shapedGlyph.sourceCodepoint val canUseGlyphIndex = shapedGlyph.fontId == font.descriptor.fontId - val fromIndex = if (canUseGlyphIndex) { - font.meta.glyphByIndex(shapedGlyph.glyphIndex) - } else { - null - } + val fromIndex = + if (canUseGlyphIndex) { + font.meta.glyphByIndex(shapedGlyph.glyphIndex) + } else { + null + } val indexLooksMissing = isMissingGlyphIndex(font, shapedGlyph.glyphIndex, fromIndex) - val indexMatchesSource = if (!indexLooksMissing) { - val fromIndexCodepoint = fromIndex?.codepoint - fromIndex != null && ( - fromIndexCodepoint == null || + val indexMatchesSource = + if (!indexLooksMissing) { + val fromIndexCodepoint = fromIndex?.codepoint + fromIndex != null && + ( + fromIndexCodepoint == null || fromIndexCodepoint == sourceCodepoint ) - } else { - false - } + } else { + false + } if (sourceCodepoint == REPLACEMENT_CODEPOINT) { return preferredMissingGlyph(font) @@ -623,9 +658,10 @@ internal class MsdfTextRenderer { val questionByIndex = font.preferredQuestionGlyphIndex?.let(meta::glyphByIndex) if (questionByIndex != null) return questionByIndex - val replacementByIndex = font.preferredMissingGlyphIndex - ?.takeIf { it != 0 } - ?.let(meta::glyphByIndex) + val replacementByIndex = + font.preferredMissingGlyphIndex + ?.takeIf { it != 0 } + ?.let(meta::glyphByIndex) if (replacementByIndex != null) return replacementByIndex val notDef = meta.glyphByIndex(0) if (notDef != null) return notDef @@ -654,7 +690,7 @@ internal class MsdfTextRenderer { atlasHeight: Int, fontScalePx: Float, italic: Boolean, - italicSkewPx: Float + italicSkewPx: Float, ) { val plane = glyph.planeBounds ?: return val atlas = glyph.atlasBounds ?: return @@ -684,16 +720,17 @@ internal class MsdfTextRenderer { command: RenderCommand.DrawText, prepared: PreparedText, primaryFont: LoadedMsdfFont, - fontSize: Int + fontSize: Int, ): LayoutCacheEntry { - val key = LayoutCacheKey( - text = prepared.text, - primaryFontId = primaryFont.descriptor.fontId, - fontSize = fontSize, - textFormatting = command.textFormatting, - baseFlagsMask = baseFlagsMask(command), - styleSpansHash = styleSpansFingerprint(prepared.styleSpans) - ) + val key = + LayoutCacheKey( + text = prepared.text, + primaryFontId = primaryFont.descriptor.fontId, + fontSize = fontSize, + textFormatting = command.textFormatting, + baseFlagsMask = baseFlagsMask(command), + styleSpansHash = styleSpansFingerprint(prepared.styleSpans), + ) synchronized(layoutCache) { val cached = layoutCache[key] if (cached != null) { @@ -707,18 +744,20 @@ internal class MsdfTextRenderer { val lines = ArrayList(slices.size) slices.forEach { slice -> debugCounters.glyphVectorRequests += 1 - val shaped = FontRegistry.shapeTextRange( - text = prepared.text, - startIndex = slice.start, - endIndexExclusive = slice.endExclusive, - fontId = command.fontId, - fontSize = fontSize, - formattingMode = command.textFormatting.name - ) - lines += CachedLineLayout( - start = slice.start, - shaped = shaped - ) + val shaped = + FontRegistry.shapeTextRange( + text = prepared.text, + startIndex = slice.start, + endIndexExclusive = slice.endExclusive, + fontId = command.fontId, + fontSize = fontSize, + formattingMode = command.textFormatting.name, + ) + lines += + CachedLineLayout( + start = slice.start, + shaped = shaped, + ) } val built = LayoutCacheEntry(lines = lines) @@ -748,7 +787,8 @@ internal class MsdfTextRenderer { var index = 0 while (index < segments.size) { val kind = segments.kindAt(index) - val isDebug = kind == SEGMENT_DEBUG_BASELINE || + val isDebug = + kind == SEGMENT_DEBUG_BASELINE || kind == SEGMENT_DEBUG_UNDERLINE || kind == SEGMENT_DEBUG_STRIKE if (isDebug && !includeDebug) { @@ -762,10 +802,11 @@ internal class MsdfTextRenderer { val a = ((color ushr 24) and 0xFF) / 255f GL11.glColor4f(r, g, b, a) val y0 = segments.yAt(index) - val y1 = maxOf( - y0 + 0.5f, - y0 + segments.thicknessAt(index) - ) + val y1 = + maxOf( + y0 + 0.5f, + y0 + segments.thicknessAt(index), + ) GL11.glVertex2f(segments.startXAt(index), y0) GL11.glVertex2f(segments.endXAt(index), y0) GL11.glVertex2f(segments.endXAt(index), y1) @@ -782,22 +823,21 @@ internal class MsdfTextRenderer { } } - private fun baseFlagsMask(command: RenderCommand.DrawText): Int { - return flagsMask( + private fun baseFlagsMask(command: RenderCommand.DrawText): Int = + flagsMask( bold = command.bold, italic = command.italic, underline = command.underline, strikethrough = command.strikethrough, - obfuscated = command.obfuscated + obfuscated = command.obfuscated, ) - } private fun flagsMask( bold: Boolean, italic: Boolean, underline: Boolean, strikethrough: Boolean, - obfuscated: Boolean + obfuscated: Boolean, ): Int { var mask = 0 if (bold) mask = mask or STYLE_FLAG_BOLD @@ -829,27 +869,30 @@ internal class MsdfTextRenderer { original: MsdfGlyph, lineIndex: Int, glyphIndexInLine: Int, - avoidGlyphIndex: Int? + avoidGlyphIndex: Int?, ): MsdfGlyph? { - val buckets = obfuscationBuckets.getOrPut(font.descriptor.fontId) { - buildObfuscationBuckets(font) - } + val buckets = + obfuscationBuckets.getOrPut(font.descriptor.fontId) { + buildObfuscationBuckets(font) + } if (buckets.allGlyphs.isEmpty()) return original val baseBucket = advanceBucketKey(original.advance) - val candidates = buckets.expandedByAdvanceBucket[baseBucket] - ?: nearestExpandedCandidates(buckets, baseBucket) - ?: buckets.allGlyphs + val candidates = + buckets.expandedByAdvanceBucket[baseBucket] + ?: nearestExpandedCandidates(buckets, baseBucket) + ?: buckets.allGlyphs if (candidates.isEmpty()) return original val originalKey = original.codepoint ?: original.glyphIndex - val primaryIndex = ObfuscationTextSelector.selectCandidateIndex( - sourceKey = sourceKey, - lineIndex = lineIndex, - glyphIndexInLine = glyphIndexInLine, - timeSlice = obfuscationTimeSlice, - originalCodepoint = originalKey, - candidateCount = candidates.size - ) + val primaryIndex = + ObfuscationTextSelector.selectCandidateIndex( + sourceKey = sourceKey, + lineIndex = lineIndex, + glyphIndexInLine = glyphIndexInLine, + timeSlice = obfuscationTimeSlice, + originalCodepoint = originalKey, + candidateCount = candidates.size, + ) val primary = candidates[primaryIndex] if (avoidGlyphIndex == null || candidates.size <= 1 || primary.glyphIndex != avoidGlyphIndex) { return primary @@ -861,17 +904,18 @@ internal class MsdfTextRenderer { } private fun buildObfuscationBuckets(font: LoadedMsdfFont): ObfuscationBuckets { - val glyphs = font.meta.glyphsByIndex.values - .filter { glyph -> - val codepoint = glyph.codepoint - glyph.drawable && (codepoint == null || !TextStyleMetrics.isWhitespaceCodepoint(codepoint)) - } + val glyphs = + font.meta.glyphsByIndex.values + .filter { glyph -> + val codepoint = glyph.codepoint + glyph.drawable && (codepoint == null || !TextStyleMetrics.isWhitespaceCodepoint(codepoint)) + } if (glyphs.isEmpty()) { return ObfuscationBuckets( byAdvanceBucket = emptyMap(), expandedByAdvanceBucket = emptyMap(), sortedKeys = emptyList(), - allGlyphs = emptyList() + allGlyphs = emptyList(), ) } val grouped = linkedMapOf>() @@ -882,23 +926,22 @@ internal class MsdfTextRenderer { val frozenGrouped = grouped.mapValues { (_, value) -> value.toList() } val expanded = linkedMapOf>() sorted.forEach { key -> - expanded[key] = expandCandidatesForBucket( - grouped = frozenGrouped, - sortedKeys = sorted, - baseKey = key - ) + expanded[key] = + expandCandidatesForBucket( + grouped = frozenGrouped, + sortedKeys = sorted, + baseKey = key, + ) } return ObfuscationBuckets( byAdvanceBucket = frozenGrouped, expandedByAdvanceBucket = expanded, sortedKeys = sorted, - allGlyphs = glyphs + allGlyphs = glyphs, ) } - private fun advanceBucketKey(advance: Float): Int { - return (advance * 100f).toInt() - } + private fun advanceBucketKey(advance: Float): Int = (advance * 100f).toInt() private fun nearestExpandedCandidates(buckets: ObfuscationBuckets, key: Int): List? { val keys = buckets.sortedKeys @@ -918,7 +961,7 @@ internal class MsdfTextRenderer { private fun expandCandidatesForBucket( grouped: Map>, sortedKeys: List, - baseKey: Int + baseKey: Int, ): List { val byDistance = sortedKeys.sortedBy { key -> kotlin.math.abs(key - baseKey) } val out = ArrayList(MIN_OBFUSCATION_CANDIDATES) @@ -969,8 +1012,9 @@ internal class MsdfTextRenderer { val width = bitmap.width.coerceAtLeast(1) val height = bitmap.height.coerceAtLeast(1) if (width > maxTextureSize || height > maxTextureSize) { - throw IllegalStateException( - "Atlas '${font.descriptor.fontId}' is ${width}x${height}, exceeds GL_MAX_TEXTURE_SIZE=$maxTextureSize" + error( + "Atlas '${font.descriptor.fontId}' is ${width}x$height, " + + "exceeds GL_MAX_TEXTURE_SIZE=$maxTextureSize", ) } val buffer = BufferUtils.createByteBuffer(width * height * 4) @@ -983,25 +1027,25 @@ internal class MsdfTextRenderer { GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR) GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP) GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP) - val glError = scopedGlError { - GL11.glTexImage2D( - GL11.GL_TEXTURE_2D, - 0, - GL11.GL_RGBA8, - width, - height, - 0, - GL11.GL_RGBA, - GL11.GL_UNSIGNED_BYTE, - buffer - ) - } + val glError = + scopedGlError { + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA8, + width, + height, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + buffer, + ) + } if (glError != GL11.GL_NO_ERROR) { GL11.glDeleteTextures(textureId) - throw IllegalStateException( - "glTexImage2D failed for '${font.descriptor.fontId}' (${width}x${height}), glError=0x${ - glError.toString(16) - }" + error( + "glTexImage2D failed for '${font.descriptor.fontId}' (${width}x$height), " + + "glError=0x${glError.toString(16)}", ) } debugCounters.textureUploads += 1 @@ -1029,11 +1073,11 @@ internal class MsdfTextRenderer { private fun useProgram(): Boolean { if (programId == 0) { - val loaded = runCatching { createProgram() } - .onFailure { error -> - logRateLimited("shader:init", "[DSGL-MSDF] Failed to initialize shader: ${error.message}") - } - .getOrNull() ?: return false + val loaded = + runCatching { createProgram() } + .onFailure { error -> + logRateLimited("shader:init", "[DSGL-MSDF] Failed to initialize shader: ${error.message}") + }.getOrNull() ?: return false programId = loaded uniformAtlas = ARBShaderObjects.glGetUniformLocationARB(programId, "uAtlas") uniformPxRange = ARBShaderObjects.glGetUniformLocationARB(programId, "uPxRange") @@ -1043,26 +1087,29 @@ internal class MsdfTextRenderer { } private fun createProgram(): Int { - val vertexShader = compileShader( - type = ARBVertexShader.GL_VERTEX_SHADER_ARB, - source = VERTEX_SHADER_SOURCE - ) - val fragmentShader = compileShader( - type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, - source = FRAGMENT_SHADER_SOURCE - ) + val vertexShader = + compileShader( + type = ARBVertexShader.GL_VERTEX_SHADER_ARB, + source = VERTEX_SHADER_SOURCE, + ) + val fragmentShader = + compileShader( + type = ARBFragmentShader.GL_FRAGMENT_SHADER_ARB, + source = FRAGMENT_SHADER_SOURCE, + ) val program = ARBShaderObjects.glCreateProgramObjectARB() ARBShaderObjects.glAttachObjectARB(program, vertexShader) ARBShaderObjects.glAttachObjectARB(program, fragmentShader) ARBShaderObjects.glLinkProgramARB(program) - val linkStatus = ARBShaderObjects.glGetObjectParameteriARB( - program, - ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB - ) + val linkStatus = + ARBShaderObjects.glGetObjectParameteriARB( + program, + ARBShaderObjects.GL_OBJECT_LINK_STATUS_ARB, + ) if (linkStatus == GL11.GL_FALSE) { val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) - throw IllegalStateException("Program link failed: $info") + error("Program link failed: $info") } return program } @@ -1071,13 +1118,14 @@ internal class MsdfTextRenderer { val shader = ARBShaderObjects.glCreateShaderObjectARB(type) ARBShaderObjects.glShaderSourceARB(shader, source) ARBShaderObjects.glCompileShaderARB(shader) - val compileStatus = ARBShaderObjects.glGetObjectParameteriARB( - shader, - ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB - ) + val compileStatus = + ARBShaderObjects.glGetObjectParameteriARB( + shader, + ARBShaderObjects.GL_OBJECT_COMPILE_STATUS_ARB, + ) if (compileStatus == GL11.GL_FALSE) { val info = ARBShaderObjects.glGetInfoLogARB(shader, 4096) - throw IllegalStateException("Shader compile failed: $info") + error("Shader compile failed: $info") } return shader } @@ -1105,10 +1153,12 @@ internal class MsdfTextRenderer { val layoutSize = synchronized(layoutCache) { layoutCache.size } println( "[DSGL-MSDF] drawCalls=${debugCounters.drawCalls} " + - "layoutCache hit=${debugCounters.layoutCacheHits} miss=${debugCounters.layoutCacheMisses} size=$layoutSize " + - "glyphVectors=${debugCounters.glyphVectorRequests} glyphResolves=${debugCounters.glyphResolutionRequests} " + - "textureUploads=${debugCounters.textureUploads} " + - "textureUploadBytes=${debugCounters.textureUploadBytes}" + "layoutCache hit=${debugCounters.layoutCacheHits} " + + "miss=${debugCounters.layoutCacheMisses} size=$layoutSize " + + "glyphVectors=${debugCounters.glyphVectorRequests} " + + "glyphResolves=${debugCounters.glyphResolutionRequests} " + + "textureUploads=${debugCounters.textureUploads} " + + "textureUploadBytes=${debugCounters.textureUploadBytes}", ) } @@ -1118,12 +1168,13 @@ internal class MsdfTextRenderer { val key = "${font.descriptor.fontId}|$sample" if (!debugLogKeys.add(key)) return - val shaped = FontRegistry.shapeText( - text = sample, - fontId = font.descriptor.fontId, - fontSize = FontRegistry.DEFAULT_FONT_SIZE, - formattingMode = "debug" - ) + val shaped = + FontRegistry.shapeText( + text = sample, + fontId = font.descriptor.fontId, + fontSize = FontRegistry.DEFAULT_FONT_SIZE, + formattingMode = "debug", + ) println("[DSGL-MSDF] text='$sample' glyphs=${shaped.glyphs.size} runs=${shaped.runs.size}") shaped.glyphs.take(32).forEach { glyph -> val loaded = FontRegistry.get(glyph.fontId) @@ -1131,8 +1182,8 @@ internal class MsdfTextRenderer { println( "[DSGL-MSDF] font=${glyph.fontId} glyphIndex=${glyph.glyphIndex} sourceCp=U+%04X found=%s".format( glyph.sourceCodepoint, - atlasGlyph != null - ) + atlasGlyph != null, + ), ) } } diff --git a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostApplicationPortalFrameTests.kt b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostApplicationPortalFrameTests.kt new file mode 100644 index 0000000..4eed530 --- /dev/null +++ b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostApplicationPortalFrameTests.kt @@ -0,0 +1,207 @@ +package org.dreamfinity.dsgl.mcForge1710 + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.components.modal.BackdropMode +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.components.modal.modalPortal +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.hasActiveModalPortal +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class DsglScreenHostApplicationPortalFrameTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @After + fun cleanup() { + StyleEngine.setStylesDirectory(null) + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @Test + fun `application modal frame movement clears stale hovered portal commands before staging`() { + val hoverColor = 0xFF_12_34_56.toInt() + installStylesheet( + """ + button:hover { + background-color: #123456; + } + """.trimIndent(), + ) + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + val modalKey = "tests.host.modal.frame.hover.commands" + + try { + renderStaticModalWithButton(modalKey) + host.debugSyncApplicationPortalSurfaceForTests(ctx, width = 300, height = 120) + + assertTrue(applicationPortal.hasActiveModalPortal()) + assertTrue(applicationPortal.handleMouseMove(76, 25)) + assertRenderColorPresent( + host.debugCollectApplicationPortalCommandsForTests(ctx), + hoverColor, + ) + + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 4, mouseY = 4) + val settledCommands = host.debugCollectApplicationPortalCommandsForTests(ctx) + val stagedCommands = + host.debugStageApplicationPortalCommandsForTests( + tree = tree, + applicationPortalCommands = settledCommands, + measureContext = ctx, + ) + + assertRenderColorAbsent(settledCommands, hoverColor) + assertRenderColorAbsent(stagedCommands, hoverColor) + } finally { + renderEmptyModal(modalKey) + host.debugSyncApplicationPortalSurfaceForTests(ctx, width = 300, height = 120) + } + } + + @Test + fun `application modal button click updates portal commands after root repaint in same frame`() { + val modalKey = "tests.host.modal.frame.click.commands" + var clicked = false + var tree = renderClickStateModal(modalKey = modalKey, clicked = clicked, onClick = { clicked = true }) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + + try { + host.debugSyncApplicationPortalSurfaceForTests(ctx, width = 300, height = 120) + assertTrue(applicationPortal.hasActiveModalPortal()) + assertRenderTextPresent(host.debugCollectApplicationPortalCommandsForTests(ctx), "Before") + + assertTrue(applicationPortal.handleMouseDown(76, 25, MouseButton.LEFT)) + assertTrue(applicationPortal.handleMouseUp(76, 25, MouseButton.LEFT)) + assertTrue(clicked) + + tree = renderClickStateModal(modalKey = modalKey, clicked = clicked, onClick = { clicked = true }) + host.debugBindTreeForTests(tree, needsLayout = false) + host.debugSyncApplicationPortalSurfaceForTests(ctx, width = 300, height = 120) + + val finalPortalCommands = host.debugCollectApplicationPortalCommandsForTests(ctx) + assertRenderTextAbsent(finalPortalCommands, "Before") + assertRenderTextPresent(finalPortalCommands, "After") + } finally { + renderEmptyModal(modalKey) + host.debugSyncApplicationPortalSurfaceForTests(ctx, width = 300, height = 120) + } + } + + private fun renderStaticModalWithButton(modalKey: String) { + val modalTree = + ui { + modalPortal( + modals = + listOf( + ModalSpec( + key = "static-modal", + backdrop = BackdropMode.Static, + keyboard = false, + ) { + button("Hover", { + key = "$modalKey.button" + onMouseClick = {} + }) + }, + ), + key = modalKey, + ) { + text("content") + } + } + modalTree.render(ctx, 300, 120) + } + + private fun renderClickStateModal(modalKey: String, clicked: Boolean, onClick: () -> Unit): DomTree { + val modalTree = + ui { + modalPortal( + modals = + listOf( + ModalSpec( + key = "click-modal", + backdrop = BackdropMode.Static, + keyboard = false, + ) { + button(if (clicked) "After" else "Before", { + key = "$modalKey.button" + onMouseClick = { onClick() } + }) + }, + ), + key = modalKey, + ) { + text("content") + } + } + modalTree.render(ctx, 300, 120) + return modalTree + } + + private fun renderEmptyModal(modalKey: String) { + val modalTree = + ui { + modalPortal(modals = emptyList(), key = modalKey) { + text("content") + } + } + modalTree.render(ctx, 300, 120) + } + + private fun createHost(tree: DomTree): DsglScreenHost = + object : DsglScreenHost( + object : DsglWindow() { + override fun render(): DomTree = tree + }, + ) {}.also { host -> + host.debugBindTreeForTests(tree, needsLayout = false) + } + + private fun installStylesheet(contents: String) { + val dir = Files.createTempDirectory("dsgl-host-domain-test").toFile() + dir.resolve("test.dss").writeText(contents) + StyleEngine.setStylesDirectory(dir) + StyleEngine.forceReloadStylesheets() + } + + private fun assertRenderColorPresent(commands: List, color: Int) { + assertTrue(commands.any { command -> command is RenderCommand.DrawRect && command.color == color }) + } + + private fun assertRenderColorAbsent(commands: List, color: Int) { + assertFalse(commands.any { command -> command is RenderCommand.DrawRect && command.color == color }) + } + + private fun assertRenderTextPresent(commands: List, text: String) { + assertTrue(commands.any { command -> command is RenderCommand.DrawText && command.text == text }) + } + + private fun assertRenderTextAbsent(commands: List, text: String) { + assertFalse(commands.any { command -> command is RenderCommand.DrawText && command.text == text }) + } +} diff --git a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostDomainOrchestrationTests.kt b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostDomainOrchestrationTests.kt new file mode 100644 index 0000000..849e703 --- /dev/null +++ b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostDomainOrchestrationTests.kt @@ -0,0 +1,646 @@ +package org.dreamfinity.dsgl.mcForge1710 + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerController +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerLayout +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.components.modal.modalPortal +import org.dreamfinity.dsgl.core.dnd.DndRuntime +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ColorPickerInlineNode +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.Events +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.event.MouseLeaveEvent +import org.dreamfinity.dsgl.core.event.MouseMoveEvent +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.hasActiveModalPortal +import org.dreamfinity.dsgl.core.portal.toggleFloatingWindowDemo +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DsglScreenHostDomainOrchestrationTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @Test + fun `host domain paint orchestration uses six surface render order`() { + val host = createHost() + + val commands = + host.debugComposeDomainPaintCommandsForTests( + applicationRoot = listOf(command(1)), + applicationPortal = listOf(command(2)), + systemRoot = listOf(command(3)), + systemPortal = listOf(command(4)), + debugRoot = listOf(command(5)), + debugPortal = listOf(command(6)), + ) + + assertEquals(listOf(1, 2, 3, 4, 5, 6), commandColors(commands)) + } + + @Test + fun `host domain paint orchestration preserves render debug skips`() { + val host = createHost() + + val commands = + host.debugComposeDomainPaintCommandsForTests( + applicationRoot = listOf(command(1)), + applicationPortal = listOf(command(2)), + systemPortal = listOf(command(3)), + debugRoot = listOf(command(4)), + shouldRenderSurface = { surface -> surface != ScreenDomainSurfaces.ApplicationPortal }, + ) + + assertEquals(listOf(1, 3, 4), commandColors(commands)) + } + + @Test + fun `host domain input orchestration preserves current priority`() { + val host = createHost() + val visited = ArrayList() + + val consumed = + host.debugFirstDomainInputConsumerForTests( + canConsume = { layer -> + visited += layer + layer == ScreenDomainSurfaces.ApplicationRoot + }, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumed) + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.ApplicationRoot, + ), + visited, + ) + } + + @Test + fun `host domain input orchestration blocks lower domains after consumption`() { + val host = createHost() + val visited = ArrayList() + + val consumed = + host.debugFirstDomainInputConsumerForTests( + canConsume = { layer -> + visited += layer + layer == ScreenDomainSurfaces.SystemPortal + }, + ) + + assertEquals(ScreenDomainSurfaces.SystemPortal, consumed) + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.SystemPortal, + ), + visited, + ) + } + + @Test + fun `host domain input orchestration preserves debug input disables`() { + val host = createHost() + val visited = ArrayList() + + val consumed = + host.debugFirstDomainInputConsumerForTests( + canConsume = { layer -> + visited += layer + layer == ScreenDomainSurfaces.DebugRoot || layer == ScreenDomainSurfaces.ApplicationPortal + }, + isSurfaceInputEnabled = { surface -> surface != ScreenDomainSurfaces.DebugRoot }, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumed) + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.ApplicationPortal, + ), + visited, + ) + } + + @Test + fun `host domain input orchestration returns null when no surface consumes`() { + val host = createHost() + + val consumed = host.debugFirstDomainInputConsumerForTests(canConsume = { false }) + + assertNull(consumed) + } + + @Test + fun `host suppresses application root release after portal-owned pointer down`() { + val host = createHost() + var rootReceivedRelease = false + + val consumedDown = + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = true, + applicationPortalConsumes = { true }, + applicationRootConsumes = { true }, + ) + + val consumedUp = + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = false, + applicationPortalConsumes = { false }, + applicationRootConsumes = { + rootReceivedRelease = true + true + }, + ) + val consumedNextRelease = + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = false, + applicationPortalConsumes = { false }, + applicationRootConsumes = { true }, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedDown) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedUp) + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumedNextRelease) + assertFalse(rootReceivedRelease) + } + + @Test + fun `application portal dom target blocks application root frame hover`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 1280, 720) } + val rootButton = + ButtonNode("Root", key = "root-button") + .apply { bounds = Rect(240, 180, 520, 300) } + .applyParent(root) + var leaveCount = 0 + EventBus.run { + rootButton.addEventListener(Events.MOUSELEAVE) { _: MouseLeaveEvent -> + leaveCount++ + } + } + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 300, mouseY = 230) + assertSame(rootButton, host.debugHoverTargetForTests()) + assertEquals(0, leaveCount) + + applicationPortal.onInputFrame(1280, 720) + applicationPortal.toggleFloatingWindowDemo(anchorX = 260, anchorY = 200) + applicationPortal.render(ctx, 1280, 720) + val portalBodyX = 540 + val portalBodyY = 370 + + host.debugUpdateFrameInteractionStateForTests( + tree = tree, + mouseX = portalBodyX, + mouseY = portalBodyY, + ) + assertNull(host.debugHoverTargetForTests()) + assertEquals(1, leaveCount) + } + + @Test + fun `application portal live pointer consumption clears application root hover immediately`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 1280, 720) } + val rootButton = + ButtonNode("Root", key = "root-button") + .apply { bounds = Rect(240, 180, 520, 300) } + .applyParent(root) + var leaveCount = 0 + EventBus.run { + rootButton.addEventListener(Events.MOUSELEAVE) { _: MouseLeaveEvent -> + leaveCount++ + } + } + val tree = DomTree(root) + val host = createHost(tree) + + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 300, mouseY = 230) + assertSame(rootButton, host.debugHoverTargetForTests()) + + val consumedBy = + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = -1, + buttonPressed = false, + mouseX = 540, + mouseY = 370, + applicationPortalConsumes = { true }, + applicationRootConsumes = { true }, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertNull(host.debugHoverTargetForTests()) + assertEquals(1, leaveCount) + } + + @Test + fun `application root frame hover still updates outside application portal dom target`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 1280, 720) } + val rootButton = + ButtonNode("Root", key = "root-button") + .apply { bounds = Rect(620, 500, 120, 32) } + .applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + + applicationPortal.onInputFrame(1280, 720) + applicationPortal.toggleFloatingWindowDemo(anchorX = 260, anchorY = 200) + applicationPortal.render(ctx, 1280, 720) + + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 630, mouseY = 510) + + assertSame(rootButton, host.debugHoverTargetForTests()) + } + + @Test + fun `application portal dom target clears inline color picker hover before root paint`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 1280, 720) } + val picker = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + key = "picker", + ).applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + val probeLayout = colorPickerLayoutProbe() + val style = ColorPickerStyle() + val hoverX = probeLayout.copyRect.x + 2 + val hoverY = probeLayout.copyRect.y + 2 + + tree.render(ctx, 1280, 720) + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = hoverX, mouseY = hoverY) + EventBus.post(MouseMoveEvent(hoverX, hoverY, hoverX - 1, hoverY - 1).also { it.target = picker }) + assertRenderColorPresent(tree.paint(ctx), style.buttonHoverColor) + + applicationPortal.onInputFrame(1280, 720) + applicationPortal.toggleFloatingWindowDemo(anchorX = hoverX, anchorY = hoverY) + applicationPortal.render(ctx, 1280, 720) + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = hoverX, mouseY = hoverY) + + assertNull(host.debugHoverTargetForTests()) + assertRenderColorAbsent(tree.paint(ctx), style.buttonHoverColor) + } + + @Test + fun `application root frame movement continues captured range drag`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val range = + RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range") + .apply { + bounds = Rect(20, 40, 100, 12) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + + host.debugDispatchApplicationRootPointerDownForTests(tree, mouseX = 20, mouseY = 46) + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 120, mouseY = 46) + + assertEquals(100L, range.value) + } + + @Test + fun `application root frame movement continues captured text selection drag`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val input = + SingleLineInputNode(text = "abcdef", key = "text-input") + .apply { + bounds = Rect(20, 40, 120, 12) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + + host.debugDispatchApplicationRootPointerDownForTests(tree, mouseX = 20, mouseY = 46) + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 56, mouseY = 46) + + assertRenderColorPresent(input.buildCommandsForTest(), input.selectionColor) + } + + @Test + fun `application portal pointer down clears pending root dnd before frame movement`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val draggable = + ContainerNode(key = "draggable") + .apply { + draggable = true + bounds = Rect(20, 40, 80, 20) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val down = + MouseDownEvent(24, 44, MouseButton.LEFT) + .also { event -> + event.target = draggable + } + + DndRuntime.engine.cancelActiveDrag() + try { + DndRuntime.engine.onMouseDown(root, draggable, down) + assertTrue(DndRuntime.engine.isPointerCaptured) + + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = true, + mouseX = 60, + mouseY = 60, + applicationPortalConsumes = { true }, + applicationRootConsumes = { true }, + ) + DndRuntime.engine.onMouseMove(root, 120, 60) + + assertFalse(DndRuntime.engine.isPointerCaptured) + assertFalse(DndRuntime.engine.isDragging) + } finally { + DndRuntime.engine.cancelActiveDrag() + } + } + + @Test + fun `active application modal frame blocks and cancels root dnd`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val draggable = + ContainerNode(key = "draggable") + .apply { + draggable = true + bounds = Rect(20, 40, 80, 20) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + val modalKey = "tests.host.modal.frame.blocks.dnd" + val down = + MouseDownEvent(24, 44, MouseButton.LEFT) + .also { event -> + event.target = draggable + } + + DndRuntime.engine.cancelActiveDrag() + try { + activateStaticModal(applicationPortal, modalKey) + DndRuntime.engine.onMouseDown(root, draggable, down) + DndRuntime.engine.onMouseMove(root, 120, 60) + assertTrue(DndRuntime.engine.isDragging) + + host.debugUpdateFrameInteractionStateForTests(tree, mouseX = 120, mouseY = 60) + + assertFalse(DndRuntime.engine.isPointerCaptured) + assertFalse(DndRuntime.engine.isDragging) + } finally { + clearStaticModal(applicationPortal, modalKey) + DndRuntime.engine.cancelActiveDrag() + } + } + + @Test + fun `application root dnd ghost commands are staged through application portal`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val draggable = + ContainerNode(key = "draggable") + .apply { + draggable = true + bounds = Rect(20, 40, 80, 20) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val down = + MouseDownEvent(24, 44, MouseButton.LEFT) + .also { event -> + event.target = draggable + } + + DndRuntime.engine.cancelActiveDrag() + try { + DndRuntime.engine.onMouseDown(root, draggable, down) + DndRuntime.engine.onMouseMove(root, 120, 60) + assertTrue(DndRuntime.engine.isDragging) + + val staged = + host.debugStageApplicationPortalCommandsForTests( + tree = tree, + applicationPortalCommands = emptyList(), + measureContext = ctx, + ) + + assertTrue( + staged.any { command -> + command is RenderCommand.DrawText && command.text == "drag" + }, + ) + } finally { + DndRuntime.engine.cancelActiveDrag() + } + } + + @Test + fun `active application modal suppresses root dnd ghost commands`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val draggable = + ContainerNode(key = "draggable") + .apply { + draggable = true + bounds = Rect(20, 40, 80, 20) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val applicationPortal = host.debugApplicationPortalHostForTests() + val modalKey = "tests.host.modal.suppresses.ghost.commands" + val down = + MouseDownEvent(24, 44, MouseButton.LEFT) + .also { event -> + event.target = draggable + } + + DndRuntime.engine.cancelActiveDrag() + try { + activateStaticModal(applicationPortal, modalKey) + DndRuntime.engine.onMouseDown(root, draggable, down) + DndRuntime.engine.onMouseMove(root, 120, 60) + assertTrue(DndRuntime.engine.isDragging) + assertTrue(applicationPortal.hasActiveModalPortal()) + + val staged = + host.debugStageApplicationPortalCommandsForTests( + tree = tree, + applicationPortalCommands = applicationPortal.paint(ctx), + measureContext = ctx, + ) + + assertFalse( + staged.any { command -> + command is RenderCommand.DrawText && command.text == "drag" + }, + ) + } finally { + clearStaticModal(applicationPortal, modalKey) + DndRuntime.engine.cancelActiveDrag() + } + } + + @Test + fun `application portal pointer release clears active root dnd`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 300, 120) } + val draggable = + ContainerNode(key = "draggable") + .apply { + draggable = true + bounds = Rect(20, 40, 80, 20) + }.applyParent(root) + val tree = DomTree(root) + val host = createHost(tree) + val down = + MouseDownEvent(24, 44, MouseButton.LEFT) + .also { event -> + event.target = draggable + } + + DndRuntime.engine.cancelActiveDrag() + try { + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = true, + mouseX = 60, + mouseY = 60, + applicationPortalConsumes = { true }, + applicationRootConsumes = { true }, + ) + DndRuntime.engine.onMouseDown(root, draggable, down) + DndRuntime.engine.onMouseMove(root, 120, 60) + assertTrue(DndRuntime.engine.isDragging) + + host.debugDispatchApplicationPortalThenRootPointerForTests( + mouseButton = 0, + buttonPressed = false, + mouseX = 120, + mouseY = 60, + applicationPortalConsumes = { true }, + applicationRootConsumes = { true }, + ) + + assertFalse(DndRuntime.engine.isPointerCaptured) + assertFalse(DndRuntime.engine.isDragging) + } finally { + DndRuntime.engine.cancelActiveDrag() + } + } + + private fun createHost(): DsglScreenHost = createHost(DomTree(ContainerNode(key = "root"))) + + private fun createHost(tree: DomTree): DsglScreenHost = + object : DsglScreenHost( + object : DsglWindow() { + override fun render(): DomTree = tree + }, + ) {}.also { host -> + host.debugBindTreeForTests(tree, needsLayout = false) + } + + private fun command(color: Int): RenderCommand = RenderCommand.DrawRect(0, 0, 1, 1, color) + + private fun commandColors(commands: List): List = + commands.map { command -> + (command as RenderCommand.DrawRect).color + } + + private fun assertRenderColorPresent(commands: List, color: Int) { + assertEquals(true, commands.any { command -> command is RenderCommand.DrawRect && command.color == color }) + } + + private fun assertRenderColorAbsent(commands: List, color: Int) { + assertEquals(false, commands.any { command -> command is RenderCommand.DrawRect && command.color == color }) + } + + private fun SingleLineInputNode.buildCommandsForTest(): List = + ArrayList().also { out -> + buildRenderCommands(ctx, out) + } + + private fun activateStaticModal(applicationPortal: ApplicationPortalHost, modalKey: String) { + val modalTree = + ui { + modalPortal( + modals = + listOf( + ModalSpec(key = "static-modal") { + text("Static") + }, + ), + key = modalKey, + ) { + text("content") + } + } + modalTree.render(ctx, 300, 120) + applicationPortal.render(ctx, 300, 120) + assertTrue(applicationPortal.hasActiveModalPortal()) + } + + private fun clearStaticModal(applicationPortal: ApplicationPortalHost, modalKey: String) { + val emptyModalTree = + ui { + modalPortal(modals = emptyList(), key = modalKey) { + text("content") + } + } + emptyModalTree.render(ctx, 300, 120) + applicationPortal.render(ctx, 300, 120) + } + + private fun colorPickerLayoutProbe(): ColorPickerLayout = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + previous = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + closeOnSelect = false, + ), + ).buildLayout(Rect(0, 0, 1280, 392)) +} diff --git a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt index 7baa683..561c6fc 100644 --- a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt +++ b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt @@ -16,17 +16,20 @@ import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Assert.assertNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test class DsglScreenHostUsedGeometryTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @Test fun `app host hover and click target match core for absolute outside ancestor bounds`() { @@ -97,24 +100,26 @@ class DsglScreenHostUsedGeometryTests { assertNull(hoverTarget(host)) assertEquals( collectHoverChain(fixture.root, 225, 28).lastOrNull(), - hoverTarget(host) + hoverTarget(host), ) } @Test fun `rebuild churn drains detached cleanup and keeps listener registrations bounded`() { - val window = object : DsglWindow() { - private var generation: Int = 0 - - override fun render(): DomTree { - generation += 1 - val root = ContainerNode(key = "rebuild-root-$generation") - ButtonNode(text = "btn-$generation", key = "btn-$generation").apply { - onMouseClick = {} - }.applyParent(root) - return DomTree(root) + val window = + object : DsglWindow() { + private var generation: Int = 0 + + override fun render(): DomTree { + generation += 1 + val root = ContainerNode(key = "rebuild-root-$generation") + ButtonNode(text = "btn-$generation", key = "btn-$generation") + .apply { + onMouseClick = {} + }.applyParent(root) + return DomTree(root) + } } - } val host = object : DsglScreenHost(window) {} host.window = window host.debugSetNeedsRenderForTests(true) @@ -127,7 +132,7 @@ class DsglScreenHostUsedGeometryTests { assertEquals( "detached cleanup queue must be drained in the same rebuild cycle", 0, - host.debugPendingCleanupCount() + host.debugPendingCleanupCount(), ) } @@ -136,20 +141,21 @@ class DsglScreenHostUsedGeometryTests { val callbackDelta = after.registeredCallbacks - baseline.registeredCallbacks assertTrue( "listener node registrations grew unexpectedly: baseline=$baseline after=$after", - nodeDelta <= 2 + nodeDelta <= 2, ) assertTrue( "listener callback registrations grew unexpectedly: baseline=$baseline after=$after", - callbackDelta <= 24 + callbackDelta <= 24, ) } private fun createHostWithTree(tree: DomTree): DsglScreenHost { - val host = object : DsglScreenHost(object : DsglWindow() { - override fun render(): DomTree { - return tree - } - }) {} + val host = + object : DsglScreenHost( + object : DsglWindow() { + override fun render(): DomTree = tree + }, + ) {} host.debugBindTreeForTests(tree, needsLayout = false) return host } @@ -158,130 +164,142 @@ class DsglScreenHostUsedGeometryTests { host.debugRefreshHoverTargetForTests(mouseX, mouseY) } - private fun hoverTarget(host: DsglScreenHost): DOMNode? { - return host.debugHoverTargetForTests() - } + private fun hoverTarget(host: DsglScreenHost): DOMNode? = host.debugHoverTargetForTests() - private fun resolvePointerDownTarget(host: DsglScreenHost): DOMNode? { - return host.debugResolvePointerDownTargetForTests() - } + private fun resolvePointerDownTarget(host: DsglScreenHost): DOMNode? = host.debugResolvePointerDownTargetForTests() - private fun resolveClickTarget(host: DsglScreenHost): DOMNode? { - return host.debugResolveClickTargetForTests() - } + private fun resolveClickTarget(host: DsglScreenHost): DOMNode? = host.debugResolveClickTargetForTests() private data class AbsoluteOutsideAncestorFixture( val tree: DomTree, val root: ContainerNode, - val child: ButtonNode + val child: ButtonNode, ) private fun createAbsoluteOutsideAncestorFixture(): AbsoluteOutsideAncestorFixture { val root = ContainerNode(key = "abs-root") - val ancestor = ContainerNode(key = "abs-ancestor").apply { - width = 40 - height = 40 - inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") - }.applyParent(root) - val child = ButtonNode("abs-child", key = "abs-child").apply { - width = 36 - height = 16 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "100px", - StyleProperty.TOP to "5px" - ) - }.applyParent(ancestor) + val ancestor = + ContainerNode(key = "abs-ancestor") + .apply { + width = 40 + height = 40 + inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") + }.applyParent(root) + val child = + ButtonNode("abs-child", key = "abs-child") + .apply { + width = 36 + height = 16 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "100px", + StyleProperty.TOP to "5px", + ) + }.applyParent(ancestor) return AbsoluteOutsideAncestorFixture(DomTree(root), root, child) } private data class PositionedOverlapFixture( val tree: DomTree, val root: ContainerNode, - val fixed: ButtonNode + val fixed: ButtonNode, ) private fun createPositionedOverlapFixture(): PositionedOverlapFixture { val root = ContainerNode(key = "root", stackLayout = true) - val early = ContainerNode(key = "early", stackLayout = true).apply { - width = 120 - height = 60 - }.applyParent(root) - ContainerNode(key = "later-container", stackLayout = true).apply { - width = 120 - height = 60 - }.apply { - ButtonNode("later", key = "later").apply { - width = 72 - height = 24 - }.applyParent(this) - }.applyParent(root) - val fixed = ButtonNode("fixed", key = "fixed").apply { - width = 72 - height = 24 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - }.applyParent(early) + val early = + ContainerNode(key = "early", stackLayout = true) + .apply { + width = 120 + height = 60 + }.applyParent(root) + ContainerNode(key = "later-container", stackLayout = true) + .apply { + width = 120 + height = 60 + }.apply { + ButtonNode("later", key = "later") + .apply { + width = 72 + height = 24 + }.applyParent(this) + }.applyParent(root) + val fixed = + ButtonNode("fixed", key = "fixed") + .apply { + width = 72 + height = 24 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + }.applyParent(early) return PositionedOverlapFixture(DomTree(root), root, fixed) } private data class ClipSemanticsFixture( val tree: DomTree, val root: ContainerNode, - val fixed: ButtonNode + val fixed: ButtonNode, ) private fun createClipSemanticsFixture(): ClipSemanticsFixture { val root = ContainerNode(key = "clip-root", stackLayout = true) - val overflowParent = ContainerNode(key = "clip-parent").apply { - width = 80 - height = 40 - overflowY = Overflow.Hidden - inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") - }.applyParent(root) - val fixed = ButtonNode("fixed", key = "clip-fixed").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "180px", - StyleProperty.TOP to "20px" - ) - }.applyParent(overflowParent) - ButtonNode("absolute", key = "clip-absolute").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "140px", - StyleProperty.TOP to "90px" - ) - }.applyParent(overflowParent) + val overflowParent = + ContainerNode(key = "clip-parent") + .apply { + width = 80 + height = 40 + overflowY = Overflow.Hidden + inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") + }.applyParent(root) + val fixed = + ButtonNode("fixed", key = "clip-fixed") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "180px", + StyleProperty.TOP to "20px", + ) + }.applyParent(overflowParent) + ButtonNode("absolute", key = "clip-absolute") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "140px", + StyleProperty.TOP to "90px", + ) + }.applyParent(overflowParent) return ClipSemanticsFixture(DomTree(root), root, fixed) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } private data class EventBusListenerSnapshot( val registeredNodes: Int, - val registeredCallbacks: Int + val registeredCallbacks: Int, ) private fun debugEventBusSnapshot(): EventBusListenerSnapshot { val snapshot = EventBus.debugListenerSnapshot() return EventBusListenerSnapshot( registeredNodes = snapshot.registeredNodes, - registeredCallbacks = snapshot.registeredCallbacks + registeredCallbacks = snapshot.registeredCallbacks, ) } } diff --git a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStackCompositionTests.kt b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStackCompositionTests.kt new file mode 100644 index 0000000..205f234 --- /dev/null +++ b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/RenderCommandTransformStackCompositionTests.kt @@ -0,0 +1,118 @@ +package org.dreamfinity.dsgl.mcForge1710 + +import org.dreamfinity.dsgl.core.dom.layout.AffineTransform2D +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RenderCommandTransformStackCompositionTests { + @Test + fun `closed-form push matches chained matrix composition`() { + val cases = + listOf( + transformCommand(), + transformCommand(originX = 10f, originY = 20f, translateX = 5f, translateY = -3f), + transformCommand(originX = 50f, originY = 25f, scaleX = 2f, scaleY = 0.5f), + transformCommand(originX = 12f, originY = 34f, rotateDeg = 45f), + transformCommand( + originX = 100f, + originY = 60f, + translateX = -8f, + translateY = 4f, + scaleX = 1.5f, + scaleY = 0.75f, + rotateDeg = 30f, + ), + transformCommand( + originX = -16f, + originY = 8f, + translateX = 3.5f, + translateY = 7.25f, + scaleX = 0.25f, + scaleY = 3f, + rotateDeg = -120f, + ), + transformCommand(translateX = 1f, translateY = 2f, scaleX = -1f, rotateDeg = 270f), + ) + cases.forEach { command -> + val stack = RenderCommandTransformStack() + stack.push(command) + assertMatrixNearlyEquals(referenceTransform(command), stack.currentTransform(), command) + } + } + + @Test + fun `pop restores the previous transform`() { + val stack = RenderCommandTransformStack() + val outer = transformCommand(originX = 5f, originY = 5f, translateX = 2f, scaleX = 2f, scaleY = 2f) + val inner = transformCommand(rotateDeg = 90f) + stack.push(outer) + val outerTransform = stack.currentTransform() + stack.push(inner) + stack.pop() + assertEquals(outerTransform, stack.currentTransform()) + stack.pop() + assertEquals(AffineTransform2D.IDENTITY, stack.currentTransform()) + } + + @Test + fun `resolveClipRect under rotation matches transformed bounding box`() { + val stack = RenderCommandTransformStack() + stack.push(transformCommand(rotateDeg = 90f)) + val clip = stack.resolveClipRect(10, 0, 20, 10) + assertEquals(-10, clip.x) + assertEquals(10, clip.y) + assertEquals(10, clip.width) + assertEquals(20, clip.height) + } + + private fun transformCommand( + originX: Float = 0f, + originY: Float = 0f, + translateX: Float = 0f, + translateY: Float = 0f, + scaleX: Float = 1f, + scaleY: Float = 1f, + rotateDeg: Float = 0f, + ): RenderCommand.PushTransform = + RenderCommand.PushTransform( + originX = originX, + originY = originY, + translateX = translateX, + translateY = translateY, + scaleX = scaleX, + scaleY = scaleY, + rotateDeg = rotateDeg, + ) + + private fun referenceTransform(command: RenderCommand.PushTransform): AffineTransform2D = + AffineTransform2D + .translation(command.originX, command.originY) + .times(AffineTransform2D.translation(command.translateX, command.translateY)) + .times(AffineTransform2D.rotation(command.rotateDeg)) + .times(AffineTransform2D.scale(command.scaleX, command.scaleY)) + .times(AffineTransform2D.translation(-command.originX, -command.originY)) + + private fun assertMatrixNearlyEquals(expected: AffineTransform2D, actual: AffineTransform2D, command: RenderCommand.PushTransform) { + assertNearlyEqual(expected.a, actual.a, "a", command) + assertNearlyEqual(expected.b, actual.b, "b", command) + assertNearlyEqual(expected.c, actual.c, "c", command) + assertNearlyEqual(expected.d, actual.d, "d", command) + assertNearlyEqual(expected.tx, actual.tx, "tx", command) + assertNearlyEqual(expected.ty, actual.ty, "ty", command) + } + + private fun assertNearlyEqual( + expected: Float, + actual: Float, + field: String, + command: RenderCommand.PushTransform, + ) { + assertTrue( + abs(expected - actual) <= 1e-3f, + "Field $field diverged for $command: expected $expected, actual $actual", + ) + } +} diff --git a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt index 4abb01d..9ae73a2 100644 --- a/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt +++ b/adapters/mc-forge-1-7-10/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt @@ -1,11 +1,5 @@ package org.dreamfinity.dsgl.mcForge1710 -import kotlin.math.abs -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent @@ -24,16 +18,25 @@ import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.math.abs +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue class StickyControlClipAlignmentTests { private val viewportWidth = 420 private val viewportHeight = 260 - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -44,11 +47,12 @@ class StickyControlClipAlignmentTests { @Test fun `non-sticky text input keeps draw and clip aligned`() { - val fixture = createControlFixture( - sticky = false, - scrollY = 0, - controlFactory = { TextInputNode(text = "hello", key = "align-text") } - ) + val fixture = + createControlFixture( + sticky = false, + scrollY = 0, + controlFactory = { TextInputNode(text = "hello", key = "align-text") }, + ) FocusManager.requestFocus(fixture.control as SingleLineInputNode) fixture.tree.paint(ctx) @@ -59,11 +63,12 @@ class StickyControlClipAlignmentTests { @Test fun `sticky-clamped text input keeps shell text and caret clip-aligned`() { - val fixture = createControlFixture( - sticky = true, - scrollY = 46, - controlFactory = { TextInputNode(text = "hello", key = "align-sticky-text") } - ) + val fixture = + createControlFixture( + sticky = true, + scrollY = 46, + controlFactory = { TextInputNode(text = "hello", key = "align-sticky-text") }, + ) FocusManager.requestFocus(fixture.control as SingleLineInputNode) fixture.tree.paint(ctx) @@ -77,11 +82,12 @@ class StickyControlClipAlignmentTests { @Test fun `sticky-clamped number input keeps text and clip aligned`() { - val fixture = createControlFixture( - sticky = true, - scrollY = 46, - controlFactory = { NumberInputNode(value = 42, key = "align-sticky-number") } - ) + val fixture = + createControlFixture( + sticky = true, + scrollY = 46, + controlFactory = { NumberInputNode(value = 42, key = "align-sticky-number") }, + ) fixture.tree.paint(ctx) val visible = visibleRect(fixture.control) @@ -93,19 +99,21 @@ class StickyControlClipAlignmentTests { @Test fun `sticky-clamped closed select keeps label text and clip aligned`() { - val fixture = createControlFixture( - sticky = true, - scrollY = 46, - controlFactory = { - SelectNode( - model = selectModel(id = "sticky-select-model") { - option("a", "Alpha") - option("b", "Beta") - }, - key = "align-sticky-select" - ) - } - ) + val fixture = + createControlFixture( + sticky = true, + scrollY = 46, + controlFactory = { + SelectNode( + model = + selectModel(id = "sticky-select-model") { + option("a", "Alpha") + option("b", "Beta") + }, + key = "align-sticky-select", + ) + }, + ) fixture.tree.paint(ctx) val visible = visibleRect(fixture.control) @@ -117,37 +125,47 @@ class StickyControlClipAlignmentTests { @Test fun `nested clip path stays coherent for sticky-clamped text input`() { - val root = ContainerNode(key = "nested-clip-root").apply { - width = 220 - height = 86 - overflowY = Overflow.Auto - } - ContainerNode(key = "nested-clip-top-spacer").apply { - width = 220 - height = 22 - }.applyParent(root) - val stickyRow = ContainerNode(key = "nested-clip-sticky-row").apply { - width = 220 - height = 30 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(root) - val nestedClip = ContainerNode(key = "nested-clip-wrapper").apply { - width = 180 - height = 20 - overflowX = Overflow.Hidden - overflowY = Overflow.Hidden - }.applyParent(stickyRow) - val input = TextInputNode(text = "nested", key = "nested-clip-input").apply { - width = 140 - height = 18 - }.applyParent(nestedClip) - ContainerNode(key = "nested-clip-filler").apply { - width = 220 - height = 280 - }.applyParent(root) + val root = + ContainerNode(key = "nested-clip-root").apply { + width = 220 + height = 86 + overflowY = Overflow.Auto + } + ContainerNode(key = "nested-clip-top-spacer") + .apply { + width = 220 + height = 22 + }.applyParent(root) + val stickyRow = + ContainerNode(key = "nested-clip-sticky-row") + .apply { + width = 220 + height = 30 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(root) + val nestedClip = + ContainerNode(key = "nested-clip-wrapper") + .apply { + width = 180 + height = 20 + overflowX = Overflow.Hidden + overflowY = Overflow.Hidden + }.applyParent(stickyRow) + val input = + TextInputNode(text = "nested", key = "nested-clip-input") + .apply { + width = 140 + height = 18 + }.applyParent(nestedClip) + ContainerNode(key = "nested-clip-filler") + .apply { + width = 220 + height = 280 + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, viewportWidth, viewportHeight) @@ -168,40 +186,44 @@ class StickyControlClipAlignmentTests { assertNotEquals(input.bounds.y, visible.y) } - private fun createControlFixture( - sticky: Boolean, - scrollY: Int, - controlFactory: () -> DOMNode - ): ControlFixture { - val root = ContainerNode(key = "clip-align-root").apply { - width = 220 - height = 86 - overflowY = Overflow.Auto - } + private fun createControlFixture(sticky: Boolean, scrollY: Int, controlFactory: () -> DOMNode): ControlFixture { + val root = + ContainerNode(key = "clip-align-root").apply { + width = 220 + height = 86 + overflowY = Overflow.Auto + } if (sticky) { - ContainerNode(key = "clip-align-top-spacer").apply { + ContainerNode(key = "clip-align-top-spacer") + .apply { + width = 220 + height = 22 + }.applyParent(root) + } + val host = + ContainerNode(key = "clip-align-host") + .apply { + width = 220 + height = 28 + if (sticky) { + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + }.applyParent(root) + val control = + controlFactory() + .apply { + width = 140 + height = 18 + }.applyParent(host) + ContainerNode(key = "clip-align-filler") + .apply { width = 220 - height = 22 + height = 280 }.applyParent(root) - } - val host = ContainerNode(key = "clip-align-host").apply { - width = 220 - height = 28 - if (sticky) { - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - }.applyParent(root) - val control = controlFactory().apply { - width = 140 - height = 18 - }.applyParent(host) - ContainerNode(key = "clip-align-filler").apply { - width = 220 - height = 280 - }.applyParent(root) val tree = DomTree(root) tree.render(ctx, viewportWidth, viewportHeight) @@ -224,7 +246,13 @@ class StickyControlClipAlignmentTests { is RenderCommand.PopTransform -> transform.pop() is RenderCommand.PushClip -> { val transformed = transform.resolveClipRect(command.x, command.y, command.width, command.height) - val raw = GuiClipRect(command.x, command.y, command.width.coerceAtLeast(0), command.height.coerceAtLeast(0)) + val raw = + GuiClipRect( + command.x, + command.y, + command.width.coerceAtLeast(0), + command.height.coerceAtLeast(0), + ) pushClips += ObservedClipPush(raw = raw, transformed = transformed) clipStack.addLast(transformed) } @@ -232,22 +260,24 @@ class StickyControlClipAlignmentTests { is RenderCommand.PopClip -> if (clipStack.isNotEmpty()) clipStack.removeLast() is RenderCommand.DrawText -> { val point = transform.transformPoint(command.x.toFloat(), command.y.toFloat()) - texts += ObservedText( - text = command.text, - x = floorToInt(point.first), - y = floorToInt(point.second), - activeClip = clipStack.lastOrNull() - ) + texts += + ObservedText( + text = command.text, + x = floorToInt(point.first), + y = floorToInt(point.second), + activeClip = clipStack.lastOrNull(), + ) } is RenderCommand.DrawRect -> { val transformed = transform.resolveClipRect(command.x, command.y, command.width, command.height) - rects += ObservedRect( - transformed = transformed, - rawWidth = command.width.coerceAtLeast(0), - rawHeight = command.height.coerceAtLeast(0), - activeClip = clipStack.lastOrNull() - ) + rects += + ObservedRect( + transformed = transformed, + rawWidth = command.width.coerceAtLeast(0), + rawHeight = command.height.coerceAtLeast(0), + activeClip = clipStack.lastOrNull(), + ) } else -> Unit @@ -262,23 +292,26 @@ class StickyControlClipAlignmentTests { assertNotNull(observed.activeClip, "Expected active clip while drawing '$text'") assertTrue( contains(observed.activeClip, observed.x, observed.y), - "Expected transformed clip to contain transformed text point for '$text': point=(${observed.x},${observed.y}) clip=${observed.activeClip}" + "Expected transformed clip to contain transformed text point for '$text': " + + "point=(${observed.x},${observed.y}) clip=${observed.activeClip}", ) } private fun assertCaretInsideActiveClip(observations: CommandObservations, nearText: String) { val text = observations.texts.firstOrNull { it.text == nearText } assertNotNull(text, "Expected DrawText('$nearText') for caret alignment assertion") - val caret = observations.rects.firstOrNull { rect -> - rect.rawWidth == 1 && - rect.activeClip != null && - abs(rect.transformed.y - text.y) <= 2 - } + val caret = + observations.rects.firstOrNull { rect -> + rect.rawWidth == 1 && + rect.activeClip != null && + abs(rect.transformed.y - text.y) <= 2 + } assertNotNull(caret, "Expected caret-like 1px rect near text '$nearText'") assertNotNull(caret.activeClip) assertTrue( containsRect(caret.activeClip, caret.transformed), - "Expected caret rect to be clipped by transformed active clip: caret=${caret.transformed} clip=${caret.activeClip}" + "Expected caret rect to be clipped by transformed active clip: " + + "caret=${caret.transformed} clip=${caret.activeClip}", ) } @@ -293,10 +326,24 @@ class StickyControlClipAlignmentTests { val maxX = maxOf(topLeft.first, topRight.first, bottomLeft.first, bottomRight.first) val minY = minOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) val maxY = maxOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) - val x = kotlin.math.floor(minX.toDouble()).toInt() - val y = kotlin.math.floor(minY.toDouble()).toInt() - val w = kotlin.math.ceil((maxX - minX).toDouble()).toInt().coerceAtLeast(0) - val h = kotlin.math.ceil((maxY - minY).toDouble()).toInt().coerceAtLeast(0) + val x = + kotlin.math + .floor(minX.toDouble()) + .toInt() + val y = + kotlin.math + .floor(minY.toDouble()) + .toInt() + val w = + kotlin.math + .ceil((maxX - minX).toDouble()) + .toInt() + .coerceAtLeast(0) + val h = + kotlin.math + .ceil((maxY - minY).toDouble()) + .toInt() + .coerceAtLeast(0) return Rect(x, y, w, h) } @@ -315,43 +362,45 @@ class StickyControlClipAlignmentTests { bottom <= clip.y + clip.height } - private fun floorToInt(value: Float): Int = kotlin.math.floor(value.toDouble()).toInt() + private fun floorToInt(value: Float): Int = + kotlin.math + .floor(value.toDouble()) + .toInt() - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } private data class ControlFixture( val tree: DomTree, - val control: DOMNode + val control: DOMNode, ) private data class CommandObservations( val pushClips: List, val texts: List, - val rects: List + val rects: List, ) private data class ObservedClipPush( val raw: GuiClipRect, - val transformed: GuiClipRect + val transformed: GuiClipRect, ) private data class ObservedText( val text: String, val x: Int, val y: Int, - val activeClip: GuiClipRect? + val activeClip: GuiClipRect?, ) private data class ObservedRect( val transformed: GuiClipRect, val rawWidth: Int, val rawHeight: Int, - val activeClip: GuiClipRect? + val activeClip: GuiClipRect?, ) } diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 427384a..8fc6d32 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -2,12 +2,24 @@ plugins { `kotlin-dsl` } +val kotlinVersion: String = rootProject.projectDir.resolveSibling("gradle.properties") + .readLines() + .firstOrNull { it.startsWith("kotlinVersion=") } + ?.substringAfter("=") + ?.trim() + ?: error("kotlinVersion not found in root gradle.properties") + repositories { gradlePluginPortal() mavenCentral() } dependencies { - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.10") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") implementation("org.jetbrains.dokka:org.jetbrains.dokka.gradle.plugin:2.1.0") + implementation(libs.plugins.ktlint.toDep()) + implementation(libs.plugins.detekt.toDep()) } + +fun Provider.toDep() = + map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" } diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..aa5e146 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,9 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" diff --git a/build-logic/src/main/kotlin/dsgl-core.conventions.gradle.kts b/build-logic/src/main/kotlin/dsgl-core.conventions.gradle.kts index 6ec3ab9..d9a6774 100644 --- a/build-logic/src/main/kotlin/dsgl-core.conventions.gradle.kts +++ b/build-logic/src/main/kotlin/dsgl-core.conventions.gradle.kts @@ -68,3 +68,7 @@ tasks.named("sourcesJar") { tasks.named("dokkaGeneratePublicationHtml") { dependsOn(generateDsglCoreMetadata) } + +tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }.configureEach { + dependsOn(generateDsglCoreMetadata) +} diff --git a/build-logic/src/main/kotlin/dsgl-linter.conventions.gradle.kts b/build-logic/src/main/kotlin/dsgl-linter.conventions.gradle.kts new file mode 100644 index 0000000..78c3063 --- /dev/null +++ b/build-logic/src/main/kotlin/dsgl-linter.conventions.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("org.jlleitschuh.gradle.ktlint") +} + +ktlint { + version.set("1.5.0") + outputToConsole.set(true) + coloredOutput.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } + filter { + exclude("**/generated/**") + exclude("**/build/**") + include("**/*.kt") + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/dsgl-mc-adapter.conventions.gradle.kts b/build-logic/src/main/kotlin/dsgl-mc-adapter.conventions.gradle.kts index a7db37b..474670c 100644 --- a/build-logic/src/main/kotlin/dsgl-mc-adapter.conventions.gradle.kts +++ b/build-logic/src/main/kotlin/dsgl-mc-adapter.conventions.gradle.kts @@ -1,11 +1,5 @@ -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.tasks.SourceSetContainer -import org.gradle.api.tasks.bundling.Jar -import org.gradle.api.GradleException -import org.gradle.kotlin.dsl.the -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.dreamfinity.buildlogic.toKotlinPackageSegmentFromProjectName -import java.io.File +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import java.util.Properties plugins { @@ -30,13 +24,13 @@ fun readRequiredModuleVersion(file: File): String { val publishEnabled = properties.getProperty("publishEnabled")?.trim()?.let { raw -> raw.toBooleanStrictOrNull() ?: throw GradleException( - "Property 'publishEnabled' in ${file.path} must be true or false." + "Property 'publishEnabled' in ${file.path} must be true or false.", ) } ?: true if (!publishEnabled) { logger.lifecycle( "Skipping DsglAdapterMetadata generation for ${project.path}: " + - "publishEnabled=false and moduleVersion is not set." + "publishEnabled=false and moduleVersion is not set.", ) return "" } @@ -62,7 +56,7 @@ val generateDsglAdapterMetadata = tasks.register("generateDsglAdapterMetadata") } val outputRoot = metadataGeneratedSourcesDir.get().asFile val outputFile = outputRoot.resolve( - packageName.replace('.', '/') + "/DsglAdapterMetadata.kt" + packageName.replace('.', '/') + "/DsglAdapterMetadata.kt", ) outputFile.parentFile.mkdirs() outputFile.writeText( @@ -72,7 +66,7 @@ val generateDsglAdapterMetadata = tasks.register("generateDsglAdapterMetadata") object DsglAdapterMetadata { const val VERSION: String = "$moduleVersion" } - """.trimIndent() + System.lineSeparator() + """.trimIndent() + System.lineSeparator(), ) } } @@ -95,12 +89,18 @@ tasks.named("dokkaGeneratePublicationHtml") { dependsOn(generateDsglAdapterMetadata) } +tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }.configureEach { + dependsOn(generateDsglAdapterMetadata) +} + val devJar = tasks.register("devJar") { + description = "Compiled JAR with deobfuscated names" from(sourceSets["main"].output) archiveClassifier.set("dev") } val devSourcesJar = tasks.register("devSourcesJar") { + description = "Source code JAR with deobfuscated names" dependsOn(generateDsglAdapterMetadata) from(sourceSets["main"].allSource) archiveClassifier.set("dev-sources") diff --git a/build-logic/src/main/kotlin/dsgl-static-analysis.conventions.gradle.kts b/build-logic/src/main/kotlin/dsgl-static-analysis.conventions.gradle.kts new file mode 100644 index 0000000..714505f --- /dev/null +++ b/build-logic/src/main/kotlin/dsgl-static-analysis.conventions.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("io.gitlab.arturbosch.detekt") +} + +//val detektBaselineFile = rootProject.file("config/detekt/baseline-${project.name}.xml") + +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(rootProject.files("config/detekt/detekt.yml")) +// baseline = detektBaselineFile + parallel = true +} + +tasks.withType().configureEach { + jvmTarget = "1.8" +// baseline.set(detektBaselineFile) + reports { + html.required.set(true) + sarif.required.set(true) + xml.required.set(false) + txt.required.set(false) + } + exclude("**/generated/**", "**/build/**") +} + +tasks.withType().configureEach { +// baseline.set(detektBaselineFile) +} + +tasks.withType().configureEach { + jvmTarget = "1.8" +} diff --git a/build.gradle.kts b/build.gradle.kts index 6b7c14a..0daa4d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,8 +8,8 @@ import java.util.zip.DeflaterOutputStream plugins { `java-library` - kotlin("jvm") version "2.3.10" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false + kotlin("jvm") apply false + id("org.jetbrains.kotlin.plugin.serialization") apply false id("org.jetbrains.dokka") version "2.1.0" apply false } @@ -80,7 +80,7 @@ tasks.register("generateMsdfAtlases") { val rgbaArgs = commonArgs + listOf( "-format", "rgba", "-imageout", rgbaAtlasOutArg, - "-json", jsonOutArg + "-json", jsonOutArg, ) println("Generating png atlas for $fontArg") @@ -94,7 +94,7 @@ tasks.register("generateMsdfAtlases") { if (genPNGResult.exitValue != 0) { throw GradleException( "msdf-atlas-gen failed for '$fontArg' with exit code ${genPNGResult.exitValue}. " + - "Expected outputs: '$pngAtlasOutArg', '$jsonOutArg'" + "Expected outputs: '$pngAtlasOutArg', '$jsonOutArg'", ) } @@ -109,7 +109,7 @@ tasks.register("generateMsdfAtlases") { if (genRGBAResult.exitValue != 0) { throw GradleException( "msdf-atlas-gen failed for '$fontArg' with exit code ${genRGBAResult.exitValue}. " + - "Expected outputs: '$pngAtlasOutArg', '$jsonOutArg'" + "Expected outputs: '$pngAtlasOutArg', '$jsonOutArg'", ) } @@ -191,7 +191,7 @@ tasks.register("compressMsdfRgbaAtlases") { tmpOut.toPath(), out.toPath(), StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE + StandardCopyOption.ATOMIC_MOVE, ) logger.lifecycle("Flip+deflate: ${rgba.name} -> ${out.name} (${rgba.length()} -> ${out.length()} bytes)") @@ -225,7 +225,7 @@ data class BumpConfig( val versionFile: File, val versionKey: String = "version", val syncedKeys: List = emptyList(), - val publishTaskPath: String + val publishTaskPath: String, ) data class BumpSummary( @@ -235,7 +235,7 @@ data class BumpSummary( val newVersion: String, val updatedKeys: List, val publishedModules: List, - val coordinates: List + val coordinates: List, ) val semVerRegex = Regex("""^(\d+)\.(\d+)\.(\d+)$""") @@ -244,7 +244,7 @@ val coreBumpConfig = BumpConfig( projectPath = ":core", versionFile = rootProject.file("core/gradle.properties"), versionKey = "moduleVersion", - publishTaskPath = ":core:publishToMavenLocal" + publishTaskPath = ":core:publishToMavenLocal", ) val mc1710BumpConfig = BumpConfig( target = BumpTarget.MC1710, @@ -252,13 +252,13 @@ val mc1710BumpConfig = BumpConfig( versionFile = rootProject.file("adapters/mc-forge-1-7-10/gradle.properties"), versionKey = "moduleVersion", syncedKeys = listOf("modVersion"), - publishTaskPath = ":adapters:mc-forge-1-7-10:publishToMavenLocal" + publishTaskPath = ":adapters:mc-forge-1-7-10:publishToMavenLocal", ) fun parseVersion(version: String): Triple { val match = semVerRegex.matchEntire(version) ?: throw GradleException( - "Version '$version' is not strict SemVer (MAJOR.MINOR.PATCH integers only)." + "Version '$version' is not strict SemVer (MAJOR.MINOR.PATCH integers only).", ) val major = match.groupValues[1].toInt() val minor = match.groupValues[2].toInt() @@ -315,7 +315,7 @@ fun ensurePublishTaskConfigured(config: BumpConfig) { if (project == null || project.tasks.findByName(taskName) == null) { throw GradleException( "Publishing is not configured for '${config.publishTaskPath}'. " + - "Ensure maven-publish and publishToMavenLocal are configured for ${config.projectPath}." + "Ensure maven-publish and publishToMavenLocal are configured for ${config.projectPath}.", ) } } @@ -330,7 +330,7 @@ fun applyRuntimeVersion(config: BumpConfig, newVersion: String): Pair() @@ -342,7 +342,7 @@ fun applyRuntimeVersion(config: BumpConfig, newVersion: String): Pair $newVersion") @@ -449,3 +449,51 @@ tasks.register("runDemoClient") { description = "Run Minecraft client with DSGL showcase demo module." dependsOn(":adapters:mc-forge-1-7-10:demo:runClient") } + +tasks.register("detektAll") { + group = "verification" + description = "Run detekt across all modules that have detekt applied" + + doLast { + val violatingModules = project.subprojects + .filter { it.plugins.hasPlugin("io.gitlab.arturbosch.detekt") } + .filter { subproject -> + val sarifFile = subproject.file("build/reports/detekt/detekt.sarif") + sarifFile.exists() && sarifFile.readText().contains("\"ruleId\"") + } + .map { it.path } + + if (violatingModules.isNotEmpty()) { + throw GradleException( + "detekt found violations in: ${violatingModules.joinToString(", ")}. " + + "See each module's build/reports/detekt/ for details.", + ) + } + } +} + +subprojects { + plugins.withId("io.gitlab.arturbosch.detekt") { + val detektTask = tasks.named("detekt") + rootProject.tasks.named("detektAll").configure { + dependsOn(detektTask) + } + gradle.taskGraph.whenReady { + if (hasTask(":detektAll")) { + detektTask.configure { + (this as? VerificationTask)?.ignoreFailures = true + } + } + } + } +} + +tasks.register("installGitHooks") { + group = "setup" + description = "Configure git to use hooks from .githooks/. Run once after cloning." + commandLine("git", "config", "core.hooksPath", ".githooks") + doLast { + fileTree(".githooks").forEach { it.setExecutable(true) } + logger.lifecycle("Git hooks installed. Hook path set to .githooks/") + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..d97ee2a --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,795 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + ignoreDefaultCompanionObject: false + UndocumentedPublicFunction: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [ ] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 80 # DSL builder functions are naturally long + LongParameterList: + active: true + functionThreshold: 10 # DSL props objects have many params + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [ ] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + thresholdInFiles: 25 # UI DSLs have lots of small functions + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + ignoreAnnotatedFunctions: [ ] + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [ ] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + FunctionParameterNaming: + active: true + parameterPattern: '_?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + SpreadOperator: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [ ] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [ ] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: [ '**/*.kts' ] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [ ] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [ ] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [ ] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + ignoreNumbers: [ '-1', '0', '1', '2', '3', '4', '8', '10', '16', '100', '255' ] + ignorePropertyDeclaration: true + ignoreEnums: true + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ] + ignoreHashCodeFunction: true + ignoreLocalVariableDeclaration: false + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludes: + - "**/test/**" + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: true + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 6 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [ ] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected|_.*' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: false + excludeImports: + - 'java.util.*' diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 7dc5fd7..fcd8aaf 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,5 +1,7 @@ plugins { id("dsgl-core.conventions") + id("dsgl-linter.conventions") + id("dsgl-static-analysis.conventions") id("dsgl-releaseable-module.conventions") id("org.jetbrains.kotlin.plugin.serialization") jacoco @@ -11,9 +13,10 @@ dependencies { testImplementation(kotlin("test")) } -val minLineCoverageRatio = providers - .gradleProperty("dsgl.jacoco.min.line") - .orElse("0.35") +val minLineCoverageRatio = + providers + .gradleProperty("dsgl.jacoco.min.line") + .orElse("0.35") jacoco { toolVersion = "0.8.12" @@ -51,13 +54,14 @@ tasks.check { } tasks.processResources { - val allowedCoreFontBases = setOf( - "fonts/minecraft/MinecraftDefault-Regular", - "fonts/ubuntu/Ubuntu-Regular", - "fonts/noto/Noto_Sans/NotoSans-Regular", - "fonts/jetbrains_mono/JetBrainsMono-Regular", - "fonts/telegrafico/telegrafico" - ) + val allowedCoreFontBases = + setOf( + "fonts/minecraft/MinecraftDefault-Regular", + "fonts/ubuntu/Ubuntu-Regular", + "fonts/noto/Noto_Sans/NotoSans-Regular", + "fonts/jetbrains_mono/JetBrainsMono-Regular", + "fonts/telegrafico/telegrafico", + ) eachFile { if (isDirectory || !path.startsWith("fonts/")) { @@ -65,17 +69,18 @@ tasks.processResources { } val isAllowedFontArtifact = path.endsWith(".ttf") || - path.endsWith(".json") || - path.endsWith(".rgba.deflate") + path.endsWith(".json") || + path.endsWith(".rgba.deflate") if (!isAllowedFontArtifact) { exclude() return@eachFile } - val isAllowedCoreFontArtifact = allowedCoreFontBases.any { base -> - path == "$base.ttf" || + val isAllowedCoreFontArtifact = + allowedCoreFontBases.any { base -> + path == "$base.ttf" || path == "$base-meta.json" || path == "$base-mtsdf.rgba.deflate" - } + } if (!isAllowedCoreFontArtifact) { exclude() } diff --git a/core/detekt-baseline.xml b/core/detekt-baseline.xml new file mode 100644 index 0000000..af3c86c --- /dev/null +++ b/core/detekt-baseline.xml @@ -0,0 +1,454 @@ + + + + + ComplexCondition:ColorPickerInlineNode.kt$ColorPickerInlineNode$!force && currentArgb == syncedColorArgb && previousArgb == syncedPreviousArgb && mode == syncedMode && alphaEnabled == syncedAlphaEnabled && closeOnSelect == syncedCloseOnSelect + ComplexCondition:ColorPickerPopupEngine.kt$ColorPickerPopupEngine$previous.anchorRect != request.anchorRect || previous.width != request.width || previous.style != request.style || previous.state.alphaEnabled != request.state.alphaEnabled + ComplexCondition:DOMNode.kt$DOMNode$border.top <= 0 && border.right <= 0 && border.bottom <= 0 && border.left <= 0 + ComplexCondition:DOMNode.kt$DOMNode$relativeOffsetX == 0 && relativeOffsetY == 0 && stickyOffsetX == 0 && stickyOffsetY == 0 + ComplexCondition:DomTree.kt$DomTree$( !laidOut || styleReport.layoutDirty || scrollInvalidation.layoutDirty || requiresIsolatedScopeScrollLayoutFallback ) && lastWidth > 0 && lastHeight > 0 + ComplexCondition:FontRegistry.kt$FontRegistry$end > start && end < text.length && Character.isLowSurrogate(text[end]) && Character.isHighSurrogate(text[end - 1]) + ComplexCondition:FontRegistry.kt$FontRegistry$start > 0 && start < text.length && Character.isLowSurrogate(text[start]) && Character.isHighSurrogate(text[start - 1]) + ComplexCondition:InspectorController.kt$InspectorController$!dragMoved && ( kotlin.math.abs(resized.width - dragStartRect.width) >= 2 || kotlin.math.abs(resized.height - dragStartRect.height) >= 2 || kotlin.math.abs(resized.x - dragStartRect.x) >= 2 || kotlin.math.abs(resized.y - dragStartRect.y) >= 2 ) + ComplexCondition:InspectorController.kt$InspectorController$!hoverDirty && lastHoverMouseX == mouseX && lastHoverMouseY == mouseY && lastHoverLayoutVersion == layoutVersion + ComplexCondition:InspectorController.kt$InspectorController$a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0 + ComplexCondition:InspectorController.kt$InspectorController$cached != null && cached.key == key && cached.nodeClass == klass && cached.layoutVersion == layoutVersion + ComplexCondition:InspectorController.kt$InspectorController$endedMode == DragMode.MinimizedMove && clickLike && panelState == InspectorPanelState.Minimized && minimizedBounds.contains(mouseX, mouseY) + ComplexCondition:InspectorController.kt$InspectorController$numberText.isEmpty() || numberText == "-" || numberText == "." || numberText == "-." + ComplexCondition:SurfaceDomInputRouter.kt$SurfaceDomInputRouter$node.onMouseDown != null || node.onMouseUp != null || node.onMouseClick != null || node.onMouseDrag != null || node.onMouseWheel != null || node.onMouseMove != null || node.onKeyDown != null || node.onKeyUp != null + ComplexCondition:ModalPortalSessionStore.kt$ModalPortalSessionStore$(needsFocusOnTop || (topMost.trapFocus && focusOutsideTop)) && !FocusManager.requestFocusFirstInSubtree(topDialogKey) + ComplexCondition:SelectNode.kt$SelectNode$!controlled && uncontrolledValue == null && value != null && optionExists(value) + ComplexCondition:SingleLineInputNode.kt$SingleLineInputNode$!showPlaceholder && focused && !styleDisabled && editState.isCaretVisible(caretBlinkPeriodMs) + ComplexCondition:StyleSelector.kt$StyleSelector.Companion$index < token.length && (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-') + ComplexCondition:StyleSelector.kt$StyleSelector.Companion$index < trimmed.length && !trimmed[index].isWhitespace() && trimmed[index] != '>' && trimmed[index] != '+' && trimmed[index] != '~' + ComplexCondition:SystemColorPickerCustomSurfaceNodes.kt$ColorFieldSurfaceNode$style != typedTemplate.style || hueDeg != typedTemplate.hueDeg || saturation != typedTemplate.saturation || brightness != typedTemplate.brightness + ComplexCondition:SystemColorPickerCustomSurfaceNodes.kt$ColorFieldSurfaceNode$this.style != style || this.hueDeg != hueDeg || saturation != nextSaturation || brightness != nextBrightness + ComplexCondition:SystemColorPickerCustomSurfaceNodes.kt$EyedropperMagnifierDrawNode$this.columns != nextColumns || this.rows != nextRows || this.magnification != nextMagnification || this.gridEnabled != gridEnabled || this.gridColor != gridColor + ComplexCondition:TextAreaNode.kt$TextAreaNode$!showPlaceholder && focused && !styleDisabled && editState.isCaretVisible(caretBlinkPeriodMs) + ComplexCondition:TextDecorationLayout.kt$TextDecorationLayout$last != null && last.type == quad.type && last.lineIndex == quad.lineIndex && kotlin.math.abs(last.xEnd - quad.xStart) <= 0.51f && kotlin.math.abs(last.y - quad.y) <= 0.51f && kotlin.math.abs(last.thickness - quad.thickness) <= 0.1f && last.color == quad.color + CyclomaticComplexMethod:ColorPickerController.kt$ColorPickerController$fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean + CyclomaticComplexMethod:ColorPickerController.kt$ColorPickerController$fun handleMouseDown( globalX: Int, globalY: Int, button: MouseButton, layout: ColorPickerLayout, ): Boolean + CyclomaticComplexMethod:ColorPickerController.kt$ColorPickerController$private fun applyInputDraftValue(key: String, rawValue: String): Boolean + CyclomaticComplexMethod:ColorTextCodec.kt$ColorTextCodec$private fun parseHsbLike(raw: String): RgbaColor? + CyclomaticComplexMethod:ColorTextCodec.kt$ColorTextCodec$private fun parseRgbLike(raw: String): ParsedColorText? + CyclomaticComplexMethod:ComponentHookRuntime.kt$ComponentHookRuntime$private fun applyEffectCommitBatch(batch: PendingEffectCommitBatch) + CyclomaticComplexMethod:ContainerNode.kt$ContainerNode$private fun measureBlock(ctx: UiMeasureContext, children: List<DOMNode>, wrapWidth: Int?): Size + CyclomaticComplexMethod:ContainerNode.kt$ContainerNode$private fun measureWithConstraint(ctx: UiMeasureContext, constrainedContentWidth: Int?): Size + CyclomaticComplexMethod:ContainerNode.kt$ContainerNode$private fun renderFlex(ctx: UiMeasureContext, children: List<DOMNode>) + CyclomaticComplexMethod:ContainerNode.kt$ContainerNode$private fun renderGrid(ctx: UiMeasureContext, children: List<DOMNode>) + CyclomaticComplexMethod:ContextMenuEngine.kt$ContextMenuEngine$@Suppress("LoopWithTooManyJumpStatements") private fun ensureLayout() + CyclomaticComplexMethod:ContextMenuEngine.kt$ContextMenuEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + CyclomaticComplexMethod:ContextMenuEngine.kt$ContextMenuEngine$fun handleKeyDown(keyCode: Int): Boolean + CyclomaticComplexMethod:ContextMenuMeasurementCache.kt$ContextMenuMeasurementCache$fun measure( menuToken: Long, entries: List<MenuEntry>, style: ContextMenuStyle, fontId: String?, fontSize: Int?, ctx: UiMeasureContext, dpiScale: Float, ): Measurement + CyclomaticComplexMethod:CssLength.kt$fun parseCssLength(raw: String, allowUnitlessZero: Boolean = true): CssLength + CyclomaticComplexMethod:DOMNode.kt$DOMNode$fun scrollContainerState(): ScrollContainerState + CyclomaticComplexMethod:DOMNode.kt$DOMNode$internal fun applyComputedStyle(style: ComputedStyle): NodeStyleApplyResult + CyclomaticComplexMethod:DOMNode.kt$DOMNode$internal fun restoreScrollSessionSnapshot(snapshot: ScrollSessionSnapshot?) + CyclomaticComplexMethod:DOMNode.kt$DOMNode$private fun advanceScrollAnimationAxis( scrollContainer: Boolean, maxScroll: Int, vertical: Boolean, dtSeconds: Double, ): Boolean + CyclomaticComplexMethod:DOMNode.kt$DOMNode$private fun resolveScrollbarResolution( overflowX: Overflow, overflowY: Overflow, contentExtent: Size, baseViewportWidth: Int, baseViewportHeight: Int, ): ScrollbarResolution + CyclomaticComplexMethod:DefaultDndEngine.kt$DefaultDndEngine$private fun shiftCommand(command: RenderCommand, dx: Int, dy: Int): RenderCommand + CyclomaticComplexMethod:DomTree.kt$DomTree$fun paint(ctx: UiMeasureContext, applyStyles: Boolean = true): List<RenderCommand> + CyclomaticComplexMethod:DssParser.kt$DssParser$@Suppress("ThrowsCount") private fun parseDeclarations( sourceName: String, text: String, fromIndex: Int, declarations: StyleDeclarations, rootVars: MutableMap<String, String>, allowVariables: Boolean, warnings: ParseWarnings, ): Int + CyclomaticComplexMethod:EventBus.kt$EventBus$fun post(event: Event) + CyclomaticComplexMethod:FontRegistry.kt$FontRegistry$private fun buildShapingSegments( text: String, startIndex: Int, endIndexExclusive: Int, primary: LoadedMsdfFont, fallback: LoadedMsdfFont?, ): List<MutableShapingSegment> + CyclomaticComplexMethod:InlineLayoutTests.kt$InlineLayoutTests$private fun createDisplayInlineScenario(rootWidth: Int, inlineContentWidth: Int): Pair<ContainerNode, ContainerNode> + CyclomaticComplexMethod:InspectorController.kt$InspectorController$fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + CyclomaticComplexMethod:InspectorController.kt$InspectorController$private fun applyStyleEdit( selected: DOMNode, property: StyleProperty, operation: EditOperation, step: Float, payload: String?, actionBounds: Rect, ) + CyclomaticComplexMethod:InspectorController.kt$InspectorController$private fun buildExpandedDomSnapshot( root: DOMNode, viewportWidth: Int, viewportHeight: Int, ): InspectorDomSnapshot + CyclomaticComplexMethod:InspectorController.kt$InspectorController$private fun literalFromComputed(style: ComputedStyle, property: StyleProperty): String + CyclomaticComplexMethod:InspectorController.kt$InspectorController$private fun updateExpandedDrag( mouseX: Int, mouseY: Int, viewportWidth: Int, viewportHeight: Int, ) + CyclomaticComplexMethod:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$fun build(context: InspectorStyleEditorSnapshotBuildContext): InspectorStyleEditorSnapshotBuildResult + CyclomaticComplexMethod:KeyInput.kt$KeyInput$fun applyShift(ch: Char, shiftDown: Boolean): Char + CyclomaticComplexMethod:MinecraftFormattingParser.kt$MinecraftFormattingParser$@Suppress("LoopWithTooManyJumpStatements") private fun parseMinecraft(text: String): ParsedText + CyclomaticComplexMethod:ModalDsl.kt$fun UiScope.modalPortal(modals: List<ModalSpec>, modalKey: String = "modal.host", content: UiScope.() -> Unit) + CyclomaticComplexMethod:MsdfFontMetaParser.kt$MsdfFontMetaParser$fun parse(rawJson: String): MsdfFontMeta + CyclomaticComplexMethod:FloatingPanel.kt$FloatingPanel$private fun buildResizedRect(viewportWidth: Int, viewportHeight: Int): Rect + CyclomaticComplexMethod:SelectEngine.kt$SelectEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + CyclomaticComplexMethod:SingleLineInputNode.kt$SingleLineInputNode$override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList<RenderCommand>) + CyclomaticComplexMethod:StyleEngine.kt$StyleEngine$private fun applyProperty( current: ComputedStyle, parentComputed: ComputedStyle?, property: StyleProperty, expression: StyleExpression, variables: Map<String, String>, rootFontSizePx: Int, ): ComputedStyle + CyclomaticComplexMethod:StyleEngine.kt$StyleEngine$private fun selectorLabel(selector: StyleSelector): String + CyclomaticComplexMethod:StyleEngine.kt$StyleEngine$private fun selectorPartMatches(node: DOMNode, part: StyleSelectorPart): Boolean + CyclomaticComplexMethod:StyleSelector.kt$StyleSelector.Companion$@Suppress("LoopWithTooManyJumpStatements") fun parse(rawSelector: String): StyleSelector + CyclomaticComplexMethod:StyleSelector.kt$StyleSelector.Companion$private fun parsePartToken(token: String, rawSelector: String): StyleSelectorPart + CyclomaticComplexMethod:StyleValueParsing.kt$fun validateLiteralForProperty( property: StyleProperty, literal: String, warningReporter: StyleWarningReporter? = null, deprecatedLengthWarningKey: String = "deprecated.unitless-length", ) + CyclomaticComplexMethod:StylesheetManager.kt$StylesheetManager$@Synchronized fun pollForChanges(force: Boolean = false) + CyclomaticComplexMethod:SystemPortalInspectorNativeEntryTests.kt$SystemPortalInspectorNativeEntryTests$@Test fun `inspector clipped body blocks hidden row input and accepts visible portion`() + CyclomaticComplexMethod:SystemPortalInspectorNativeEntryTests.kt$SystemPortalInspectorNativeEntryTests$@Test fun `inspector native body content remains clipped in narrow viewport`() + CyclomaticComplexMethod:TextAreaNode.kt$TextAreaNode$override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList<RenderCommand>) + CyclomaticComplexMethod:TextAreaNode.kt$TextAreaNode$private fun handleKey(event: KeyboardKeyDownEvent) + CyclomaticComplexMethod:TextEditShortcutDispatcher.kt$TextEditShortcutDispatcher$fun dispatch(event: KeyboardKeyDownEvent, callbacks: TextShortcutCallbacks): Boolean + LargeClass:ColorPickerController.kt$ColorPickerController + LargeClass:ColorPickerPopupEngineTests.kt$ColorPickerPopupEngineTests + LargeClass:ComponentHookRuntime.kt$ComponentHookRuntime + LargeClass:ContainerNode.kt$ContainerNode : DOMNode + LargeClass:ContextMenuEngine.kt$ContextMenuEngine : ContextMenuPortalService + LargeClass:DOMNode.kt$DOMNode + LargeClass:DefaultDndEngine.kt$DefaultDndEngine : DndEngine + LargeClass:FontRegistry.kt$FontRegistry + LargeClass:InspectorController.kt$InspectorController + LargeClass:PositionedLayoutStickyBehaviorTests.kt$PositionedLayoutStickyBehaviorTests + LargeClass:SelectEngine.kt$SelectEngine + LargeClass:StyleEngine.kt$StyleEngine + LargeClass:StyleScope.kt$StyleScope : CssLengthUnitsDsl + LargeClass:SystemColorPickerPopupBodyNode.kt$SystemColorPickerPopupBodyNode : DOMNode + LargeClass:SystemInspectorPortalNode.kt$SystemInspectorPortalNode : DOMNode + LargeClass:SystemPortalColorPickerEntryTests.kt$SystemPortalColorPickerEntryTests + LargeClass:SystemPortalInspectorNativeEntryTests.kt$SystemPortalInspectorNativeEntryTests + LargeClass:TextAreaNode.kt$TextAreaNode : DOMNode + LargeClass:TextPerformanceHotPathCharacterizationTests.kt$TextPerformanceHotPathCharacterizationTests + LongMethod:ColorPickerController.kt$ColorPickerController$fun appendCommands( layout: ColorPickerLayout, out: MutableList<RenderCommand>, nowMs: Long = System.currentTimeMillis(), ) + LongMethod:ColorPickerController.kt$ColorPickerController$fun appendEyedropperPreview(viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>) + LongMethod:ColorPickerController.kt$ColorPickerController$fun buildLayout(bounds: Rect): ColorPickerLayout + LongMethod:ColorPickerController.kt$ColorPickerController$fun handleMouseDown( globalX: Int, globalY: Int, button: MouseButton, layout: ColorPickerLayout, ): Boolean + LongMethod:ComponentHookRuntime.kt$ComponentHookRuntime$private fun applyEffectCommitBatch(batch: PendingEffectCommitBatch) + LongMethod:ContainerNode.kt$ContainerNode$private fun renderFlex(ctx: UiMeasureContext, children: List<DOMNode>) + LongMethod:ContainerNode.kt$ContainerNode$private fun renderGrid(ctx: UiMeasureContext, children: List<DOMNode>) + LongMethod:ContextMenuEngine.kt$ContextMenuEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + LongMethod:ContextMenuMeasurementCache.kt$ContextMenuMeasurementCache$fun measure( menuToken: Long, entries: List<MenuEntry>, style: ContextMenuStyle, fontId: String?, fontSize: Int?, ctx: UiMeasureContext, dpiScale: Float, ): Measurement + LongMethod:DOMNode.kt$DOMNode$@Suppress("UnusedParameter") internal fun resolveLayoutStyleValues(ctx: UiMeasureContext, parentContentWidth: Int?, parentContentHeight: Int?) + LongMethod:DOMNode.kt$DOMNode$fun scrollContainerState(): ScrollContainerState + LongMethod:DOMNode.kt$DOMNode$internal fun applyComputedStyle(style: ComputedStyle): NodeStyleApplyResult + LongMethod:DOMNode.kt$DOMNode$internal fun syncBaseFrom(template: DOMNode) + LongMethod:DndHooks.kt$internal fun DsglWindow.useSortable( id: String, nodeKey: Any = id, containerId: String, items: List<String>, data: Any? = null, previewMode: DragPreviewMode = DragPreviewMode.ORIGINAL, hideSourceWhileDragging: Boolean = true, ): Sortable + LongMethod:DomTree.kt$DomTree$fun paint(ctx: UiMeasureContext, applyStyles: Boolean = true): List<RenderCommand> + LongMethod:DssParser.kt$DssParser$@Suppress("ThrowsCount") private fun parseDeclarations( sourceName: String, text: String, fromIndex: Int, declarations: StyleDeclarations, rootVars: MutableMap<String, String>, allowVariables: Boolean, warnings: ParseWarnings, ): Int + LongMethod:InlineLayoutTests.kt$InlineLayoutTests$private fun createDisplayInlineScenario(rootWidth: Int, inlineContentWidth: Int): Pair<ContainerNode, ContainerNode> + LongMethod:InspectorController.kt$InspectorController$private fun buildExpandedDomSnapshot( root: DOMNode, viewportWidth: Int, viewportHeight: Int, ): InspectorDomSnapshot + LongMethod:InspectorController.kt$InspectorController$private fun literalFromComputed(style: ComputedStyle, property: StyleProperty): String + LongMethod:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$fun build(context: InspectorStyleEditorSnapshotBuildContext): InspectorStyleEditorSnapshotBuildResult + LongMethod:ModalDsl.kt$fun UiScope.modalPortal(modals: List<ModalSpec>, modalKey: String = "modal.host", content: UiScope.() -> Unit) + LongMethod:MsdfFontMetaParser.kt$MsdfFontMetaParser$fun parse(rawJson: String): MsdfFontMeta + LongMethod:FloatingPanel.kt$FloatingPanel$private fun buildResizedRect(viewportWidth: Int, viewportHeight: Int): Rect + LongMethod:PositionedLayoutStickyBehaviorTests.kt$PositionedLayoutStickyBehaviorTests$@Test fun `non-sticky positioned modes remain unchanged with sticky enabled`() + LongMethod:SelectEngine.kt$SelectEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + LongMethod:StyleEngine.kt$StyleEngine$private fun applyProperty( current: ComputedStyle, parentComputed: ComputedStyle?, property: StyleProperty, expression: StyleExpression, variables: Map<String, String>, rootFontSizePx: Int, ): ComputedStyle + LongMethod:StyleValueParsing.kt$fun validateLiteralForProperty( property: StyleProperty, literal: String, warningReporter: StyleWarningReporter? = null, deprecatedLengthWarningKey: String = "deprecated.unitless-length", ) + LongMethod:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$private fun renderExpanded(ctx: UiMeasureContext, snapshot: InspectorDomSnapshot, viewportRect: Rect) + LongMethod:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$private fun renderHighlights(scope: UiScope, ctx: UiMeasureContext) + LongMethod:SystemPortalInspectorNativeEntryTests.kt$SystemPortalInspectorNativeEntryTests$@Test fun `inspector clipped body blocks hidden row input and accepts visible portion`() + LongMethod:SystemPortalInspectorNativeEntryTests.kt$SystemPortalInspectorNativeEntryTests$@Test fun `inspector wheel scrolling remains symmetric across rebuilds`() + LongMethod:TextPerformanceHotPathCharacterizationTests.kt$TextPerformanceHotPathCharacterizationTests$@Test fun `cache boundaries are explicit for wrapped layout paths`() + LongParameterList:ColorPickerInlineNode.kt$ColorPickerInlineNode$( controlled: Boolean = false, value: RgbaColor? = null, defaultValue: RgbaColor = RgbaColor.WHITE, previousValue: RgbaColor? = null, mode: ColorFormatMode = ColorFormatMode.HEX, alphaEnabled: Boolean = true, key: Any? = null, ) + LongParameterList:ColorPickerPopupPaneNode.kt$ColorPickerPopupPaneNode$( controlled: Boolean = false, value: RgbaColor? = null, defaultValue: RgbaColor = RgbaColor.WHITE, previousValue: RgbaColor? = null, mode: ColorFormatMode = ColorFormatMode.HEX, alphaEnabled: Boolean = true, key: Any? = null, ) + LongParameterList:ColorPickerPopupEngine.kt$ColorPickerPopupManager$( ownerDomain: ScreenDomainId = ScreenDomainId.Application, anchorRect: Rect, title: String, state: ColorPickerState, style: ColorPickerStyle = ColorPickerStyle(), width: Int = 320, draggable: Boolean = true, closeOnOutsideClick: Boolean = false, onPreview: ((RgbaColor) -> Unit)? = null, onChange: ((RgbaColor) -> Unit)? = null, onCommit: ((RgbaColor) -> Unit)? = null, onClose: (() -> Unit)? = null, ) + LongParameterList:ComponentProps.kt$ComponentProps$( var style: StyleScope.() -> Unit = {}, var key: Any? = null, var id: String? = null, var className: String = "", var classes: Set<String> = emptySet(), var overlapChildren: Boolean = false, var disabled: Boolean = false, var draggable: Boolean = false, var droppable: Boolean = false, var dragPreviewMode: DragPreviewMode = DragPreviewMode.GHOST, var hideSourceWhileDragging: Boolean = false, var dragPreview: (DragPreviewScope.() -> Unit)? = null, var dragPlaceholder: (PlaceholderScope.() -> Unit)? = null, var ref: RefTarget<ElementHandle>? = null, var onMouseEnter: ((MouseEnterEvent) -> Unit)? = null, var onMouseLeave: ((MouseLeaveEvent) -> Unit)? = null, var onMouseOver: ((MouseOverEvent) -> Unit)? = null, var onMouseMove: ((MouseMoveEvent) -> Unit)? = null, var onMouseDown: ((MouseDownEvent) -> Unit)? = null, var onMouseUp: ((MouseUpEvent) -> Unit)? = null, var onMouseClick: ((MouseClickEvent) -> Unit)? = null, var onMouseDrag: ((MouseDragEvent) -> Unit)? = null, var onMouseWheel: ((MouseWheelEvent) -> Unit)? = null, var onKeyDown: ((KeyboardKeyDownEvent) -> Unit)? = null, var onKeyUp: ((KeyboardKeyUpEvent) -> Unit)? = null, var onKeyPressed: ((KeyboardKeyDownEvent) -> Unit)? = null, var onKeyReleased: ((KeyboardKeyUpEvent) -> Unit)? = null, var onFocusGain: ((FocusGainEvent) -> Unit)? = null, var onFocusLose: ((FocusLoseEvent) -> Unit)? = null, var onInput: ((InputEvent) -> Unit)? = null, var onValueChange: ((ValueChangedEvent) -> Unit)? = null, var onDragStart: ((DragStartEvent) -> Unit)? = null, var onDrag: ((DragEvent) -> Unit)? = null, var onDragEnd: ((DragEndEvent) -> Unit)? = null, var onDragEnter: ((DragEnterEvent) -> Unit)? = null, var onDragOver: ((DragOverEvent) -> Unit)? = null, var onDragLeave: ((DragLeaveEvent) -> Unit)? = null, var onDrop: ((DropEvent) -> Unit)? = null, ) + LongParameterList:ContainerNode.kt$ContainerNode$( ctx: UiMeasureContext, child: DOMNode, parentContentX: Int, parentContentY: Int, parentContentWidth: Int, parentContentHeight: Int, desiredX: Int, desiredY: Int, desiredWidth: Int, desiredHeight: Int, ) + LongParameterList:DndHooks.kt$( id: String, nodeKey: Any = id, type: String = "default", data: Any? = null, previewMode: DragPreviewMode = DragPreviewMode.GHOST, hideSourceWhileDragging: Boolean = false, renderPreview: (DragPreviewScope.() -> Unit)? = null, renderPlaceholder: (PlaceholderScope.() -> Unit)? = null, onDragStart: ((DragStartEvent) -> Unit)? = null, onDrag: ((DragEvent) -> Unit)? = null, onDragEnd: ((DragEndEvent) -> Unit)? = null, ) + LongParameterList:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$( x: Int, y: Int, width: Int, options: List<String>, property: StyleProperty, unitSelect: Boolean, pointerProjectionScrollY: Int, rowHeightPx: Int, viewportWidth: Int, viewportHeight: Int, mouseX: Int, mouseY: Int, currentScrollIndex: Int, ) + LongParameterList:InspectorStyleEditorSnapshotBuilderTests.kt$InspectorStyleEditorSnapshotBuilderTests$( selected: ContainerNode, inspection: org.dreamfinity.dsgl.core.style.StyleInspection, panelRect: Rect = Rect(20, 20, 360, 260), editableProperties: List<StyleProperty>, pointerProjectionScrollY: Int = 0, mouseX: Int = 180, mouseY: Int = 120, openValueSelectProperty: StyleProperty? = null, openUnitSelectProperty: StyleProperty? = null, openValueSelectScrollIndex: Int = 0, openUnitSelectScrollIndex: Int = 0, ) + LongParameterList:SelectNode.kt$SelectNode$( model: SelectModel, controlled: Boolean = false, value: String? = null, defaultValue: String? = null, closeOnSelect: Boolean = true, ownerDomain: ScreenDomainId = ScreenDomainId.Application, key: Any? = null, ) + LongParameterList:SystemColorPickerPanelManager.kt$SystemColorPickerPortalService$( anchorRect: Rect, title: String, state: ColorPickerState, style: ColorPickerStyle = ColorPickerStyle(), width: Int = 320, draggable: Boolean = true, closeOnOutsideClick: Boolean = false, onPreview: ((RgbaColor) -> Unit)? = null, onChange: ((RgbaColor) -> Unit)? = null, onCommit: ((RgbaColor) -> Unit)? = null, onClose: (() -> Unit)? = null, ) + MagicNumber:AnimationModel.kt$ColorAnimatable$0xFF + MagicNumber:AnimationModel.kt$ColorAnimatable$24 + MagicNumber:AnimationModel.kt$TransformAnimatable$180f + MagicNumber:AnimationModel.kt$TransformAnimatable$360f + MagicNumber:CheckboxGroupNode.kt$CheckboxGroupNode$6 + MagicNumber:ColorPickerController.kt$ColorPickerController$0x33222A34 + MagicNumber:ColorPickerController.kt$ColorPickerController$12 + MagicNumber:ColorPickerController.kt$ColorPickerController$156 + MagicNumber:ColorPickerController.kt$ColorPickerController$20 + MagicNumber:ColorPickerController.kt$ColorPickerController$24 + MagicNumber:ColorPickerController.kt$ColorPickerController$32 + MagicNumber:ColorPickerController.kt$ColorPickerController$360f + MagicNumber:ColorPickerController.kt$ColorPickerController$40 + MagicNumber:ColorPickerController.kt$ColorPickerController$44 + MagicNumber:ColorPickerController.kt$ColorPickerController$5 + MagicNumber:ColorPickerController.kt$ColorPickerController$500L + MagicNumber:ColorPickerController.kt$ColorPickerController$6 + MagicNumber:ColorPickerController.kt$ColorPickerController$64 + MagicNumber:ColorPickerController.kt$ColorPickerController$7 + MagicNumber:ColorPickerController.kt$ColorPickerController$84 + MagicNumber:ColorPickerPopupGeometry.kt$ColorPickerPopupGeometry$20 + MagicNumber:ColorPickerPopupPaneNode.kt$ColorPickerPopupPaneNode$120 + MagicNumber:ColorPickerPopupPaneNode.kt$ColorPickerPopupPaneNode$24 + MagicNumber:ColorPickerPopupPaneNode.kt$ColorPickerPopupPaneNode$42 + MagicNumber:ColorPickerPopupEngine.kt$ColorPickerPopupEngine$18 + MagicNumber:ColorPickerPopupEngine.kt$ColorPickerPopupEngine$20 + MagicNumber:ColorPickerPopupEngine.kt$ColorPickerPopupEngine$220 + MagicNumber:ColorTextCodec.kt$ColorTextCodec$0.5f + MagicNumber:ColorTextCodec.kt$ColorTextCodec$0xFF + MagicNumber:ColorTextCodec.kt$ColorTextCodec$1000f + MagicNumber:ColorTextCodec.kt$ColorTextCodec$1e-6f + MagicNumber:ColorTextCodec.kt$ColorTextCodec$24 + MagicNumber:ColorTextCodec.kt$ColorTextCodec$360 + MagicNumber:ColorTextCodec.kt$ColorTextCodec$360f + MagicNumber:ColorTextCodec.kt$ColorTextCodec$6 + MagicNumber:ColorTypes.kt$ColorConversions$0.5f + MagicNumber:ColorTypes.kt$ColorConversions$120f + MagicNumber:ColorTypes.kt$ColorConversions$1e-6f + MagicNumber:ColorTypes.kt$ColorConversions$240f + MagicNumber:ColorTypes.kt$ColorConversions$360f + MagicNumber:ColorTypes.kt$ColorConversions$5f + MagicNumber:ColorTypes.kt$ColorConversions$60f + MagicNumber:ColorTypes.kt$ColorConversions$6f + MagicNumber:ColorTypes.kt$HslColor$360f + MagicNumber:ColorTypes.kt$HsvColor$360f + MagicNumber:ColorTypes.kt$RgbaColor$0.5f + MagicNumber:ColorTypes.kt$RgbaColor$24 + MagicNumber:ColorTypes.kt$RgbaColor.Companion$0xFF + MagicNumber:ColorTypes.kt$RgbaColor.Companion$24 + MagicNumber:ContainerNode.kt$ContainerNode$0xFFFF_FFFFL + MagicNumber:ContainerNode.kt$ContainerNode$32 + MagicNumber:ContextMenuMeasurementCache.kt$ContextMenuMeasurementCache$14 + MagicNumber:ContextMenuMeasurementCache.kt$ContextMenuMeasurementCache$31 + MagicNumber:DOMNode.kt$DOMNode$0.8f + MagicNumber:DOMNode.kt$DOMNode$0.999f + MagicNumber:DOMNode.kt$DOMNode$120 + MagicNumber:DOMNode.kt$DOMNode$31L + MagicNumber:DateInputNode.kt$DateInputNode$13 + MagicNumber:DateInputNode.kt$DateInputNode$5 + MagicNumber:DefaultDndEngine.kt$DefaultDndEngine$12 + MagicNumber:DefaultDndEngine.kt$DefaultDndEngine$14 + MagicNumber:DefaultDndEngine.kt$DefaultDndEngine$32 + MagicNumber:DefaultDndEngine.kt$DefaultDndEngine$48 + MagicNumber:DefaultDndEngine.kt$DefaultDndEngine$6 + MagicNumber:DomTree.kt$DomTree$0.2 + MagicNumber:DomTree.kt$DomTree$0.999f + MagicNumber:DomTree.kt$DomTree$1_000_000_000.0 + MagicNumber:DomTree.kt$DomTree$240.0 + MagicNumber:DomTree.kt$DomTree$2_000L + MagicNumber:DomTree.kt$DomTree$31L + MagicNumber:DomTree.kt$DomTree$60.0 + MagicNumber:DragPresentation.kt$PlaceholderScope$5 + MagicNumber:Easing.kt$CubicBezierEasing$0.5 + MagicNumber:Easing.kt$CubicBezierEasing$18 + MagicNumber:Easing.kt$CubicBezierEasing$1e-5 + MagicNumber:FontRegistry.kt$AtlasPayload$0xFF + MagicNumber:FontRegistry.kt$AtlasPayload$24 + MagicNumber:FontRegistry.kt$FontRegistry$0.6f + MagicNumber:FontRegistry.kt$FontRegistry$1_000_000L + MagicNumber:FontRegistry.kt$FontRegistry$3_000L + MagicNumber:InspectorController.kt$InspectorController$0.56f + MagicNumber:InspectorController.kt$InspectorController$12 + MagicNumber:InspectorController.kt$InspectorController$120 + MagicNumber:InspectorController.kt$InspectorController$128 + MagicNumber:InspectorController.kt$InspectorController$140 + MagicNumber:InspectorController.kt$InspectorController$160 + MagicNumber:InspectorController.kt$InspectorController$18 + MagicNumber:InspectorController.kt$InspectorController$20 + MagicNumber:InspectorController.kt$InspectorController$24 + MagicNumber:InspectorController.kt$InspectorController$26 + MagicNumber:InspectorController.kt$InspectorController$264 + MagicNumber:InspectorController.kt$InspectorController$32 + MagicNumber:InspectorController.kt$InspectorController$40 + MagicNumber:InspectorController.kt$InspectorController$44 + MagicNumber:InspectorController.kt$InspectorController$5 + MagicNumber:InspectorController.kt$InspectorController$6 + MagicNumber:InspectorController.kt$InspectorController$64 + MagicNumber:InspectorController.kt$InspectorController$86 + MagicNumber:InspectorController.kt$InspectorController$96 + MagicNumber:InspectorEditorRegistry.kt$InspectorEditorRegistry$6 + MagicNumber:InspectorPresentationSupport.kt$InspectorPresentationSupport$0.56f + MagicNumber:InspectorPresentationSupport.kt$InspectorPresentationSupport$6 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$0.40f + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$12 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$140 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$148 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$160 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$18 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$20 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$22 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$24 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$28 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$30 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$34 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$36 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$40 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$6 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$64 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$68 + MagicNumber:InspectorStyleEditorSnapshotBuilder.kt$InspectorStyleEditorSnapshotBuilder$80 + MagicNumber:ItemStackNode.kt$ItemStackNode$11.0 + MagicNumber:LayoutValidator.kt$LayoutValidator$0x6688C15B + MagicNumber:LayoutValidator.kt$LayoutValidator$0x7700AEEF + MagicNumber:MeasuredTextRangeWidthSource.kt$MeasuredTextRangeWidthSource$0xFFFF_FFFFL + MagicNumber:MeasuredTextRangeWidthSource.kt$MeasuredTextRangeWidthSource$32 + MagicNumber:MeasuredTextRangeWidthSource.kt$MeasuredTextRangeWidthSource.Companion$31 + MagicNumber:MediaDsl.kt$ItemStackProps$11.0 + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$0x00FF_FFFF + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$0xFF + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$13 + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$14 + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$24 + MagicNumber:MinecraftFormattingParser.kt$MinecraftFormattingParser$6 + MagicNumber:ModalDsl.kt$132 + MagicNumber:ModalDsl.kt$184 + MagicNumber:ModalDsl.kt$232 + MagicNumber:ModalDsl.kt$6 + MagicNumber:MsdfFontMeta.kt$MsdfFontMeta$0.25f + MagicNumber:MsdfFontMeta.kt$MsdfFontMeta$0.5f + MagicNumber:MsdfFontMeta.kt$MsdfFontMeta$0x00A0 + MagicNumber:MsdfFontMeta.kt$MsdfFontMeta.Companion$0xFFFF_FFFFL + MagicNumber:MsdfFontMeta.kt$MsdfFontMeta.Companion$32 + MagicNumber:ObfuscationTextSelector.kt$ObfuscationTextSelector$0x7FFF_FFFFL + MagicNumber:ObfuscationTextSelector.kt$ObfuscationTextSelector$17 + MagicNumber:ObfuscationTextSelector.kt$ObfuscationTextSelector$31 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$120 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$176 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$18 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$24 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$300 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$34 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$56 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlHost$96 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlRootNode$0x5F000000 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlRootNode$14 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlRootNode$18 + MagicNumber:DebugDomainHosts.kt$DebugDomainControlRootNode$20 + MagicNumber:DomainSurfaceDebugState.kt$DomainSurfaceDebugState$1000.0 + MagicNumber:FloatingPanel.kt$FloatingPanel$6 + MagicNumber:RadioGroupNode.kt$RadioGroupNode$6 + MagicNumber:RangeInputNode.kt$RangeInputNode$12 + MagicNumber:RangeInputNode.kt$RangeInputNode$120 + MagicNumber:SelectEngine.kt$SelectEngine$0.001f + MagicNumber:SelectEngine.kt$SelectEngine$0.999f + MagicNumber:SelectMeasurementCache.kt$SelectMeasurementCache$14 + MagicNumber:SelectMeasurementCache.kt$SelectMeasurementCache$31 + MagicNumber:SelectNode.kt$SelectNode$31L + MagicNumber:SelectNode.kt$SelectNode$6 + MagicNumber:SelectStyle.kt$SelectStyle$4f + MagicNumber:SingleLineInputNode.kt$SingleLineInputNode$17L + MagicNumber:SingleLineInputNode.kt$SingleLineInputNode$31L + MagicNumber:SingleLineInputNode.kt$SingleLineInputNode$6 + MagicNumber:StyleAnimationEngine.kt$StyleAnimationEngine$1000.0 + MagicNumber:StyleAnimationEngine.kt$StyleAnimationEngine$1e-6f + MagicNumber:StyleEngine.kt$StyleEngine$20 + MagicNumber:StyleEngine.kt$StyleEngine$31 + MagicNumber:StyleEngine.kt$StyleEngine$5 + MagicNumber:StyleEngine.kt$StyleEngine$6 + MagicNumber:StyleModel.kt$StyleDeclarations$31 + MagicNumber:StyleScope.kt$StyleScope$0xFFFFFFFFL + MagicNumber:StyleValueParsing.kt$0xFF + MagicNumber:StyleValueParsing.kt$24 + MagicNumber:StyleValueParsing.kt$6 + MagicNumber:SystemColorPickerCustomSurfaceNodes.kt$ColorFieldSurfaceNode$5 + MagicNumber:SystemColorPickerCustomSurfaceNodes.kt$ColorFieldSurfaceNode$7 + MagicNumber:SystemColorPickerCustomSurfaceNodes.kt$ColorSwatchSurfaceNode$0x33222A34 + MagicNumber:SystemColorPickerCustomSurfaceNodes.kt$HueSurfaceNode$360f + MagicNumber:SystemColorPickerPopupBodyNode.kt$SystemColorPickerEyedropperPreviewNode$6 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x18212C39 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x1B293746 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x1E263241 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x22313D4B + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x2A425164 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x2A465968 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x2A4E3F56 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x3346596E + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x334D5D70 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x3A47A0FF + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x4426A69A + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x444285F4 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x44F3B33D + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x55394654 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x553F4A57 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x663F4A57 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x66FF5252 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x775E738C + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x77607084 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$0x777A5C84 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$12 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$14 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$160 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$18 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$20 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$22 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$24 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$264 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$32 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$34 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$36 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$40 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$5 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$58 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$6 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$64 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$86 + MagicNumber:SystemInspectorPortalNode.kt$SystemInspectorPortalNode$96 + MagicNumber:TextAreaNode.kt$TextAreaNode$17L + MagicNumber:TextAreaNode.kt$TextAreaNode$31L + MagicNumber:TextAreaNode.kt$TextAreaNode$6 + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.06f + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.08f + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.1f + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.30f + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.51f + MagicNumber:TextDecorationLayout.kt$TextDecorationLayout$0.5f + MagicNumber:TextEditOps.kt$TextEditOps$127 + MagicNumber:TextLayoutEngine.kt$TextLayoutEngine$31 + MagicNumber:TextStyleMetrics.kt$TextStyleMetrics$0x00A0 + NestedBlockDepth:ComponentHookRuntime.kt$ComponentHookRuntime$private fun applyEffectCommitBatch(batch: PendingEffectCommitBatch) + NestedBlockDepth:ContainerNode.kt$ContainerNode$private fun computeGridPlacements(children: List<DOMNode>, columns: Int): List<GridPlacement> + NestedBlockDepth:ContextMenuEngine.kt$ContextMenuEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + NestedBlockDepth:DssParser.kt$DssParser$@Suppress("ThrowsCount") private fun parseDeclarations( sourceName: String, text: String, fromIndex: Int, declarations: StyleDeclarations, rootVars: MutableMap<String, String>, allowVariables: Boolean, warnings: ParseWarnings, ): Int + NestedBlockDepth:EventBus.kt$EventBus$fun post(event: Event) + NestedBlockDepth:FontRegistry.kt$AtlasPayload$private fun decodeDeflatedRgba(bytes: ByteArray): AtlasBitmap? + NestedBlockDepth:SelectEngine.kt$SelectEngine$fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, out: MutableList<RenderCommand>, ) + NestedBlockDepth:StylesheetManager.kt$StylesheetManager$@Synchronized fun pollForChanges(force: Boolean = false) + ReturnCount:ColorPickerController.kt$ColorPickerController$fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean + ReturnCount:ColorPickerController.kt$ColorPickerController$fun handleMouseDown( globalX: Int, globalY: Int, button: MouseButton, layout: ColorPickerLayout, ): Boolean + ReturnCount:ColorPickerController.kt$ColorPickerController$private fun applyInputDraftValue(key: String, rawValue: String): Boolean + ReturnCount:ColorPickerPopupEngine.kt$ColorPickerPopupEngine$fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + ReturnCount:ColorTextCodec.kt$ColorTextCodec$private fun parseHsbLike(raw: String): RgbaColor? + ReturnCount:ColorTextCodec.kt$ColorTextCodec$private fun parseHslLike(raw: String): RgbaColor? + ReturnCount:ColorTextCodec.kt$ColorTextCodec$private fun parseRgbLike(raw: String): ParsedColorText? + ReturnCount:ComponentHookRuntime.kt$ComponentHookRuntime$private fun isInferenceFrameworkFrame(frame: StackTraceElement): Boolean + ReturnCount:ComponentHookRuntime.kt$ComponentHookRuntime$private fun normalizeInferenceMethodName(rawMethodName: String): String? + ReturnCount:ContextMenuEngine.kt$ContextMenuEngine$fun handleKeyDown(keyCode: Int): Boolean + ReturnCount:DOMNode.kt$DOMNode$open fun handleGenericWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean + ReturnCount:FontRegistry.kt$FontRegistry$private fun load(descriptor: MsdfFontResource): LoadedMsdfFont? + ReturnCount:InspectorController.kt$InspectorController$fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean + ReturnCount:InspectorController.kt$InspectorController$fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + ReturnCount:InspectorController.kt$InspectorController$fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean + ReturnCount:InspectorController.kt$InspectorController$private fun resolveResizeDragMode(mouseX: Int, mouseY: Int): DragMode + ReturnCount:SurfaceDomInputRouter.kt$SurfaceDomInputRouter$private fun collectHoverChainLocal( root: DOMNode, mouseX: Int, mouseY: Int, parentTransform: AffineTransform2D, parentInputClipRect: Rect?, out: MutableList<DOMNode>, ): Boolean + ReturnCount:FloatingPanel.kt$FloatingPanel$fun handleMouseDown( mouseX: Int, mouseY: Int, button: MouseButton, includeCloseButton: Boolean = true, ): Boolean + ReturnCount:FloatingPanel.kt$FloatingPanel$private fun resolveResizeHandle(panelRect: Rect, mouseX: Int, mouseY: Int): FloatingPanelResizeHandle? + ReturnCount:SelectEngine.kt$SelectEngine$fun handleKeyDown(keyCode: Int, keyChar: Char = 0.toChar()): Boolean + ReturnCount:SelectEngine.kt$SelectEngine$fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + ReturnCount:SelectEngine.kt$SelectEngine$private fun entryAt(current: PopupState, mouseX: Int, mouseY: Int): Int + ReturnCount:TextEditShortcutDispatcher.kt$TextEditShortcutDispatcher$fun dispatch(event: KeyboardKeyDownEvent, callbacks: TextShortcutCallbacks): Boolean + TooManyFunctions:ButtonNode.kt$ButtonNode : DOMNode + TooManyFunctions:CheckboxGroupNode.kt$CheckboxGroupNode : DOMNode + TooManyFunctions:ColorPickerController.kt$ColorPickerController + TooManyFunctions:ColorPickerInlineNode.kt$ColorPickerInlineNode : DOMNode + TooManyFunctions:ColorPickerPopupPaneNode.kt$ColorPickerPopupPaneNode : DOMNode + TooManyFunctions:ColorPickerPopupEngine.kt$ColorPickerPopupEngine : ColorPickerPopupPortalService + TooManyFunctions:ColorTextCodec.kt$ColorTextCodec + TooManyFunctions:ComponentHookRuntime.kt$ComponentHookRuntime + TooManyFunctions:ContainerNode.kt$ContainerNode : DOMNode + TooManyFunctions:ContextMenuDsl.kt$ContextMenuSubmenuBuilder + TooManyFunctions:ContextMenuEngine.kt$ContextMenuEngine : ContextMenuPortalService + TooManyFunctions:DOMNode.kt$DOMNode + TooManyFunctions:DefaultDndEngine.kt$DefaultDndEngine : DndEngine + TooManyFunctions:DomTree.kt$DomTree + TooManyFunctions:DsglWindow.kt$DsglWindow + TooManyFunctions:EventBus.kt$EventBus + TooManyFunctions:FocusManager.kt$FocusManager + TooManyFunctions:FontRegistry.kt$FontRegistry + TooManyFunctions:InspectorController.kt$InspectorController + TooManyFunctions:InspectorEditorRegistry.kt$InspectorEditorRegistry + TooManyFunctions:SurfaceDomInputRouter.kt$SurfaceDomInputRouter + TooManyFunctions:LayoutValidator.kt$LayoutValidator + TooManyFunctions:MsdfFontMeta.kt$MsdfFontMeta + TooManyFunctions:DebugDomainHosts.kt$DebugDomainControlHost + TooManyFunctions:FloatingPanel.kt$FloatingPanel + TooManyFunctions:PositionedLayoutModel.kt$PositionedLayoutModel + TooManyFunctions:RadioGroupNode.kt$RadioGroupNode : DOMNode + TooManyFunctions:RangeInputNode.kt$RangeInputNode : DOMNode + TooManyFunctions:ScrollPerformanceCounters.kt$ScrollPerformanceCounters + TooManyFunctions:SelectEngine.kt$SelectEngine + TooManyFunctions:SelectNode.kt$SelectNode : DOMNode + TooManyFunctions:SingleLineInputNode.kt$SingleLineInputNode : DOMNode + TooManyFunctions:StyleAnimationEngine.kt$StyleAnimationEngine + TooManyFunctions:StyleEngine.kt$StyleEngine + TooManyFunctions:StyleScope.kt$StyleScope : CssLengthUnitsDsl + TooManyFunctions:StyleValueParsing.kt$org.dreamfinity.dsgl.core.style.StyleValueParsing.kt + TooManyFunctions:SystemColorPickerPopupBodyNode.kt$SystemColorPickerEyedropperPreviewNode : DOMNode + TooManyFunctions:SystemColorPickerPopupBodyNode.kt$SystemColorPickerPopupBodyNode : DOMNode + TooManyFunctions:SystemInspectorPortalNode.kt$SystemInspectorPortalNode : DOMNode + TooManyFunctions:SystemPortalHost.kt$SystemPortalHost : DomainSurfaceHost + TooManyFunctions:SystemPortalHost.kt$SystemPortalHost$ColorPickerPortalEntry : SystemPortalEntrySystemColorPickerPortalService + TooManyFunctions:TextAreaNode.kt$TextAreaNode : DOMNode + TooManyFunctions:ToggleNode.kt$ToggleNode : DOMNode + + diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DomTree.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DomTree.kt index bd49ec9..faeb4f3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DomTree.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DomTree.kt @@ -18,7 +18,9 @@ import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleApplicationScope import org.dreamfinity.dsgl.core.style.StyleEngine -import java.util.* +import java.util.Collections +import java.util.IdentityHashMap +import java.util.WeakHashMap /** * Retained DOM tree. Render phase builds this tree, paint phase draws it. @@ -28,7 +30,7 @@ import java.util.* */ class DomTree( var root: DOMNode, - private val styleScope: StyleApplicationScope = StyleApplicationScope.Application + private val styleScope: StyleApplicationScope = StyleApplicationScope.Application, ) { data class PaintStats( val frames: Long, @@ -37,14 +39,14 @@ class DomTree( val chunkNodesRebuiltLastFrame: Int, val styledNodesLastFrame: Int, val styleCacheHitsLastFrame: Int, - val styleRecomputedLastFrame: Int + val styleRecomputedLastFrame: Int, ) private var lastWidth: Int = 0 private var lastHeight: Int = 0 private var laidOut: Boolean = false - private val paintBuffer: MutableList = ArrayList(256) - private val stagingPaintBuffer: MutableList = ArrayList(256) + private var paintBuffer: MutableList = ArrayList(256) + private var stagingPaintBuffer: MutableList = ArrayList(256) private val refManager: RefManager = RefManager() private var lastViolations: List = emptyList() private var strictInvalidLayout: Boolean = false @@ -58,14 +60,17 @@ class DomTree( private var chunkTreeChangedThisFrame: Boolean = false private var lastPaintBuildErrorMs: Long = 0L private val chunksByNode: MutableMap = WeakHashMap() - private val debugCommandStackChecks: Boolean = java.lang.Boolean.getBoolean("dsgl.render.debug.stack") - private var lastStyleReport: StyleEngine.StyleApplyReport = StyleEngine.StyleApplyReport( - layoutDirty = false, - visualDirty = false, - visitedNodes = 0, - cacheHits = 0, - recomputedNodes = 0 - ) + private val debugCommandStackChecks: Boolean = + java.lang.Boolean + .getBoolean("dsgl.render.debug.stack") + private var lastStyleReport: StyleEngine.StyleApplyReport = + StyleEngine.StyleApplyReport( + layoutDirty = false, + visualDirty = false, + visitedNodes = 0, + cacheHits = 0, + recomputedNodes = 0, + ) /** Measures and lays out the tree for the given viewport. */ fun render(ctx: UiMeasureContext, width: Int, height: Int) { @@ -77,7 +82,7 @@ class DomTree( root.resolveLayoutStyleValues( ctx = ctx, parentContentWidth = width, - parentContentHeight = height + parentContentHeight = height, ) root.render(ctx, 0, 0, width, height) validateLayout(ctx) @@ -86,7 +91,12 @@ class DomTree( commandsDirty = true } - /** Builds render commands for the current layout. */ + /** + * Builds render commands for the current layout. + * + * The returned list is valid until the next [paint] call and must not be retained + * across frames; consume or copy it within the same frame. + */ fun paint(ctx: UiMeasureContext, applyStyles: Boolean = true): List { val paintStartNanos = System.nanoTime() try { @@ -98,31 +108,26 @@ class DomTree( } val scrollInvalidation = root.consumeScrollInvalidationRecursively() val styleRevision = if (applyStyles) StyleEngine.currentStyleRevision(styleScope) else lastStyleRevision - val styleReport = if (applyStyles && (styleRevision != lastStyleRevision || !laidOut)) { - val styleStartNanos = System.nanoTime() - StyleEngine.applyStylesRecursivelyDetailed(root, styleScope).also { - lastStyleReport = it - lastStyleRevision = styleRevision - ScrollPerformanceCounters.recordStyleApplyDuration(System.nanoTime() - styleStartNanos) + val styleReport = + if (applyStyles && (styleRevision != lastStyleRevision || !laidOut)) { + val styleStartNanos = System.nanoTime() + StyleEngine.applyStylesRecursivelyDetailed(root, styleScope).also { + lastStyleReport = it + lastStyleRevision = styleRevision + ScrollPerformanceCounters.recordStyleApplyDuration(System.nanoTime() - styleStartNanos) + } + } else { + StyleEngine.StyleApplyReport.CLEAN } - } else { - StyleEngine.StyleApplyReport( - layoutDirty = false, - visualDirty = false, - visitedNodes = 0, - cacheHits = 0, - recomputedNodes = 0 - ) - } val canUseGuardedScrollVisualFastPath = laidOut && - lastWidth > 0 && - lastHeight > 0 && - styleScope != StyleApplicationScope.SystemOverlay && - !styleReport.layoutDirty && - !styleReport.visualDirty && - scrollInvalidation.visualDirty && - !scrollInvalidation.layoutDirty + lastWidth > 0 && + lastHeight > 0 && + styleScope == StyleApplicationScope.Application && + !styleReport.layoutDirty && + !styleReport.visualDirty && + scrollInvalidation.visualDirty && + !scrollInvalidation.layoutDirty val stickyLayoutInvalidated = styleReport.layoutDirty || scrollInvalidation.layoutDirty if (stickyLayoutInvalidated) { root.invalidateStickyVisualOffsetsRecursively() @@ -136,17 +141,20 @@ class DomTree( ScrollPerformanceCounters.recordScrollVisualGeometryRefresh(translatedNodes) ScrollPerformanceCounters.recordStickyVisualRefresh(resolvedStickyNodes) } - val requiresSystemOverlayScrollLayoutFallback = - styleScope == StyleApplicationScope.SystemOverlay && scrollInvalidation.visualDirty - if ((!laidOut || styleReport.layoutDirty || scrollInvalidation.layoutDirty || requiresSystemOverlayScrollLayoutFallback) && - lastWidth > 0 && - lastHeight > 0 - ) { + val requiresIsolatedScopeScrollLayoutFallback = + styleScope != StyleApplicationScope.Application && scrollInvalidation.visualDirty + val requiresLayoutPass = + !laidOut || + styleReport.layoutDirty || + scrollInvalidation.layoutDirty || + requiresIsolatedScopeScrollLayoutFallback + val hasKnownViewport = lastWidth > 0 && lastHeight > 0 + if (requiresLayoutPass && hasKnownViewport) { val layoutStartNanos = System.nanoTime() root.resolveLayoutStyleValues( ctx = ctx, parentContentWidth = lastWidth, - parentContentHeight = lastHeight + parentContentHeight = lastHeight, ) root.render(ctx, 0, 0, lastWidth, lastHeight) val resolvedStickyNodes = root.refreshStickyVisualOffsetsRecursively() @@ -188,17 +196,16 @@ class DomTree( return result } - fun paintStats(): PaintStats { - return PaintStats( + fun paintStats(): PaintStats = + PaintStats( frames = frames, commandRebuilds = commandRebuilds, chunkNodesVisitedLastFrame = chunkNodesVisitedLastFrame, chunkNodesRebuiltLastFrame = chunkNodesRebuiltLastFrame, styledNodesLastFrame = lastStyleReport.visitedNodes, styleCacheHitsLastFrame = lastStyleReport.cacheHits, - styleRecomputedLastFrame = lastStyleReport.recomputedNodes + styleRecomputedLastFrame = lastStyleReport.recomputedNodes, ) - } fun markVisualDirty() { commandsDirty = true @@ -211,12 +218,13 @@ class DomTree( private fun nextScrollAnimationDtSeconds(): Double { val nowNanos = System.nanoTime() - val dtSeconds = if (lastScrollAnimationNanos == 0L) { - 1.0 / 60.0 - } else { - ((nowNanos - lastScrollAnimationNanos).toDouble() / 1_000_000_000.0) - .coerceIn(1.0 / 240.0, 0.2) - } + val dtSeconds = + if (lastScrollAnimationNanos == 0L) { + 1.0 / 60.0 + } else { + ((nowNanos - lastScrollAnimationNanos).toDouble() / 1_000_000_000.0) + .coerceIn(1.0 / 240.0, 0.2) + } lastScrollAnimationNanos = nowNanos return dtSeconds } @@ -227,12 +235,13 @@ class DomTree( chunkNodesVisitedLastFrame = 0 chunkNodesRebuiltLastFrame = 0 chunkTreeChangedThisFrame = false - val chunkChanged = if (!strictInvalidLayout) { - rebuildChunkRecursive(root, ctx, nowMs) - chunkTreeChangedThisFrame - } else { - false - } + val chunkChanged = + if (!strictInvalidLayout) { + rebuildChunkRecursive(root, ctx, nowMs) + chunkTreeChangedThisFrame + } else { + false + } val changed = commandsDirty || chunkChanged if (!changed) { return false @@ -248,15 +257,24 @@ class DomTree( if (debugCommandStackChecks) { validateCommandStacks(stagingPaintBuffer) } - paintBuffer.clear() - paintBuffer.addAll(stagingPaintBuffer) + // Swap instead of copy: addAll would clone the full command list every rebuild. + val rebuilt = stagingPaintBuffer + stagingPaintBuffer = paintBuffer + paintBuffer = rebuilt commandsDirty = false - changed - } catch (error: Throwable) { + true + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { + // Paint rebuild is a defensive boundary: any element can throw during chunk building. + // Swallow and keep the previous frame's commands to avoid tearing down the UI. + // TODO(Veritaris): Add some error display val now = System.currentTimeMillis() if (now - lastPaintBuildErrorMs >= 2_000L) { lastPaintBuildErrorMs = now - println("[DSGL-DomTree] Paint command rebuild failed; keeping previous frame commands: ${error.message}") + println( + "[DSGL-DomTree] Paint command rebuild failed; keeping previous frame commands: ${error.message}", + ) } false } @@ -327,40 +345,42 @@ class DomTree( val chunk = chunksByNode.getOrPut(node) { RenderCommandChunk() } val nodeHidden = node.dragRenderHidden || node.display == Display.None - val childSignature = if (nodeHidden) { - if (chunk.children.isNotEmpty()) { - chunk.children.clear() - chunkTreeChangedThisFrame = true - } - 0L - } else { - var signature = 1L - val expectedChildren = node.orderedChildrenForPaintTraversal() - var childrenChanged = chunk.children.size != expectedChildren.size - if (childrenChanged) { - chunk.children.clear() - } - expectedChildren.forEachIndexed { index, child -> - val childChunk = rebuildChunkRecursive(child, ctx, nowMs) + val childSignature = + if (nodeHidden) { + if (chunk.children.isNotEmpty()) { + chunk.children.clear() + chunkTreeChangedThisFrame = true + } + 0L + } else { + var signature = 1L + val expectedChildren = node.orderedChildrenForPaintTraversal() + var childrenChanged = chunk.children.size != expectedChildren.size if (childrenChanged) { - chunk.children += childChunk - } else if (chunk.children[index] !== childChunk) { - childrenChanged = true + chunk.children.clear() } - signature = 31L * signature + childChunk.subtreeSignature - } - if (childrenChanged) { - chunkTreeChangedThisFrame = true - chunk.children.clear() - expectedChildren.forEach { child -> - chunk.children += chunksByNode.getOrPut(child) { RenderCommandChunk() } + expectedChildren.forEachIndexed { index, child -> + val childChunk = rebuildChunkRecursive(child, ctx, nowMs) + if (childrenChanged) { + chunk.children += childChunk + } else if (chunk.children[index] !== childChunk) { + childrenChanged = true + } + signature = 31L * signature + childChunk.subtreeSignature } + if (childrenChanged) { + chunkTreeChangedThisFrame = true + chunk.children.clear() + expectedChildren.forEach { child -> + chunk.children += chunksByNode.getOrPut(child) { RenderCommandChunk() } + } + } + signature } - signature - } val nodeSignature = node.renderCommandsSignature(nowMs) - val rebuildSelf = chunk.lastNodeSignature != nodeSignature || + val rebuildSelf = + chunk.lastNodeSignature != nodeSignature || chunk.lastChildrenSignature != childSignature || chunk.lastNodeSignature == Long.MIN_VALUE @@ -381,7 +401,7 @@ class DomTree( node: DOMNode, chunk: RenderCommandChunk, ctx: UiMeasureContext, - nodeHidden: Boolean + nodeHidden: Boolean, ) { chunk.prefixCommands.clear() chunk.selfCommands.clear() @@ -394,33 +414,36 @@ class DomTree( val activeOpacity = node.effectiveOpacity() val transformPushed = !activeTransform.isIdentity() val opacityPushed = activeOpacity < 0.999f - val promotedFixedRootViewportClipRect = if (node.position == PositionMode.Fixed) { - node.fixedViewportClipRectForPromotedParticipation() - } else { - null - } + val promotedFixedRootViewportClipRect = + if (node.position == PositionMode.Fixed) { + node.fixedViewportClipRectForPromotedParticipation() + } else { + null + } if (promotedFixedRootViewportClipRect != null) { - chunk.prefixCommands += RenderCommand.PushClip( - x = promotedFixedRootViewportClipRect.x, - y = promotedFixedRootViewportClipRect.y, - width = promotedFixedRootViewportClipRect.width.coerceAtLeast(0), - height = promotedFixedRootViewportClipRect.height.coerceAtLeast(0) - ) + chunk.prefixCommands += + RenderCommand.PushClip( + x = promotedFixedRootViewportClipRect.x, + y = promotedFixedRootViewportClipRect.y, + width = promotedFixedRootViewportClipRect.width.coerceAtLeast(0), + height = promotedFixedRootViewportClipRect.height.coerceAtLeast(0), + ) } if (transformPushed) { val ox = node.bounds.x + node.bounds.width * node.transformOrigin.originX val oy = node.bounds.y + node.bounds.height * node.transformOrigin.originY - chunk.prefixCommands += RenderCommand.PushTransform( - originX = ox, - originY = oy, - translateX = activeTransform.translateX, - translateY = activeTransform.translateY, - scaleX = activeTransform.scaleX, - scaleY = activeTransform.scaleY, - rotateDeg = activeTransform.rotateDeg - ) + chunk.prefixCommands += + RenderCommand.PushTransform( + originX = ox, + originY = oy, + translateX = activeTransform.translateX, + translateY = activeTransform.translateY, + scaleX = activeTransform.scaleX, + scaleY = activeTransform.scaleY, + rotateDeg = activeTransform.rotateDeg, + ) } if (opacityPushed) { chunk.prefixCommands += RenderCommand.PushOpacity(activeOpacity) @@ -432,12 +455,13 @@ class DomTree( val clipRect = node.overflowViewportRect() if (clipRect != null) { - chunk.childrenPrefixCommands += RenderCommand.PushClip( - x = clipRect.x, - y = clipRect.y, - width = clipRect.width.coerceAtLeast(0), - height = clipRect.height.coerceAtLeast(0) - ) + chunk.childrenPrefixCommands += + RenderCommand.PushClip( + x = clipRect.x, + y = clipRect.y, + width = clipRect.width.coerceAtLeast(0), + height = clipRect.height.coerceAtLeast(0), + ) chunk.childrenSuffixCommands += RenderCommand.PopClip } @@ -453,14 +477,21 @@ class DomTree( } private fun appendChunkCommands(chunk: RenderCommandChunk, out: MutableList) { - out.addAll(chunk.prefixCommands) - out.addAll(chunk.selfCommands) - out.addAll(chunk.childrenPrefixCommands) - chunk.children.forEach { child -> - appendChunkCommands(child, out) + appendCommands(chunk.prefixCommands, out) + appendCommands(chunk.selfCommands, out) + appendCommands(chunk.childrenPrefixCommands, out) + val children = chunk.children + for (index in children.indices) { + appendChunkCommands(children[index], out) + } + appendCommands(chunk.childrenSuffixCommands, out) + appendCommands(chunk.suffixCommands, out) + } + + private fun appendCommands(source: List, out: MutableList) { + for (index in source.indices) { + out.add(source[index]) } - out.addAll(chunk.childrenSuffixCommands) - out.addAll(chunk.suffixCommands) } fun dispatchClick(event: MouseClickEvent): Boolean { @@ -499,9 +530,8 @@ class DomTree( val first = violations.first() println( "[DSGL-Layout] strict mode invalidated paint/hit-test due to ${first.code} " + - "key=${first.nodeKey} parent=${first.parentKey}: ${first.message}" + "key=${first.nodeKey} parent=${first.parentKey}: ${first.message}", ) } } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglColors.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglColors.kt index 0e19ede..91d7f4c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglColors.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglColors.kt @@ -10,4 +10,4 @@ object DsglColors { const val BUTTON: Int = 0xFF3A3A40.toInt() const val TEXT: Int = 0xFFE0E0E0.toInt() const val BORDER: Int = 0xFF000000.toInt() -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglWindow.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglWindow.kt index 43406e6..0782eef 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglWindow.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/DsglWindow.kt @@ -1,8 +1,7 @@ package org.dreamfinity.dsgl.core import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.hooks.ComponentHookRuntime import org.dreamfinity.dsgl.core.hooks.HookRenderSessionMode import org.dreamfinity.dsgl.core.host.DsglWindowHost @@ -29,6 +28,7 @@ abstract class DsglWindow { } /** Records the open time for date/time controls. */ + @Suppress("UnusedParameter", "EmptyFunctionBlock") fun markOpened(instant: Instant, zoneId: ZoneId) { } @@ -42,9 +42,7 @@ abstract class DsglWindow { * * This is intentionally separate from component-local hook state. */ - fun state(initial: T): MutableState { - return mutableStateOf(initial) { invalidate() } - } + fun state(initial: T): MutableState = mutableStateOf(initial) { invalidate() } /** Build the current UI tree. Called by the host on rebuild. */ abstract fun render(): DomTree @@ -52,16 +50,12 @@ abstract class DsglWindow { /** * Builds a tree bound to this window so UiScope hook APIs can resolve the owning runtime. */ - protected fun ui(block: UiScope.() -> Unit): DomTree { - return ui(this, block) - } + protected fun ui(block: UiScope.() -> Unit): DomTree = ui(this, block) /** * Builds a tree from a custom root bound to this window. */ - protected fun ui(root: DOMNode, block: UiScope.() -> Unit): DomTree { - return ui(this, root, block) - } + protected fun ui(root: DOMNode, block: UiScope.() -> Unit): DomTree = ui(this, root, block) /** Lifecycle callback when the UI is opened by the host. */ open fun onOpen() {} @@ -134,4 +128,3 @@ abstract class DsglWindow { internal fun hookRuntime(): ComponentHookRuntime = componentHookRuntime } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/HotReloadBridge.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/HotReloadBridge.kt index 596e8bc..6d5cf24 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/HotReloadBridge.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/HotReloadBridge.kt @@ -12,7 +12,5 @@ object HotReloadBridge { } @JvmStatic - fun consumeHotSwap(): Boolean { - return HOTSWAP_PENDING.getAndSet(false) - } -} \ No newline at end of file + fun consumeHotSwap(): Boolean = HOTSWAP_PENDING.getAndSet(false) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/ItemStackRef.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/ItemStackRef.kt index ff8f6ab..4898e96 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/ItemStackRef.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/ItemStackRef.kt @@ -3,4 +3,4 @@ package org.dreamfinity.dsgl.core /** * Opaque reference to a platform-specific ItemStack. */ -interface ItemStackRef \ No newline at end of file +interface ItemStackRef diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/State.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/State.kt index 7e5d204..b197a21 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/State.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/State.kt @@ -14,7 +14,7 @@ interface State { */ class MutableState( initial: T, - private val onChange: () -> Unit = {} + private val onChange: () -> Unit = {}, ) : State { private var _value: T = initial @@ -34,6 +34,4 @@ class MutableState( } /** Creates mutable state with an optional change callback. */ -fun mutableStateOf(initial: T, onChange: () -> Unit = {}): MutableState { - return MutableState(initial, onChange) -} \ No newline at end of file +fun mutableStateOf(initial: T, onChange: () -> Unit = {}): MutableState = MutableState(initial, onChange) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationDslBuilders.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationDslBuilders.kt index d4292d5..be17782 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationDslBuilders.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationDslBuilders.kt @@ -12,14 +12,15 @@ class TransitionBuilder internal constructor() { property: AnimatedStyleProperty, durationMs: Int, delayMs: Int = 0, - easing: Easing = Easings.EASE + easing: Easing = Easings.EASE, ) { - specs += TransitionPropertySpec( - property = property, - durationMs = durationMs.coerceAtLeast(0), - delayMs = delayMs.coerceAtLeast(0), - easing = easing - ) + specs += + TransitionPropertySpec( + property = property, + durationMs = durationMs.coerceAtLeast(0), + delayMs = delayMs.coerceAtLeast(0), + easing = easing, + ) } internal fun build(): TransitionSpec = TransitionSpec(specs.toList()) @@ -36,32 +37,27 @@ class AnimationListBuilder internal constructor() { iterationCount: IterationCount = IterationCount.Count(1), direction: AnimationDirection = AnimationDirection.Normal, fillMode: AnimationFillMode = AnimationFillMode.None, - playState: AnimationPlayState = AnimationPlayState.Running + playState: AnimationPlayState = AnimationPlayState.Running, ) { - specs += AnimationSpec( - name = name, - durationMs = durationMs.coerceAtLeast(0), - delayMs = delayMs.coerceAtLeast(0), - easing = easing, - iterationCount = iterationCount, - direction = direction, - fillMode = fillMode, - playState = playState - ) + specs += + AnimationSpec( + name = name, + durationMs = durationMs.coerceAtLeast(0), + delayMs = delayMs.coerceAtLeast(0), + easing = easing, + iterationCount = iterationCount, + direction = direction, + fillMode = fillMode, + playState = playState, + ) } internal fun build(): List = specs.toList() } -fun keyframes( - name: String, - block: KeyframesBuilder.() -> Unit -) { +fun keyframes(name: String, block: KeyframesBuilder.() -> Unit) { val definition = KeyframesBuilder(name).apply(block).build() KeyframesRegistry.register(definition) } -fun transform(block: UiTransformBuilder.() -> Unit): UiTransform { - return UiTransformBuilder().apply(block).build() -} - +fun transform(block: UiTransformBuilder.() -> Unit): UiTransform = UiTransformBuilder().apply(block).build() diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationModel.kt index 26bf4c4..39d7744 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/AnimationModel.kt @@ -5,7 +5,7 @@ import org.dreamfinity.dsgl.core.style.UiTransform enum class AnimatedStyleProperty { Transform, Opacity, - Color + Color, } object StyleAnimProps { @@ -17,7 +17,7 @@ object StyleAnimProps { data class StylePropertyKey( val id: String, val property: AnimatedStyleProperty, - val animatable: Animatable + val animatable: Animatable, ) interface Animatable { @@ -25,9 +25,7 @@ interface Animatable { } object FloatAnimatable : Animatable { - override fun interpolate(from: Float, to: Float, t: Float): Float { - return from + (to - from) * t - } + override fun interpolate(from: Float, to: Float, t: Float): Float = from + (to - from) * t } object ColorAnimatable : Animatable { @@ -58,7 +56,7 @@ object TransformAnimatable : Animatable { translateY = from.translateY + (to.translateY - from.translateY) * t, scaleX = from.scaleX + (to.scaleX - from.scaleX) * t, scaleY = from.scaleY + (to.scaleY - from.scaleY) * t, - rotateDeg = from.rotateDeg + rotateDelta * t + rotateDeg = from.rotateDeg + rotateDelta * t, ) } @@ -81,45 +79,50 @@ data class TransitionPropertySpec( val property: AnimatedStyleProperty, val durationMs: Int, val delayMs: Int = 0, - val easing: Easing = Easings.EASE + val easing: Easing = Easings.EASE, ) data class TransitionSpec( - val properties: List + val properties: List, ) { companion object { val NONE: TransitionSpec = TransitionSpec(emptyList()) } - fun forProperty(property: AnimatedStyleProperty): TransitionPropertySpec? { - return properties.lastOrNull { it.property == property } - } + fun forProperty(property: AnimatedStyleProperty): TransitionPropertySpec? = + properties.lastOrNull { + it.property == property + } } enum class AnimationDirection { Normal, Reverse, Alternate, - AlternateReverse + AlternateReverse, } enum class AnimationFillMode { None, Forwards, Backwards, - Both + Both, } enum class AnimationPlayState { Running, - Paused + Paused, } sealed class IterationCount { - data class Count(val value: Int) : IterationCount() + data class Count( + val value: Int, + ) : IterationCount() + data object Infinite : IterationCount() fun isInfinite(): Boolean = this is Infinite + fun finiteValueOrNull(): Int? = (this as? Count)?.value } @@ -131,25 +134,29 @@ data class AnimationSpec( val iterationCount: IterationCount = IterationCount.Count(1), val direction: AnimationDirection = AnimationDirection.Normal, val fillMode: AnimationFillMode = AnimationFillMode.None, - val playState: AnimationPlayState = AnimationPlayState.Running + val playState: AnimationPlayState = AnimationPlayState.Running, ) data class KeyframeValue( val transform: UiTransform? = null, val opacity: Float? = null, - val color: Int? = null + val color: Int? = null, ) data class Keyframe( val fraction: Float, - val value: KeyframeValue + val value: KeyframeValue, ) data class KeyframesDefinition( val name: String, - val frames: List + val frames: List, ) { init { require(frames.isNotEmpty()) { "Keyframes '$name' must contain at least one frame." } } + + val transformFrames: List = frames.filter { it.value.transform != null } + val opacityFrames: List = frames.filter { it.value.opacity != null } + val colorFrames: List = frames.filter { it.value.color != null } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/Easing.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/Easing.kt index 9b6b57e..1a20377 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/Easing.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/Easing.kt @@ -13,20 +13,26 @@ object Easings { val EASE_OUT: Easing = cubicBezier(0f, 0f, 0.58f, 1f) val EASE_IN_OUT: Easing = cubicBezier(0.42f, 0f, 0.58f, 1f) - fun cubicBezier(x1: Float, y1: Float, x2: Float, y2: Float): Easing { - return CubicBezierEasing(x1, y1, x2, y2) - } + fun cubicBezier( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + ): Easing = CubicBezierEasing(x1, y1, x2, y2) } -fun cubicBezier(x1: Float, y1: Float, x2: Float, y2: Float): Easing { - return Easings.cubicBezier(x1, y1, x2, y2) -} +fun cubicBezier( + x1: Float, + y1: Float, + x2: Float, + y2: Float, +): Easing = Easings.cubicBezier(x1, y1, x2, y2) private class CubicBezierEasing( private val x1: Float, private val y1: Float, private val x2: Float, - private val y2: Float + private val y2: Float, ) : Easing { override fun map(t: Float): Float { val clamped = t.coerceIn(0f, 1f) @@ -52,15 +58,14 @@ private class CubicBezierEasing( private fun sampleCurveX(t: Double): Double { val omt = 1.0 - t return 3.0 * omt * omt * t * x1 + - 3.0 * omt * t * t * x2 + - t * t * t + 3.0 * omt * t * t * x2 + + t * t * t } private fun sampleCurveY(t: Double): Double { val omt = 1.0 - t return 3.0 * omt * omt * t * y1 + - 3.0 * omt * t * t * y2 + - t * t * t + 3.0 * omt * t * t * y2 + + t * t * t } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/KeyframesRegistry.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/KeyframesRegistry.kt index ba20bb3..380ddf2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/KeyframesRegistry.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/KeyframesRegistry.kt @@ -18,7 +18,7 @@ object KeyframesRegistry { } class KeyframesBuilder internal constructor( - private val name: String + private val name: String, ) { private val frames: MutableList = ArrayList() @@ -48,13 +48,12 @@ class KeyframeScope internal constructor() { transform = UiTransformBuilder().apply(block).build() } - internal fun build(): KeyframeValue { - return KeyframeValue( + internal fun build(): KeyframeValue = + KeyframeValue( transform = transform, opacity = opacity?.coerceIn(0f, 1f), - color = color + color = color, ) - } } class UiTransformBuilder internal constructor() { @@ -78,17 +77,16 @@ class UiTransformBuilder internal constructor() { rotate += deg } - fun build(): UiTransform { - return UiTransform(translateX = tx, translateY = ty, scaleX = sx, scaleY = sy, rotateDeg = rotate) - } + fun build(): UiTransform = + UiTransform(translateX = tx, translateY = ty, scaleX = sx, scaleY = sy, rotateDeg = rotate) } internal fun KeyframesDefinition.normalized(): KeyframesDefinition { - val normalizedFrames = frames - .sortedBy { it.fraction } - .map { frame -> - frame.copy(fraction = frame.fraction.coerceIn(0f, 1f)) - } + val normalizedFrames = + frames + .sortedBy { it.fraction } + .map { frame -> + frame.copy(fraction = frame.fraction.coerceIn(0f, 1f)) + } return copy(frames = normalizedFrames) } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/StyleAnimationEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/StyleAnimationEngine.kt index f3d634d..d85c7fd 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/StyleAnimationEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/animation/StyleAnimationEngine.kt @@ -11,7 +11,7 @@ object StyleAnimationEngine { val activeKeyframes: List, val effectiveTransform: UiTransform, val effectiveOpacity: Float, - val effectiveColor: Int? + val effectiveColor: Int?, ) private data class TransitionState( @@ -22,16 +22,22 @@ object StyleAnimationEngine { val delaySec: Double, val durationSec: Double, val easing: Easing, - val animatable: Animatable + val animatable: Animatable, ) { - fun valueAt(nowSec: Double): Pair { - if (durationSec <= 0.0) return to to true + fun valueAt(nowSec: Double): T { + if (durationSec <= 0.0) return to val local = nowSec - startSec - if (local <= delaySec) return from to false + if (local <= delaySec) return from val t = ((local - delaySec) / durationSec).toFloat().coerceIn(0f, 1f) val eased = easing.map(t) - val value = animatable.interpolate(from, to, eased) - return value to (t >= 1f) + return animatable.interpolate(from, to, eased) + } + + fun isFinishedAt(nowSec: Double): Boolean { + if (durationSec <= 0.0) return true + val local = nowSec - startSec + if (local <= delaySec) return false + return ((local - delaySec) / durationSec).toFloat() >= 1f } } @@ -39,7 +45,7 @@ object StyleAnimationEngine { val spec: AnimationSpec, var startedAtSec: Double, var pausedElapsedSec: Double? = null, - var previousPlayState: AnimationPlayState = spec.playState + var previousPlayState: AnimationPlayState = spec.playState, ) private data class NodeAnimationState( @@ -52,7 +58,7 @@ object StyleAnimationEngine { var effectiveTransform: UiTransform = UiTransform.IDENTITY, var effectiveOpacity: Float = 1f, var effectiveColor: Int? = null, - var lastSeenFrame: Long = 0L + var lastSeenFrame: Long = 0L, ) private val states: MutableMap = linkedMapOf() @@ -75,21 +81,21 @@ object StyleAnimationEngine { transitions = transitions, property = AnimatedStyleProperty.Transform, previousValue = previous.transform, - currentValue = current.transform + currentValue = current.transform, ) maybeRetargetTransition( state = state, transitions = transitions, property = AnimatedStyleProperty.Opacity, previousValue = previous.opacity, - currentValue = current.opacity + currentValue = current.opacity, ) maybeRetargetTransition( state = state, transitions = transitions, property = AnimatedStyleProperty.Color, previousValue = previous.foregroundColor, - currentValue = current.foregroundColor + currentValue = current.foregroundColor, ) } @@ -130,25 +136,38 @@ object StyleAnimationEngine { } private fun debugSnapshot(state: NodeAnimationState): DebugSnapshot { - val transitions = state.transitions.values.map { transition -> - val local = (nowSec - transition.startSec - transition.delaySec).coerceAtLeast(0.0) - val progress = if (transition.durationSec <= 0.0) 1.0 else (local / transition.durationSec).coerceIn(0.0, 1.0) - "${transition.property.name.lowercase()}:${(progress * 100.0).toInt()}%" - } - val keyframes = state.keyframes.map { running -> - val elapsedSec = resolveElapsed(running, nowSec) - val delaySec = running.spec.delayMs.coerceAtLeast(0) / 1000.0 - val durationSec = running.spec.durationMs.coerceAtLeast(1) / 1000.0 - val active = (elapsedSec - delaySec).coerceAtLeast(0.0) - val rawProgress = if (durationSec <= 0.0) 1.0 else (active % durationSec) / durationSec - "${running.spec.name}:${(rawProgress * 100.0).toInt()}%:${running.spec.playState.name.lowercase()}" - } + val transitions = + state.transitions.values.map { transition -> + val local = (nowSec - transition.startSec - transition.delaySec).coerceAtLeast(0.0) + val progress = + if (transition.durationSec <= + 0.0 + ) { + 1.0 + } else { + (local / transition.durationSec).coerceIn(0.0, 1.0) + } + "${transition.property.name.lowercase()}:${(progress * 100.0).toInt()}%" + } + val keyframes = + state.keyframes.map { running -> + val elapsedSec = resolveElapsed(running, nowSec) + val delaySec = + running.spec.delayMs + .coerceAtLeast(0) / 1000.0 + val durationSec = + running.spec.durationMs + .coerceAtLeast(1) / 1000.0 + val active = (elapsedSec - delaySec).coerceAtLeast(0.0) + val rawProgress = if (durationSec <= 0.0) 1.0 else (active % durationSec) / durationSec + "${running.spec.name}:${(rawProgress * 100.0).toInt()}%:${running.spec.playState.name.lowercase()}" + } return DebugSnapshot( activeTransitions = transitions, activeKeyframes = keyframes, effectiveTransform = state.effectiveTransform, effectiveOpacity = state.effectiveOpacity, - effectiveColor = state.effectiveColor + effectiveColor = state.effectiveColor, ) } @@ -186,10 +205,11 @@ object StyleAnimationEngine { } if (node.applyAnimationVisuals( - transform = resolvedTransform, - opacity = resolvedOpacity, - color = resolvedColor - )) { + transform = resolvedTransform, + opacity = resolvedOpacity, + color = resolvedColor, + ) + ) { changed = true } state.effectiveTransform = node.effectiveTransform() @@ -204,7 +224,7 @@ object StyleAnimationEngine { state.effectiveColor = node.animationColorOverride() logRateLimited( key = "anim:${node.key ?: node.styleType}", - message = "[DSGL-Anim] ${node.key ?: node.styleType}: ${error.message}" + message = "[DSGL-Anim] ${node.key ?: node.styleType}: ${error.message}", ) } } @@ -232,24 +252,26 @@ object StyleAnimationEngine { transitions: TransitionSpec, property: AnimatedStyleProperty, previousValue: UiTransform, - currentValue: UiTransform + currentValue: UiTransform, ) { if (previousValue == currentValue) return - val spec = transitions.forProperty(property) ?: run { - state.transitions.remove(property) - return - } + val spec = + transitions.forProperty(property) ?: run { + state.transitions.remove(property) + return + } val currentVisual = transitionValue(state, property) ?: previousValue - state.transitions[property] = TransitionState( - property = property, - from = currentVisual, - to = currentValue, - startSec = nowSec, - delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, - durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, - easing = spec.easing, - animatable = TransformAnimatable - ) + state.transitions[property] = + TransitionState( + property = property, + from = currentVisual, + to = currentValue, + startSec = nowSec, + delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, + durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, + easing = spec.easing, + animatable = TransformAnimatable, + ) } private fun maybeRetargetTransition( @@ -257,24 +279,26 @@ object StyleAnimationEngine { transitions: TransitionSpec, property: AnimatedStyleProperty, previousValue: Float, - currentValue: Float + currentValue: Float, ) { if (previousValue == currentValue) return - val spec = transitions.forProperty(property) ?: run { - state.transitions.remove(property) - return - } + val spec = + transitions.forProperty(property) ?: run { + state.transitions.remove(property) + return + } val currentVisual = transitionValue(state, property) ?: previousValue - state.transitions[property] = TransitionState( - property = property, - from = currentVisual, - to = currentValue, - startSec = nowSec, - delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, - durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, - easing = spec.easing, - animatable = FloatAnimatable - ) + state.transitions[property] = + TransitionState( + property = property, + from = currentVisual, + to = currentValue, + startSec = nowSec, + delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, + durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, + easing = spec.easing, + animatable = FloatAnimatable, + ) } private fun maybeRetargetTransition( @@ -282,40 +306,39 @@ object StyleAnimationEngine { transitions: TransitionSpec, property: AnimatedStyleProperty, previousValue: Int, - currentValue: Int + currentValue: Int, ) { if (previousValue == currentValue) return - val spec = transitions.forProperty(property) ?: run { - state.transitions.remove(property) - return - } + val spec = + transitions.forProperty(property) ?: run { + state.transitions.remove(property) + return + } val currentVisual = transitionValue(state, property) ?: previousValue - state.transitions[property] = TransitionState( - property = property, - from = currentVisual, - to = currentValue, - startSec = nowSec, - delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, - durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, - easing = spec.easing, - animatable = ColorAnimatable - ) + state.transitions[property] = + TransitionState( + property = property, + from = currentVisual, + to = currentValue, + startSec = nowSec, + delaySec = spec.delayMs.coerceAtLeast(0) / 1000.0, + durationSec = spec.durationMs.coerceAtLeast(0) / 1000.0, + easing = spec.easing, + animatable = ColorAnimatable, + ) } @Suppress("UNCHECKED_CAST") private fun transitionValue(state: NodeAnimationState, property: AnimatedStyleProperty): T? { - val transition = state.transitions[property] as TransitionState? ?: return null - val (value, finished) = transition.valueAt(nowSec) - if (finished) { + val transition = state.transitions[property] as? TransitionState? ?: return null + val value = transition.valueAt(nowSec) + if (transition.isFinishedAt(nowSec)) { state.transitions.remove(property) } return value } - private fun sampleKeyframe( - definition: KeyframesDefinition, - running: RunningKeyframe - ): KeyframeValue { + private fun sampleKeyframe(definition: KeyframesDefinition, running: RunningKeyframe): KeyframeValue { val spec = running.spec if (spec.durationMs <= 0) { return sampleDefinition(definition, progress = 1f, easing = spec.easing) @@ -329,12 +352,15 @@ object StyleAnimationEngine { if (activeSec < 0.0) { return if (spec.fillMode == AnimationFillMode.Backwards || spec.fillMode == AnimationFillMode.Both) { - val startProgress = when (spec.direction) { - AnimationDirection.Reverse, - AnimationDirection.AlternateReverse -> 1f - AnimationDirection.Normal, - AnimationDirection.Alternate -> 0f - } + val startProgress = + when (spec.direction) { + AnimationDirection.Reverse, + AnimationDirection.AlternateReverse, + -> 1f + AnimationDirection.Normal, + AnimationDirection.Alternate, + -> 0f + } sampleDefinition(definition, startProgress, spec.easing) } else { KeyframeValue() @@ -377,72 +403,72 @@ object StyleAnimationEngine { return nowSec - running.startedAtSec } - private fun sampleDefinition( - definition: KeyframesDefinition, - progress: Float, - easing: Easing - ): KeyframeValue { - return KeyframeValue( + private fun sampleDefinition(definition: KeyframesDefinition, progress: Float, easing: Easing): KeyframeValue = + KeyframeValue( transform = sampleTransform(definition, progress, easing), opacity = sampleOpacity(definition, progress, easing), - color = sampleColor(definition, progress, easing) + color = sampleColor(definition, progress, easing), ) - } - private fun sampleTransform(definition: KeyframesDefinition, progress: Float, easing: Easing): UiTransform? { - val frames = definition.frames.filter { it.value.transform != null } - if (frames.isEmpty()) return null - if (progress <= frames.first().fraction) return frames.first().value.transform - if (progress >= frames.last().fraction) return frames.last().value.transform - val pair = surrounding(frames, progress) ?: return frames.last().value.transform - val t = normalizedProgress(pair.first.fraction, pair.second.fraction, progress) - val eased = easing.map(t) - return TransformAnimatable.interpolate( - pair.first.value.transform!!, - pair.second.value.transform!!, - eased + private fun sampleTransform(definition: KeyframesDefinition, progress: Float, easing: Easing): UiTransform? = + sampleProperty( + frames = definition.transformFrames, + progress = progress, + easing = easing, + valueOf = { it.transform }, + interpolate = { from, to, t -> TransformAnimatable.interpolate(from, to, t) }, ) - } - private fun sampleOpacity(definition: KeyframesDefinition, progress: Float, easing: Easing): Float? { - val frames = definition.frames.filter { it.value.opacity != null } - if (frames.isEmpty()) return null - if (progress <= frames.first().fraction) return frames.first().value.opacity - if (progress >= frames.last().fraction) return frames.last().value.opacity - val pair = surrounding(frames, progress) ?: return frames.last().value.opacity - val t = normalizedProgress(pair.first.fraction, pair.second.fraction, progress) - val eased = easing.map(t) - return FloatAnimatable.interpolate( - pair.first.value.opacity!!, - pair.second.value.opacity!!, - eased - ).coerceIn(0f, 1f) - } + private fun sampleOpacity(definition: KeyframesDefinition, progress: Float, easing: Easing): Float? = + sampleProperty( + frames = definition.opacityFrames, + progress = progress, + easing = easing, + valueOf = { it.opacity }, + interpolate = { from, to, t -> FloatAnimatable.interpolate(from, to, t).coerceIn(0f, 1f) }, + ) + + private fun sampleColor(definition: KeyframesDefinition, progress: Float, easing: Easing): Int? = + sampleProperty( + frames = definition.colorFrames, + progress = progress, + easing = easing, + valueOf = { it.color }, + interpolate = { from, to, t -> ColorAnimatable.interpolate(from, to, t) }, + ) - private fun sampleColor(definition: KeyframesDefinition, progress: Float, easing: Easing): Int? { - val frames = definition.frames.filter { it.value.color != null } + private inline fun sampleProperty( + frames: List, + progress: Float, + easing: Easing, + valueOf: (KeyframeValue) -> T?, + interpolate: (T, T, Float) -> T, + ): T? { if (frames.isEmpty()) return null - if (progress <= frames.first().fraction) return frames.first().value.color - if (progress >= frames.last().fraction) return frames.last().value.color - val pair = surrounding(frames, progress) ?: return frames.last().value.color - val t = normalizedProgress(pair.first.fraction, pair.second.fraction, progress) + if (progress <= frames.first().fraction) { + return valueOf(frames.first().value) + } + if (progress >= frames.last().fraction) { + return valueOf(frames.last().value) + } + val leftIndex = surroundingLeftIndex(frames, progress) + if (leftIndex < 0) { + return valueOf(frames.last().value) + } + val left = frames[leftIndex] + val right = frames[leftIndex + 1] + val t = normalizedProgress(left.fraction, right.fraction, progress) val eased = easing.map(t) - return ColorAnimatable.interpolate( - pair.first.value.color!!, - pair.second.value.color!!, - eased - ) + return interpolate(valueOf(left.value)!!, valueOf(right.value)!!, eased) } - private fun surrounding(frames: List, progress: Float): Pair? { + private fun surroundingLeftIndex(frames: List, progress: Float): Int { for (index in 0 until frames.lastIndex) { - val left = frames[index] - val right = frames[index + 1] - if (progress >= left.fraction && progress <= right.fraction) { - return left to right + if (progress >= frames[index].fraction && progress <= frames[index + 1].fraction) { + return index } } - return null + return -1 } private fun normalizedProgress(left: Float, right: Float, value: Float): Float { @@ -450,18 +476,15 @@ object StyleAnimationEngine { return ((value - left) / span).coerceIn(0f, 1f) } - private fun isIterationReversed(direction: AnimationDirection, iterationIndex: Int): Boolean { - return when (direction) { + private fun isIterationReversed(direction: AnimationDirection, iterationIndex: Int): Boolean = + when (direction) { AnimationDirection.Normal -> false AnimationDirection.Reverse -> true AnimationDirection.Alternate -> iterationIndex % 2 == 1 AnimationDirection.AlternateReverse -> iterationIndex % 2 == 0 } - } - private fun animationToken(node: DOMNode): Any { - return node.key ?: node - } + private fun animationToken(node: DOMNode): Any = node.key ?: node private fun logRateLimited(key: String, message: String) { val last = errorRateLimitByKey[key] diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ActiveColorSamplerOwnership.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ActiveColorSamplerOwnership.kt index 29cdf26..fab8ae0 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ActiveColorSamplerOwnership.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ActiveColorSamplerOwnership.kt @@ -2,8 +2,12 @@ package org.dreamfinity.dsgl.core.colorpicker sealed interface ActiveColorSamplerOwner { data object None : ActiveColorSamplerOwner + data object Popup : ActiveColorSamplerOwner - data class Inline(val token: Any) : ActiveColorSamplerOwner + + data class Inline( + val token: Any, + ) : ActiveColorSamplerOwner } /** @@ -22,26 +26,25 @@ class ActiveColorSamplerOwnershipRouter { previousInlineActiveTokens = emptySet() } - fun update( - popupEyedropperActive: Boolean, - inlineActiveTokens: Set - ): ActiveColorSamplerOwner { + fun update(popupEyedropperActive: Boolean, inlineActiveTokens: Set): ActiveColorSamplerOwner { val currentInlineTokens = inlineActiveTokens.toSet() val popupActivatedNow = popupEyedropperActive && !previousPopupActive - val inlineActivatedNow = currentInlineTokens.firstOrNull { token -> - !previousInlineActiveTokens.contains(token) - } + val inlineActivatedNow = + currentInlineTokens.firstOrNull { token -> + !previousInlineActiveTokens.contains(token) + } - val next = when { - inlineActivatedNow != null -> ActiveColorSamplerOwner.Inline(inlineActivatedNow) - activeOwner is ActiveColorSamplerOwner.Inline && + val next = + when { + inlineActivatedNow != null -> ActiveColorSamplerOwner.Inline(inlineActivatedNow) + activeOwner is ActiveColorSamplerOwner.Inline && currentInlineTokens.contains((activeOwner as ActiveColorSamplerOwner.Inline).token) -> activeOwner - popupActivatedNow -> ActiveColorSamplerOwner.Popup - activeOwner === ActiveColorSamplerOwner.Popup && popupEyedropperActive -> ActiveColorSamplerOwner.Popup - currentInlineTokens.isNotEmpty() -> ActiveColorSamplerOwner.Inline(currentInlineTokens.first()) - popupEyedropperActive -> ActiveColorSamplerOwner.Popup - else -> ActiveColorSamplerOwner.None - } + popupActivatedNow -> ActiveColorSamplerOwner.Popup + activeOwner === ActiveColorSamplerOwner.Popup && popupEyedropperActive -> ActiveColorSamplerOwner.Popup + currentInlineTokens.isNotEmpty() -> ActiveColorSamplerOwner.Inline(currentInlineTokens.first()) + popupEyedropperActive -> ActiveColorSamplerOwner.Popup + else -> ActiveColorSamplerOwner.None + } activeOwner = next previousPopupActive = popupEyedropperActive diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerController.kt index 3914dc8..60a7384 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerController.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerController.kt @@ -12,12 +12,12 @@ data class ColorPickerInputSlot( val key: String, val label: String, val labelRect: Rect, - val inputRect: Rect + val inputRect: Rect, ) data class ColorPickerModeOptionSlot( val mode: ColorFormatMode, - val rect: Rect + val rect: Rect, ) data class ColorPickerLayout( @@ -36,27 +36,28 @@ data class ColorPickerLayout( val pasteRect: Rect, val pipetteRect: Rect, val inputSlots: List, - val recentRects: List + val recentRects: List, ) -data class ColorPickerEyedropperOverlayModel( +data class ColorPickerEyedropperPreviewModel( val panelRect: Rect, val magnifierRect: Rect, val captureSourceRect: Rect, val centerRect: Rect, val swatchRect: Rect, val modeText: String, - val valueText: String + val valueText: String, ) class ColorPickerController( initial: ColorPickerState, private val style: ColorPickerStyle = ColorPickerStyle(), private val recentHistory: ColorRecentHistory = ColorRecentHistory(), - private val screenSampler: ScreenColorSampler? = ScreenColorSampler { x, y -> - val sampled = ScreenColorSamplerBridge.sampleColorAt(x, y) ?: return@ScreenColorSampler null - sampled.toArgbInt() - } + private val screenSampler: ScreenColorSampler? = + ScreenColorSampler { x, y -> + val sampled = ScreenColorSamplerBridge.sampleColorAt(x, y) ?: return@ScreenColorSampler null + sampled.toArgbInt() + }, ) { private var state: ColorPickerState = initial.withColor(initial.color) private var hueDeg: Float = ColorConversions.rgbToHsv(state.color).hueDeg @@ -93,8 +94,12 @@ class ColorPickerController( } private var eyedropperActive: Boolean = false private var eyedropperBaseColor: RgbaColor = state.color - private val eyedropperOverlayDrag: FloatingPaneDragModel = FloatingPaneDragModel() - private var eyedropperOverlayRect: Rect? = null + private val eyedropperPreviewDrag: FloatingPaneDragModel = FloatingPaneDragModel() + private var eyedropperPreviewRect: Rect? = null + private var domFocusedInputKey: String? = null + private var domInputFocusResyncRequested: Boolean = false + private var domLastFocusedInputKey: String? = null + private var domPendingFocusResyncKey: String? = null var onPreview: ((RgbaColor) -> Unit)? = null var onChange: ((RgbaColor) -> Unit)? = null @@ -113,8 +118,12 @@ class ColorPickerController( eyedropperActive = false interaction.clearDragTarget() modeDropdownOpen = false - eyedropperOverlayDrag.end() - eyedropperOverlayRect = null + eyedropperPreviewDrag.end() + eyedropperPreviewRect = null + domFocusedInputKey = null + domInputFocusResyncRequested = false + domLastFocusedInputKey = null + domPendingFocusResyncKey = null clearInputEdit() } @@ -132,6 +141,8 @@ class ColorPickerController( internal fun viewHoverPosition(): Pair = hoverX to hoverY + internal fun clearHover(): Boolean = interaction.clearHover() + internal fun viewModeDropdownOpen(): Boolean = modeDropdownOpen internal fun viewActiveInputKey(): String? = activeInputKey @@ -140,16 +151,160 @@ class ColorPickerController( internal fun viewInputValues(): Map = inputValues() - internal fun viewInputDefinitions(): List> { - return inputDefinitions().map { it.key to it.label } - } + internal fun viewInputDefinitions(): List> = inputDefinitions().map { it.key to it.label } internal fun viewRecentColors(): List = recentHistory.snapshot() internal fun viewCaretVisible(nowMs: Long): Boolean = caretVisible(nowMs) - internal fun viewFormattedColor(): String { - return ColorTextCodec.format(state.color, state.mode, state.alphaEnabled, state.rgbOrder) + internal fun viewFormattedColor(): String = + ColorTextCodec.format(state.color, state.mode, state.alphaEnabled, state.rgbOrder) + + internal fun handleDomInputFocused(key: String) { + domFocusedInputKey = key + domLastFocusedInputKey = key + if (activeInputKey == key) { + clearInputEdit() + } + } + + internal fun handleDomInputBlurred(key: String) { + if (domFocusedInputKey == key) { + domFocusedInputKey = null + } + } + + internal fun handleDomInputDraft(key: String, value: String): Boolean { + domFocusedInputKey = key + domLastFocusedInputKey = key + return semanticApplyInputDraftValue(key, value) + } + + internal fun commitDomInputEdit(key: String, value: String): Boolean { + domFocusedInputKey = key + domLastFocusedInputKey = key + return semanticCommitInputEdit(key, value) + } + + internal fun cancelDomInputEdit(key: String) { + if (domFocusedInputKey == key) { + domFocusedInputKey = null + } + semanticCancelInputEdit() + } + + internal fun resolveDomInputValue(key: String): String = inputValues()[key].orEmpty() + + internal fun consumeDomInputFocusResyncKey(): String? { + if (!domInputFocusResyncRequested) return null + domInputFocusResyncRequested = false + val key = domPendingFocusResyncKey + domPendingFocusResyncKey = null + return key + } + + internal fun semanticSetMode(mode: ColorFormatMode) { + state = state.copy(mode = mode) + modeDropdownOpen = false + requestDomInputFocusResync() + clearInputEdit() + } + + internal fun semanticSetRgbOrder(order: RgbChannelOrder) { + state = state.copy(rgbOrder = order) + modeDropdownOpen = false + requestDomInputFocusResync() + clearInputEdit() + } + + internal fun semanticToggleModeDropdown() { + modeDropdownOpen = !modeDropdownOpen + clearInputEdit() + } + + internal fun semanticCloseModeDropdown() { + modeDropdownOpen = false + } + + internal fun semanticUpdateFromField( + globalX: Int, + globalY: Int, + rect: Rect, + commit: Boolean, + ) { + updateFromField(globalX, globalY, rect, commit) + } + + internal fun semanticUpdateFromHue(globalX: Int, rect: Rect, commit: Boolean) { + updateFromHue(globalX, rect, commit) + } + + internal fun semanticUpdateFromAlpha(globalX: Int, rect: Rect, commit: Boolean) { + updateFromAlpha(globalX, rect, commit) + } + + internal fun semanticPreviewPreviousSwatch() { + applyColor(state.previous, notifyPreview = true, commit = false) + } + + internal fun semanticCommitCurrentColor() { + commitCurrentColor() + } + + internal fun semanticCopyCurrentColor() { + ColorClipboardSupport.copy(state.color, state.mode, state.alphaEnabled, state.rgbOrder) + } + + internal fun semanticPasteFromClipboard(): Boolean { + val parsed = ColorClipboardSupport.paste() ?: return false + val next = if (state.alphaEnabled) parsed.color else parsed.color.copy(a = 1f) + applyColor(next, notifyPreview = true, commit = false) + state = + state.copy( + mode = parsed.detectedMode, + rgbOrder = parsed.detectedRgbOrder ?: state.rgbOrder, + ) + return true + } + + internal fun semanticBeginEyedropper() { + beginEyedropper() + } + + internal fun semanticCancelEyedropper() { + cancelEyedropper() + } + + internal fun semanticAcceptEyedropperSelection() { + commitCurrentColor() + eyedropperActive = false + } + + internal fun semanticBeginInputEdit(key: String) { + interaction.textInput.begin(key, inputValues()[key].orEmpty()) + } + + internal fun semanticPreviewRecentSwatch(index: Int): Boolean { + val color = recentHistory.snapshot().getOrNull(index) ?: return false + applyColor(color, notifyPreview = true, commit = false) + return true + } + + internal fun semanticApplyInputDraftValue(key: String, value: String): Boolean = applyInputDraftValue(key, value) + + internal fun semanticCommitInputEdit(key: String, value: String): Boolean { + val applied = semanticApplyInputDraftValue(key, value) + semanticCommitCurrentColor() + semanticCancelInputEdit() + return applied + } + + internal fun semanticCancelInputEdit() { + clearInputEdit() + } + + internal fun semanticRequestClose() { + onRequestClose?.invoke() } fun beginEyedropper() { @@ -159,8 +314,8 @@ class ColorPickerController( eyedropperBaseColor = state.color } eyedropperActive = true - eyedropperOverlayDrag.end() - eyedropperOverlayRect = null + eyedropperPreviewDrag.end() + eyedropperPreviewRect = null modeDropdownOpen = false clearInputEdit() } @@ -168,8 +323,8 @@ class ColorPickerController( fun cancelEyedropper() { if (!eyedropperActive) return eyedropperActive = false - eyedropperOverlayDrag.end() - eyedropperOverlayRect = null + eyedropperPreviewDrag.end() + eyedropperPreviewRect = null applyColor(eyedropperBaseColor, notifyPreview = true, commit = false) } @@ -184,21 +339,24 @@ class ColorPickerController( val modeRowGap = style.modeRowGap.coerceAtLeast(1) val orderLabelWidth = style.rgbOrderLabelWidth.coerceAtLeast(32) val modeSelectMinWidth = style.modeSelectMinWidth.coerceAtLeast(84) - val showRgbOrderSwitch = state.alphaEnabled && - innerW >= (modeSelectMinWidth + modeRowGap + orderLabelWidth * 2 + orderSlotGap) - val modeRowReserved = if (showRgbOrderSwitch) { - orderLabelWidth * 2 + orderSlotGap + modeRowGap - } else { - 0 - } + val showRgbOrderSwitch = + state.alphaEnabled && + innerW >= (modeSelectMinWidth + modeRowGap + orderLabelWidth * 2 + orderSlotGap) + val modeRowReserved = + if (showRgbOrderSwitch) { + orderLabelWidth * 2 + orderSlotGap + modeRowGap + } else { + 0 + } val modeSelectMax = (innerW - modeRowReserved).coerceAtLeast(1) val modeSelectWidth = minOf(style.modeSelectWidth.coerceAtLeast(84), modeSelectMax).coerceAtLeast(1) - val modeSelectRect = Rect( - x = innerX, - y = innerY, - width = modeSelectWidth, - height = style.modeSelectHeight - ) + val modeSelectRect = + Rect( + x = innerX, + y = innerY, + width = modeSelectWidth, + height = style.modeSelectHeight, + ) val rgbaOrderRect: Rect? val argbOrderRect: Rect? if (showRgbOrderSwitch) { @@ -209,18 +367,20 @@ class ColorPickerController( val firstWidth = ((orderAvailable - gap) / 2).coerceAtLeast(1) val secondX = orderStartX + firstWidth + gap val secondWidth = (innerX + innerW - secondX).coerceAtLeast(1) - rgbaOrderRect = Rect( - x = orderStartX, - y = innerY, - width = firstWidth, - height = style.modeSelectHeight - ) - argbOrderRect = Rect( - x = secondX, - y = innerY, - width = secondWidth, - height = style.modeSelectHeight - ) + rgbaOrderRect = + Rect( + x = orderStartX, + y = innerY, + width = firstWidth, + height = style.modeSelectHeight, + ) + argbOrderRect = + Rect( + x = secondX, + y = innerY, + width = secondWidth, + height = style.modeSelectHeight, + ) } else { rgbaOrderRect = null argbOrderRect = null @@ -230,49 +390,54 @@ class ColorPickerController( argbOrderRect = null } val modeOptions = ArrayList(ColorFormatMode.entries.size) - val modeOptionsRect = if (modeDropdownOpen) { - val optionHeight = style.modeOptionHeight - val popupWidth = maxOf(modeSelectRect.width, style.modeSelectMinWidth) - val popupHeight = optionHeight * ColorFormatMode.entries.size + 2 - val minX = innerX - val maxX = (innerX + innerW - popupWidth).coerceAtLeast(innerX) - val popupX = if (minX <= maxX) modeSelectRect.x.coerceIn(minX, maxX) else minX - val preferredBelowY = modeSelectRect.y + modeSelectRect.height + 2 - val minY = innerY - val maxY = (bounds.y + bounds.height - padding - popupHeight).coerceAtLeast(minY) - val popupY = if (preferredBelowY <= maxY) { - preferredBelowY + val modeOptionsRect = + if (modeDropdownOpen) { + val optionHeight = style.modeOptionHeight + val popupWidth = maxOf(modeSelectRect.width, style.modeSelectMinWidth) + val popupHeight = optionHeight * ColorFormatMode.entries.size + 2 + val minX = innerX + val maxX = (innerX + innerW - popupWidth).coerceAtLeast(innerX) + val popupX = if (minX <= maxX) modeSelectRect.x.coerceIn(minX, maxX) else minX + val preferredBelowY = modeSelectRect.y + modeSelectRect.height + 2 + val minY = innerY + val maxY = (bounds.y + bounds.height - padding - popupHeight).coerceAtLeast(minY) + val popupY = + if (preferredBelowY <= maxY) { + preferredBelowY + } else { + if (minY <= maxY) (modeSelectRect.y - popupHeight - 2).coerceIn(minY, maxY) else minY + } + ColorFormatMode.entries.forEachIndexed { index, mode -> + modeOptions += + ColorPickerModeOptionSlot( + mode = mode, + rect = + Rect( + x = popupX + 1, + y = popupY + 1 + index * optionHeight, + width = popupWidth - 2, + height = optionHeight, + ), + ) + } + Rect(popupX, popupY, popupWidth, popupHeight) } else { - if (minY <= maxY) (modeSelectRect.y - popupHeight - 2).coerceIn(minY, maxY) else minY - } - ColorFormatMode.entries.forEachIndexed { index, mode -> - modeOptions += ColorPickerModeOptionSlot( - mode = mode, - rect = Rect( - x = popupX + 1, - y = popupY + 1 + index * optionHeight, - width = popupWidth - 2, - height = optionHeight - ) - ) + null } - Rect(popupX, popupY, popupWidth, popupHeight) - } else { - null - } var y = innerY + style.modeSelectHeight + rowGap val fieldRect = Rect(innerX, y, innerW, style.colorFieldHeight) y += style.colorFieldHeight + rowGap val hueRect = Rect(innerX, y, innerW, style.sliderHeight) y += style.sliderHeight + rowGap - val alphaRect = if (state.alphaEnabled) { - Rect(innerX, y, innerW, style.sliderHeight).also { - y += style.sliderHeight + rowGap + val alphaRect = + if (state.alphaEnabled) { + Rect(innerX, y, innerW, style.sliderHeight).also { + y += style.sliderHeight + rowGap + } + } else { + null } - } else { - null - } val swatchWidth = ((innerW - rowGap * 4) / 5).coerceAtLeast(24) val previousSwatchRect = Rect(innerX, y, swatchWidth, style.swatchHeight) @@ -289,31 +454,35 @@ class ColorPickerController( val labelWidth = style.inputLabelWidth.coerceAtLeast(12) inputDefs.forEachIndexed { index, def -> val slotX = innerX + index * (inputWidth + rowGap) - val localLabelWidth = if (def.key == "hex") { - labelWidth + 8 - } else { - labelWidth - } + val localLabelWidth = + if (def.key == "hex") { + labelWidth + 8 + } else { + labelWidth + } val inputX = slotX + localLabelWidth + style.inputLabelGap val inputW = (inputWidth - localLabelWidth - style.inputLabelGap).coerceAtLeast(20) - val labelRect = Rect( - x = slotX, - y = y, - width = localLabelWidth, - height = style.inputHeight - ) - val inputRect = Rect( - x = inputX, - y = y, - width = inputW, - height = style.inputHeight - ) - inputs += ColorPickerInputSlot( - key = def.key, - label = def.label, - labelRect = labelRect, - inputRect = inputRect - ) + val labelRect = + Rect( + x = slotX, + y = y, + width = localLabelWidth, + height = style.inputHeight, + ) + val inputRect = + Rect( + x = inputX, + y = y, + width = inputW, + height = style.inputHeight, + ) + inputs += + ColorPickerInputSlot( + key = def.key, + label = def.label, + labelRect = labelRect, + inputRect = inputRect, + ) } y += style.inputHeight + rowGap @@ -348,19 +517,20 @@ class ColorPickerController( pasteRect = pasteRect, pipetteRect = pipetteRect, inputSlots = inputs, - recentRects = recentRects + recentRects = recentRects, ) } fun preferredHeight(alphaEnabled: Boolean = state.alphaEnabled): Int { val rowGap = style.rowGap - val core = style.padding * 2 + - style.modeSelectHeight + rowGap + - style.colorFieldHeight + rowGap + - style.sliderHeight + rowGap + - (if (alphaEnabled) style.sliderHeight + rowGap else 0) + - style.swatchHeight + rowGap + - style.inputHeight + rowGap + val core = + style.padding * 2 + + style.modeSelectHeight + rowGap + + style.colorFieldHeight + rowGap + + style.sliderHeight + rowGap + + (if (alphaEnabled) style.sliderHeight + rowGap else 0) + + style.swatchHeight + rowGap + + style.inputHeight + rowGap val recentGrid = style.recentCellSize * 8 + style.recentCellGap * 7 return core + recentGrid } @@ -368,7 +538,7 @@ class ColorPickerController( fun appendCommands( layout: ColorPickerLayout, out: MutableList, - nowMs: Long = System.currentTimeMillis() + nowMs: Long = System.currentTimeMillis(), ) { val bounds = layout.bounds out += RenderCommand.DrawRect(bounds.x, bounds.y, bounds.width, bounds.height, style.panelBackgroundColor) @@ -397,34 +567,51 @@ class ColorPickerController( layout.inputSlots.forEach { slot -> val active = activeInputKey == slot.key val hovered = slot.inputRect.contains(hoverX, hoverY) - val border = when { - active -> style.inputActiveBorderColor - hovered -> style.buttonHoverColor - else -> style.inputBorderColor - } - out += RenderCommand.DrawText( - text = slot.label, - x = slot.labelRect.x + 2, - y = slot.labelRect.y + 2, - color = style.mutedTextColor, - fontSize = style.fontSize - ) - out += RenderCommand.DrawRect( - slot.inputRect.x, - slot.inputRect.y, - slot.inputRect.width, - slot.inputRect.height, - style.inputBackgroundColor - ) + val border = + when { + active -> style.inputActiveBorderColor + hovered -> style.buttonHoverColor + else -> style.inputBorderColor + } + out += + RenderCommand.DrawText( + text = slot.label, + x = slot.labelRect.x + 2, + y = slot.labelRect.y + 2, + color = style.mutedTextColor, + fontSize = style.fontSize, + ) + out += + RenderCommand.DrawRect( + slot.inputRect.x, + slot.inputRect.y, + slot.inputRect.width, + slot.inputRect.height, + style.inputBackgroundColor, + ) drawBorder(out, slot.inputRect, border) - val value = if (active) activeInputBuffer + if (caretVisible(nowMs)) "|" else "" else inputValues[slot.key].orEmpty() - out += RenderCommand.DrawText( - text = truncate(value, 12), - x = slot.inputRect.x + 4, - y = slot.inputRect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) + val value = + if (active) { + activeInputBuffer + + if (caretVisible( + nowMs, + ) + ) { + "|" + } else { + "" + } + } else { + inputValues[slot.key].orEmpty() + } + out += + RenderCommand.DrawText( + text = truncate(value, 12), + x = slot.inputRect.x + 4, + y = slot.inputRect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) } val recents = recentHistory.snapshot() @@ -444,11 +631,7 @@ class ColorPickerController( drawModeOptions(layout, out) } - fun appendEyedropperOverlay( - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { + fun appendEyedropperPreview(viewportWidth: Int, viewportHeight: Int, out: MutableList) { if (!eyedropperActive) return if (hoverX == Int.MIN_VALUE || hoverY == Int.MIN_VALUE) return @@ -464,64 +647,73 @@ class ColorPickerController( val preferredX = hoverX + style.eyedropperGapToCursor val preferredY = hoverY + style.eyedropperGapToCursor - val desiredRect = clampOverlayRect( - rect = Rect(preferredX, preferredY, panelWidth, panelHeight), - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) - val currentRect = eyedropperOverlayRect + val desiredRect = + clampPreviewRect( + rect = Rect(preferredX, preferredY, panelWidth, panelHeight), + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) + val currentRect = eyedropperPreviewRect if (currentRect == null || currentRect.width != panelWidth || currentRect.height != panelHeight) { - eyedropperOverlayRect = desiredRect - eyedropperOverlayDrag.begin(mouseX = hoverX, mouseY = hoverY, rect = desiredRect) + eyedropperPreviewRect = desiredRect + eyedropperPreviewDrag.begin(mouseX = hoverX, mouseY = hoverY, rect = desiredRect) } - val nextRect = eyedropperOverlayDrag.update( - mouseX = hoverX, - mouseY = hoverY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight, - clamp = ::clampOverlayRect - ) - eyedropperOverlayRect = nextRect + val nextRect = + eyedropperPreviewDrag.update( + mouseX = hoverX, + mouseY = hoverY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + clamp = ::clampPreviewRect, + ) + eyedropperPreviewRect = nextRect val panelX = nextRect.x val panelY = nextRect.y val magnifierX = panelX + 4 val magnifierY = panelY + 4 - out += RenderCommand.CaptureScreenRegion( - sourceX = hoverX - gridSize / 2, - sourceY = hoverY - gridSize / 2, - sourceWidth = gridSize, - sourceHeight = gridSize, - fallbackColor = state.color.toArgbInt() - ) + out += + RenderCommand.CaptureScreenRegion( + sourceX = hoverX - gridSize / 2, + sourceY = hoverY - gridSize / 2, + sourceWidth = gridSize, + sourceHeight = gridSize, + fallbackColor = state.color.toArgbInt(), + ) out += RenderCommand.DrawRect(panelX + 2, panelY + 2, panelWidth, panelHeight, style.panelShadowColor) - out += RenderCommand.DrawRect(panelX, panelY, panelWidth, panelHeight, style.eyedropperOverlayBackgroundColor) - drawBorder(out, Rect(panelX, panelY, panelWidth, panelHeight), style.eyedropperOverlayBorderColor) - out += RenderCommand.DrawCapturedScreenRegion( - x = magnifierX, - y = magnifierY, - width = magnifierContentSize, - height = magnifierContentSize - ) - if (style.eyedropperGridOverlayEnabled) { - drawEyedropperGridOverlay( + out += RenderCommand.DrawRect(panelX, panelY, panelWidth, panelHeight, style.eyedropperPreviewBackgroundColor) + drawBorder(out, Rect(panelX, panelY, panelWidth, panelHeight), style.eyedropperPreviewBorderColor) + out += + RenderCommand.DrawCapturedScreenRegion( + x = magnifierX, + y = magnifierY, + width = magnifierContentSize, + height = magnifierContentSize, + ) + if (style.eyedropperGridEnabled) { + drawEyedropperGrid( out = out, rect = Rect(magnifierX, magnifierY, magnifierContentSize, magnifierContentSize), columns = gridSize, rows = gridSize, cellSize = cell, - color = style.eyedropperGridOverlayColor + color = style.eyedropperGridColor, ) } - drawBorder(out, Rect(magnifierX, magnifierY, magnifierContentSize, magnifierContentSize), style.inputBorderColor) + drawBorder( + out, + Rect(magnifierX, magnifierY, magnifierContentSize, magnifierContentSize), + style.inputBorderColor, + ) val center = gridSize / 2 - val centerRect = Rect( - x = magnifierX + center * cell, - y = magnifierY + center * cell, - width = cell, - height = cell - ) + val centerRect = + Rect( + x = magnifierX + center * cell, + y = magnifierY + center * cell, + width = cell, + height = cell, + ) drawBorder(out, centerRect, style.eyedropperCenterBorderColor) val tooltipY = magnifierY + magnifierContentSize + 6 @@ -529,32 +721,35 @@ class ColorPickerController( val swatchRect = Rect(panelX + 6, tooltipY + 5, swatchSize, swatchSize) drawSwatch(swatchRect, state.color, out) - val modeText = if (state.mode == ColorFormatMode.RGB && state.alphaEnabled) { - "Mode: ${state.mode.name} (${state.rgbOrder.name})" - } else { - "Mode: ${state.mode.name}" - } + val modeText = + if (state.mode == ColorFormatMode.RGB && state.alphaEnabled) { + "Mode: ${state.mode.name} (${state.rgbOrder.name})" + } else { + "Mode: ${state.mode.name}" + } val valueText = ColorTextCodec.format(state.color, state.mode, state.alphaEnabled, state.rgbOrder) - out += RenderCommand.DrawText( - text = modeText, - x = swatchRect.x + swatchRect.width + 8, - y = tooltipY + 6, - color = style.mutedTextColor, - fontSize = style.fontSize - ) - out += RenderCommand.DrawText( - text = valueText, - x = swatchRect.x + swatchRect.width + 8, - y = tooltipY + 6 + style.fontSize, - color = style.textColor, - fontSize = style.fontSize - ) + out += + RenderCommand.DrawText( + text = modeText, + x = swatchRect.x + swatchRect.width + 8, + y = tooltipY + 6, + color = style.mutedTextColor, + fontSize = style.fontSize, + ) + out += + RenderCommand.DrawText( + text = valueText, + x = swatchRect.x + swatchRect.width + 8, + y = tooltipY + 6 + style.fontSize, + color = style.textColor, + fontSize = style.fontSize, + ) } - internal fun resolveEyedropperOverlayModel( + internal fun resolveEyedropperPreviewModel( viewportWidth: Int, - viewportHeight: Int - ): ColorPickerEyedropperOverlayModel? { + viewportHeight: Int, + ): ColorPickerEyedropperPreviewModel? { if (!eyedropperActive) return null if (hoverX == Int.MIN_VALUE || hoverY == Int.MIN_VALUE) return null @@ -570,60 +765,66 @@ class ColorPickerController( val preferredX = hoverX + style.eyedropperGapToCursor val preferredY = hoverY + style.eyedropperGapToCursor - val desiredRect = clampOverlayRect( - rect = Rect(preferredX, preferredY, panelWidth, panelHeight), - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) - val currentRect = eyedropperOverlayRect + val desiredRect = + clampPreviewRect( + rect = Rect(preferredX, preferredY, panelWidth, panelHeight), + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) + val currentRect = eyedropperPreviewRect if (currentRect == null || currentRect.width != panelWidth || currentRect.height != panelHeight) { - eyedropperOverlayRect = desiredRect - eyedropperOverlayDrag.begin(mouseX = hoverX, mouseY = hoverY, rect = desiredRect) + eyedropperPreviewRect = desiredRect + eyedropperPreviewDrag.begin(mouseX = hoverX, mouseY = hoverY, rect = desiredRect) } - val nextRect = eyedropperOverlayDrag.update( - mouseX = hoverX, - mouseY = hoverY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight, - clamp = ::clampOverlayRect - ) - eyedropperOverlayRect = nextRect - - val magnifierRect = Rect( - x = nextRect.x + 4, - y = nextRect.y + 4, - width = magnifierContentSize, - height = magnifierContentSize - ) + val nextRect = + eyedropperPreviewDrag.update( + mouseX = hoverX, + mouseY = hoverY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + clamp = ::clampPreviewRect, + ) + eyedropperPreviewRect = nextRect + + val magnifierRect = + Rect( + x = nextRect.x + 4, + y = nextRect.y + 4, + width = magnifierContentSize, + height = magnifierContentSize, + ) val center = gridSize / 2 - val centerRect = Rect( - x = magnifierRect.x + center * cell, - y = magnifierRect.y + center * cell, - width = cell, - height = cell - ) + val centerRect = + Rect( + x = magnifierRect.x + center * cell, + y = magnifierRect.y + center * cell, + width = cell, + height = cell, + ) val tooltipY = magnifierRect.y + magnifierRect.height + 6 val swatchSize = tooltipHeight - 10 val swatchRect = Rect(nextRect.x + 6, tooltipY + 5, swatchSize, swatchSize) - val modeText = if (state.mode == ColorFormatMode.RGB && state.alphaEnabled) { - "Mode: ${state.mode.name} (${state.rgbOrder.name})" - } else { - "Mode: ${state.mode.name}" - } + val modeText = + if (state.mode == ColorFormatMode.RGB && state.alphaEnabled) { + "Mode: ${state.mode.name} (${state.rgbOrder.name})" + } else { + "Mode: ${state.mode.name}" + } val valueText = ColorTextCodec.format(state.color, state.mode, state.alphaEnabled, state.rgbOrder) - return ColorPickerEyedropperOverlayModel( + return ColorPickerEyedropperPreviewModel( panelRect = nextRect, magnifierRect = magnifierRect, - captureSourceRect = Rect( - x = hoverX - gridSize / 2, - y = hoverY - gridSize / 2, - width = gridSize, - height = gridSize - ), + captureSourceRect = + Rect( + x = hoverX - gridSize / 2, + y = hoverY - gridSize / 2, + width = gridSize, + height = gridSize, + ), centerRect = centerRect, swatchRect = swatchRect, modeText = modeText, - valueText = valueText + valueText = valueText, ) } @@ -634,19 +835,19 @@ class ColorPickerController( } when (dragTarget) { ColorPickerDragTarget.Field -> { - updateFromField(globalX, globalY, layout.colorFieldRect, commit = false) + semanticUpdateFromField(globalX, globalY, layout.colorFieldRect, commit = false) return true } ColorPickerDragTarget.Hue -> { - updateFromHue(globalX, layout.hueRect, commit = false) + semanticUpdateFromHue(globalX, layout.hueRect, commit = false) return true } ColorPickerDragTarget.Alpha -> { val alphaRect = layout.alphaRect if (alphaRect != null) { - updateFromAlpha(globalX, alphaRect, commit = false) + semanticUpdateFromAlpha(globalX, alphaRect, commit = false) } return true } @@ -658,18 +859,22 @@ class ColorPickerController( } } - fun handleMouseDown(globalX: Int, globalY: Int, button: MouseButton, layout: ColorPickerLayout): Boolean { + fun handleMouseDown( + globalX: Int, + globalY: Int, + button: MouseButton, + layout: ColorPickerLayout, + ): Boolean { interaction.setHover(globalX, globalY) if (eyedropperActive) { return when (button) { MouseButton.LEFT -> { - commitCurrentColor() - eyedropperActive = false + semanticAcceptEyedropperSelection() true } MouseButton.RIGHT -> { - cancelEyedropper() + semanticCancelEyedropper() true } @@ -681,105 +886,89 @@ class ColorPickerController( modeDropdownOpen = false return true } - return layout.bounds.contains(globalX, globalY) || layout.modeOptionsRect?.contains(globalX, globalY) == true + return layout.bounds.contains(globalX, globalY) || + layout.modeOptionsRect?.contains(globalX, globalY) == true } - val modeOptionHit = if (modeDropdownOpen) { - layout.modeOptions.firstOrNull { it.rect.contains(globalX, globalY) } - } else { - null - } + val modeOptionHit = + if (modeDropdownOpen) { + layout.modeOptions.firstOrNull { it.rect.contains(globalX, globalY) } + } else { + null + } if (modeOptionHit != null) { - state = state.copy(mode = modeOptionHit.mode) - modeDropdownOpen = false - clearInputEdit() + semanticSetMode(modeOptionHit.mode) return true } if (layout.rgbaOrderRect?.contains(globalX, globalY) == true) { - state = state.copy(rgbOrder = RgbChannelOrder.RGBA) - modeDropdownOpen = false - clearInputEdit() + semanticSetRgbOrder(RgbChannelOrder.RGBA) return true } if (layout.argbOrderRect?.contains(globalX, globalY) == true) { - state = state.copy(rgbOrder = RgbChannelOrder.ARGB) - modeDropdownOpen = false - clearInputEdit() + semanticSetRgbOrder(RgbChannelOrder.ARGB) return true } if (layout.modeSelectRect.contains(globalX, globalY)) { - modeDropdownOpen = !modeDropdownOpen - clearInputEdit() + semanticToggleModeDropdown() return true } if (!layout.bounds.contains(globalX, globalY)) { - modeDropdownOpen = false - clearInputEdit() + semanticCloseModeDropdown() + semanticCancelInputEdit() return false } - modeDropdownOpen = false + semanticCloseModeDropdown() if (layout.colorFieldRect.contains(globalX, globalY)) { dragTarget = ColorPickerDragTarget.Field - clearInputEdit() - updateFromField(globalX, globalY, layout.colorFieldRect, commit = false) + semanticCancelInputEdit() + semanticUpdateFromField(globalX, globalY, layout.colorFieldRect, commit = false) return true } if (layout.hueRect.contains(globalX, globalY)) { dragTarget = ColorPickerDragTarget.Hue - clearInputEdit() - updateFromHue(globalX, layout.hueRect, commit = false) + semanticCancelInputEdit() + semanticUpdateFromHue(globalX, layout.hueRect, commit = false) return true } if (layout.alphaRect?.contains(globalX, globalY) == true) { dragTarget = ColorPickerDragTarget.Alpha - clearInputEdit() - updateFromAlpha(globalX, layout.alphaRect, commit = false) + semanticCancelInputEdit() + semanticUpdateFromAlpha(globalX, layout.alphaRect, commit = false) return true } if (layout.previousSwatchRect.contains(globalX, globalY)) { - applyColor(state.previous, notifyPreview = true, commit = false) + semanticPreviewPreviousSwatch() return true } if (layout.currentSwatchRect.contains(globalX, globalY)) { - commitCurrentColor() + semanticCommitCurrentColor() return true } if (layout.copyRect.contains(globalX, globalY)) { - ColorClipboardSupport.copy(state.color, state.mode, state.alphaEnabled, state.rgbOrder) + semanticCopyCurrentColor() return true } if (layout.pasteRect.contains(globalX, globalY)) { - val parsed = ColorClipboardSupport.paste() - if (parsed != null) { - val next = if (state.alphaEnabled) parsed.color else parsed.color.copy(a = 1f) - applyColor(next, notifyPreview = true, commit = false) - state = state.copy( - mode = parsed.detectedMode, - rgbOrder = parsed.detectedRgbOrder ?: state.rgbOrder - ) - } + semanticPasteFromClipboard() return true } if (layout.pipetteRect.contains(globalX, globalY)) { - beginEyedropper() + semanticBeginEyedropper() return true } val inputHit = layout.inputSlots.firstOrNull { it.inputRect.contains(globalX, globalY) } if (inputHit != null) { - interaction.textInput.begin(inputHit.key, inputValues()[inputHit.key].orEmpty()) + semanticBeginInputEdit(inputHit.key) return true } - clearInputEdit() + semanticCancelInputEdit() val recentIndex = layout.recentRects.indexOfFirst { it.contains(globalX, globalY) } if (recentIndex >= 0) { - val color = recentHistory.snapshot().getOrNull(recentIndex) - if (color != null) { - applyColor(color, notifyPreview = true, commit = false) - } + semanticPreviewRecentSwatch(recentIndex) return true } @@ -792,7 +981,7 @@ class ColorPickerController( val dragged = interaction.hasActiveDragTarget() interaction.clearDragTarget() if (dragged) { - commitCurrentColor() + semanticCommitCurrentColor() return true } return eyedropperActive @@ -800,34 +989,30 @@ class ColorPickerController( fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { if (eyedropperActive && keyCode == KeyCodes.ESCAPE) { - cancelEyedropper() + semanticCancelEyedropper() return true } if (KeyModifiers.shortcutDown && keyCode == KeyCodes.C) { - ColorClipboardSupport.copy(state.color, state.mode, state.alphaEnabled, state.rgbOrder) + semanticCopyCurrentColor() return true } if (KeyModifiers.shortcutDown && keyCode == KeyCodes.V) { - val parsed = ColorClipboardSupport.paste() ?: return true - applyColor(parsed.color, notifyPreview = true, commit = false) - state = state.copy( - mode = parsed.detectedMode, - rgbOrder = parsed.detectedRgbOrder ?: state.rgbOrder - ) + semanticPasteFromClipboard() return true } - val key = activeInputKey ?: run { - if (keyCode == KeyCodes.ESCAPE) { - if (modeDropdownOpen) { - modeDropdownOpen = false - return true + val key = + activeInputKey ?: run { + if (keyCode == KeyCodes.ESCAPE) { + if (modeDropdownOpen) { + semanticCloseModeDropdown() + return true + } } + return false } - return false - } when (keyCode) { KeyCodes.ESCAPE -> { - clearInputEdit() + semanticCancelInputEdit() return true } @@ -858,9 +1043,13 @@ class ColorPickerController( } fun sampleEyedropperAtHover() { - if (!eyedropperActive) return - if (hoverX == Int.MIN_VALUE || hoverY == Int.MIN_VALUE) return - sampleEyedropper(hoverX, hoverY, commit = false) + sampleEyedropperAtHoverAndReportChange() + } + + internal fun sampleEyedropperAtHoverAndReportChange(): Boolean { + if (!eyedropperActive) return false + if (hoverX == Int.MIN_VALUE || hoverY == Int.MIN_VALUE) return false + return sampleEyedropper(hoverX, hoverY, commit = false) } private fun commitInputEdit(key: String) { @@ -875,8 +1064,10 @@ class ColorPickerController( interaction.textInput.clear() } - private fun applyInputDraft(key: String): Boolean { - val value = activeInputBuffer.trim() + private fun applyInputDraft(key: String): Boolean = applyInputDraftValue(key, activeInputBuffer) + + private fun applyInputDraftValue(key: String, rawValue: String): Boolean { + val value = rawValue.trim() if (value.isEmpty()) return false val current = state.color when (key) { @@ -891,12 +1082,13 @@ class ColorPickerController( val g = if (key == "g") value.toFloatOrNull() ?: return false else current.g * 255f val b = if (key == "b") value.toFloatOrNull() ?: return false else current.b * 255f val a = if (key == "a") value.toFloatOrNull() ?: return false else current.a * 100f - val next = RgbaColor( - r = (r / 255f).coerceIn(0f, 1f), - g = (g / 255f).coerceIn(0f, 1f), - b = (b / 255f).coerceIn(0f, 1f), - a = (a / 100f).coerceIn(0f, 1f) - ) + val next = + RgbaColor( + r = (r / 255f).coerceIn(0f, 1f), + g = (g / 255f).coerceIn(0f, 1f), + b = (b / 255f).coerceIn(0f, 1f), + a = (a / 100f).coerceIn(0f, 1f), + ) applyColor(next, notifyPreview = true, commit = false) return true } @@ -905,33 +1097,46 @@ class ColorPickerController( val hue = if (key == "h") value.toFloatOrNull() ?: return false else hueDeg val currentHsl = ColorConversions.rgbToHsl(current, hueDeg) val currentHsv = ColorConversions.rgbToHsv(current, hueDeg) - val sValue = if (key == "s") value.toFloatOrNull() ?: return false else { - if (state.mode == ColorFormatMode.HSL) currentHsl.saturation * 100f else currentHsv.saturation * 100f - } - val thirdValue = when { - key == "l" || (key == "v" && state.mode == ColorFormatMode.HSB) -> value.toFloatOrNull() ?: return false - state.mode == ColorFormatMode.HSL -> currentHsl.lightness * 100f - else -> currentHsv.brightness * 100f - } - val next = if (state.mode == ColorFormatMode.HSL) { - ColorConversions.hslToRgb( - HslColor( - hueDeg = normalizeHue(hue), - saturation = (sValue / 100f).coerceIn(0f, 1f), - lightness = (thirdValue / 100f).coerceIn(0f, 1f) - ), - alpha = current.a - ) - } else { - ColorConversions.hsvToRgb( - HsvColor( - hueDeg = normalizeHue(hue), - saturation = (sValue / 100f).coerceIn(0f, 1f), - brightness = (thirdValue / 100f).coerceIn(0f, 1f) - ), - alpha = current.a - ) - } + val sValue = + if (key == "s") { + value.toFloatOrNull() ?: return false + } else { + if (state.mode == + ColorFormatMode.HSL + ) { + currentHsl.saturation * 100f + } else { + currentHsv.saturation * 100f + } + } + val thirdValue = + when { + key == "l" || (key == "v" && state.mode == ColorFormatMode.HSB) -> + value.toFloatOrNull() + ?: return false + state.mode == ColorFormatMode.HSL -> currentHsl.lightness * 100f + else -> currentHsv.brightness * 100f + } + val next = + if (state.mode == ColorFormatMode.HSL) { + ColorConversions.hslToRgb( + HslColor( + hueDeg = normalizeHue(hue), + saturation = (sValue / 100f).coerceIn(0f, 1f), + lightness = (thirdValue / 100f).coerceIn(0f, 1f), + ), + alpha = current.a, + ) + } else { + ColorConversions.hsvToRgb( + HsvColor( + hueDeg = normalizeHue(hue), + saturation = (sValue / 100f).coerceIn(0f, 1f), + brightness = (thirdValue / 100f).coerceIn(0f, 1f), + ), + alpha = current.a, + ) + } applyColor(next, notifyPreview = true, commit = false) return true } @@ -947,11 +1152,12 @@ class ColorPickerController( } ColorFormatMode.RGB -> { - val rgbValues = hashMapOf( - "r" to ((state.color.r * 255f).roundToInt().coerceIn(0, 255)).toString(), - "g" to ((state.color.g * 255f).roundToInt().coerceIn(0, 255)).toString(), - "b" to ((state.color.b * 255f).roundToInt().coerceIn(0, 255)).toString() - ) + val rgbValues = + hashMapOf( + "r" to ((state.color.r * 255f).roundToInt().coerceIn(0, 255)).toString(), + "g" to ((state.color.g * 255f).roundToInt().coerceIn(0, 255)).toString(), + "b" to ((state.color.b * 255f).roundToInt().coerceIn(0, 255)).toString(), + ) if (state.alphaEnabled) { rgbValues["a"] = ((state.color.a * 100f).roundToInt().coerceIn(0, 100)).toString() } @@ -962,7 +1168,10 @@ class ColorPickerController( ColorFormatMode.HSL -> { val hsl = ColorConversions.rgbToHsl(state.color, hueDeg) - values["h"] = hsl.hueDeg.roundToInt().toString() + values["h"] = + hsl.hueDeg + .roundToInt() + .toString() values["s"] = (hsl.saturation * 100f).roundToInt().toString() values["l"] = (hsl.lightness * 100f).roundToInt().toString() if (state.alphaEnabled) { @@ -972,7 +1181,10 @@ class ColorPickerController( ColorFormatMode.HSB -> { val hsb = ColorConversions.rgbToHsv(state.color, hueDeg) - values["h"] = hsb.hueDeg.roundToInt().toString() + values["h"] = + hsb.hueDeg + .roundToInt() + .toString() values["s"] = (hsb.saturation * 100f).roundToInt().toString() values["v"] = (hsb.brightness * 100f).roundToInt().toString() if (state.alphaEnabled) { @@ -989,13 +1201,14 @@ class ColorPickerController( ColorFormatMode.RGB -> { val defs = ArrayList() rgbInputOrder().forEach { key -> - defs += when (key) { - "r" -> InputDefinition("r", "R") - "g" -> InputDefinition("g", "G") - "b" -> InputDefinition("b", "B") - "a" -> InputDefinition("a", "A%") - else -> return@forEach - } + defs += + when (key) { + "r" -> InputDefinition("r", "R") + "g" -> InputDefinition("g", "G") + "b" -> InputDefinition("b", "B") + "a" -> InputDefinition("a", "A%") + else -> return@forEach + } } defs } @@ -1020,44 +1233,78 @@ class ColorPickerController( } } - private fun updateFromField(globalX: Int, globalY: Int, rect: Rect, commit: Boolean) { - val px = ((globalX - rect.x).toFloat() / rect.width.coerceAtLeast(1).toFloat()).coerceIn(0f, 1f) - val py = ((globalY - rect.y).toFloat() / rect.height.coerceAtLeast(1).toFloat()).coerceIn(0f, 1f) + private fun updateFromField( + globalX: Int, + globalY: Int, + rect: Rect, + commit: Boolean, + ) { + val px = + ( + (globalX - rect.x).toFloat() / + rect.width + .coerceAtLeast(1) + .toFloat() + ).coerceIn(0f, 1f) + val py = + ( + (globalY - rect.y).toFloat() / + rect.height + .coerceAtLeast(1) + .toFloat() + ).coerceIn(0f, 1f) val saturation = px val brightness = 1f - py - val next = ColorConversions.hsvToRgb( - hsv = HsvColor(hueDeg = hueDeg, saturation = saturation, brightness = brightness), - alpha = state.color.a - ) + val next = + ColorConversions.hsvToRgb( + hsv = HsvColor(hueDeg = hueDeg, saturation = saturation, brightness = brightness), + alpha = state.color.a, + ) applyColor(next, notifyPreview = true, commit = commit) } private fun updateFromHue(globalX: Int, rect: Rect, commit: Boolean) { - val progress = ((globalX - rect.x).toFloat() / rect.width.coerceAtLeast(1).toFloat()).coerceIn(0f, 1f) + val progress = + ( + (globalX - rect.x).toFloat() / + rect.width + .coerceAtLeast(1) + .toFloat() + ).coerceIn(0f, 1f) hueDeg = progress * 360f val hsv = ColorConversions.rgbToHsv(state.color, hueDeg) - val next = ColorConversions.hsvToRgb( - hsv.copy(hueDeg = hueDeg), - alpha = state.color.a - ) + val next = + ColorConversions.hsvToRgb( + hsv.copy(hueDeg = hueDeg), + alpha = state.color.a, + ) applyColor(next, notifyPreview = true, commit = commit) } private fun updateFromAlpha(globalX: Int, rect: Rect, commit: Boolean) { if (!state.alphaEnabled) return - val progress = ((globalX - rect.x).toFloat() / rect.width.coerceAtLeast(1).toFloat()).coerceIn(0f, 1f) + val progress = + ( + (globalX - rect.x).toFloat() / + rect.width + .coerceAtLeast(1) + .toFloat() + ).coerceIn(0f, 1f) applyColor(state.color.copy(a = progress), notifyPreview = true, commit = commit) } - private fun sampleEyedropper(x: Int, y: Int, commit: Boolean) { - val argb = screenSampler?.sampleColorAt(x, y) ?: return + private fun sampleEyedropper(x: Int, y: Int, commit: Boolean): Boolean { + val argb = screenSampler?.sampleColorAt(x, y) ?: return false val sampled = RgbaColor.fromArgbInt(argb) - val color = if (state.alphaEnabled) { - sampled.copy(a = state.color.a) - } else { - sampled.copy(a = 1f) - } + val color = + if (state.alphaEnabled) { + sampled.copy(a = state.color.a) + } else { + sampled.copy(a = 1f) + } + val before = state.color.toArgbInt() applyColor(color, notifyPreview = true, commit = commit) + return state.color.toArgbInt() != before } private fun normalizedEyedropperGridSize(): Int { @@ -1065,7 +1312,7 @@ class ColorPickerController( return if ((raw and 1) == 0) raw + 1 else raw } - private fun clampOverlayRect(rect: Rect, viewportWidth: Int, viewportHeight: Int): Rect { + private fun clampPreviewRect(rect: Rect, viewportWidth: Int, viewportHeight: Int): Rect { val safeViewportW = viewportWidth.coerceAtLeast(rect.width + 4) val safeViewportH = viewportHeight.coerceAtLeast(rect.height + 4) val minX = 2 @@ -1076,7 +1323,7 @@ class ColorPickerController( x = rect.x.coerceIn(minX, maxX), y = rect.y.coerceIn(minY, maxY), width = rect.width, - height = rect.height + height = rect.height, ) } @@ -1106,20 +1353,22 @@ class ColorPickerController( val fill = if (hovered || modeDropdownOpen) style.buttonHoverColor else style.buttonBackgroundColor out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, rect.height, fill) drawBorder(out, rect, if (modeDropdownOpen) style.inputActiveBorderColor else style.inputBorderColor) - out += RenderCommand.DrawText( - text = state.mode.name, - x = rect.x + 6, - y = rect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) - out += RenderCommand.DrawText( - text = if (modeDropdownOpen) "^" else "v", - x = rect.x + rect.width - 12, - y = rect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) + out += + RenderCommand.DrawText( + text = state.mode.name, + x = rect.x + 6, + y = rect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) + out += + RenderCommand.DrawText( + text = if (modeDropdownOpen) "^" else "v", + x = rect.x + rect.width - 12, + y = rect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) } private fun drawRgbOrderSwitch(layout: ColorPickerLayout, out: MutableList) { @@ -1130,14 +1379,14 @@ class ColorPickerController( label = "RGBA", active = state.rgbOrder == RgbChannelOrder.RGBA, hovered = rgbaRect.contains(hoverX, hoverY), - out = out + out = out, ) drawOrderLabel( rect = argbRect, label = "ARGB", active = state.rgbOrder == RgbChannelOrder.ARGB, hovered = argbRect.contains(hoverX, hoverY), - out = out + out = out, ) } @@ -1146,66 +1395,84 @@ class ColorPickerController( label: String, active: Boolean, hovered: Boolean, - out: MutableList + out: MutableList, ) { - val fill = when { - active -> style.buttonActiveColor - hovered -> style.buttonHoverColor - else -> style.buttonBackgroundColor - } + val fill = + when { + active -> style.buttonActiveColor + hovered -> style.buttonHoverColor + else -> style.buttonBackgroundColor + } out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, rect.height, fill) drawBorder(out, rect, if (active) style.inputActiveBorderColor else style.inputBorderColor) - out += RenderCommand.DrawText( - text = label, - x = rect.x + 6, - y = rect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) + out += + RenderCommand.DrawText( + text = label, + x = rect.x + 6, + y = rect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) } private fun drawModeOptions(layout: ColorPickerLayout, out: MutableList) { val popupRect = layout.modeOptionsRect ?: return - out += RenderCommand.DrawRect(popupRect.x, popupRect.y, popupRect.width, popupRect.height, style.inputBackgroundColor) + out += + RenderCommand.DrawRect( + popupRect.x, + popupRect.y, + popupRect.width, + popupRect.height, + style.inputBackgroundColor, + ) drawBorder(out, popupRect, style.inputActiveBorderColor) layout.modeOptions.forEach { option -> val hovered = option.rect.contains(hoverX, hoverY) val selected = option.mode == state.mode if (hovered || selected) { - out += RenderCommand.DrawRect( - option.rect.x, - option.rect.y, - option.rect.width, - option.rect.height, - if (selected) style.buttonActiveColor else style.buttonHoverColor - ) + out += + RenderCommand.DrawRect( + option.rect.x, + option.rect.y, + option.rect.width, + option.rect.height, + if (selected) style.buttonActiveColor else style.buttonHoverColor, + ) } - out += RenderCommand.DrawText( - text = option.mode.name, - x = option.rect.x + 6, - y = option.rect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) + out += + RenderCommand.DrawText( + text = option.mode.name, + x = option.rect.x + 6, + y = option.rect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) } } - private fun drawButton(rect: Rect, label: String, hovered: Boolean, out: MutableList) { - out += RenderCommand.DrawRect( - rect.x, - rect.y, - rect.width, - rect.height, - if (hovered) style.buttonHoverColor else style.buttonBackgroundColor - ) + private fun drawButton( + rect: Rect, + label: String, + hovered: Boolean, + out: MutableList, + ) { + out += + RenderCommand.DrawRect( + rect.x, + rect.y, + rect.width, + rect.height, + if (hovered) style.buttonHoverColor else style.buttonBackgroundColor, + ) drawBorder(out, rect, style.inputBorderColor) - out += RenderCommand.DrawText( - text = label, - x = rect.x + 4, - y = rect.y + 2, - color = style.textColor, - fontSize = style.fontSize - ) + out += + RenderCommand.DrawText( + text = label, + x = rect.x + 4, + y = rect.y + 2, + color = style.textColor, + fontSize = style.fontSize, + ) } private fun drawSwatch(rect: Rect, color: RgbaColor, out: MutableList) { @@ -1215,13 +1482,14 @@ class ColorPickerController( } private fun drawColorField(rect: Rect, out: MutableList) { - out += RenderCommand.DrawColorField( - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height, - hueDeg = hueDeg - ) + out += + RenderCommand.DrawColorField( + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + hueDeg = hueDeg, + ) drawBorder(out, rect, style.inputBorderColor) } @@ -1234,12 +1502,13 @@ class ColorPickerController( } private fun drawHueSlider(rect: Rect, out: MutableList) { - out += RenderCommand.DrawHueBar( - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height - ) + out += + RenderCommand.DrawHueBar( + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + ) drawBorder(out, rect, style.inputBorderColor) } @@ -1251,13 +1520,14 @@ class ColorPickerController( private fun drawAlphaSlider(rect: Rect, out: MutableList) { drawChecker(rect, out) val rgb = state.color.copy(a = 1f) - out += RenderCommand.DrawAlphaBar( - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height, - rgbColor = rgb.toArgbInt() - ) + out += + RenderCommand.DrawAlphaBar( + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + rgbColor = rgb.toArgbInt(), + ) drawBorder(out, rect, style.inputBorderColor) } @@ -1266,13 +1536,13 @@ class ColorPickerController( out += RenderCommand.DrawRect(x - 1, rect.y - 1, 3, rect.height + 2, style.thumbOutlineColor) } - private fun drawEyedropperGridOverlay( + private fun drawEyedropperGrid( out: MutableList, rect: Rect, columns: Int, rows: Int, cellSize: Int, - color: Int + color: Int, ) { if (rect.width <= 1 || rect.height <= 1) return if (cellSize <= 0) return @@ -1289,17 +1559,19 @@ class ColorPickerController( out += RenderCommand.DrawRect(rect.x, lineY, rect.width, 1, color) } } + private fun drawChecker(rect: Rect, out: MutableList) { if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawCheckerboard( - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height, - cellSize = 4, - lightColor = style.checkerLightColor, - darkColor = style.checkerDarkColor - ) + out += + RenderCommand.DrawCheckerboard( + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + cellSize = 4, + lightColor = style.checkerLightColor, + darkColor = style.checkerDarkColor, + ) } private fun drawBorder(out: MutableList, rect: Rect, color: Int) { @@ -1310,9 +1582,7 @@ class ColorPickerController( out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) } - private fun caretVisible(nowMs: Long): Boolean { - return ((nowMs / 500L) % 2L) == 0L - } + private fun caretVisible(nowMs: Long): Boolean = ((nowMs / 500L) % 2L) == 0L private fun truncate(value: String, maxChars: Int): String { if (value.length <= maxChars) return value @@ -1334,9 +1604,14 @@ class ColorPickerController( } } + private fun requestDomInputFocusResync() { + val key = domFocusedInputKey ?: domLastFocusedInputKey ?: return + domPendingFocusResyncKey = key + domInputFocusResyncRequested = true + } + private data class InputDefinition( val key: String, - val label: String + val label: String, ) } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInfrastructure.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInfrastructure.kt index ebd4398..d57ef83 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInfrastructure.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInfrastructure.kt @@ -5,7 +5,13 @@ import org.dreamfinity.dsgl.core.input.ClipboardBridge fun interface ScreenColorSampler { fun sampleColorAt(x: Int, y: Int): Int? - fun sampleArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { + fun sampleArea( + x: Int, + y: Int, + width: Int, + height: Int, + outArgb: IntArray, + ): Boolean { if (width <= 0 || height <= 0) return false val required = width * height if (outArgb.size < required) return false @@ -44,9 +50,13 @@ object ScreenColorSamplerBridge { return RgbaColor.fromArgbInt(argb).normalized() } - fun sampleArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { - return sampler?.sampleArea(x, y, width, height, outArgb) == true - } + fun sampleArea( + x: Int, + y: Int, + width: Int, + height: Int, + outArgb: IntArray, + ): Boolean = sampler?.sampleArea(x, y, width, height, outArgb) == true } object ColorClipboardSupport { @@ -58,7 +68,7 @@ object ColorClipboardSupport { color: RgbaColor, mode: ColorFormatMode, includeAlpha: Boolean, - rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA + rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA, ) { val text = ColorTextCodec.format(color, mode, includeAlpha, rgbOrder) ClipboardBridge.writeText(text) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInteractionSession.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInteractionSession.kt index e8469ea..7288308 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInteractionSession.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInteractionSession.kt @@ -4,7 +4,7 @@ internal enum class ColorPickerDragTarget { None, Field, Hue, - Alpha + Alpha, } internal class ColorPickerTextInputSession { @@ -48,6 +48,13 @@ internal class ColorPickerInteractionSession { hoverY = y } + fun clearHover(): Boolean { + if (hoverX == Int.MIN_VALUE && hoverY == Int.MIN_VALUE) return false + hoverX = Int.MIN_VALUE + hoverY = Int.MIN_VALUE + return true + } + fun clearDragTarget() { dragTarget = ColorPickerDragTarget.None } @@ -58,7 +65,6 @@ internal class ColorPickerInteractionSession { clearDragTarget() modeDropdownOpen = false textInput.clear() - hoverX = Int.MIN_VALUE - hoverY = Int.MIN_VALUE + clearHover() } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngine.kt similarity index 51% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupRuntime.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngine.kt index 8b711f2..1ee8a88 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupRuntime.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngine.kt @@ -1,23 +1,29 @@ package org.dreamfinity.dsgl.core.colorpicker +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorPickerDebugCounters import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope import org.dreamfinity.dsgl.core.popup.FloatingPaneDragModel +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand -interface ColorPickerPopupHost { +interface ColorPickerPopupPortalService { fun open(request: ColorPickerPopupRequest) + fun close(owner: Any) + fun closeAll() + fun isOpenFor(owner: Any): Boolean + fun isOpen(): Boolean } data class ColorPickerPopupRequest( val owner: Any, - val ownerScope: OverlayOwnerScope = OverlayOwnerScope.Application, + val ownerDomain: ScreenDomainId = ScreenDomainId.Application, val anchorRect: Rect, val title: String = "Color Picker", val state: ColorPickerState, @@ -28,10 +34,18 @@ data class ColorPickerPopupRequest( val onPreview: ((RgbaColor) -> Unit)? = null, val onChange: ((RgbaColor) -> Unit)? = null, val onCommit: ((RgbaColor) -> Unit)? = null, - val onClose: (() -> Unit)? = null + val onClose: (() -> Unit)? = null, ) -class ColorPickerPopupEngine : ColorPickerPopupHost { +class ColorPickerPopupEngine : ColorPickerPopupPortalService { + private data class LayoutDirtyKey( + val bodyRect: Rect, + val mode: ColorFormatMode, + val rgbOrder: RgbChannelOrder, + val alphaEnabled: Boolean, + val modeDropdownOpen: Boolean, + ) + private data class PopupState( val owner: Any, var request: ColorPickerPopupRequest, @@ -41,8 +55,9 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { var bodyRect: Rect, var closeRect: Rect, var layout: ColorPickerLayout, + var layoutDirtyKey: LayoutDirtyKey? = null, val dragModel: FloatingPaneDragModel = FloatingPaneDragModel(), - var consumedEyedropperPress: Boolean = false + var consumedEyedropperPress: Boolean = false, ) private var popup: PopupState? = null @@ -51,6 +66,12 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { private val headerHeight: Int = 26 private val panelPadding: Int = 6 private val positionStore: ColorPickerPopupPositionStore = ColorPickerPopupPositionStore() + private val debugCountersEnabled: Boolean = + java.lang.Boolean + .getBoolean("dsgl.colorPicker.debugCounters") + private val debugReportIntervalMs: Long = 4000L + private val nanosPerMillisecond: Double = 1_000_000.0 + private var debugNextReportAtMs: Long = 0L override fun open(request: ColorPickerPopupRequest) { val current = popup @@ -60,32 +81,40 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { } current?.let { positionStore.remember(it.owner, it.panelRect) - it.request.onClose?.invoke() + it.request.onClose + ?.invoke() } - val controller = ColorPickerController( - initial = request.state, - style = request.style - ) + val controller = + ColorPickerController( + initial = request.state, + style = request.style, + ) val rememberedPanel = positionStore.remembered(request.owner) val initialX = rememberedPanel?.x ?: request.anchorRect.x val initialY = rememberedPanel?.y ?: request.anchorRect.y val initialRect = Rect(initialX, initialY, request.width.coerceAtLeast(220), 1) val initialBody = Rect(initialRect.x + panelPadding, initialRect.y + headerHeight + panelPadding, 1, 1) + ColorPickerDebugCounters.onBuildLayoutCall(request.ownerDomain == ScreenDomainId.System) val initialLayout = controller.buildLayout(initialBody) - val state = PopupState( - owner = request.owner, - request = request, - controller = controller, - panelRect = initialRect, - headerRect = Rect(initialRect.x, initialRect.y, initialRect.width, headerHeight), - bodyRect = initialBody, - closeRect = Rect(initialRect.x + initialRect.width - 20, initialRect.y + 3, 16, 18), - layout = initialLayout - ) + val state = + PopupState( + owner = request.owner, + request = request, + controller = controller, + panelRect = initialRect, + headerRect = Rect(initialRect.x, initialRect.y, initialRect.width, headerHeight), + bodyRect = initialBody, + closeRect = Rect(initialRect.x + initialRect.width - 20, initialRect.y + 3, 16, 18), + layout = initialLayout, + ) popup = state bindController(state) relayout(state, keepPosition = rememberedPanel != null) + if (debugCountersEnabled) { + ColorPickerDebugCounters.reset() + debugNextReportAtMs = System.currentTimeMillis() + debugReportIntervalMs + } } fun sync(request: ColorPickerPopupRequest) { @@ -123,16 +152,24 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { if (current.owner != owner) return positionStore.remember(current.owner, current.panelRect) current.dragModel.end() - current.request.onClose?.invoke() + current.request.onClose + ?.invoke() popup = null + if (debugCountersEnabled) { + debugNextReportAtMs = 0L + } } override fun closeAll() { val current = popup ?: return positionStore.remember(current.owner, current.panelRect) current.dragModel.end() - current.request.onClose?.invoke() + current.request.onClose + ?.invoke() popup = null + if (debugCountersEnabled) { + debugNextReportAtMs = 0L + } } override fun isOpenFor(owner: Any): Boolean { @@ -178,10 +215,10 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { return current.request.style } - internal fun debugOwnerScope(owner: Any): OverlayOwnerScope? { + internal fun debugOwnerDomain(owner: Any): ScreenDomainId? { val current = popup ?: return null if (current.owner != owner) return null - return current.request.ownerScope + return current.request.ownerDomain } internal fun debugController(owner: Any): ColorPickerController? { @@ -190,44 +227,40 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { return current.controller } - internal fun debugActiveController(): ColorPickerController? { - return popup?.controller - } + internal fun debugActiveController(): ColorPickerController? = popup?.controller - internal fun debugActiveLayout(): ColorPickerLayout? { - return popup?.layout - } + internal fun debugActiveLayout(): ColorPickerLayout? = popup?.layout - internal fun debugActiveStyle(): ColorPickerStyle? { - return popup?.request?.style - } + internal fun debugActiveStyle(): ColorPickerStyle? = popup?.request?.style - internal fun debugActivePanelRect(): Rect? { - return popup?.panelRect - } + internal fun debugActivePanelRect(): Rect? = popup?.panelRect - internal fun debugActiveOwnerScope(): OverlayOwnerScope? { - return popup?.request?.ownerScope - } + internal fun debugActiveOwnerDomain(): ScreenDomainId? = popup?.request?.ownerDomain + + internal fun debugIsDraggingPopup(): Boolean = popup?.dragModel?.dragging == true - internal fun debugIsDraggingPopup(): Boolean { - return popup?.dragModel?.dragging == true + internal fun debugResetCounters() { + ColorPickerDebugCounters.reset() } + internal fun debugCountersSnapshot(): ColorPickerDebugCounters.Snapshot = ColorPickerDebugCounters.snapshot() + internal fun forcePanelRect(owner: Any, panelRect: Rect) { val current = popup ?: return if (current.owner != owner) return - val clamped = ColorPickerPopupGeometry.clampPanel( - rect = panelRect, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) + val clamped = + ColorPickerPopupGeometry.clampPanel( + rect = panelRect, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) if (clamped == current.panelRect) return current.panelRect = clamped rebuildRects(current) } fun onFrame(viewportWidth: Int, viewportHeight: Int) { + reportDebugCountersIfDue() if (this.viewportWidth != viewportWidth || this.viewportHeight != viewportHeight) { this.viewportWidth = viewportWidth this.viewportHeight = viewportHeight @@ -238,13 +271,14 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { fun onCursorPosition(mouseX: Int, mouseY: Int) { val current = popup ?: return if (current.dragModel.dragging) { - val clamped = current.dragModel.update( - mouseX = mouseX, - mouseY = mouseY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight, - clamp = ColorPickerPopupGeometry::clampPanel - ) + val clamped = + current.dragModel.update( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + clamp = ColorPickerPopupGeometry::clampPanel, + ) if (clamped != current.panelRect) { current.panelRect = clamped rebuildRects(current) @@ -255,80 +289,100 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { current.controller.handleMouseMove(mouseX, mouseY, current.layout) } - fun captureEyedropperSample() { - popup?.controller?.sampleEyedropperAtHover() - } + fun captureEyedropperSample(): Boolean = popup?.controller?.sampleEyedropperAtHoverAndReportChange() == true - fun hasActiveEyedropper(): Boolean { - return popup?.controller?.isEyedropperActive() == true - } + fun hasActiveEyedropper(): Boolean = popup?.controller?.isEyedropperActive() == true - fun appendOverlayCommands(out: MutableList) { + fun appendPortalCommands(out: MutableList) { val current = popup ?: return refreshLayout(current) val panel = current.panelRect out += RenderCommand.PushClip(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)) - out += RenderCommand.DrawRect(panel.x + 2, panel.y + 2, panel.width, panel.height, current.request.style.panelShadowColor) - out += RenderCommand.DrawRect(panel.x, panel.y, panel.width, panel.height, current.request.style.panelBackgroundColor) + out += + RenderCommand.DrawRect( + panel.x + 2, + panel.y + 2, + panel.width, + panel.height, + current.request.style.panelShadowColor, + ) + out += + RenderCommand.DrawRect( + panel.x, + panel.y, + panel.width, + panel.height, + current.request.style.panelBackgroundColor, + ) drawBorder(out, panel, current.request.style.panelBorderColor) - out += RenderCommand.DrawRect( - current.headerRect.x, - current.headerRect.y, - current.headerRect.width, - current.headerRect.height, - current.request.style.buttonBackgroundColor - ) + out += + RenderCommand.DrawRect( + current.headerRect.x, + current.headerRect.y, + current.headerRect.width, + current.headerRect.height, + current.request.style.buttonBackgroundColor, + ) drawBorder(out, current.headerRect, current.request.style.inputBorderColor) - out += RenderCommand.DrawText( - text = current.request.title, - x = current.headerRect.x + 6, - y = current.headerRect.y + 3, - color = current.request.style.textColor, - fontSize = current.request.style.fontSize - ) - out += RenderCommand.DrawRect( - current.closeRect.x, - current.closeRect.y, - current.closeRect.width, - current.closeRect.height, - current.request.style.buttonBackgroundColor - ) + out += + RenderCommand.DrawText( + text = current.request.title, + x = current.headerRect.x + 6, + y = current.headerRect.y + 3, + color = current.request.style.textColor, + fontSize = current.request.style.fontSize, + ) + out += + RenderCommand.DrawRect( + current.closeRect.x, + current.closeRect.y, + current.closeRect.width, + current.closeRect.height, + current.request.style.buttonBackgroundColor, + ) drawBorder(out, current.closeRect, current.request.style.inputBorderColor) - out += RenderCommand.DrawText( - text = "x", - x = current.closeRect.x + 5, - y = current.closeRect.y + 2, - color = current.request.style.textColor, - fontSize = current.request.style.fontSize - ) + out += + RenderCommand.DrawText( + text = "x", + x = current.closeRect.x + 5, + y = current.closeRect.y + 2, + color = current.request.style.textColor, + fontSize = current.request.style.fontSize, + ) - out += RenderCommand.PushClip(current.bodyRect.x, current.bodyRect.y, current.bodyRect.width, current.bodyRect.height) - appendOverlayBodyCommands(out) + out += + RenderCommand.PushClip( + current.bodyRect.x, + current.bodyRect.y, + current.bodyRect.width, + current.bodyRect.height, + ) + appendPortalBodyCommands(out) out += RenderCommand.PopClip - appendEyedropperOverlayCommands( + appendEyedropperPortalCommands( viewportWidth = viewportWidth.coerceAtLeast(1), viewportHeight = viewportHeight.coerceAtLeast(1), - out = out + out = out, ) out += RenderCommand.PopClip } - internal fun appendOverlayBodyCommands(out: MutableList) { + internal fun appendPortalBodyCommands(out: MutableList) { val current = popup ?: return current.controller.appendCommands(current.layout, out) } - internal fun appendEyedropperOverlayCommands( + internal fun appendEyedropperPortalCommands( viewportWidth: Int = this.viewportWidth.coerceAtLeast(1), viewportHeight: Int = this.viewportHeight.coerceAtLeast(1), - out: MutableList + out: MutableList, ) { val current = popup ?: return - current.controller.appendEyedropperOverlay( + current.controller.appendEyedropperPreview( viewportWidth = viewportWidth.coerceAtLeast(1), viewportHeight = viewportHeight.coerceAtLeast(1), - out = out + out = out, ) } @@ -371,6 +425,62 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { return handled } + fun shouldRouteSystemInputSlotMouseDownToDom(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + val current = popup ?: return false + if (current.request.ownerDomain != ScreenDomainId.System) return false + if (button != MouseButton.LEFT) return false + if (current.controller.isEyedropperActive()) return false + val hit = + current.layout.inputSlots + .any { slot -> slot.inputRect.contains(mouseX, mouseY) } + ColorPickerDebugCounters.onRouteSystemInputSlotCheck(hit) + return hit + } + + fun shouldRouteSystemBodyIntentMouseDownToDom(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + val current = popup ?: return false + if (current.request.ownerDomain != ScreenDomainId.System) return false + if (button != MouseButton.LEFT) return false + if (current.controller.isEyedropperActive()) return false + refreshLayout(current) + val hit = + current.layout.previousSwatchRect + .contains(mouseX, mouseY) || + current.layout.currentSwatchRect + .contains(mouseX, mouseY) || + current.layout.copyRect + .contains(mouseX, mouseY) || + current.layout.pasteRect + .contains(mouseX, mouseY) || + current.layout.pipetteRect + .contains(mouseX, mouseY) || + current.layout.rgbaOrderRect + ?.contains(mouseX, mouseY) == true || + current.layout.argbOrderRect + ?.contains(mouseX, mouseY) == true || + current.layout.modeSelectRect + .contains(mouseX, mouseY) || + current.layout.modeOptionsRect + ?.contains(mouseX, mouseY) == true || + current.layout.recentRects + .any { rect -> rect.contains(mouseX, mouseY) } + ColorPickerDebugCounters.onRouteSystemBodyIntentCheck(hit) + return hit + } + + fun focusSystemInputSlotForDomEditing(mouseX: Int, mouseY: Int, focusInputByIndex: (Int) -> Boolean): Boolean { + val current = popup ?: return false + if (current.request.ownerDomain != ScreenDomainId.System) return false + val slotIndex = + current.layout.inputSlots + .indexOfFirst { slot -> slot.inputRect.contains(mouseX, mouseY) } + if (slotIndex < 0) return false + val slot = current.layout.inputSlots[slotIndex] + current.controller.handleDomInputFocused(slot.key) + val focused = focusInputByIndex(slotIndex) + return focused + } + fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { val current = popup ?: return false if (current.consumedEyedropperPress && (button == MouseButton.LEFT || button == MouseButton.RIGHT)) { @@ -405,7 +515,8 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { state.controller.onPreview = state.request.onPreview state.controller.onChange = state.request.onChange state.controller.onCommit = { color -> - state.request.onCommit?.invoke(color) + state.request.onCommit + ?.invoke(color) if (state.request.state.closeOnSelect) { close(state.owner) } @@ -417,38 +528,68 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { } private fun relayout(state: PopupState, keepPosition: Boolean) { - val width = state.request.width.coerceAtLeast(state.request.style.minWidth) + val width = + state.request.width + .coerceAtLeast(state.request.style.minWidth) val bodyHeight = state.controller.preferredHeight(state.request.state.alphaEnabled) val height = headerHeight + panelPadding + bodyHeight + panelPadding - state.panelRect = ColorPickerPopupGeometry.resolvePanelRect( - owner = state.owner, - anchorRect = state.request.anchorRect, - width = width, - height = height, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight, - keepPosition = keepPosition, - currentRect = state.panelRect, - store = positionStore - ) + state.panelRect = + ColorPickerPopupGeometry.resolvePanelRect( + owner = state.owner, + anchorRect = state.request.anchorRect, + width = width, + height = height, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + keepPosition = keepPosition, + currentRect = state.panelRect, + store = positionStore, + ) rebuildRects(state) } private fun rebuildRects(state: PopupState) { - val frame = ColorPickerPopupGeometry.buildFrame( - panelRect = state.panelRect, - headerHeight = headerHeight, - panelPadding = panelPadding - ) + val frame = + ColorPickerPopupGeometry.buildFrame( + panelRect = state.panelRect, + headerHeight = headerHeight, + panelPadding = panelPadding, + ) state.panelRect = frame.panelRect state.headerRect = frame.headerRect state.bodyRect = frame.bodyRect state.closeRect = frame.closeRect - state.layout = state.controller.buildLayout(frame.bodyRect) + rebuildLayout(state) } private fun refreshLayout(state: PopupState) { + ColorPickerDebugCounters.onRefreshLayoutCall(state.request.ownerDomain == ScreenDomainId.System) + ensureLayoutUpToDate(state) + } + + private fun ensureLayoutUpToDate(state: PopupState) { + val nextKey = resolveLayoutDirtyKey(state) + if (state.layoutDirtyKey == nextKey) { + return + } + rebuildLayout(state) + } + + private fun rebuildLayout(state: PopupState) { + ColorPickerDebugCounters.onBuildLayoutCall(state.request.ownerDomain == ScreenDomainId.System) state.layout = state.controller.buildLayout(state.bodyRect) + state.layoutDirtyKey = resolveLayoutDirtyKey(state) + } + + private fun resolveLayoutDirtyKey(state: PopupState): LayoutDirtyKey { + val snapshot = state.controller.snapshot() + return LayoutDirtyKey( + bodyRect = state.bodyRect, + mode = snapshot.mode, + rgbOrder = snapshot.rgbOrder, + alphaEnabled = snapshot.alphaEnabled, + modeDropdownOpen = state.controller.viewModeDropdownOpen(), + ) } private fun drawBorder(out: MutableList, rect: Rect, color: Int) { @@ -459,22 +600,56 @@ class ColorPickerPopupEngine : ColorPickerPopupHost { out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) } - private fun sameStateContract(a: ColorPickerState, b: ColorPickerState): Boolean { - return a.color.toArgbInt() == b.color.toArgbInt() && - a.previous.toArgbInt() == b.previous.toArgbInt() && - a.mode == b.mode && - a.rgbOrder == b.rgbOrder && - a.alphaEnabled == b.alphaEnabled && - a.closeOnSelect == b.closeOnSelect + private fun sameStateContract(a: ColorPickerState, b: ColorPickerState): Boolean = + a.color.toArgbInt() == b.color.toArgbInt() && + a.previous.toArgbInt() == b.previous.toArgbInt() && + a.mode == b.mode && + a.rgbOrder == b.rgbOrder && + a.alphaEnabled == b.alphaEnabled && + a.closeOnSelect == b.closeOnSelect + + private fun reportDebugCountersIfDue() { + if (!debugCountersEnabled) return + val current = popup ?: return + val now = System.currentTimeMillis() + if (debugNextReportAtMs == 0L) { + debugNextReportAtMs = now + debugReportIntervalMs + return + } + if (now < debugNextReportAtMs) return + + val snapshot = ColorPickerDebugCounters.snapshot() + val composeMs = nanosToMsString(snapshot.recentSwatchComposeNanos) + val removeMs = nanosToMsString(snapshot.recentSwatchRemoveNanos) + println( + "dsgl.colorPicker.debugCounters " + + "ownerDomain=${current.request.ownerDomain} " + + "recentComposeCalls=${snapshot.recentSwatchGridComposeCalls} " + + "recentCreated=${snapshot.recentSwatchNodesCreated} " + + "recentRemoved=${snapshot.recentSwatchNodesRemoved} " + + "recentComposeMs=$composeMs " + + "recentRemoveMs=$removeMs " + + "recentSnapshotReads=${snapshot.recentColorsSnapshotReads} " + + "refreshLayoutCalls=${snapshot.refreshLayoutCalls} " + + "buildLayoutCalls=${snapshot.buildLayoutCalls} " + + "bodyIntentChecks=${snapshot.routeSystemBodyIntentChecks} " + + "bodyIntentHits=${snapshot.routeSystemBodyIntentHits} " + + "renderInvalidations=${snapshot.renderInvalidationCalls}", + ) + ColorPickerDebugCounters.reset() + debugNextReportAtMs = now + debugReportIntervalMs } + + private fun nanosToMsString(nanos: Long): String = + String.format(java.util.Locale.ROOT, "%.3f", nanos / nanosPerMillisecond) } class ColorPickerPopupManager( - private val host: ColorPickerPopupHost = ColorPickerRuntime.host, - private val ownerToken: Any = Any() + private val portalService: ColorPickerPopupPortalService = DomainPortalServices.applicationColorPickerEngine, + private val ownerToken: Any = Any(), ) { fun open( - ownerScope: OverlayOwnerScope = OverlayOwnerScope.Application, + ownerDomain: ScreenDomainId = ScreenDomainId.Application, anchorRect: Rect, title: String, state: ColorPickerState, @@ -485,12 +660,12 @@ class ColorPickerPopupManager( onPreview: ((RgbaColor) -> Unit)? = null, onChange: ((RgbaColor) -> Unit)? = null, onCommit: ((RgbaColor) -> Unit)? = null, - onClose: (() -> Unit)? = null + onClose: (() -> Unit)? = null, ) { - host.open( + portalService.open( ColorPickerPopupRequest( owner = ownerToken, - ownerScope = ownerScope, + ownerDomain = ownerDomain, anchorRect = anchorRect, title = title, state = state, @@ -501,20 +676,14 @@ class ColorPickerPopupManager( onPreview = onPreview, onChange = onChange, onCommit = onCommit, - onClose = onClose - ) + onClose = onClose, + ), ) } fun close() { - host.close(ownerToken) + portalService.close(ownerToken) } - fun isOpen(): Boolean = host.isOpenFor(ownerToken) -} - -object ColorPickerRuntime { - val engine: ColorPickerPopupEngine = ColorPickerPopupEngine() - val host: ColorPickerPopupHost = engine + fun isOpen(): Boolean = portalService.isOpenFor(ownerToken) } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometry.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometry.kt index b04a126..b2bc7db 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometry.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometry.kt @@ -9,7 +9,7 @@ internal data class ColorPickerPopupFrame( val panelRect: Rect, val headerRect: Rect, val bodyRect: Rect, - val closeRect: Rect + val closeRect: Rect, ) internal class ColorPickerPopupPositionStore { @@ -25,9 +25,8 @@ internal class ColorPickerPopupPositionStore { internal object ColorPickerPopupGeometry { private const val MIN_VALID_VIEWPORT_SIZE = 2 - fun hasValidViewport(viewportWidth: Int, viewportHeight: Int): Boolean { - return viewportWidth >= MIN_VALID_VIEWPORT_SIZE && viewportHeight >= MIN_VALID_VIEWPORT_SIZE - } + fun hasValidViewport(viewportWidth: Int, viewportHeight: Int): Boolean = + viewportWidth >= MIN_VALID_VIEWPORT_SIZE && viewportHeight >= MIN_VALID_VIEWPORT_SIZE fun resolvePanelRect( owner: Any, @@ -38,7 +37,7 @@ internal object ColorPickerPopupGeometry { viewportHeight: Int, keepPosition: Boolean, currentRect: Rect?, - store: ColorPickerPopupPositionStore + store: ColorPickerPopupPositionStore, ): Rect { val viewportReady = hasValidViewport(viewportWidth, viewportHeight) if (keepPosition && currentRect != null) { @@ -57,41 +56,44 @@ internal object ColorPickerPopupGeometry { x = anchorRect.x, y = anchorRect.y + anchorRect.height, width = width, - height = height + height = height, ) } - val placement = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = Rect( - anchorRect.x, - anchorRect.y + anchorRect.height, - width, - height + val placement = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = + Rect( + anchorRect.x, + anchorRect.y + anchorRect.height, + width, + height, + ), + popupSize = Size(width, height), + viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + padding = 8, + horizontalFlipX = anchorRect.x + anchorRect.width - width, ), - popupSize = Size(width, height), - viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), - padding = 8, - horizontalFlipX = anchorRect.x + anchorRect.width - width ) - ) return placement.rect } fun buildFrame(panelRect: Rect, headerHeight: Int, panelPadding: Int): ColorPickerPopupFrame { val headerRect = Rect(panelRect.x, panelRect.y, panelRect.width, headerHeight) - val bodyRect = Rect( - panelRect.x + panelPadding, - panelRect.y + headerHeight + panelPadding, - (panelRect.width - panelPadding * 2).coerceAtLeast(1), - (panelRect.height - headerHeight - panelPadding * 2).coerceAtLeast(1) - ) + val bodyRect = + Rect( + panelRect.x + panelPadding, + panelRect.y + headerHeight + panelPadding, + (panelRect.width - panelPadding * 2).coerceAtLeast(1), + (panelRect.height - headerHeight - panelPadding * 2).coerceAtLeast(1), + ) val closeRect = Rect(panelRect.x + panelRect.width - 20, panelRect.y + 4, 16, 16) return ColorPickerPopupFrame( panelRect = panelRect, headerRect = headerRect, bodyRect = bodyRect, - closeRect = closeRect + closeRect = closeRect, ) } @@ -107,7 +109,7 @@ internal object ColorPickerPopupGeometry { x = rect.x.coerceIn(minX, maxX), y = rect.y.coerceIn(minY, maxY), width = rect.width, - height = rect.height + height = rect.height, ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPortalController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPortalController.kt new file mode 100644 index 0000000..7726dd3 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPortalController.kt @@ -0,0 +1,199 @@ +package org.dreamfinity.dsgl.core.colorpicker + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.PortalDismissPolicy +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalEntryBounds +import org.dreamfinity.dsgl.core.portal.PortalEntryId +import org.dreamfinity.dsgl.core.portal.PortalEntryOrder +import org.dreamfinity.dsgl.core.portal.PortalEntryPlacement +import org.dreamfinity.dsgl.core.portal.PortalEntryState +import org.dreamfinity.dsgl.core.portal.PortalFocusPolicy +import org.dreamfinity.dsgl.core.portal.PortalHost +import org.dreamfinity.dsgl.core.portal.PortalInputPolicy +import org.dreamfinity.dsgl.core.portal.PortalPointerDispatch +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.render.RenderCommand + +internal class ColorPickerPortalController( + private val engine: ColorPickerPopupEngine, +) : PortalPointerDispatch { + private val portalHost: PortalHost = + PortalHost(ScreenDomainSurfaces.ApplicationPortal) + private val entry: ColorPickerPortalEntry = ColorPickerPortalEntry(engine) + + init { + portalHost.register(entry) + } + + fun onFrame( + viewportWidth: Int, + viewportHeight: Int, + mouseX: Int, + mouseY: Int, + ) { + entry.onFrame(viewportWidth, viewportHeight, mouseX, mouseY) + } + + fun appendCommands( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, + ) { + entry.updatePaintContext(viewportWidth, viewportHeight) + entry.syncActivePlacement() + out += portalHost.paint(measureContext) + } + + fun close() { + entry.close() + } + + val isOpen: Boolean + get() = entry.isApplicationPopupOpen + + val hasActiveEyedropper: Boolean + get() = isOpen && engine.hasActiveEyedropper() + + fun captureEyedropperSample() { + if (isOpen) { + engine.captureEyedropperSample() + } + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + portalHost.dispatchInput { it.handleMouseMove(mouseX, mouseY) } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseDown(mouseX, mouseY, button) } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseUp(mouseX, mouseY, button) } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + portalHost.dispatchInput { it.handleMouseWheel(mouseX, mouseY, delta) } + + fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyDown(keyCode, keyChar) } + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyUp(keyCode, keyChar) } +} + +private class ColorPickerPortalEntry( + private val engine: ColorPickerPopupEngine, +) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("application.color-picker"), + ownerToken = engine, + surface = ScreenDomainSurfaces.ApplicationPortal, + order = PortalEntryOrder(zIndex = 0), + dismissPolicy = PortalDismissPolicy.EscapeOrOutsidePointerDown, + inputPolicy = PortalInputPolicy.ManualOnly, + focusPolicy = PortalFocusPolicy.Preserve, + ) + override val node: DOMNode? = null + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + + fun onFrame( + viewportWidth: Int, + viewportHeight: Int, + mouseX: Int, + mouseY: Int, + ) { + updatePaintContext(viewportWidth, viewportHeight) + if (!isApplicationPopupOpen) { + state.deactivate() + return + } + engine.onFrame(this.viewportWidth, this.viewportHeight) + engine.onCursorPosition(mouseX, mouseY) + syncActivePlacement() + } + + fun updatePaintContext(viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + } + + override fun paint(ctx: UiMeasureContext): List { + if (!isApplicationPopupOpen) { + state.deactivate() + return emptyList() + } + val commands = ArrayList() + engine.appendPortalCommands(commands) + syncActivePlacement() + return commands + } + + override fun close() { + if (isApplicationPopupOpen) { + engine.closeAll() + } + state.deactivate() + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + if (isApplicationPopupOpen) { + engine.handleMouseMove(mouseX, mouseY).also { syncActivePlacement() } + } else { + false + } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + if (isApplicationPopupOpen) { + engine.handleMouseDown(mouseX, mouseY, button).also { syncActivePlacement() } + } else { + false + } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + if (isApplicationPopupOpen) { + engine.handleMouseUp(mouseX, mouseY, button).also { syncActivePlacement() } + } else { + false + } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + if (isApplicationPopupOpen) { + engine.handleMouseWheel(mouseX, mouseY, delta).also { syncActivePlacement() } + } else { + false + } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + if (isApplicationPopupOpen) { + engine.handleKeyDown(keyCode, keyChar).also { syncActivePlacement() } + } else { + false + } + + fun syncActivePlacement() { + if (!isApplicationPopupOpen) { + state.deactivate() + return + } + val panelRect = engine.debugActivePanelRect() ?: return + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth, viewportHeight), + entryBounds = panelRect, + ), + ), + ) + } + + val isApplicationPopupOpen: Boolean + get() = engine.isOpen() && engine.debugActiveOwnerDomain() == ScreenDomainId.Application +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerState.kt index 5a21647..5a009a5 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerState.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerState.kt @@ -6,21 +6,21 @@ data class ColorPickerState( val mode: ColorFormatMode = ColorFormatMode.HEX, val rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA, val alphaEnabled: Boolean = true, - val closeOnSelect: Boolean = true + val closeOnSelect: Boolean = true, ) { constructor( color: RgbaColor, previous: RgbaColor = color, mode: ColorFormatMode = ColorFormatMode.HEX, alphaEnabled: Boolean = true, - closeOnSelect: Boolean = true + closeOnSelect: Boolean = true, ) : this( color = color, previous = previous, mode = mode, rgbOrder = RgbChannelOrder.RGBA, alphaEnabled = alphaEnabled, - closeOnSelect = closeOnSelect + closeOnSelect = closeOnSelect, ) fun withColor(next: RgbaColor): ColorPickerState { @@ -29,11 +29,7 @@ data class ColorPickerState( return copy(color = merged) } - fun withCommittedCurrent(): ColorPickerState { - return copy(previous = color) - } + fun withCommittedCurrent(): ColorPickerState = copy(previous = color) - fun withRestoredPrevious(): ColorPickerState { - return copy(color = previous) - } + fun withRestoredPrevious(): ColorPickerState = copy(color = previous) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStyle.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStyle.kt index b1ae63e..ab1f37e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStyle.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStyle.kt @@ -40,14 +40,13 @@ data class ColorPickerStyle( val eyedropperGapToCursor: Int = 14, val eyedropperTooltipWidth: Int = 228, val eyedropperTooltipHeight: Int = 52, - val eyedropperOverlayBackgroundColor: Int = 0xF01A222C.toInt(), - val eyedropperOverlayBorderColor: Int = 0xFF7F96AD.toInt(), + val eyedropperPreviewBackgroundColor: Int = 0xF01A222C.toInt(), + val eyedropperPreviewBorderColor: Int = 0xFF7F96AD.toInt(), val eyedropperCenterBorderColor: Int = 0xFFFFFFFF.toInt(), val rowGap: Int = 6, val recentCellSize: Int = 14, val recentCellGap: Int = 2, val minWidth: Int = 280, - val eyedropperGridOverlayEnabled: Boolean = true, - val eyedropperGridOverlayColor: Int = 0x22FFFFff.toInt() + val eyedropperGridEnabled: Boolean = true, + val eyedropperGridColor: Int = 0x22FFFFFF, ) - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorRecentHistory.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorRecentHistory.kt index d82fa4e..db209b8 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorRecentHistory.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorRecentHistory.kt @@ -1,7 +1,7 @@ package org.dreamfinity.dsgl.core.colorpicker class ColorRecentHistory( - val capacity: Int = 64 + val capacity: Int = 64, ) { private val colors: MutableList = ArrayList(capacity.coerceAtLeast(1)) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodec.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodec.kt index 1c1286c..27e7a24 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodec.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodec.kt @@ -6,26 +6,21 @@ import kotlin.math.roundToInt data class ParsedColorText( val color: RgbaColor, val detectedMode: ColorFormatMode, - val detectedRgbOrder: RgbChannelOrder? = null + val detectedRgbOrder: RgbChannelOrder? = null, ) { constructor( color: RgbaColor, - detectedMode: ColorFormatMode + detectedMode: ColorFormatMode, ) : this( color = color, detectedMode = detectedMode, - detectedRgbOrder = null + detectedRgbOrder = null, ) } object ColorTextCodec { - fun format( - color: RgbaColor, - mode: ColorFormatMode, - includeAlpha: Boolean - ): String { - return format(color, mode, includeAlpha, RgbChannelOrder.RGBA) - } + fun format(color: RgbaColor, mode: ColorFormatMode, includeAlpha: Boolean): String = + format(color, mode, includeAlpha, RgbChannelOrder.RGBA) fun parse(raw: String): ParsedColorText? { val text = raw.trim() @@ -41,7 +36,7 @@ object ColorTextCodec { color: RgbaColor, mode: ColorFormatMode, includeAlpha: Boolean, - rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA + rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA, ): String { val normalized = color.normalized() return when (mode) { @@ -65,11 +60,7 @@ object ColorTextCodec { } } - fun formatRgb( - color: RgbaColor, - includeAlpha: Boolean, - rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA - ): String { + fun formatRgb(color: RgbaColor, includeAlpha: Boolean, rgbOrder: RgbChannelOrder = RgbChannelOrder.RGBA): String { val c = color.normalized() val a = formatFraction(c.a) val r = (c.r * 255f + 0.5f).toInt().coerceIn(0, 255) @@ -85,54 +76,52 @@ object ColorTextCodec { } } - fun formatRgb(color: RgbaColor, includeAlpha: Boolean): String { - return formatRgb(color, includeAlpha, RgbChannelOrder.RGBA) - } + fun formatRgb(color: RgbaColor, includeAlpha: Boolean): String = + formatRgb(color, includeAlpha, RgbChannelOrder.RGBA) - fun formatHsl( - color: RgbaColor, - includeAlpha: Boolean, - fallbackHueDeg: Float = 0f - ): String { + fun formatHsl(color: RgbaColor, includeAlpha: Boolean, fallbackHueDeg: Float = 0f): String { val c = color.normalized() val hsl = ColorConversions.rgbToHsl(c, fallbackHueDeg) - val h = hsl.hueDeg.roundToInt().coerceIn(0, 360) + val h = + hsl.hueDeg + .roundToInt() + .coerceIn(0, 360) val s = (hsl.saturation * 100f).roundToInt().coerceIn(0, 100) val l = (hsl.lightness * 100f).roundToInt().coerceIn(0, 100) return if (includeAlpha) { - "hsla($h, ${s}%, ${l}%, ${formatFraction(c.a)})" + "hsla($h, $s%, $l%, ${formatFraction(c.a)})" } else { - "hsl($h, ${s}%, ${l}%)" + "hsl($h, $s%, $l%)" } } - fun formatHsb( - color: RgbaColor, - includeAlpha: Boolean, - fallbackHueDeg: Float = 0f - ): String { + fun formatHsb(color: RgbaColor, includeAlpha: Boolean, fallbackHueDeg: Float = 0f): String { val c = color.normalized() val hsb = ColorConversions.rgbToHsv(c, fallbackHueDeg) - val h = hsb.hueDeg.roundToInt().coerceIn(0, 360) + val h = + hsb.hueDeg + .roundToInt() + .coerceIn(0, 360) val s = (hsb.saturation * 100f).roundToInt().coerceIn(0, 100) val b = (hsb.brightness * 100f).roundToInt().coerceIn(0, 100) return if (includeAlpha) { - "hsba($h, ${s}%, ${b}%, ${formatFraction(c.a)})" + "hsba($h, $s%, $b%, ${formatFraction(c.a)})" } else { - "hsb($h, ${s}%, ${b}%)" + "hsb($h, $s%, $b%)" } } private fun parseHex(raw: String): RgbaColor? { if (!raw.startsWith("#")) return null val hex = raw.substring(1) - val expanded = when (hex.length) { - 3 -> "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}FF" - 4 -> "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}" - 6 -> "${hex}FF" - 8 -> hex - else -> return null - } + val expanded = + when (hex.length) { + 3 -> "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}FF" + 4 -> "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}" + 6 -> "${hex}FF" + 8 -> hex + else -> return null + } val value = expanded.toLongOrNull(16) ?: return null val r = ((value ushr 24) and 0xFF).toInt() / 255f val g = ((value ushr 16) and 0xFF).toInt() / 255f @@ -142,42 +131,44 @@ object ColorTextCodec { } private fun parseRgbLike(raw: String): ParsedColorText? { - val prefix = when { - raw.startsWith("rgba(", ignoreCase = true) -> "rgba" - raw.startsWith("argb(", ignoreCase = true) -> "argb" - raw.startsWith("rgb(", ignoreCase = true) -> "rgb" - else -> return null - } + val prefix = + when { + raw.startsWith("rgba(", ignoreCase = true) -> "rgba" + raw.startsWith("argb(", ignoreCase = true) -> "argb" + raw.startsWith("rgb(", ignoreCase = true) -> "rgb" + else -> return null + } val values = parseFunctionArgs(raw, prefix) ?: return null if (prefix == "rgb" && values.size != 3) return null if ((prefix == "rgba" || prefix == "argb") && values.size != 4) return null - if (values.size !in 3..4) return null - val (r, g, b, a, order) = if (prefix == "argb") { - val a = parseAlphaComponent(values[0]) ?: return null - val r = parseRgbComponent(values[1]) ?: return null - val g = parseRgbComponent(values[2]) ?: return null - val b = parseRgbComponent(values[3]) ?: return null - RgbParseResult(r, g, b, a, RgbChannelOrder.ARGB) - } else { - val r = parseRgbComponent(values[0]) ?: return null - val g = parseRgbComponent(values[1]) ?: return null - val b = parseRgbComponent(values[2]) ?: return null - val a = if (values.size >= 4) parseAlphaComponent(values[3]) ?: return null else 1f - RgbParseResult(r, g, b, a, if (values.size == 4) RgbChannelOrder.RGBA else null) - } + val parsed = + if (prefix == "argb") { + val a = parseAlphaComponent(values[0]) ?: return null + val r = parseRgbComponent(values[1]) ?: return null + val g = parseRgbComponent(values[2]) ?: return null + val b = parseRgbComponent(values[3]) ?: return null + RgbParseResult(r, g, b, a, RgbChannelOrder.ARGB) + } else { + val r = parseRgbComponent(values[0]) ?: return null + val g = parseRgbComponent(values[1]) ?: return null + val b = parseRgbComponent(values[2]) ?: return null + val a = if (values.size >= 4) parseAlphaComponent(values[3]) ?: return null else 1f + RgbParseResult(r, g, b, a, if (values.size == 4) RgbChannelOrder.RGBA else null) + } return ParsedColorText( - color = RgbaColor(r, g, b, a).normalized(), + color = RgbaColor(parsed.r, parsed.g, parsed.b, parsed.a).normalized(), detectedMode = ColorFormatMode.RGB, - detectedRgbOrder = order + detectedRgbOrder = parsed.order, ) } private fun parseHslLike(raw: String): RgbaColor? { - val prefix = when { - raw.startsWith("hsla(", ignoreCase = true) -> "hsla" - raw.startsWith("hsl(", ignoreCase = true) -> "hsl" - else -> return null - } + val prefix = + when { + raw.startsWith("hsla(", ignoreCase = true) -> "hsla" + raw.startsWith("hsl(", ignoreCase = true) -> "hsl" + else -> return null + } val values = parseFunctionArgs(raw, prefix) ?: return null if (prefix == "hsl" && values.size != 3) return null if (prefix == "hsla" && values.size != 4) return null @@ -187,17 +178,18 @@ object ColorTextCodec { val a = if (values.size >= 4) parseAlphaComponent(values[3]) ?: return null else 1f return ColorConversions.hslToRgb( hsl = HslColor(h, s, l), - alpha = a + alpha = a, ) } private fun parseHsbLike(raw: String): RgbaColor? { - val prefix = when { - raw.startsWith("hsba(", ignoreCase = true) -> "hsba" - raw.startsWith("hsv(", ignoreCase = true) -> "hsv" - raw.startsWith("hsb(", ignoreCase = true) -> "hsb" - else -> return null - } + val prefix = + when { + raw.startsWith("hsba(", ignoreCase = true) -> "hsba" + raw.startsWith("hsv(", ignoreCase = true) -> "hsv" + raw.startsWith("hsb(", ignoreCase = true) -> "hsb" + else -> return null + } val values = parseFunctionArgs(raw, prefix) ?: return null if ((prefix == "hsb" || prefix == "hsv") && values.size != 3) return null if (prefix == "hsba" && values.size != 4) return null @@ -207,7 +199,7 @@ object ColorTextCodec { val a = if (values.size >= 4) parseAlphaComponent(values[3]) ?: return null else 1f return ColorConversions.hsvToRgb( hsv = HsvColor(h, s, b), - alpha = a + alpha = a, ) } @@ -215,7 +207,11 @@ object ColorTextCodec { if (!raw.endsWith(")")) return null val head = raw.indexOf('(') if (head < 0) return null - val name = raw.substring(0, head).trim().lowercase() + val name = + raw + .substring(0, head) + .trim() + .lowercase() if (name != prefix) return null val body = raw.substring(head + 1, raw.length - 1) if (body.isBlank()) return emptyList() @@ -226,11 +222,11 @@ object ColorTextCodec { val value = raw.trim() return if (value.endsWith("%")) { val p = value.dropLast(1).toFloatOrNull() ?: return null - if (p < 0f || p > 100f) return null + if (p !in 0f..100f) return null p / 100f } else { val number = value.toFloatOrNull() ?: return null - if (number < 0f || number > 255f) return null + if (number !in 0f..255f) return null number / 255f } } @@ -239,7 +235,7 @@ object ColorTextCodec { val value = raw.trim() return if (value.endsWith("%")) { val p = value.dropLast(1).toFloatOrNull() ?: return null - if (p < 0f || p > 100f) return null + if (p !in 0f..100f) return null p / 100f } else { val f = value.toFloatOrNull() ?: return null @@ -254,7 +250,11 @@ object ColorTextCodec { } private fun parseHue(raw: String): Float? { - val normalized = raw.trim().removeSuffix("deg").trim() + val normalized = + raw + .trim() + .removeSuffix("deg") + .trim() val value = normalized.toFloatOrNull() ?: return null var hue = value % 360f if (hue < 0f) hue += 360f @@ -265,7 +265,7 @@ object ColorTextCodec { val value = raw.trim() return if (value.endsWith("%")) { val p = value.dropLast(1).toFloatOrNull() ?: return null - if (p < 0f || p > 100f) return null + if (p !in 0f..100f) return null p / 100f } else { val f = value.toFloatOrNull() ?: return null @@ -294,6 +294,6 @@ object ColorTextCodec { val g: Float, val b: Float, val a: Float, - val order: RgbChannelOrder? + val order: RgbChannelOrder?, ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTypes.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTypes.kt index 53fe0ad..8b42af6 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTypes.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTypes.kt @@ -6,28 +6,27 @@ enum class ColorFormatMode { HEX, RGB, HSL, - HSB + HSB, } enum class RgbChannelOrder { RGBA, - ARGB + ARGB, } data class RgbaColor( val r: Float, val g: Float, val b: Float, - val a: Float = 1f + val a: Float = 1f, ) { - fun normalized(): RgbaColor { - return RgbaColor( + fun normalized(): RgbaColor = + RgbaColor( r = r.coerceIn(0f, 1f), g = g.coerceIn(0f, 1f), b = b.coerceIn(0f, 1f), - a = a.coerceIn(0f, 1f) + a = a.coerceIn(0f, 1f), ) - } fun toArgbInt(): Int { val c = normalized() @@ -54,7 +53,7 @@ data class RgbaColor( data class HsvColor( val hueDeg: Float, val saturation: Float, - val brightness: Float + val brightness: Float, ) { fun normalized(): HsvColor { var hue = hueDeg % 360f @@ -62,7 +61,7 @@ data class HsvColor( return HsvColor( hueDeg = hue, saturation = saturation.coerceIn(0f, 1f), - brightness = brightness.coerceIn(0f, 1f) + brightness = brightness.coerceIn(0f, 1f), ) } } @@ -70,7 +69,7 @@ data class HsvColor( data class HslColor( val hueDeg: Float, val saturation: Float, - val lightness: Float + val lightness: Float, ) { fun normalized(): HslColor { var hue = hueDeg % 360f @@ -78,7 +77,7 @@ data class HslColor( return HslColor( hueDeg = hue, saturation = saturation.coerceIn(0f, 1f), - lightness = lightness.coerceIn(0f, 1f) + lightness = lightness.coerceIn(0f, 1f), ) } } @@ -91,12 +90,13 @@ object ColorConversions { val delta = max - min val brightness = max val saturation = if (max <= 0f) 0f else delta / max - val hue = when { - delta <= 1e-6f -> normalizeHue(fallbackHueDeg) - abs(max - c.r) < 1e-6f -> normalizeHue((60f * ((c.g - c.b) / delta) + 360f) % 360f) - abs(max - c.g) < 1e-6f -> normalizeHue(60f * ((c.b - c.r) / delta) + 120f) - else -> normalizeHue(60f * ((c.r - c.g) / delta) + 240f) - } + val hue = + when { + delta <= 1e-6f -> normalizeHue(fallbackHueDeg) + abs(max - c.r) < 1e-6f -> normalizeHue((60f * ((c.g - c.b) / delta) + 360f) % 360f) + abs(max - c.g) < 1e-6f -> normalizeHue(60f * ((c.b - c.r) / delta) + 120f) + else -> normalizeHue(60f * ((c.r - c.g) / delta) + 240f) + } return HsvColor(hue, saturation, brightness).normalized() } @@ -106,19 +106,20 @@ object ColorConversions { val c = n.brightness * n.saturation val x = c * (1f - abs(h % 2f - 1f)) val m = n.brightness - c - val (r1, g1, b1) = when { - h < 1f -> Triple(c, x, 0f) - h < 2f -> Triple(x, c, 0f) - h < 3f -> Triple(0f, c, x) - h < 4f -> Triple(0f, x, c) - h < 5f -> Triple(x, 0f, c) - else -> Triple(c, 0f, x) - } + val (r1, g1, b1) = + when { + h < 1f -> Triple(c, x, 0f) + h < 2f -> Triple(x, c, 0f) + h < 3f -> Triple(0f, c, x) + h < 4f -> Triple(0f, x, c) + h < 5f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } return RgbaColor( r = r1 + m, g = g1 + m, b = b1 + m, - a = alpha.coerceIn(0f, 1f) + a = alpha.coerceIn(0f, 1f), ).normalized() } @@ -128,17 +129,19 @@ object ColorConversions { val min = minOf(c.r, minOf(c.g, c.b)) val delta = max - min val lightness = (max + min) * 0.5f - val saturation = if (delta <= 1e-6f) { - 0f - } else { - delta / (1f - abs(2f * lightness - 1f)) - } - val hue = when { - delta <= 1e-6f -> normalizeHue(fallbackHueDeg) - abs(max - c.r) < 1e-6f -> normalizeHue(60f * (((c.g - c.b) / delta) % 6f)) - abs(max - c.g) < 1e-6f -> normalizeHue(60f * (((c.b - c.r) / delta) + 2f)) - else -> normalizeHue(60f * (((c.r - c.g) / delta) + 4f)) - } + val saturation = + if (delta <= 1e-6f) { + 0f + } else { + delta / (1f - abs(2f * lightness - 1f)) + } + val hue = + when { + delta <= 1e-6f -> normalizeHue(fallbackHueDeg) + abs(max - c.r) < 1e-6f -> normalizeHue(60f * (((c.g - c.b) / delta) % 6f)) + abs(max - c.g) < 1e-6f -> normalizeHue(60f * (((c.b - c.r) / delta) + 2f)) + else -> normalizeHue(60f * (((c.r - c.g) / delta) + 4f)) + } return HslColor(hue, saturation.coerceIn(0f, 1f), lightness.coerceIn(0f, 1f)) } @@ -148,14 +151,15 @@ object ColorConversions { val h = n.hueDeg / 60f val x = c * (1f - abs(h % 2f - 1f)) val m = n.lightness - c * 0.5f - val (r1, g1, b1) = when { - h < 1f -> Triple(c, x, 0f) - h < 2f -> Triple(x, c, 0f) - h < 3f -> Triple(0f, c, x) - h < 4f -> Triple(0f, x, c) - h < 5f -> Triple(x, 0f, c) - else -> Triple(c, 0f, x) - } + val (r1, g1, b1) = + when { + h < 1f -> Triple(c, x, 0f) + h < 2f -> Triple(x, c, 0f) + h < 3f -> Triple(0f, c, x) + h < 4f -> Triple(0f, x, c) + h < 5f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } return RgbaColor(r1 + m, g1 + m, b1 + m, alpha.coerceIn(0f, 1f)).normalized() } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerDebugCounters.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerDebugCounters.kt new file mode 100644 index 0000000..50d1006 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerDebugCounters.kt @@ -0,0 +1,172 @@ +package org.dreamfinity.dsgl.core.colorpicker.internal + +internal object ColorPickerDebugCounters { + @Volatile + private var enabled: Boolean = + java.lang.Boolean + .getBoolean("dsgl.colorPicker.debugCounters") + + @Volatile + private var recentSwatchGridComposeCalls: Long = 0L + + @Volatile + private var recentSwatchNodesCreated: Long = 0L + + @Volatile + private var recentSwatchNodesRemoved: Long = 0L + + @Volatile + private var recentSwatchComposeNanos: Long = 0L + + @Volatile + private var recentSwatchRemoveNanos: Long = 0L + + @Volatile + private var recentColorsSnapshotReads: Long = 0L + + @Volatile + private var refreshLayoutCalls: Long = 0L + + @Volatile + private var refreshLayoutSystemCalls: Long = 0L + + @Volatile + private var buildLayoutCalls: Long = 0L + + @Volatile + private var buildLayoutSystemCalls: Long = 0L + + @Volatile + private var routeSystemInputSlotChecks: Long = 0L + + @Volatile + private var routeSystemInputSlotHits: Long = 0L + + @Volatile + private var routeSystemBodyIntentChecks: Long = 0L + + @Volatile + private var routeSystemBodyIntentHits: Long = 0L + + @Volatile + private var renderInvalidationCalls: Long = 0L + + data class Snapshot( + val recentSwatchGridComposeCalls: Long, + val recentSwatchNodesCreated: Long, + val recentSwatchNodesRemoved: Long, + val recentSwatchComposeNanos: Long, + val recentSwatchRemoveNanos: Long, + val recentColorsSnapshotReads: Long, + val refreshLayoutCalls: Long, + val refreshLayoutSystemCalls: Long, + val buildLayoutCalls: Long, + val buildLayoutSystemCalls: Long, + val routeSystemInputSlotChecks: Long, + val routeSystemInputSlotHits: Long, + val routeSystemBodyIntentChecks: Long, + val routeSystemBodyIntentHits: Long, + val renderInvalidationCalls: Long, + ) + + fun reset() { + recentSwatchGridComposeCalls = 0L + recentSwatchNodesCreated = 0L + recentSwatchNodesRemoved = 0L + recentSwatchComposeNanos = 0L + recentSwatchRemoveNanos = 0L + recentColorsSnapshotReads = 0L + refreshLayoutCalls = 0L + refreshLayoutSystemCalls = 0L + buildLayoutCalls = 0L + buildLayoutSystemCalls = 0L + routeSystemInputSlotChecks = 0L + routeSystemInputSlotHits = 0L + routeSystemBodyIntentChecks = 0L + routeSystemBodyIntentHits = 0L + renderInvalidationCalls = 0L + } + + fun snapshot(): Snapshot = + Snapshot( + recentSwatchGridComposeCalls = recentSwatchGridComposeCalls, + recentSwatchNodesCreated = recentSwatchNodesCreated, + recentSwatchNodesRemoved = recentSwatchNodesRemoved, + recentSwatchComposeNanos = recentSwatchComposeNanos, + recentSwatchRemoveNanos = recentSwatchRemoveNanos, + recentColorsSnapshotReads = recentColorsSnapshotReads, + refreshLayoutCalls = refreshLayoutCalls, + refreshLayoutSystemCalls = refreshLayoutSystemCalls, + buildLayoutCalls = buildLayoutCalls, + buildLayoutSystemCalls = buildLayoutSystemCalls, + routeSystemInputSlotChecks = routeSystemInputSlotChecks, + routeSystemInputSlotHits = routeSystemInputSlotHits, + routeSystemBodyIntentChecks = routeSystemBodyIntentChecks, + routeSystemBodyIntentHits = routeSystemBodyIntentHits, + renderInvalidationCalls = renderInvalidationCalls, + ) + + fun onRecentSwatchCompose( + createdNodes: Int, + removedNodes: Int, + composeDurationNanos: Long, + removeDurationNanos: Long, + ) { + if (!enabled) return + recentSwatchGridComposeCalls += 1L + if (createdNodes > 0) { + recentSwatchNodesCreated += createdNodes.toLong() + } + if (removedNodes > 0) { + recentSwatchNodesRemoved += removedNodes.toLong() + } + if (composeDurationNanos > 0L) { + recentSwatchComposeNanos += composeDurationNanos + } + if (removeDurationNanos > 0L) { + recentSwatchRemoveNanos += removeDurationNanos + } + } + + fun onRecentColorsSnapshotRead() { + if (!enabled) return + recentColorsSnapshotReads += 1L + } + + fun onRefreshLayoutCall(systemOwner: Boolean) { + if (!enabled) return + refreshLayoutCalls += 1L + if (systemOwner) { + refreshLayoutSystemCalls += 1L + } + } + + fun onBuildLayoutCall(systemOwner: Boolean) { + if (!enabled) return + buildLayoutCalls += 1L + if (systemOwner) { + buildLayoutSystemCalls += 1L + } + } + + fun onRouteSystemInputSlotCheck(hit: Boolean) { + if (!enabled) return + routeSystemInputSlotChecks += 1L + if (hit) { + routeSystemInputSlotHits += 1L + } + } + + fun onRouteSystemBodyIntentCheck(hit: Boolean) { + if (!enabled) return + routeSystemBodyIntentChecks += 1L + if (hit) { + routeSystemBodyIntentHits += 1L + } + } + + fun onRenderInvalidationCall() { + if (!enabled) return + renderInvalidationCalls += 1L + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerPopupMount.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerPopupMount.kt new file mode 100644 index 0000000..b5a6a67 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/ColorPickerPopupMount.kt @@ -0,0 +1,31 @@ +package org.dreamfinity.dsgl.core.colorpicker.internal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanel +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragSession +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelState + +internal class ColorPickerPopupMount( + ownerId: Any, + panelState: FloatingPanelState, + dragSession: FloatingPanelDragSession, + initialOwnerToken: Any = Any(), +) { + val ownerToken: Any = initialOwnerToken + val popupEngine: ColorPickerPopupEngine = ColorPickerPopupEngine() + val floatingPanel: FloatingPanel = + FloatingPanel( + ownerId = ownerId, + panelState = panelState, + dragSession = dragSession, + ) + + val node: ColorPickerPopupPortalNode = + ColorPickerPopupPortalNode( + popupEngine = popupEngine, + floatingPanel = floatingPanel, + ) + + val transientNode: ColorPickerTransientPortalNode = + ColorPickerTransientPortalNode(popupEngine = popupEngine) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerCustomSurfaceNodes.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerCustomSurfaceNodes.kt new file mode 100644 index 0000000..8342439 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerCustomSurfaceNodes.kt @@ -0,0 +1,397 @@ +package org.dreamfinity.dsgl.core.colorpicker.internal + +import org.dreamfinity.dsgl.core.colorpicker.* +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.Display +import kotlin.math.roundToInt + +internal class EyedropperCaptureNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-eyedropper-capture" + + private var sourceRect: Rect? = null + private var fallbackColor: Int = 0 + + fun bind(sourceRect: Rect, fallbackColor: Int) { + if (this.sourceRect != sourceRect || this.fallbackColor != fallbackColor) { + markRenderCommandsDirty() + } + this.sourceRect = sourceRect + this.fallbackColor = fallbackColor + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (display == Display.None) return + val source = sourceRect ?: return + if (source.width <= 0 || source.height <= 0) return + out += + RenderCommand.CaptureScreenRegion( + sourceX = source.x, + sourceY = source.y, + sourceWidth = source.width, + sourceHeight = source.height, + fallbackColor = fallbackColor, + ) + } +} + +internal class EyedropperMagnifierDrawNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-eyedropper-magnifier" + + private var columns: Int = 1 + private var rows: Int = 1 + private var magnification: Int = 1 + private var gridEnabled: Boolean = true + private var gridColor: Int = 0x66FFFFFF + + fun bind( + columns: Int, + rows: Int, + magnification: Int, + gridEnabled: Boolean, + gridColor: Int, + ) { + val nextColumns = columns.coerceAtLeast(1) + val nextRows = rows.coerceAtLeast(1) + val nextMagnification = magnification.coerceAtLeast(1) + if (this.columns != nextColumns || + this.rows != nextRows || + this.magnification != nextMagnification || + this.gridEnabled != gridEnabled || + this.gridColor != gridColor + ) { + markRenderCommandsDirty() + } + this.columns = nextColumns + this.rows = nextRows + this.magnification = nextMagnification + this.gridEnabled = gridEnabled + this.gridColor = gridColor + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (display == Display.None) return + if (bounds.width <= 0 || bounds.height <= 0) return + out += + RenderCommand.DrawCapturedScreenRegion( + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + grid = + if (gridEnabled) { + RenderCommand.CapturedGrid( + columns = columns, + rows = rows, + magnification = magnification, + color = gridColor, + ) + } else { + null + }, + ) + } +} + +internal class ColorFieldSurfaceNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-color-field" + + private var style: ColorPickerStyle = ColorPickerStyle() + private var hueDeg: Float = 0f + private var saturation: Float = 0f + private var brightness: Float = 1f + + fun bind(style: ColorPickerStyle, color: RgbaColor, hueDeg: Float) { + val hsv = ColorConversions.rgbToHsv(color, hueDeg) + val nextSaturation = hsv.saturation + val nextBrightness = hsv.brightness + if (this.style != style || + this.hueDeg != hueDeg || + saturation != nextSaturation || + brightness != nextBrightness + ) { + markRenderCommandsDirty() + } + this.style = style + this.hueDeg = hueDeg + saturation = nextSaturation + brightness = nextBrightness + } + + internal override fun syncCustomFrom(template: DOMNode) { + val typedTemplate = template as ColorFieldSurfaceNode + if (style != typedTemplate.style || + hueDeg != typedTemplate.hueDeg || + saturation != typedTemplate.saturation || + brightness != typedTemplate.brightness + ) { + markRenderCommandsDirty() + } + style = typedTemplate.style + hueDeg = typedTemplate.hueDeg + saturation = typedTemplate.saturation + brightness = typedTemplate.brightness + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (bounds.width <= 0 || bounds.height <= 0) return + out += + RenderCommand.DrawColorField( + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + hueDeg = hueDeg, + ) + drawBorder(out, bounds, style.inputBorderColor) + val thumbX = bounds.x + (saturation * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) + val thumbY = + bounds.y + ((1f - brightness) * bounds.height.toFloat()).roundToInt().coerceIn(0, bounds.height - 1) + out += RenderCommand.DrawRect(thumbX - 3, thumbY - 3, 7, 7, style.thumbShadowColor) + drawBorder(out, Rect(thumbX - 2, thumbY - 2, 5, 5), style.thumbOutlineColor) + } +} + +internal class HueSurfaceNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-hue-slider" + + private var style: ColorPickerStyle = ColorPickerStyle() + private var hueDeg: Float = 0f + + fun bind(style: ColorPickerStyle, hueDeg: Float) { + if (this.style != style || this.hueDeg != hueDeg) { + markRenderCommandsDirty() + } + this.style = style + this.hueDeg = hueDeg + } + + internal override fun syncCustomFrom(template: DOMNode) { + val typedTemplate = template as HueSurfaceNode + if (style != typedTemplate.style || hueDeg != typedTemplate.hueDeg) { + markRenderCommandsDirty() + } + style = typedTemplate.style + hueDeg = typedTemplate.hueDeg + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (bounds.width <= 0 || bounds.height <= 0) return + out += + RenderCommand.DrawHueBar( + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + ) + drawBorder(out, bounds, style.inputBorderColor) + val thumbX = bounds.x + ((hueDeg / 360f) * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) + out += RenderCommand.DrawRect(thumbX - 1, bounds.y - 1, 3, bounds.height + 2, style.thumbOutlineColor) + } +} + +internal class AlphaSurfaceNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-alpha-slider" + + private var style: ColorPickerStyle = ColorPickerStyle() + private var color: RgbaColor = RgbaColor.WHITE + + fun bind(style: ColorPickerStyle, color: RgbaColor) { + if (this.style != style || this.color != color) { + markRenderCommandsDirty() + } + this.style = style + this.color = color + } + + internal override fun syncCustomFrom(template: DOMNode) { + val typedTemplate = template as AlphaSurfaceNode + if (style != typedTemplate.style || color != typedTemplate.color) { + markRenderCommandsDirty() + } + style = typedTemplate.style + color = typedTemplate.color + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (bounds.width <= 0 || bounds.height <= 0) return + drawChecker(out, bounds, style) + out += + RenderCommand.DrawAlphaBar( + x = bounds.x, + y = bounds.y, + width = bounds.width, + height = bounds.height, + rgbColor = color.copy(a = 1f).toArgbInt(), + ) + drawBorder(out, bounds, style.inputBorderColor) + val thumbX = bounds.x + (color.a * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) + out += RenderCommand.DrawRect(thumbX - 1, bounds.y - 1, 3, bounds.height + 2, style.thumbOutlineColor) + } +} + +internal class ColorSwatchSurfaceNode( + private val allowEmpty: Boolean = false, + key: Any?, +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker-swatch" + + private var style: ColorPickerStyle = ColorPickerStyle() + private var color: RgbaColor? = RgbaColor.WHITE + private var highlighted: Boolean = false + + fun bind(style: ColorPickerStyle, color: RgbaColor?, highlighted: Boolean) { + if (this.style != style || this.color != color || this.highlighted != highlighted) { + markRenderCommandsDirty() + } + this.style = style + this.color = color + this.highlighted = highlighted + } + + internal override fun syncCustomFrom(template: DOMNode) { + val typedTemplate = template as ColorSwatchSurfaceNode + if (style != typedTemplate.style || + color != typedTemplate.color || + highlighted != typedTemplate.highlighted + ) { + markRenderCommandsDirty() + } + style = typedTemplate.style + color = typedTemplate.color + highlighted = typedTemplate.highlighted + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (bounds.width <= 0 || bounds.height <= 0) return + val localColor = color + if (localColor == null && allowEmpty) { + out += RenderCommand.DrawRect(bounds.x, bounds.y, bounds.width, bounds.height, 0x33222A34) + drawBorder(out, bounds, if (highlighted) style.inputActiveBorderColor else style.recentGridBorderColor) + return + } + drawChecker(out, bounds, style) + out += + RenderCommand.DrawRect( + bounds.x, + bounds.y, + bounds.width, + bounds.height, + (localColor ?: RgbaColor.WHITE).toArgbInt(), + ) + drawBorder(out, bounds, if (highlighted) style.inputActiveBorderColor else style.inputBorderColor) + } +} + +private fun drawChecker(out: MutableList, rect: Rect, style: ColorPickerStyle) { + if (rect.width <= 0 || rect.height <= 0) return + out += + RenderCommand.DrawCheckerboard( + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + cellSize = 4, + lightColor = style.checkerLightColor, + darkColor = style.checkerDarkColor, + ) +} + +private fun drawBorder(out: MutableList, rect: Rect, color: Int) { + if (rect.width <= 0 || rect.height <= 0) return + out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, 1, color) + out += RenderCommand.DrawRect(rect.x, rect.y + rect.height - 1, rect.width, 1, color) + out += RenderCommand.DrawRect(rect.x, rect.y, 1, rect.height, color) + out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerOverlayNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerOverlayNode.kt deleted file mode 100644 index c210a1c..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerOverlayNode.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.dreamfinity.dsgl.core.colorpicker.internal - -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanel -import org.dreamfinity.dsgl.core.style.Display - -internal class SystemColorPickerOverlayNode( - private val popupEngine: ColorPickerPopupEngine, - private val overlayPanel: OverlayPanel, - key: Any? = "dsgl-system-color-picker" -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker" - - private var cursorX: Int = 0 - private var cursorY: Int = 0 - - private val panelNode: DOMNode = overlayPanel.node().applyParent(this) - private val bodyNode: SystemColorPickerPopupBodyNode = - SystemColorPickerPopupBodyNode(popupEngine = popupEngine).also(overlayPanel::setBodyContent) - - fun updateCursor(mouseX: Int, mouseY: Int) { - cursorX = mouseX - cursorY = mouseY - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - popupEngine.onFrame(width, height) - popupEngine.onCursorPosition(cursorX, cursorY) - - val panelRect = overlayPanel.panelRect() - bodyNode.display = if (panelRect == null) Display.None else Display.Block - panelNode.render(ctx, x, y, width, height) - } -} - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPanelManager.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPanelManager.kt index f590d45..4138d75 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPanelManager.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPanelManager.kt @@ -5,9 +5,9 @@ import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle import org.dreamfinity.dsgl.core.colorpicker.RgbaColor import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.portal.ScreenDomainId -interface InspectorColorPickerHost { +interface SystemColorPickerPortalService { fun open( anchorRect: Rect, title: String, @@ -19,7 +19,7 @@ interface InspectorColorPickerHost { onPreview: ((RgbaColor) -> Unit)? = null, onChange: ((RgbaColor) -> Unit)? = null, onCommit: ((RgbaColor) -> Unit)? = null, - onClose: (() -> Unit)? = null + onClose: (() -> Unit)? = null, ) fun close() @@ -28,8 +28,8 @@ interface InspectorColorPickerHost { } internal class SystemColorPickerPanelManager( - private val delegate: ColorPickerPopupManager = ColorPickerPopupManager() -) : InspectorColorPickerHost { + private val delegate: ColorPickerPopupManager = ColorPickerPopupManager(), +) : SystemColorPickerPortalService { override fun open( anchorRect: Rect, title: String, @@ -41,10 +41,10 @@ internal class SystemColorPickerPanelManager( onPreview: ((RgbaColor) -> Unit)?, onChange: ((RgbaColor) -> Unit)?, onCommit: ((RgbaColor) -> Unit)?, - onClose: (() -> Unit)? + onClose: (() -> Unit)?, ) { delegate.open( - ownerScope = OverlayOwnerScope.System, + ownerDomain = ScreenDomainId.System, anchorRect = anchorRect, title = title, state = state, @@ -55,7 +55,7 @@ internal class SystemColorPickerPanelManager( onPreview = onPreview, onChange = onChange, onCommit = onCommit, - onClose = onClose + onClose = onClose, ) } @@ -65,4 +65,3 @@ internal class SystemColorPickerPanelManager( override fun isOpen(): Boolean = delegate.isOpen() } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPopupBodyNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPopupBodyNode.kt index 4fa91c2..91f791e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPopupBodyNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPopupBodyNode.kt @@ -8,87 +8,140 @@ import org.dreamfinity.dsgl.core.dom.layout.Border import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.dsl.button -import org.dreamfinity.dsgl.core.dsl.div -import org.dreamfinity.dsgl.core.dsl.text -import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.event.* import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.TextWrap -import kotlin.math.roundToInt internal class SystemColorPickerPopupBodyNode( private val popupEngine: ColorPickerPopupEngine, - key: Any? = "dsgl-system-color-picker-native-body" + key: Any? = "dsgl-system-color-picker-native-body", ) : DOMNode(key) { override val styleType: String = "dsgl-system-color-picker-native-body" private val scope = UiScope(this) private val inputLabelValues: MutableList = MutableList(MAX_INPUT_SLOTS) { "" } + private val inputSemanticKeys: MutableList = MutableList(MAX_INPUT_SLOTS) { null } + private var focusedSemanticInputKey: String? = null - private val modeSelectButton: ButtonNode = scope.button("", { - this.key = "dsgl-system-color-picker-mode-select" - }) - private val rgbaOrderButton: ButtonNode = scope.button("RGBA", { - this.key = "dsgl-system-color-picker-order-rgba" - }) - private val argbOrderButton: ButtonNode = scope.button("ARGB", { - this.key = "dsgl-system-color-picker-order-argb" - }) - private val colorFieldNode: ColorFieldSurfaceNode = ColorFieldSurfaceNode( - key = "dsgl-system-color-picker-surface-field" - ).applyParent(this) - private val hueSliderNode: HueSurfaceNode = HueSurfaceNode( - key = "dsgl-system-color-picker-surface-hue" - ).applyParent(this) - private val alphaSliderNode: AlphaSurfaceNode = AlphaSurfaceNode( - key = "dsgl-system-color-picker-surface-alpha" - ).applyParent(this) - - private val previousSwatchNode: ColorSwatchSurfaceNode = ColorSwatchSurfaceNode( - key = "dsgl-system-color-picker-swatch-previous" - ).applyParent(this) - private val currentSwatchNode: ColorSwatchSurfaceNode = ColorSwatchSurfaceNode( - key = "dsgl-system-color-picker-swatch-current" - ).applyParent(this) - - private val copyButton: ButtonNode = scope.button("Copy", { - this.key = "dsgl-system-color-picker-button-copy" - }) - private val pasteButton: ButtonNode = scope.button("Paste", { - this.key = "dsgl-system-color-picker-button-paste" - }) - private val pipetteButton: ButtonNode = scope.button("Pipette", { - this.key = "dsgl-system-color-picker-button-pipette" - }) - - private val inputLabelNodes: List = (0 until MAX_INPUT_SLOTS).map { index -> - scope.text(props = { - this.key = "dsgl-system-color-picker-input-label-$index" - source = TextSource.Dynamic { inputLabelValues[index] } - style = { - textWrap = TextWrap.NoWrap - } + private val modeSelectButton: ButtonNode = + scope.button("", { + this.key = "dsgl-system-color-picker-mode-select" + }) + private val rgbaOrderButton: ButtonNode = + scope.button("RGBA", { + this.key = "dsgl-system-color-picker-order-rgba" + }) + private val argbOrderButton: ButtonNode = + scope.button("ARGB", { + this.key = "dsgl-system-color-picker-order-argb" + }) + private val colorFieldNode: ColorFieldSurfaceNode = + scope.colorField({ + this.key = "dsgl-system-color-picker-surface-field" + }) + private val hueSliderNode: HueSurfaceNode = + scope.hueSlider({ + this.key = "dsgl-system-color-picker-surface-hue" + }) + private val alphaSliderNode: AlphaSurfaceNode = + scope.alphaSlider({ + this.key = "dsgl-system-color-picker-surface-alpha" }) - } - private val inputValueNodes: List = (0 until MAX_INPUT_SLOTS).map { index -> - TextInputNode(key = "dsgl-system-color-picker-input-value-$index").applyParent(this) - } - private val recentSwatchNodes: List = (0 until RECENT_SWATCH_COUNT).map { index -> - ColorSwatchSurfaceNode( - allowEmpty = true, - key = "dsgl-system-color-picker-recent-$index" - ).applyParent(this) - } + private val previousSwatchNode: ColorSwatchSurfaceNode = + scope.colorSwatch({ + this.key = "dsgl-system-color-picker-swatch-previous" + }) + private val currentSwatchNode: ColorSwatchSurfaceNode = + scope.colorSwatch({ + this.key = "dsgl-system-color-picker-swatch-current" + }) + private val recentSwatchNodes: List = + (0 until RECENT_SWATCH_COUNT).map { index -> + scope.colorSwatch({ + allowEmpty = true + this.key = recentSwatchNodeKey(index) + onMouseDown = { event -> + if (event.mouseButton == MouseButton.LEFT) { + val controller = popupEngine.debugActiveController() + controller?.semanticCancelInputEdit() + controller?.semanticPreviewRecentSwatch(index) + event.cancelled = true + } + } + }) + } + + private val copyButton: ButtonNode = + scope.button("Copy", { + this.key = "dsgl-system-color-picker-button-copy" + }) + private val pasteButton: ButtonNode = + scope.button("Paste", { + this.key = "dsgl-system-color-picker-button-paste" + }) + private val pipetteButton: ButtonNode = + scope.button("Pipette", { + this.key = "dsgl-system-color-picker-button-pipette" + }) + + private val inputLabelNodes: List = + (0 until MAX_INPUT_SLOTS).map { index -> + scope.text( + props = { + this.key = "dsgl-system-color-picker-input-label-$index" + source = TextSource.Dynamic { inputLabelValues[index] } + style = { + textWrap = TextWrap.NoWrap + } + }, + ) + } + private val inputValueNodes: List = + (0 until MAX_INPUT_SLOTS).map { index -> + TextInputNode(key = "dsgl-system-color-picker-input-value-$index") + .applyParent(this) + .also { node -> configureInputValueNode(index, node) } + } private var appliedStyle: ColorPickerStyle? = null - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + init { + bindSemanticBodyClickHandlers() + } + + fun focusInputSlot(index: Int, mouseX: Int, mouseY: Int): Boolean { + val inputNode = inputValueNodes.getOrNull(index) ?: return false + if (inputNode.display == Display.None) return false + val key = inputSemanticKeys.getOrNull(index) + focusedSemanticInputKey = key + if (key != null) { + popupEngine.debugActiveController()?.handleDomInputFocused(key) + } + val down = MouseDownEvent(mouseX = mouseX, mouseY = mouseY, mouseButton = MouseButton.LEFT) + down.target = inputNode + EventBus.post(down) + val focused = FocusManager.isFocused(inputNode) + return focused + } + + fun syncFocusedInputForModeOrOrderChange() { + val controller = popupEngine.debugActiveController() ?: return + val layout = popupEngine.debugActiveLayout() ?: return + resyncFocusedInputForModeOrOrderChange(controller, layout) } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) val controller = popupEngine.debugActiveController() if (controller == null || popupEngine.debugActivePanelRect() == null) { @@ -107,38 +160,125 @@ internal class SystemColorPickerPopupBodyNode( val hover = controller.viewHoverPosition() val hoverX = hover.first val hoverY = hover.second - val nowMs = System.currentTimeMillis() val modeDropdownOpen = controller.viewModeDropdownOpen() - val activeInputKey = controller.viewActiveInputKey() - val activeInputBuffer = controller.viewActiveInputBuffer() val inputValues = controller.viewInputValues() + ColorPickerDebugCounters.onRecentColorsSnapshotRead() val recentColors = controller.viewRecentColors() + val definitionsByKey = controller.viewInputDefinitions().associate { it.first to it.second } - modeSelectButton.text = if (modeDropdownOpen) "${state.mode.name} ^" else "${state.mode.name} v" - applyButtonVisual( - button = modeSelectButton, + renderTopControls( + ctx = ctx, + controller = controller, + layout = layout, + style = style, + state = state, + hueDeg = hueDeg, + hoverX = hoverX, + hoverY = hoverY, + modeDropdownOpen = modeDropdownOpen, + ) + renderInputRows( + ctx = ctx, + controller = controller, + layout = layout, style = style, - hovered = layout.modeSelectRect.contains(hoverX, hoverY), - selected = modeDropdownOpen + hoverX = hoverX, + hoverY = hoverY, + inputValues = inputValues, + definitionsByKey = definitionsByKey, ) - renderNode(ctx, modeSelectButton, layout.modeSelectRect) + renderRecentSwatchGrid( + ctx = ctx, + layout = layout, + style = style, + hoverX = hoverX, + hoverY = hoverY, + recentColors = recentColors, + ) + } + + private data class TopControlsRenderState( + val controller: ColorPickerController, + val layout: ColorPickerLayout, + val style: ColorPickerStyle, + val state: ColorPickerState, + val hueDeg: Float, + val hoverX: Int, + val hoverY: Int, + val modeDropdownOpen: Boolean, + ) - val showOrder = layout.rgbaOrderRect != null && layout.argbOrderRect != null + private data class InputRowsRenderState( + val controller: ColorPickerController, + val layout: ColorPickerLayout, + val style: ColorPickerStyle, + val hoverX: Int, + val hoverY: Int, + val inputValues: Map, + val definitionsByKey: Map, + ) + + private fun renderTopControls( + ctx: UiMeasureContext, + controller: ColorPickerController, + layout: ColorPickerLayout, + style: ColorPickerStyle, + state: ColorPickerState, + hueDeg: Float, + hoverX: Int, + hoverY: Int, + modeDropdownOpen: Boolean, + ) { + val renderState = + TopControlsRenderState( + controller = controller, + layout = layout, + style = style, + state = state, + hueDeg = hueDeg, + hoverX = hoverX, + hoverY = hoverY, + modeDropdownOpen = modeDropdownOpen, + ) + renderModeSelectControl(ctx, renderState) + renderOrderControls(ctx, renderState) + renderColorSurfaceControls(ctx, renderState) + renderPrimarySwatches(ctx, renderState) + renderActionControls(ctx, renderState) + } + + private fun renderModeSelectControl(ctx: UiMeasureContext, state: TopControlsRenderState) { + syncPickerButtonVisual( + button = modeSelectButton, + text = if (state.modeDropdownOpen) "${state.state.mode.name} ^" else "${state.state.mode.name} v", + style = state.style, + hovered = + state.layout.modeSelectRect + .contains(state.hoverX, state.hoverY), + selected = state.modeDropdownOpen, + ) + renderNode(ctx, modeSelectButton, state.layout.modeSelectRect) + } + + private fun renderOrderControls(ctx: UiMeasureContext, state: TopControlsRenderState) { + val showOrder = state.layout.rgbaOrderRect != null && state.layout.argbOrderRect != null if (showOrder) { - val rgbaRect = layout.rgbaOrderRect - val argbRect = layout.argbOrderRect - applyButtonVisual( + val rgbaRect = state.layout.rgbaOrderRect + val argbRect = state.layout.argbOrderRect + syncPickerButtonVisual( button = rgbaOrderButton, - style = style, - hovered = rgbaRect.contains(hoverX, hoverY), - selected = state.rgbOrder == RgbChannelOrder.RGBA + text = null, + style = state.style, + hovered = rgbaRect.contains(state.hoverX, state.hoverY), + selected = state.state.rgbOrder == RgbChannelOrder.RGBA, ) - applyButtonVisual( + syncPickerButtonVisual( button = argbOrderButton, - style = style, - hovered = argbRect.contains(hoverX, hoverY), - selected = state.rgbOrder == RgbChannelOrder.ARGB + text = null, + style = state.style, + hovered = argbRect.contains(state.hoverX, state.hoverY), + selected = state.state.rgbOrder == RgbChannelOrder.ARGB, ) renderNode(ctx, rgbaOrderButton, rgbaRect) renderNode(ctx, argbOrderButton, argbRect) @@ -146,126 +286,366 @@ internal class SystemColorPickerPopupBodyNode( renderNode(ctx, rgbaOrderButton, null) renderNode(ctx, argbOrderButton, null) } + } - colorFieldNode.bind(style = style, color = state.color, hueDeg = hueDeg) - renderNode(ctx, colorFieldNode, layout.colorFieldRect) + private fun renderColorSurfaceControls(ctx: UiMeasureContext, state: TopControlsRenderState) { + colorFieldNode.bind(style = state.style, color = state.state.color, hueDeg = state.hueDeg) + renderNode(ctx, colorFieldNode, state.layout.colorFieldRect) - hueSliderNode.bind(style = style, hueDeg = hueDeg) - renderNode(ctx, hueSliderNode, layout.hueRect) + hueSliderNode.bind(style = state.style, hueDeg = state.hueDeg) + renderNode(ctx, hueSliderNode, state.layout.hueRect) - if (state.alphaEnabled && layout.alphaRect != null) { - alphaSliderNode.bind(style = style, color = state.color) - renderNode(ctx, alphaSliderNode, layout.alphaRect) + if (state.state.alphaEnabled && state.layout.alphaRect != null) { + alphaSliderNode.bind(style = state.style, color = state.state.color) + renderNode(ctx, alphaSliderNode, state.layout.alphaRect) } else { renderNode(ctx, alphaSliderNode, null) } + } + private fun renderPrimarySwatches(ctx: UiMeasureContext, state: TopControlsRenderState) { previousSwatchNode.bind( - style = style, - color = state.previous, - highlighted = layout.previousSwatchRect.contains(hoverX, hoverY) + style = state.style, + color = state.state.previous, + highlighted = + state.layout.previousSwatchRect + .contains(state.hoverX, state.hoverY), ) currentSwatchNode.bind( - style = style, - color = state.color, - highlighted = layout.currentSwatchRect.contains(hoverX, hoverY) + style = state.style, + color = state.state.color, + highlighted = + state.layout.currentSwatchRect + .contains(state.hoverX, state.hoverY), ) - renderNode(ctx, previousSwatchNode, layout.previousSwatchRect) - renderNode(ctx, currentSwatchNode, layout.currentSwatchRect) + renderNode(ctx, previousSwatchNode, state.layout.previousSwatchRect) + renderNode(ctx, currentSwatchNode, state.layout.currentSwatchRect) + } - applyButtonVisual( + private fun renderActionControls(ctx: UiMeasureContext, state: TopControlsRenderState) { + syncPickerButtonVisual( button = copyButton, - style = style, - hovered = layout.copyRect.contains(hoverX, hoverY), - selected = false + text = null, + style = state.style, + hovered = + state.layout.copyRect + .contains(state.hoverX, state.hoverY), + selected = false, ) - applyButtonVisual( + syncPickerButtonVisual( button = pasteButton, - style = style, - hovered = layout.pasteRect.contains(hoverX, hoverY), - selected = false + text = null, + style = state.style, + hovered = + state.layout.pasteRect + .contains(state.hoverX, state.hoverY), + selected = false, ) - applyButtonVisual( + syncPickerButtonVisual( button = pipetteButton, - style = style, - hovered = layout.pipetteRect.contains(hoverX, hoverY), - selected = controller.isEyedropperActive() + text = if (state.controller.isEyedropperActive()) "Pick..." else "Pipette", + style = state.style, + hovered = + state.layout.pipetteRect + .contains(state.hoverX, state.hoverY), + selected = state.controller.isEyedropperActive(), ) - pipetteButton.text = if (controller.isEyedropperActive()) "Pick..." else "Pipette" - renderNode(ctx, copyButton, layout.copyRect) - renderNode(ctx, pasteButton, layout.pasteRect) - renderNode(ctx, pipetteButton, layout.pipetteRect) + renderNode(ctx, copyButton, state.layout.copyRect) + renderNode(ctx, pasteButton, state.layout.pasteRect) + renderNode(ctx, pipetteButton, state.layout.pipetteRect) + } - val definitionsByKey = controller.viewInputDefinitions().associate { it.first to it.second } + private fun renderInputRows( + ctx: UiMeasureContext, + controller: ColorPickerController, + layout: ColorPickerLayout, + style: ColorPickerStyle, + hoverX: Int, + hoverY: Int, + inputValues: Map, + definitionsByKey: Map, + ) { + resyncFocusedInputForModeOrOrderChange(controller, layout) + val renderState = + InputRowsRenderState( + controller = controller, + layout = layout, + style = style, + hoverX = hoverX, + hoverY = hoverY, + inputValues = inputValues, + definitionsByKey = definitionsByKey, + ) for (index in 0 until MAX_INPUT_SLOTS) { - val inputSlot = layout.inputSlots.getOrNull(index) - val labelNode = inputLabelNodes[index] - val inputNode = inputValueNodes[index] - if (inputSlot == null) { - inputLabelValues[index] = "" - renderNode(ctx, labelNode, null) - renderNode(ctx, inputNode, null) - continue - } + renderInputRow(ctx, renderState, index) + } + } - val key = inputSlot.key - val label = definitionsByKey[key] ?: inputSlot.label - inputLabelValues[index] = label - labelNode.color = style.mutedTextColor + private fun renderInputRow(ctx: UiMeasureContext, state: InputRowsRenderState, index: Int) { + val inputSlot = + state.layout.inputSlots + .getOrNull(index) + val labelNode = inputLabelNodes[index] + val inputNode = inputValueNodes[index] + if (inputSlot == null) { + renderMissingInputRow(ctx, state, labelNode, inputNode, index) + return + } + renderPresentInputRow(ctx, state, labelNode, inputNode, inputSlot, index) + } - val borderColor = when { - activeInputKey == key -> style.inputActiveBorderColor - inputSlot.inputRect.contains(hoverX, hoverY) -> style.buttonHoverColor - else -> style.inputBorderColor - } - val value = if (activeInputKey == key) { - activeInputBuffer + if (controller.viewCaretVisible(nowMs)) "|" else "" - } else { - inputValues[key].orEmpty() - } - if (inputNode.text != value) { - inputNode.text = value + private fun renderMissingInputRow( + ctx: UiMeasureContext, + state: InputRowsRenderState, + labelNode: TextNode, + inputNode: TextInputNode, + index: Int, + ) { + inputSemanticKeys[index] = null + if (FocusManager.isFocused(inputNode)) { + FocusManager.clearFocus() + focusedSemanticInputKey = null + } + inputLabelValues[index] = "" + syncTextNodeVisual( + node = labelNode, + text = "", + color = state.style.mutedTextColor, + ) + renderNode(ctx, labelNode, null) + renderNode(ctx, inputNode, null) + } + + private fun renderPresentInputRow( + ctx: UiMeasureContext, + state: InputRowsRenderState, + labelNode: TextNode, + inputNode: TextInputNode, + inputSlot: ColorPickerInputSlot, + index: Int, + ) { + val key = inputSlot.key + inputSemanticKeys[index] = key + val label = state.definitionsByKey[key] ?: inputSlot.label + inputLabelValues[index] = label + syncTextNodeVisual( + node = labelNode, + text = label, + color = state.style.mutedTextColor, + ) + val focused = FocusManager.isFocused(inputNode) + + val borderColor = + when { + focused -> state.style.inputActiveBorderColor + inputSlot.inputRect.contains(state.hoverX, state.hoverY) -> state.style.buttonHoverColor + else -> state.style.inputBorderColor } - inputNode.border = Border.all(1, borderColor) - inputNode.backgroundColor = style.inputBackgroundColor - inputNode.focusedBackgroundColor = style.inputBackgroundColor - inputNode.textColor = style.textColor - inputNode.placeholderColor = style.mutedTextColor - inputNode.fontSize = style.fontSize + val value = if (focused) null else state.inputValues[key].orEmpty() + syncTextInputVisual( + node = inputNode, + value = value, + border = Border.all(1, borderColor), + background = state.style.inputBackgroundColor, + focusedBackground = state.style.inputBackgroundColor, + textColor = state.style.textColor, + placeholderColor = state.style.mutedTextColor, + fontSize = state.style.fontSize, + ) - renderNode(ctx, labelNode, inputSlot.labelRect) - renderNode(ctx, inputNode, inputSlot.inputRect) - } + renderNode(ctx, labelNode, inputSlot.labelRect) + renderNode(ctx, inputNode, inputSlot.inputRect) + } + + private fun renderRecentSwatchGrid( + ctx: UiMeasureContext, + layout: ColorPickerLayout, + style: ColorPickerStyle, + hoverX: Int, + hoverY: Int, + recentColors: List, + ) { val hoveredRecent = layout.recentRects.indexOfFirst { it.contains(hoverX, hoverY) } + val recentSwatchNodes = + composeRecentSwatchNodes( + style = style, + recentColors = recentColors, + hoveredRecent = hoveredRecent, + ) for (index in 0 until RECENT_SWATCH_COUNT) { - val swatchNode = recentSwatchNodes[index] - val swatchRect = layout.recentRects.getOrNull(index) - if (swatchRect == null) { - renderNode(ctx, swatchNode, null) - continue - } - swatchNode.bind( + renderRecentSwatch(ctx, layout, recentSwatchNodes, index) + } + } + + private fun composeRecentSwatchNodes( + style: ColorPickerStyle, + recentColors: List, + hoveredRecent: Int, + ): List { + val composeStartNanos = System.nanoTime() + for (index in 0 until RECENT_SWATCH_COUNT) { + val node = recentSwatchNodes[index] + node.bind( style = style, color = recentColors.getOrNull(index), - highlighted = index == hoveredRecent + highlighted = index == hoveredRecent, ) - renderNode(ctx, swatchNode, swatchRect) } + val composeDurationNanos = System.nanoTime() - composeStartNanos + ColorPickerDebugCounters.onRecentSwatchCompose( + createdNodes = 0, + removedNodes = 0, + composeDurationNanos = composeDurationNanos, + removeDurationNanos = 0L, + ) + return recentSwatchNodes } - private fun applyStaticStyle(style: ColorPickerStyle) { - val buttons = buildList { - add(modeSelectButton) - add(rgbaOrderButton) - add(argbOrderButton) - add(copyButton) - add(pasteButton) - add(pipetteButton) + private fun renderRecentSwatch( + ctx: UiMeasureContext, + layout: ColorPickerLayout, + recentSwatchNodes: List, + index: Int, + ) { + val swatchNode = recentSwatchNodes[index] + val swatchRect = layout.recentRects.getOrNull(index) + if (swatchRect == null) { + renderNode(ctx, swatchNode, null) + return + } + renderNode(ctx, swatchNode, swatchRect) + } + + private fun configureInputValueNode(index: Int, inputNode: TextInputNode) { + EventBus.run { + inputNode.addEventListener(Events.FOCUS) { _: FocusGainEvent -> + val key = inputSemanticKeys[index] ?: return@addEventListener + focusedSemanticInputKey = key + popupEngine.debugActiveController()?.handleDomInputFocused(key) + } + inputNode.addEventListener(Events.BLUR) { _: FocusLoseEvent -> + val key = inputSemanticKeys[index] + if (focusedSemanticInputKey == key) { + focusedSemanticInputKey = null + } + if (key != null) { + popupEngine.debugActiveController()?.handleDomInputBlurred(key) + } + } + inputNode.addEventListener(Events.INPUT) { _: InputEvent -> + val key = inputSemanticKeys[index] ?: return@addEventListener + popupEngine.debugActiveController()?.handleDomInputDraft(key, inputNode.text) + } + inputNode.addEventListener(Events.KEYDOWN) { event: KeyboardKeyDownEvent -> + val key = inputSemanticKeys[index] ?: return@addEventListener + val controller = popupEngine.debugActiveController() ?: return@addEventListener + when (event.keyCode) { + KeyCodes.ENTER -> { + controller.commitDomInputEdit(key, inputNode.text) + event.cancelled = true + } + + KeyCodes.ESCAPE -> { + controller.cancelDomInputEdit(key) + val restoredValue = controller.resolveDomInputValue(key) + if (inputNode.text != restoredValue) { + inputNode.text = restoredValue + requestRenderCommandsInvalidationTracked(inputNode) + } + event.cancelled = true + } + } + } + } + } + + private fun bindSemanticBodyClickHandlers() { + val left = MouseButton.LEFT + previousSwatchNode.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticPreviewPreviousSwatch() + event.cancelled = true + } + } + currentSwatchNode.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticCommitCurrentColor() + event.cancelled = true + } + } + copyButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticCopyCurrentColor() + event.cancelled = true + } } + pasteButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticPasteFromClipboard() + event.cancelled = true + } + } + pipetteButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticBeginEyedropper() + event.cancelled = true + } + } + rgbaOrderButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticSetRgbOrder(RgbChannelOrder.RGBA) + event.cancelled = true + } + } + argbOrderButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticSetRgbOrder(RgbChannelOrder.ARGB) + event.cancelled = true + } + } + modeSelectButton.onMouseDown = { event -> + if (event.mouseButton == left) { + popupEngine.debugActiveController()?.semanticToggleModeDropdown() + event.cancelled = true + } + } + } + + private fun resyncFocusedInputForModeOrOrderChange(controller: ColorPickerController, layout: ColorPickerLayout) { + val focusedIndex = inputValueNodes.indexOf(FocusManager.focusedNode()) + val focusedSlotKey = if (focusedIndex >= 0) inputSemanticKeys.getOrNull(focusedIndex) else null + val resyncKey = + controller.consumeDomInputFocusResyncKey() + ?: focusedSemanticInputKey + ?: focusedSlotKey + ?: return + val targetIndex = layout.inputSlots.indexOfFirst { it.key == resyncKey } + if (targetIndex >= 0) { + FocusManager.requestFocus(inputValueNodes[targetIndex]) + focusedSemanticInputKey = resyncKey + return + } + FocusManager.clearFocus() + focusedSemanticInputKey = null + } + + private fun applyStaticStyle(style: ColorPickerStyle) { + val buttons = + buildList { + add(modeSelectButton) + add(rgbaOrderButton) + add(argbOrderButton) + add(copyButton) + add(pasteButton) + add(pipetteButton) + } buttons.forEach { button -> button.textColor = style.textColor button.applyStyle { - border { width = 1.px; color = style.inputBorderColor } + border { + width = 1.px + color = style.inputBorderColor + } fontSize = style.fontSize.px padding = 0.px textWrap = TextWrap.NoWrap @@ -285,17 +665,104 @@ internal class SystemColorPickerPopupBodyNode( } } - private fun applyButtonVisual(button: ButtonNode, style: ColorPickerStyle, hovered: Boolean, selected: Boolean) { - button.backgroundColor = when { - selected -> style.buttonActiveColor - hovered -> style.buttonHoverColor - else -> style.buttonBackgroundColor + private fun syncPickerButtonVisual( + button: ButtonNode, + text: String?, + style: ColorPickerStyle, + hovered: Boolean, + selected: Boolean, + ) { + val nextBackground = + when { + selected -> style.buttonActiveColor + hovered -> style.buttonHoverColor + else -> style.buttonBackgroundColor + } + val nextBorder = Border.all(1, if (selected) style.inputActiveBorderColor else style.inputBorderColor) + var changed = false + if (text != null && button.text != text) { + button.text = text + changed = true + } + if (button.backgroundColor != nextBackground) { + button.backgroundColor = nextBackground + changed = true + } + if (button.border != nextBorder) { + button.border = nextBorder + changed = true + } + if (button.textColor != style.textColor) { + button.textColor = style.textColor + changed = true + } + if (button.fontSize != style.fontSize) { + button.fontSize = style.fontSize + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(button) + } + } + + private fun syncTextNodeVisual(node: TextNode, text: String, color: Int) { + var changed = false + if (node.text != text) { + node.setText(text) + changed = false + } + if (node.color != color) { + node.color = color + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(node) } - button.border = Border.all(1, if (selected) style.inputActiveBorderColor else style.inputBorderColor) - button.textColor = style.textColor - button.fontSize = style.fontSize } + private fun syncTextInputVisual( + node: TextInputNode, + value: String?, + border: Border, + background: Int, + focusedBackground: Int, + textColor: Int, + placeholderColor: Int, + fontSize: Int, + ) { + var changed = false + if (value != null && node.text != value) { + node.text = value + changed = true + } + if (node.border != border) { + node.border = border + changed = true + } + if (node.backgroundColor != background) { + node.backgroundColor = background + changed = true + } + if (node.focusedBackgroundColor != focusedBackground) { + node.focusedBackgroundColor = focusedBackground + changed = true + } + if (node.textColor != textColor) { + node.textColor = textColor + changed = true + } + if (node.placeholderColor != placeholderColor) { + node.placeholderColor = placeholderColor + changed = true + } + if (node.fontSize != fontSize) { + node.fontSize = fontSize + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(node) + } + } private fun renderNode(ctx: UiMeasureContext, node: DOMNode, rect: Rect?) { if (rect == null || rect.width <= 0 || rect.height <= 0) { @@ -315,87 +782,144 @@ internal class SystemColorPickerPopupBodyNode( } private companion object { - const val MAX_INPUT_SLOTS: Int = 4 - const val RECENT_SWATCH_COUNT: Int = 64 + private const val MAX_INPUT_SLOTS: Int = 4 + private const val RECENT_SWATCH_COUNT: Int = 64 + private const val RECENT_SWATCH_KEY_PREFIX: String = "dsgl-system-color-picker-recent-" + + private fun recentSwatchNodeKey(index: Int): String = "$RECENT_SWATCH_KEY_PREFIX$index" } } -internal class SystemColorPickerTransientOverlayNode( +internal class SystemColorPickerTransientPortalNode( private val popupEngine: ColorPickerPopupEngine, - key: Any? = "dsgl-system-color-picker-native-transient" + key: Any? = "dsgl-system-color-picker-native-transient", ) : DOMNode(key) { override val styleType: String = "dsgl-system-color-picker-native-transient" - private val modeDropdownOverlayNode: SystemColorPickerModeDropdownOverlayNode = - SystemColorPickerModeDropdownOverlayNode(popupEngine).applyParent(this) - private val eyedropperOverlayNode: SystemColorPickerEyedropperOverlayNode = - SystemColorPickerEyedropperOverlayNode(popupEngine).applyParent(this) - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + private val modeDropdownPortalNode: SystemColorPickerModeDropdownPortalNode = + SystemColorPickerModeDropdownPortalNode(popupEngine).applyParent(this) + private val eyedropperPreviewNode: SystemColorPickerEyedropperPreviewNode = + SystemColorPickerEyedropperPreviewNode(popupEngine).applyParent(this) + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + modeDropdownPortalNode.render(ctx, x, y, width, height) + eyedropperPreviewNode.render(ctx, x, y, width, height) } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - modeDropdownOverlayNode.render(ctx, x, y, width, height) - eyedropperOverlayNode.render(ctx, x, y, width, height) + fun invalidateColorState() { + markRenderCommandsDirty() } } -internal class SystemColorPickerModeDropdownOverlayNode( +internal class SystemColorPickerModeDropdownPortalNode( private val popupEngine: ColorPickerPopupEngine, - key: Any? = "dsgl-system-color-picker-native-mode-dropdown-overlay" + key: Any? = "dsgl-system-color-picker-native-mode-dropdown-portal", ) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-native-mode-dropdown-overlay" + override val styleType: String = "dsgl-system-color-picker-native-mode-dropdown-portal" private val scope = UiScope(this) - private val popupBackgroundNode: ContainerNode = ContainerNode( - key = "dsgl-system-color-picker-mode-dropdown-background" - ).applyParent(this) - private val modeOptionButtons: Map = ColorFormatMode.entries.associateWith { mode -> - scope.button(mode.name, { - this.key = "dsgl-system-color-picker-mode-option-${mode.name.lowercase()}" - }) - } + private val popupBackgroundNode: ContainerNode = + ContainerNode( + key = "dsgl-system-color-picker-mode-dropdown-background", + ).applyParent(this) + private val modeOptionButtons: Map = + ColorFormatMode.entries.associateWith { mode -> + scope.button( + mode.name, + { + this.key = "dsgl-system-color-picker-mode-option-${mode.name.lowercase()}" + onMouseDown = { event -> + if (event.mouseButton == MouseButton.LEFT) { + popupEngine.debugActiveController()?.semanticSetMode(mode) + event.cancelled = true + } + } + }, + ) + } private var appliedStyle: ColorPickerStyle? = null - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } + private data class ModeDropdownRenderState( + val style: ColorPickerStyle, + val layout: ColorPickerLayout, + val popupRect: Rect, + val hoverX: Int, + val hoverY: Int, + val selectedMode: ColorFormatMode, + ) + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) - val controller = popupEngine.debugActiveController() + val renderState = + resolveRenderState() ?: run { + hideAll(ctx) + return + } + renderPopupBackground(ctx, renderState) + renderModeOptions(ctx, renderState) + } + + private fun resolveRenderState(): ModeDropdownRenderState? { + val controller = popupEngine.debugActiveController() ?: return null val panelRect = popupEngine.debugActivePanelRect() - if (controller == null || panelRect == null || !controller.viewModeDropdownOpen()) { - hideAll(ctx) - return - } + if (panelRect == null || !controller.viewModeDropdownOpen()) return null val style = popupEngine.debugActiveStyle() ?: controller.style() if (appliedStyle != style) { applyStaticStyle(style) appliedStyle = style } - - val layout = popupEngine.debugActiveLayout() ?: run { - hideAll(ctx) - return - } - val popupRect = layout.modeOptionsRect ?: run { - hideAll(ctx) - return - } + val layout = popupEngine.debugActiveLayout() ?: return null + val popupRect = layout.modeOptionsRect ?: return null val hover = controller.viewHoverPosition() - val hoverX = hover.first - val hoverY = hover.second + return ModeDropdownRenderState( + style = style, + layout = layout, + popupRect = popupRect, + hoverX = hover.first, + hoverY = hover.second, + selectedMode = controller.snapshot().mode, + ) + } + private fun renderPopupBackground(ctx: UiMeasureContext, state: ModeDropdownRenderState) { popupBackgroundNode.display = Display.Block - popupBackgroundNode.backgroundColor = style.inputBackgroundColor - popupBackgroundNode.border = Border.all(1, style.inputBorderColor) - popupBackgroundNode.render(ctx, popupRect.x, popupRect.y, popupRect.width, popupRect.height) + syncContainerVisual( + node = popupBackgroundNode, + backgroundColor = state.style.inputBackgroundColor, + border = Border.all(1, state.style.inputBorderColor), + ) + popupBackgroundNode.render( + ctx, + state.popupRect.x, + state.popupRect.y, + state.popupRect.width, + state.popupRect.height, + ) + } - val state = controller.snapshot() - val optionsByMode = layout.modeOptions.associateBy { it.mode } + private fun renderModeOptions(ctx: UiMeasureContext, state: ModeDropdownRenderState) { + val optionsByMode = + state.layout.modeOptions + .associateBy { it.mode } ColorFormatMode.entries.forEach { mode -> val button = modeOptionButtons[mode] ?: return@forEach val optionRect = optionsByMode[mode]?.rect @@ -404,9 +928,13 @@ internal class SystemColorPickerModeDropdownOverlayNode( button.render(ctx, 0, 0, 0, 0) return@forEach } - val hovered = optionRect.contains(hoverX, hoverY) - val selected = state.mode == mode - applyButtonVisual(button = button, style = style, hovered = hovered, selected = selected) + syncPickerButtonVisual( + button = button, + text = null, + style = state.style, + hovered = optionRect.contains(state.hoverX, state.hoverY), + selected = state.selectedMode == mode, + ) button.display = Display.Block button.render(ctx, optionRect.x, optionRect.y, optionRect.width, optionRect.height) } @@ -415,7 +943,10 @@ internal class SystemColorPickerModeDropdownOverlayNode( private fun applyStaticStyle(style: ColorPickerStyle) { modeOptionButtons.values.forEach { button -> button.applyStyle { - border { width = 1.px; color = style.inputBorderColor } + border { + width = 1.px + color = style.inputBorderColor + } fontSize = style.fontSize.px padding = 0.px textWrap = TextWrap.NoWrap @@ -423,15 +954,59 @@ internal class SystemColorPickerModeDropdownOverlayNode( } } - private fun applyButtonVisual(button: ButtonNode, style: ColorPickerStyle, hovered: Boolean, selected: Boolean) { - button.backgroundColor = when { - selected -> style.buttonActiveColor - hovered -> style.buttonHoverColor - else -> style.buttonBackgroundColor + private fun syncPickerButtonVisual( + button: ButtonNode, + text: String?, + style: ColorPickerStyle, + hovered: Boolean, + selected: Boolean, + ) { + val nextBackground = + when { + selected -> style.buttonActiveColor + hovered -> style.buttonHoverColor + else -> style.buttonBackgroundColor + } + val nextBorder = Border.all(1, if (selected) style.inputActiveBorderColor else style.inputBorderColor) + var changed = false + if (text != null && button.text != text) { + button.text = text + changed = true + } + if (button.backgroundColor != nextBackground) { + button.backgroundColor = nextBackground + changed = true + } + if (button.border != nextBorder) { + button.border = nextBorder + changed = true + } + if (button.textColor != style.textColor) { + button.textColor = style.textColor + changed = true + } + if (button.fontSize != style.fontSize) { + button.fontSize = style.fontSize + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(button) + } + } + + private fun syncContainerVisual(node: ContainerNode, backgroundColor: Int?, border: Border) { + var changed = false + if (node.backgroundColor != backgroundColor) { + node.backgroundColor = backgroundColor + changed = true + } + if (node.border != border) { + node.border = border + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(node) } - button.border = Border.all(1, if (selected) style.inputActiveBorderColor else style.inputBorderColor) - button.textColor = style.textColor - button.fontSize = style.fontSize } private fun hideAll(ctx: UiMeasureContext) { @@ -444,135 +1019,229 @@ internal class SystemColorPickerModeDropdownOverlayNode( } } -internal class SystemColorPickerEyedropperOverlayNode( +internal class SystemColorPickerEyedropperPreviewNode( private val popupEngine: ColorPickerPopupEngine, - key: Any? = "dsgl-system-color-picker-native-eyedropper-overlay" + key: Any? = "dsgl-system-color-picker-native-eyedropper-preview", ) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-native-eyedropper-overlay" + override val styleType: String = "dsgl-system-color-picker-native-eyedropper-preview" private val scope = UiScope(this) - private val captureNode: EyedropperCaptureNode = EyedropperCaptureNode( - key = "dsgl-system-color-picker-eyedropper-capture" - ).applyParent(this) - private val shadowNode: ContainerNode = scope.div({ - this.key = "dsgl-system-color-picker-eyedropper-shadow" - }) - private val panelNode: ContainerNode = scope.div({ - this.key = "dsgl-system-color-picker-eyedropper-panel" - }) - private val magnifierDrawNode: EyedropperMagnifierDrawNode = EyedropperMagnifierDrawNode( - key = "dsgl-system-color-picker-eyedropper-magnifier" - ).applyParent(this) - private val centerNode: ContainerNode = scope.div({ - this.key = "dsgl-system-color-picker-eyedropper-center" - }) - private val swatchNode: ColorSwatchSurfaceNode = ColorSwatchSurfaceNode( - allowEmpty = false, - key = "dsgl-system-color-picker-eyedropper-swatch" - ).applyParent(this) - private val modeTextNode: TextNode = createOverlayTextNode( - key = "dsgl-system-color-picker-eyedropper-mode", - text = "" + private val captureNode: EyedropperCaptureNode = + EyedropperCaptureNode( + key = "dsgl-system-color-picker-eyedropper-capture", + ).applyParent(this) + private val shadowNode: ContainerNode = + scope.div({ + this.key = "dsgl-system-color-picker-eyedropper-shadow" + }) + private val panelNode: ContainerNode = + scope.div({ + this.key = "dsgl-system-color-picker-eyedropper-panel" + }) + private val magnifierDrawNode: EyedropperMagnifierDrawNode = + scope.eyedropperMagnifier({ + this.key = "dsgl-system-color-picker-eyedropper-magnifier" + }) + private val centerNode: ContainerNode = + scope.div({ + this.key = "dsgl-system-color-picker-eyedropper-center" + }) + private val swatchNode: ColorSwatchSurfaceNode = + scope.colorSwatch({ + allowEmpty = false + this.key = "dsgl-system-color-picker-eyedropper-swatch" + }) + private val modeTextNode: TextNode = + createPreviewTextNode( + key = "dsgl-system-color-picker-eyedropper-mode", + text = "", + ) + private val valueTextNode: TextNode = + createPreviewTextNode( + key = "dsgl-system-color-picker-eyedropper-value", + text = "", + ) + + private data class EyedropperRenderState( + val model: ColorPickerEyedropperPreviewModel, + val style: ColorPickerStyle, + val color: RgbaColor, ) - private val valueTextNode: TextNode = createOverlayTextNode( - key = "dsgl-system-color-picker-eyedropper-value", - text = "" + + private data class EyedropperTextRects( + val modeRect: Rect, + val valueRect: Rect, ) - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) - val controller = popupEngine.debugActiveController() ?: run { - hideAll(ctx) - return - } - val model = controller.resolveEyedropperOverlayModel( - viewportWidth = bounds.width.coerceAtLeast(1), - viewportHeight = bounds.height.coerceAtLeast(1) - ) ?: run { - hideAll(ctx) - return - } - val style = popupEngine.debugActiveStyle() ?: controller.style() - val color = controller.snapshot().color - - syncOverlayText(model.modeText, model.valueText) - shadowNode.backgroundColor = style.panelShadowColor - shadowNode.border = Border.NONE + val renderState = + resolveRenderState() ?: run { + hideAll(ctx) + return + } - panelNode.backgroundColor = style.eyedropperOverlayBackgroundColor - panelNode.border = Border.all(1, style.eyedropperOverlayBorderColor) + syncVisuals(renderState) + bindVisualNodes(renderState) + renderPreviewNodes(ctx, renderState) + } - centerNode.backgroundColor = null - centerNode.border = Border.all(1, style.eyedropperCenterBorderColor) + private fun resolveRenderState(): EyedropperRenderState? { + val controller = popupEngine.debugActiveController() ?: return null + val model = + controller.resolveEyedropperPreviewModel( + viewportWidth = bounds.width.coerceAtLeast(1), + viewportHeight = bounds.height.coerceAtLeast(1), + ) ?: return null + val style = popupEngine.debugActiveStyle() ?: controller.style() + return EyedropperRenderState( + model = model, + style = style, + color = controller.snapshot().color, + ) + } - modeTextNode.color = style.mutedTextColor - modeTextNode.fontSize = style.fontSize - valueTextNode.color = style.textColor - valueTextNode.fontSize = style.fontSize + private fun syncVisuals(state: EyedropperRenderState) { + syncPreviewText(state.model.modeText, state.model.valueText) + syncContainerVisual( + node = shadowNode, + backgroundColor = state.style.panelShadowColor, + border = Border.NONE, + ) + syncContainerVisual( + node = panelNode, + backgroundColor = state.style.eyedropperPreviewBackgroundColor, + border = Border.all(1, state.style.eyedropperPreviewBorderColor), + ) + syncContainerVisual( + node = centerNode, + backgroundColor = null, + border = Border.all(1, state.style.eyedropperCenterBorderColor), + ) + syncPreviewTextVisual(modeTextNode, state.style.mutedTextColor, state.style.fontSize) + syncPreviewTextVisual(valueTextNode, state.style.textColor, state.style.fontSize) + } + private fun bindVisualNodes(state: EyedropperRenderState) { captureNode.bind( - sourceRect = model.captureSourceRect, - fallbackColor = color.toArgbInt() + sourceRect = state.model.captureSourceRect, + fallbackColor = state.color.toArgbInt(), ) - swatchNode.bind(style = style, color = color, highlighted = false) + swatchNode.bind(style = state.style, color = state.color, highlighted = false) magnifierDrawNode.bind( - columns = model.captureSourceRect.width, - rows = model.captureSourceRect.height, - cellSize = (model.magnifierRect.width / model.captureSourceRect.width.coerceAtLeast(1)).coerceAtLeast(1), - gridEnabled = style.eyedropperGridOverlayEnabled, - gridColor = style.eyedropperGridOverlayColor + columns = state.model.captureSourceRect.width, + rows = state.model.captureSourceRect.height, + magnification = + ( + state.model.magnifierRect.width / + state.model.captureSourceRect.width + .coerceAtLeast(1) + ).coerceAtLeast(1), + gridEnabled = state.style.eyedropperGridEnabled, + gridColor = state.style.eyedropperGridColor, ) + } - val shadowRect = Rect( - x = model.panelRect.x + 2, - y = model.panelRect.y + 2, - width = model.panelRect.width, - height = model.panelRect.height - ) - val textX = model.swatchRect.x + model.swatchRect.width + 8 - val textWidth = (model.panelRect.x + model.panelRect.width - 6 - textX).coerceAtLeast(1) - val modeRect = Rect( - x = textX, - y = model.swatchRect.y + 1, - width = textWidth, - height = (style.fontSize + 2).coerceAtLeast(1) - ) - val valueRect = Rect( - x = textX, - y = modeRect.y + style.fontSize, - width = textWidth, - height = (style.fontSize + 2).coerceAtLeast(1) - ) + private fun renderPreviewNodes(ctx: UiMeasureContext, state: EyedropperRenderState) { + val shadowRect = + Rect( + x = state.model.panelRect.x + 2, + y = state.model.panelRect.y + 2, + width = state.model.panelRect.width, + height = state.model.panelRect.height, + ) + val textRects = resolveTextRects(state) - renderNode(ctx, captureNode, model.panelRect) + renderNode(ctx, captureNode, state.model.panelRect) renderNode(ctx, shadowNode, shadowRect) - renderNode(ctx, panelNode, model.panelRect) - renderNode(ctx, magnifierDrawNode, model.magnifierRect) - renderNode(ctx, centerNode, model.centerRect) - renderNode(ctx, swatchNode, model.swatchRect) - renderNode(ctx, modeTextNode, modeRect) - renderNode(ctx, valueTextNode, valueRect) + renderNode(ctx, panelNode, state.model.panelRect) + renderNode(ctx, magnifierDrawNode, state.model.magnifierRect) + renderNode(ctx, centerNode, state.model.centerRect) + renderNode(ctx, swatchNode, state.model.swatchRect) + renderNode(ctx, modeTextNode, textRects.modeRect) + renderNode(ctx, valueTextNode, textRects.valueRect) } - private fun syncOverlayText(modeText: String, valueText: String) { + private fun resolveTextRects(state: EyedropperRenderState): EyedropperTextRects { + val textX = state.model.swatchRect.x + state.model.swatchRect.width + 8 + val textWidth = + ( + state.model.panelRect.x + state.model.panelRect.width - 6 - textX + ).coerceAtLeast(1) + val modeRect = + Rect( + x = textX, + y = state.model.swatchRect.y + 1, + width = textWidth, + height = (state.style.fontSize + 2).coerceAtLeast(1), + ) + val valueRect = + Rect( + x = textX, + y = modeRect.y + state.style.fontSize, + width = textWidth, + height = (state.style.fontSize + 2).coerceAtLeast(1), + ) + return EyedropperTextRects( + modeRect = modeRect, + valueRect = valueRect, + ) + } + + private fun syncPreviewText(modeText: String, valueText: String) { if (modeTextNode.text != modeText) { - modeTextNode.syncSourceFrom(TextNode(TextSource.Static(modeText))) + modeTextNode.setText(modeText) } if (valueTextNode.text != valueText) { - valueTextNode.syncSourceFrom(TextNode(TextSource.Static(valueText))) + valueTextNode.setText(valueText) + } + } + + private fun syncPreviewTextVisual(node: TextNode, color: Int, fontSize: Int) { + var changed = false + if (node.color != color) { + node.color = color + changed = true + } + if (node.fontSize != fontSize) { + node.fontSize = fontSize + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(node) } } - private fun createOverlayTextNode(key: Any, text: String): TextNode { - return TextNode(TextSource.Static(text), key = key).apply { - textWrap = TextWrap.NoWrap - }.applyParent(this) + private fun syncContainerVisual(node: ContainerNode, backgroundColor: Int?, border: Border) { + var changed = false + if (node.backgroundColor != backgroundColor) { + node.backgroundColor = backgroundColor + changed = true + } + if (node.border != border) { + node.border = border + changed = true + } + if (changed) { + requestRenderCommandsInvalidationTracked(node) + } } + private fun createPreviewTextNode(key: Any, text: String): TextNode = + TextNode(TextSource.Static(text), key = key) + .apply { + textWrap = TextWrap.NoWrap + }.applyParent(this) + private fun renderNode(ctx: UiMeasureContext, node: DOMNode, rect: Rect?) { if (rect == null || rect.width <= 0 || rect.height <= 0) { node.display = Display.None @@ -591,269 +1260,11 @@ internal class SystemColorPickerEyedropperOverlayNode( } } -private class EyedropperCaptureNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-eyedropper-capture" - - private var sourceRect: Rect? = null - private var fallbackColor: Int = 0 - - fun bind(sourceRect: Rect, fallbackColor: Int) { - this.sourceRect = sourceRect - this.fallbackColor = fallbackColor - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } +internal typealias ColorPickerPopupBodyNode = SystemColorPickerPopupBodyNode - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } +internal typealias ColorPickerTransientPortalNode = SystemColorPickerTransientPortalNode - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (display == Display.None) return - val source = sourceRect ?: return - if (source.width <= 0 || source.height <= 0) return - out += RenderCommand.CaptureScreenRegion( - sourceX = source.x, - sourceY = source.y, - sourceWidth = source.width, - sourceHeight = source.height, - fallbackColor = fallbackColor - ) - } -} - -private class EyedropperMagnifierDrawNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-eyedropper-magnifier" - - private var columns: Int = 1 - private var rows: Int = 1 - private var cellSize: Int = 1 - private var gridEnabled: Boolean = true - private var gridColor: Int = 0x66FFFFFF - - fun bind(columns: Int, rows: Int, cellSize: Int, gridEnabled: Boolean, gridColor: Int) { - this.columns = columns.coerceAtLeast(1) - this.rows = rows.coerceAtLeast(1) - this.cellSize = cellSize.coerceAtLeast(1) - this.gridEnabled = gridEnabled - this.gridColor = gridColor - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (display == Display.None) return - if (bounds.width <= 0 || bounds.height <= 0) return - out += RenderCommand.DrawCapturedScreenRegion( - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height - ) - if (!gridEnabled) return - for (column in 1 until columns) { - val lineX = bounds.x + column * cellSize - if (lineX <= bounds.x || lineX >= bounds.x + bounds.width) continue - out += RenderCommand.DrawRect(lineX, bounds.y, 1, bounds.height, gridColor) - } - for (row in 1 until rows) { - val lineY = bounds.y + row * cellSize - if (lineY <= bounds.y || lineY >= bounds.y + bounds.height) continue - out += RenderCommand.DrawRect(bounds.x, lineY, bounds.width, 1, gridColor) - } - } -} - -private class ColorFieldSurfaceNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-color-field" - - private var style: ColorPickerStyle = ColorPickerStyle() - private var hueDeg: Float = 0f - private var saturation: Float = 0f - private var brightness: Float = 1f - - fun bind(style: ColorPickerStyle, color: RgbaColor, hueDeg: Float) { - this.style = style - this.hueDeg = hueDeg - val hsv = ColorConversions.rgbToHsv(color, hueDeg) - saturation = hsv.saturation - brightness = hsv.brightness - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (bounds.width <= 0 || bounds.height <= 0) return - out += RenderCommand.DrawColorField( - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - hueDeg = hueDeg - ) - drawBorder(out, bounds, style.inputBorderColor) - val thumbX = bounds.x + (saturation * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) - val thumbY = - bounds.y + ((1f - brightness) * bounds.height.toFloat()).roundToInt().coerceIn(0, bounds.height - 1) - out += RenderCommand.DrawRect(thumbX - 3, thumbY - 3, 7, 7, style.thumbShadowColor) - drawBorder(out, Rect(thumbX - 2, thumbY - 2, 5, 5), style.thumbOutlineColor) - } -} - -private class HueSurfaceNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-hue-slider" - - private var style: ColorPickerStyle = ColorPickerStyle() - private var hueDeg: Float = 0f - - fun bind(style: ColorPickerStyle, hueDeg: Float) { - this.style = style - this.hueDeg = hueDeg - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (bounds.width <= 0 || bounds.height <= 0) return - out += RenderCommand.DrawHueBar( - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height - ) - drawBorder(out, bounds, style.inputBorderColor) - val thumbX = bounds.x + ((hueDeg / 360f) * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) - out += RenderCommand.DrawRect(thumbX - 1, bounds.y - 1, 3, bounds.height + 2, style.thumbOutlineColor) - } +private fun requestRenderCommandsInvalidationTracked(node: DOMNode) { + ColorPickerDebugCounters.onRenderInvalidationCall() + node.requestRenderCommandsInvalidation() } - -private class AlphaSurfaceNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-alpha-slider" - - private var style: ColorPickerStyle = ColorPickerStyle() - private var color: RgbaColor = RgbaColor.WHITE - - fun bind(style: ColorPickerStyle, color: RgbaColor) { - this.style = style - this.color = color - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (bounds.width <= 0 || bounds.height <= 0) return - drawChecker(out, bounds, style) - out += RenderCommand.DrawAlphaBar( - x = bounds.x, - y = bounds.y, - width = bounds.width, - height = bounds.height, - rgbColor = color.copy(a = 1f).toArgbInt() - ) - drawBorder(out, bounds, style.inputBorderColor) - val thumbX = bounds.x + (color.a * bounds.width.toFloat()).roundToInt().coerceIn(0, bounds.width - 1) - out += RenderCommand.DrawRect(thumbX - 1, bounds.y - 1, 3, bounds.height + 2, style.thumbOutlineColor) - } -} - -private class ColorSwatchSurfaceNode( - private val allowEmpty: Boolean = false, - key: Any? -) : DOMNode(key) { - override val styleType: String = "dsgl-system-color-picker-swatch" - - private var style: ColorPickerStyle = ColorPickerStyle() - private var color: RgbaColor? = RgbaColor.WHITE - private var highlighted: Boolean = false - - fun bind(style: ColorPickerStyle, color: RgbaColor?, highlighted: Boolean) { - this.style = style - this.color = color - this.highlighted = highlighted - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (bounds.width <= 0 || bounds.height <= 0) return - val localColor = color - if (localColor == null && allowEmpty) { - out += RenderCommand.DrawRect(bounds.x, bounds.y, bounds.width, bounds.height, 0x33222A34) - drawBorder(out, bounds, if (highlighted) style.inputActiveBorderColor else style.recentGridBorderColor) - return - } - drawChecker(out, bounds, style) - out += RenderCommand.DrawRect( - bounds.x, - bounds.y, - bounds.width, - bounds.height, - (localColor ?: RgbaColor.WHITE).toArgbInt() - ) - drawBorder(out, bounds, if (highlighted) style.inputActiveBorderColor else style.inputBorderColor) - } -} - -private fun drawChecker(out: MutableList, rect: Rect, style: ColorPickerStyle) { - if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawCheckerboard( - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height, - cellSize = 4, - lightColor = style.checkerLightColor, - darkColor = style.checkerDarkColor - ) -} - -private fun drawBorder(out: MutableList, rect: Rect, color: Int) { - if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y + rect.height - 1, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y, 1, rect.height, color) - out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) -} - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPortalNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPortalNode.kt new file mode 100644 index 0000000..fd6376c --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/colorpicker/internal/SystemColorPickerPortalNode.kt @@ -0,0 +1,69 @@ +package org.dreamfinity.dsgl.core.colorpicker.internal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanel +import org.dreamfinity.dsgl.core.style.Display + +internal class ColorPickerPopupPortalNode( + private val popupEngine: ColorPickerPopupEngine, + private val floatingPanel: FloatingPanel, + key: Any? = "dsgl-system-color-picker", +) : DOMNode(key) { + override val styleType: String = "dsgl-system-color-picker" + + private var cursorX: Int = 0 + private var cursorY: Int = 0 + private var domInputRoutingReady: Boolean = false + + private val panelNode: DOMNode = floatingPanel.node().applyParent(this) + private val bodyNode: ColorPickerPopupBodyNode = + ColorPickerPopupBodyNode(popupEngine = popupEngine).also(floatingPanel::setBodyContent) + + fun updateCursor(mouseX: Int, mouseY: Int) { + cursorX = mouseX + cursorY = mouseY + } + + fun focusInputSlot(index: Int, mouseX: Int, mouseY: Int): Boolean = bodyNode.focusInputSlot(index, mouseX, mouseY) + + fun syncInputFocusForDomEditing() { + bodyNode.syncFocusedInputForModeOrOrderChange() + } + + fun isDomInputRoutingReady(): Boolean = domInputRoutingReady + + fun resetDomInputRoutingReadiness() { + domInputRoutingReady = false + } + + fun invalidateColorState() { + markRenderCommandsDirty() + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + popupEngine.onFrame(width, height) + popupEngine.onCursorPosition(cursorX, cursorY) + + val panelRect = floatingPanel.panelRect() + bodyNode.display = if (panelRect == null) Display.None else Display.Block + domInputRoutingReady = bodyNode.display == Display.Block + panelNode.render(ctx, x, y, width, height) + } +} + +internal typealias SystemColorPickerPortalNode = ColorPickerPopupPortalNode diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalDsl.kt index acdbd7e..0334aba 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalDsl.kt @@ -1,8 +1,9 @@ package org.dreamfinity.dsgl.core.components.modal -import org.dreamfinity.dsgl.core.components.modal.internal.ModalHostNode -import org.dreamfinity.dsgl.core.components.modal.internal.ModalRuntime -import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalAnchorNode +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalRootNode +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalSessionStore +import org.dreamfinity.dsgl.core.components.modal.internal.modalLifecycleKey import org.dreamfinity.dsgl.core.dom.elements.InputType import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.FocusManager @@ -13,17 +14,13 @@ import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.JustifyContent -fun UiScope.modalHost( - modals: List, - modalKey: String = "modal.host", - content: UiScope.() -> Unit -) { - ModalRuntime.onBuild(modalKey, modals) - val hostNode = mount(ModalHostNode(modalKey)) +fun UiScope.modalPortal(modals: List, key: String = "modal.portal", content: UiScope.() -> Unit) { + ModalPortalSessionStore.onBuild(key, modals) + val hostNode = mount(ModalPortalAnchorNode(key)) hostNode.onKeyDown = { event -> val topMost = modals.lastOrNull() if (topMost != null) { - val topDialogKey = ModalRuntime.dialogKey(modalKey, topMost.key) + val topDialogKey = ModalPortalSessionStore.dialogKey(key, topMost.key) val focusInsideTop = FocusManager.isFocusWithinSubtree(topDialogKey) if (event.keyCode == KeyCodes.ESCAPE) { if (topMost.keyboard) { @@ -40,78 +37,35 @@ fun UiScope.modalHost( } val hostScope = childScope(hostNode) - hostScope.div({ key = "$modalKey.content" }) { + hostScope.div({ this.key = "$key.content" }) { content() } - modals.forEachIndexed { index, spec -> - val isTopMost = index == modals.lastIndex - val dialogKey = ModalRuntime.dialogKey(modalKey, spec.key) - val backdropColor = when (spec.backdrop) { - BackdropMode.True, BackdropMode.Static -> 0x88000000.toInt() - BackdropMode.False -> 0x00000000 - } - hostScope.div({ - key = "$modalKey.modal.${spec.key}.layer" - onMouseDown = { event -> - if (!isTopMost) { - event.cancelled = true - } else { - val insideDialog = isTargetInsideDialog(event.target, dialogKey) - if (!insideDialog && spec.trapFocus) { - FocusManager.requestFocusFirstInSubtree(dialogKey) - } - event.cancelled = true - } - } - onMouseClick = { event -> - if (!isTopMost) { - event.cancelled = true - } else { - val insideDialog = isTargetInsideDialog(event.target, dialogKey) - if (!insideDialog) { - if (spec.backdrop == BackdropMode.True) { - spec.onHide?.invoke() - } - event.cancelled = true - } - } - } - onMouseWheel = { event -> - val insideDialog = isTargetInsideDialog(event.target, dialogKey) - if (!insideDialog) { - event.cancelled = true - } - } - style = { - backgroundColor = backdropColor - display = Display.Flex - flexDirection = FlexDirection.Column - alignItems = AlignItems.Center - justifyContent = if (spec.centered) JustifyContent.Center else JustifyContent.Start - padding { all((if (spec.centered) 6 else 10).px) } - } + val portalRoot = ModalPortalRootNode("$key.portal") + val portalScope = childScope(portalRoot) + hostNode.refTarget = ModalPortalSessionStore.portalHostRef(key) + ModalPortalSessionStore.registerPortalTemplate(key, portalRoot) + portalRoot.onKeyDown = hostNode.onKeyDown + buildModalLayers(portalScope, modals, key) +} - }) { - modalFrame( - spec = spec, - dialogKey = dialogKey, - scope = ModalScope( - dismiss = spec.onHide, - isTopMost = isTopMost, - modalKey = spec.key - ) - ) - } +private fun buildModalLayers(hostScope: UiScope, modals: List, modalKey: String) { + modals.forEachIndexed { index, spec -> + hostScope.modalLayer( + spec = spec, + modalKey = modalKey, + isTopMost = index == modals.lastIndex, + ) } hostScope.div({ - key = "$modalKey.modal.lifecycle" - ref = RefTarget { handle -> - if (handle != null) { - ModalRuntime.onCommit(modalKey, modals) + key = modalLifecycleKey(modalKey) + ref = + RefTarget { handle -> + if (handle != null) { + ModalPortalSessionStore.onCommit(modalKey, modals) + } } - } style = { width = 0.px height = 0.px @@ -120,19 +74,52 @@ fun UiScope.modalHost( }) } +private fun UiScope.modalLayer(spec: ModalSpec, modalKey: String, isTopMost: Boolean) { + val dialogKey = ModalPortalSessionStore.dialogKey(modalKey, spec.key) + div({ + key = "$modalKey.modal.${spec.key}.layer" + style = { + backgroundColor = spec.backdropColor() + display = Display.Flex + flexDirection = FlexDirection.Column + alignItems = AlignItems.Center + justifyContent = if (spec.centered) JustifyContent.Center else JustifyContent.Start + padding { all((if (spec.centered) 6 else 10).px) } + } + }) { + modalFrame( + spec = spec, + dialogKey = dialogKey, + scope = + ModalScope( + dismiss = spec.onHide, + isTopMost = isTopMost, + modalKey = spec.key, + ), + ) + } +} + +private fun ModalSpec.backdropColor(): Int = + when (backdrop) { + BackdropMode.True, BackdropMode.Static -> 0x88000000.toInt() + BackdropMode.False -> 0x00000000 + } + fun UiScope.modalFrame( spec: ModalSpec, dialogKey: String = "modal.dialog.${spec.key}", - scope: ModalScope = ModalScope( - dismiss = spec.onHide, - isTopMost = true, - modalKey = spec.key - ) + scope: ModalScope = + ModalScope( + dismiss = spec.onHide, + isTopMost = true, + modalKey = spec.key, + ), ) { modalDialog( centered = spec.centered, size = spec.size, - modalKey = dialogKey + modalKey = dialogKey, ) { spec.content(this, scope) } @@ -142,13 +129,14 @@ fun UiScope.modalDialog( centered: Boolean = false, size: ModalSize? = null, modalKey: Any? = null, - block: UiScope.() -> Unit + block: UiScope.() -> Unit, ) { - val presetWidth = when (size) { - ModalSize.Sm -> 132 - ModalSize.Lg -> 232 - null -> 184 - } + val presetWidth = + when (size) { + ModalSize.Sm -> 132 + ModalSize.Lg -> 232 + null -> 184 + } div({ key = modalKey style = { @@ -159,21 +147,24 @@ fun UiScope.modalDialog( gap = 0.px backgroundColor = 0xFF2F3A46.toInt() if (!centered) { - margin { top = 6.px; right = 0.px; bottom = 0.px; left = 0.px } + margin { + top = 6.px + right = 0.px + bottom = 0.px + left = 0.px + } + } + border { + width = 1.px + color = 0xFF6E7D8C.toInt() } - border { width = 1.px; color = 0xFF6E7D8C.toInt() } } - }) { block() } } -fun UiScope.modalHeader( - closeButton: Boolean = false, - onHide: (() -> Unit)? = null, - block: UiScope.() -> Unit = {} -) { +fun UiScope.modalHeader(closeButton: Boolean = false, onHide: (() -> Unit)? = null, block: UiScope.() -> Unit = {}) { div({ key = "modal.header" style = { @@ -204,10 +195,7 @@ fun UiScope.modalHeader( } } -fun UiScope.modalTitle( - text: String, - modalTitleKey: Any? = null -) { +fun UiScope.modalTitle(text: String, modalTitleKey: Any? = null) { div({ key = modalTitleKey style = { @@ -220,10 +208,7 @@ fun UiScope.modalTitle( } } -fun UiScope.modalBody( - modalBodyKey: Any? = null, - block: UiScope.() -> Unit -) { +fun UiScope.modalBody(modalBodyKey: Any? = null, block: UiScope.() -> Unit) { div({ key = modalBodyKey style = { @@ -238,10 +223,7 @@ fun UiScope.modalBody( } } -fun UiScope.modalFooter( - modalFooterKey: Any? = null, - block: UiScope.() -> Unit -) { +fun UiScope.modalFooter(modalFooterKey: Any? = null, block: UiScope.() -> Unit) { div({ key = modalFooterKey style = { @@ -255,7 +237,6 @@ fun UiScope.modalFooter( justifyContent = JustifyContent.End alignItems = AlignItems.Center } - }) { block() } @@ -265,11 +246,11 @@ fun alertModal( modalKey: String, title: String, message: String, - onClose: () -> Unit -): ModalSpec { - return ModalSpec( + onClose: () -> Unit, +): ModalSpec = + ModalSpec( key = modalKey, - onHide = onClose + onHide = onClose, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle(title) @@ -283,7 +264,6 @@ fun alertModal( }) } } -} fun confirmModal( modalKey: String, @@ -292,11 +272,11 @@ fun confirmModal( confirmText: String = "Confirm", cancelText: String = "Cancel", onConfirm: () -> Unit, - onCancel: () -> Unit -): ModalSpec { - return ModalSpec( + onCancel: () -> Unit, +): ModalSpec = + ModalSpec( key = modalKey, - onHide = onCancel + onHide = onCancel, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle(title) @@ -315,7 +295,6 @@ fun confirmModal( }) } } -} fun promptModal( modalKey: String, @@ -325,25 +304,23 @@ fun promptModal( confirmText: String = "Apply", cancelText: String = "Cancel", onConfirm: () -> Unit, - onCancel: () -> Unit -): ModalSpec { - return ModalSpec( + onCancel: () -> Unit, +): ModalSpec = + ModalSpec( key = modalKey, - onHide = onCancel + onHide = onCancel, ) { scope -> modalHeader(closeButton = true, onHide = scope.dismiss) { modalTitle(title) } modalBody { - input( - InputType.Text(value = value, placeholder = "Enter value"), { - this.key = "modal.prompt.input.$key" - style = { width = 150.px } - onInput = { event -> - onValueInput(event.value) - } + input(InputType.Text(value = value, placeholder = "Enter value"), { + this.key = "modal.prompt.input.$key" + style = { width = 150.px } + onInput = { event -> + onValueInput(event.value) } - ) + }) } modalFooter { button(cancelText, { @@ -354,14 +331,3 @@ fun promptModal( }) } } -} - -private fun isTargetInsideDialog(target: DOMNode?, dialogKey: String): Boolean { - var node = target - while (node != null) { - if (node.key == dialogKey) return true - node = node.parent - } - return false -} - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalModels.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalModels.kt index 64ecff8..e00a94e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalModels.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalModels.kt @@ -5,12 +5,12 @@ import org.dreamfinity.dsgl.core.dsl.UiScope enum class BackdropMode { True, False, - Static + Static, } enum class ModalSize { Sm, - Lg + Lg, } data class ModalSpec( @@ -22,11 +22,11 @@ data class ModalSpec( val trapFocus: Boolean = true, val restoreFocus: Boolean = true, val onHide: (() -> Unit)? = null, - val content: UiScope.(ModalScope) -> Unit + val content: UiScope.(ModalScope) -> Unit, ) data class ModalScope( val dismiss: (() -> Unit)?, val isTopMost: Boolean, - val modalKey: String + val modalKey: String, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalHostNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalHostNode.kt deleted file mode 100644 index 7b7565d..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalHostNode.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dreamfinity.dsgl.core.components.modal.internal - -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.StyleEngine - -/** - * Root node for modal host composition: - * child[0] is regular content and children[1..] are full-viewport modal layers. - */ -internal class ModalHostNode( - key: Any? -) : DOMNode(key) { - override val styleType: String = "modal-host" - - override fun measure(ctx: UiMeasureContext): Size { - val content = children.firstOrNull() - val contentSize = content?.measure(ctx) ?: Size(0, 0) - val totalWidth = (width ?: contentSize.width) + padding.horizontal + border.horizontal - val totalHeight = (height ?: contentSize.height) + padding.vertical + border.vertical - return Size(totalWidth, totalHeight) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - val parentNode = parent - val resolvedBounds = if (parentNode != null && parentNode.parent == null) { - Rect( - x = 0, - y = 0, - width = StyleEngine.viewportWidthPx().coerceAtLeast(0), - height = StyleEngine.viewportHeightPx().coerceAtLeast(0) - ) - } else if (parentNode != null) { - Rect( - x = parentNode.bounds.x + parentNode.border.left + parentNode.padding.left, - y = parentNode.bounds.y + parentNode.border.top + parentNode.padding.top, - width = (parentNode.bounds.width - parentNode.border.horizontal - parentNode.padding.horizontal).coerceAtLeast(0), - height = (parentNode.bounds.height - parentNode.border.vertical - parentNode.padding.vertical).coerceAtLeast(0) - ) - } else { - Rect(x, y, width, height) - } - bounds = resolvedBounds - children.firstOrNull() - ?.render(ctx, resolvedBounds.x, resolvedBounds.y, resolvedBounds.width, resolvedBounds.height) - if (children.size <= 1) return - for (i in 1 until children.size) { - children[i].render(ctx, resolvedBounds.x, resolvedBounds.y, resolvedBounds.width, resolvedBounds.height) - } - } - - override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) = Unit -} - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalController.kt new file mode 100644 index 0000000..f963eee --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalController.kt @@ -0,0 +1,424 @@ +package org.dreamfinity.dsgl.core.components.modal.internal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.components.modal.BackdropMode +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.KeyCodes +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.PortalBackdropPolicy +import org.dreamfinity.dsgl.core.portal.PortalDismissPolicy +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalEntryBounds +import org.dreamfinity.dsgl.core.portal.PortalEntryId +import org.dreamfinity.dsgl.core.portal.PortalEntryOrder +import org.dreamfinity.dsgl.core.portal.PortalEntryPlacement +import org.dreamfinity.dsgl.core.portal.PortalEntryState +import org.dreamfinity.dsgl.core.portal.PortalFocusPolicy +import org.dreamfinity.dsgl.core.portal.PortalHost +import org.dreamfinity.dsgl.core.portal.PortalInputPolicy +import org.dreamfinity.dsgl.core.portal.PortalInsidePointerPolicy +import org.dreamfinity.dsgl.core.portal.PortalPointerContainmentPolicy +import org.dreamfinity.dsgl.core.portal.PortalPointerRegion +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.evaluateOutsidePointerDown +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter + +@Suppress("TooManyFunctions") +internal class ModalPortalController { + private val portalHost: PortalHost = + PortalHost(ScreenDomainSurfaces.ApplicationPortal) + private val entriesByPortalKey: LinkedHashMap = LinkedHashMap() + private var pendingPolicyPointerSequence: PendingPolicyPointerSequence? = null + private var pendingDomPointerSequence: PendingDomPointerSequence? = null + + fun sync(rootNode: DOMNode, viewportWidth: Int, viewportHeight: Int) { + val snapshots = ModalPortalSessionStore.portalSnapshots() + val activePortalKeys = snapshots.mapTo(LinkedHashSet()) { it.portalKey } + snapshots.forEach { snapshot -> + val entry = + entriesByPortalKey.getOrPut(snapshot.portalKey) { + ModalPortalEntry(snapshot.portalKey, snapshot.root).also(portalHost::register) + } + entry.reconcile(snapshot.root) + entry.syncTopMost(snapshot.topMostModal) + entry.syncActive(viewportWidth, viewportHeight) + } + entriesByPortalKey + .keys + .filter { it !in activePortalKeys } + .forEach { portalKey -> + val entry = entriesByPortalKey.remove(portalKey) ?: return@forEach + portalHost.unregister(entry.state.id) + entry.detach() + } + reconcileMountedRoots(rootNode) + } + + fun close() { + entriesByPortalKey.values.forEach { entry -> + portalHost.unregister(entry.state.id) + entry.detach() + } + entriesByPortalKey.clear() + pendingPolicyPointerSequence = null + pendingDomPointerSequence = null + } + + fun commitActivePortals() { + portalHost + .entriesInPaintOrder() + .filterIsInstance() + .forEach { entry -> + entry.syncProtectedDialogBounds() + ModalPortalSessionStore.commitPortal(entry.portalKey, entry.root) + } + } + + fun hasActivePortal(): Boolean = entriesByPortalKey.values.any { entry -> entry.state.active } + + fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { + if (!hasActivePortal()) return false + val pendingEntry = pendingDomPointerSequence?.entry + if (pendingEntry != null) { + pendingEntry.handleDomMouseMove(mouseX, mouseY) + return true + } + val result = + portalHost.evaluateOutsidePointerDown(mouseX, mouseY) + ?: run { + clearDomPointerState() + return true + } + if (result.region == PortalPointerRegion.InsideEntry) { + val entry = result.entry as? ModalPortalEntry + clearDomPointerStateExcept(entry) + entry?.handleDomMouseMove(mouseX, mouseY) + } else { + clearDomPointerState() + } + return true + } + + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + val result = + portalHost.evaluateOutsidePointerDown(mouseX, mouseY) + ?: run { + clearDomPointerState() + return hasActivePortal() + } + if (result.region == PortalPointerRegion.InsideEntry) { + val entry = result.entry as? ModalPortalEntry ?: return true + clearDomPointerStateExcept(entry) + entry.handleDomMouseDown(mouseX, mouseY, button) + pendingDomPointerSequence = PendingDomPointerSequence(button = button, entry = entry) + pendingPolicyPointerSequence = null + return true + } + clearDomPointerState() + if (result.consumed) { + (result.entry as? ModalPortalEntry)?.requestFocusForOutsidePointer() + pendingPolicyPointerSequence = + PendingPolicyPointerSequence( + button = button, + dismissEntry = result.entry.takeIf { result.shouldClose }, + ) + pendingDomPointerSequence = null + } + return result.consumed + } + + fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + val pendingDom = pendingDomPointerSequence + if (pendingDom != null && pendingDom.button == button) { + pendingDom.entry.handleDomMouseUp(mouseX, mouseY, button) + pendingDomPointerSequence = null + pendingPolicyPointerSequence = null + return true + } + val pendingPolicy = pendingPolicyPointerSequence + if (pendingPolicy != null && pendingPolicy.button == button) { + pendingPolicy.dismissEntry?.let { entry -> entry.state.dismiss(entry) } + clearDomPointerState() + pendingPolicyPointerSequence = null + pendingDomPointerSequence = null + return true + } + clearDomPointerState() + return hasActivePortal() + } + + fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { + if (!hasActivePortal()) return false + val result = + portalHost.evaluateOutsidePointerDown(mouseX, mouseY) + ?: run { + clearDomPointerState() + return true + } + if (result.region == PortalPointerRegion.InsideEntry) { + val entry = result.entry as? ModalPortalEntry + clearDomPointerStateExcept(entry) + entry?.handleDomMouseWheel(mouseX, mouseY, delta) + } else { + clearDomPointerState() + } + return true + } + + fun handlePointerPolicy( + mouseX: Int, + mouseY: Int, + button: MouseButton, + pressed: Boolean, + ): Boolean { + if (!pressed) { + val pending = pendingPolicyPointerSequence ?: return false + if (pending.button != button) return false + pending.dismissEntry?.let { entry -> entry.state.dismiss(entry) } + pendingPolicyPointerSequence = null + return true + } + val result = portalHost.evaluateOutsidePointerDown(mouseX, mouseY) ?: return false + if (result.consumed) { + if (result.region == PortalPointerRegion.OutsideEntry) { + (result.entry as? ModalPortalEntry)?.requestFocusForOutsidePointer() + } + pendingPolicyPointerSequence = + PendingPolicyPointerSequence( + button = button, + dismissEntry = result.entry.takeIf { result.shouldClose }, + ) + } + return result.consumed + } + + fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { + it.handleKeyDown(keyCode, keyChar) + } + + internal fun debugActivePortalEntryIds(): List = portalHost.entriesInPaintOrder().map { it.state.id.value } + + internal fun debugEvaluatePointerDownPolicy(mouseX: Int, mouseY: Int) = + portalHost.evaluateOutsidePointerDown(mouseX, mouseY) + + internal fun debugFindNodeByKey(key: Any?): DOMNode? = debugFindNode { node -> node.key == key } + + internal fun debugFindNode(predicate: (DOMNode) -> Boolean): DOMNode? = + portalHost + .entriesInPaintOrder() + .mapNotNull { (it as? ModalPortalEntry)?.root } + .firstNotNullOfOrNull { root -> findNode(root, predicate) } + + private fun reconcileMountedRoots(rootNode: DOMNode) { + val activeRoots = + portalHost + .entriesInPaintOrder() + .mapNotNull { (it as? ModalPortalEntry)?.root } + entriesByPortalKey.values.forEach { entry -> + if (entry.root !in activeRoots) { + entry.detach() + } + } + activeRoots.forEach { root -> + if (root.parent !== rootNode) { + root.parent + ?.children + ?.remove(root) + root.parent = rootNode + } + } + rootNode.children.removeAll(activeRoots.toSet()) + rootNode.children.addAll(activeRoots) + } + + private fun clearDomPointerState() { + entriesByPortalKey.values.forEach { entry -> entry.clearDomPointerState() } + } + + private fun clearDomPointerStateExcept(entryToKeep: ModalPortalEntry?) { + entriesByPortalKey.values.forEach { entry -> + if (entry !== entryToKeep) { + entry.clearDomPointerState() + } + } + } +} + +private data class PendingPolicyPointerSequence( + val button: MouseButton, + val dismissEntry: PortalEntry?, +) + +private data class PendingDomPointerSequence( + val button: MouseButton, + val entry: ModalPortalEntry, +) + +@Suppress("TooManyFunctions") +private class ModalPortalEntry( + val portalKey: String, + templateRoot: ModalPortalRootNode, +) : PortalEntry { + private var tree: DomTree = DomTree(templateRoot) + private val domInputRouter: SurfaceDomInputRouter = + SurfaceDomInputRouter( + rootProvider = { root }, + ) + private var topMostModal: ModalSpec? = null + + val root: ModalPortalRootNode + get() = tree.root as ModalPortalRootNode + + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("application.modal.$portalKey"), + ownerToken = portalKey, + surface = ScreenDomainSurfaces.ApplicationPortal, + order = PortalEntryOrder(zIndex = -100), + dismissPolicy = PortalDismissPolicy.None, + inputPolicy = PortalInputPolicy.DomOnly, + focusPolicy = PortalFocusPolicy.TrapFocus, + backdropPolicy = PortalBackdropPolicy.ConsumeOutsidePointerDown, + insidePointerPolicy = PortalInsidePointerPolicy.ConsumePointerDown, + pointerContainmentPolicy = PortalPointerContainmentPolicy.ProtectedBoundsOnly, + ) + override val node: DOMNode + get() = root + + fun reconcile(templateRoot: ModalPortalRootNode) { + val previousRoot = root + val parent = root.parent + val result = tree.reconcileWith(DomTree(templateRoot)) + tree.root = result.root + EventBus.run { + result.detachedRoots.forEach { detached -> detached.clearListenersDeep() } + } + if (previousRoot !== root) { + previousRoot.parent + ?.children + ?.remove(previousRoot) + previousRoot.parent = null + } + root.parent = parent + } + + fun syncTopMost(spec: ModalSpec?) { + topMostModal = spec + state.dismissAction = { + val topMost = topMostModal + if (topMost?.backdrop == BackdropMode.True) { + topMost.onHide?.invoke() + } + } + state.dismissPolicy = + if (spec?.backdrop == BackdropMode.True) { + PortalDismissPolicy.OutsidePointerDown + } else { + PortalDismissPolicy.None + } + state.backdropPolicy = + if (spec != null) { + PortalBackdropPolicy.ConsumeOutsidePointerDown + } else { + PortalBackdropPolicy.None + } + } + + fun syncActive(viewportWidth: Int, viewportHeight: Int) { + if (!ModalPortalSessionStore.shouldKeepPortalActive(portalKey)) { + state.deactivate() + return + } + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + entryBounds = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + ), + ), + ) + } + + fun syncProtectedDialogBounds() { + val topMost = + topMostModal ?: run { + state.updateProtectedBounds(emptyList()) + return + } + val dialog = findNode(root) { node -> node.key == ModalPortalSessionStore.dialogKey(portalKey, topMost.key) } + state.updateProtectedBounds(listOfNotNull(dialog?.bounds)) + } + + fun requestFocusForOutsidePointer() { + val topMost = topMostModal ?: return + if (!topMost.trapFocus) return + FocusManager.requestFocusFirstInSubtree(ModalPortalSessionStore.dialogKey(portalKey, topMost.key)) + } + + fun detach() { + root.parent + ?.children + ?.remove(root) + root.parent = null + domInputRouter.clear() + } + + fun handleDomMouseMove(mouseX: Int, mouseY: Int): Boolean = domInputRouter.handleMouseMove(mouseX, mouseY) + + fun handleDomMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + domInputRouter.handleMouseDown(mouseX, mouseY, button) + + fun handleDomMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + domInputRouter.handleMouseUp(mouseX, mouseY, button) + + fun handleDomMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + domInputRouter.handleMouseWheel(mouseX, mouseY, delta) + + fun clearDomPointerState() { + domInputRouter.clear() + } + + override fun clearRefs() { + tree.clearRefs() + domInputRouter.clear() + EventBus.run { root.clearListenersDeep() } + } + + override fun close() { + ModalPortalSessionStore.forgetPortal(portalKey) + state.deactivate() + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = false + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { + val topMost = topMostModal ?: return false + if (keyCode != KeyCodes.ESCAPE) return false + if (topMost.keyboard) { + topMost.onHide?.invoke() + } + return true + } +} + +private fun findNode(root: DOMNode, predicate: (DOMNode) -> Boolean): DOMNode? { + if (predicate(root)) return root + root.children.forEach { child -> + val found = findNode(child, predicate) + if (found != null) { + return found + } + } + return null +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalNode.kt new file mode 100644 index 0000000..90a668b --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalNode.kt @@ -0,0 +1,118 @@ +package org.dreamfinity.dsgl.core.components.modal.internal + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.StyleEngine + +/** + * Root node for modal portal composition: + * child[0] is regular content and children[1..] are full-viewport modal layers. + */ +internal class ModalPortalAnchorNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "modal-portal" + + override fun measure(ctx: UiMeasureContext): Size { + val content = children.firstOrNull() + val contentSize = content?.measure(ctx) ?: Size(0, 0) + val totalWidth = (width ?: contentSize.width) + padding.horizontal + border.horizontal + val totalHeight = (height ?: contentSize.height) + padding.vertical + border.vertical + return Size(totalWidth, totalHeight) + } + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + val parentNode = parent + val resolvedBounds = + if (parentNode != null && parentNode.parent == null) { + Rect( + x = 0, + y = 0, + width = StyleEngine.viewportWidthPx().coerceAtLeast(0), + height = StyleEngine.viewportHeightPx().coerceAtLeast(0), + ) + } else if (parentNode != null) { + Rect( + x = parentNode.bounds.x + parentNode.border.left + parentNode.padding.left, + y = parentNode.bounds.y + parentNode.border.top + parentNode.padding.top, + width = + (parentNode.bounds.width - parentNode.border.horizontal - parentNode.padding.horizontal) + .coerceAtLeast( + 0, + ), + height = + (parentNode.bounds.height - parentNode.border.vertical - parentNode.padding.vertical) + .coerceAtLeast( + 0, + ), + ) + } else { + Rect(x, y, width, height) + } + bounds = resolvedBounds + children + .firstOrNull() + ?.render(ctx, resolvedBounds.x, resolvedBounds.y, resolvedBounds.width, resolvedBounds.height) + if (children.size <= 1) return + for (i in 1 until children.size) { + children[i].render(ctx, resolvedBounds.x, resolvedBounds.y, resolvedBounds.width, resolvedBounds.height) + } + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) = Unit +} + +/** + * Application-portal root for modal layers. The regular modal portal content stays + * in the application root; modal layers render through this full-viewport root. + */ +internal class ModalPortalRootNode( + key: Any?, +) : DOMNode(key) { + override val styleType: String = "modal-portal-root" + + override fun measure(ctx: UiMeasureContext): Size = + Size( + width = StyleEngine.viewportWidthPx().coerceAtLeast(0), + height = StyleEngine.viewportHeightPx().coerceAtLeast(0), + ) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = + Rect( + x = 0, + y = 0, + width = + StyleEngine + .viewportWidthPx() + .coerceAtLeast(width) + .coerceAtLeast(0), + height = + StyleEngine + .viewportHeightPx() + .coerceAtLeast(height) + .coerceAtLeast(0), + ) + children.forEach { child -> + child.resolveLayoutStyleValues(ctx, bounds.width, bounds.height) + child.render(ctx, bounds.x, bounds.y, bounds.width, bounds.height) + } + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) = Unit +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalSessionStore.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalSessionStore.kt new file mode 100644 index 0000000..b1e7287 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalPortalSessionStore.kt @@ -0,0 +1,227 @@ +package org.dreamfinity.dsgl.core.components.modal.internal + +import org.dreamfinity.dsgl.core.components.modal.ModalSpec +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle +import org.dreamfinity.dsgl.core.hooks.ref.RefTarget +import java.util.concurrent.ConcurrentHashMap + +internal fun modalLifecycleKey(portalKey: String): String = "$portalKey.modal.lifecycle" + +private data class ModalMeta( + val restoreFocus: Boolean, +) + +private class ModalPortalState { + var previousKeys: List = emptyList() + var previousMetaByKey: Map = emptyMap() + val restoreFocusByModalKey: MutableMap = linkedMapOf() + var pendingRestoreFocusKey: Any? = null + var pendingFocusDialogKey: String? = null + var currentModals: List = emptyList() +} + +internal object ModalPortalSessionStore { + data class PortalSnapshot( + val portalKey: String, + val root: ModalPortalRootNode, + val topMostModal: ModalSpec?, + ) + + private val states: MutableMap = ConcurrentHashMap() + private val portalTemplates: MutableMap = ConcurrentHashMap() + private val portalHostRefs: MutableMap> = ConcurrentHashMap() + + fun onBuild(portalKey: String, modals: List) { + val state = states.getOrPut(portalKey) { ModalPortalState() } + val currentKeys = modals.map { it.key } + val previousKeys = state.previousKeys + + val openedKeys = currentKeys.filter { it !in previousKeys } + openedKeys.forEach { openedKey -> + state.restoreFocusByModalKey[openedKey] = FocusManager.focusedNode()?.key + } + + val currentKeySet = currentKeys.toHashSet() + val closedKeys = previousKeys.filter { it !in currentKeySet }.toHashSet() + if (closedKeys.isNotEmpty()) { + previousKeys.asReversed().forEach { closedKey -> + if (closedKey !in closedKeys) return@forEach + val previousMeta = state.previousMetaByKey[closedKey] + val restoreKey = state.restoreFocusByModalKey.remove(closedKey) + if (previousMeta?.restoreFocus == true && restoreKey != null) { + state.pendingRestoreFocusKey = restoreKey + return@forEach + } + } + closedKeys.forEach { key -> + state.restoreFocusByModalKey.remove(key) + } + } + + val previousTop = previousKeys.lastOrNull() + val currentTop = currentKeys.lastOrNull() + if (currentTop != null && currentTop != previousTop) { + state.pendingFocusDialogKey = dialogKey(portalKey, currentTop) + } + if (currentTop == null) { + state.pendingFocusDialogKey = null + } + + state.previousKeys = currentKeys + state.currentModals = modals + state.previousMetaByKey = + modals.associate { spec -> + spec.key to ModalMeta(restoreFocus = spec.restoreFocus) + } + } + + fun onCommit(portalKey: String, modals: List, focusRoot: DOMNode? = null) { + val state = states[portalKey] ?: return + val topMost = modals.lastOrNull() + + if (topMost == null) { + if (commitWithoutActiveModal(state, focusRoot)) { + states.remove(portalKey) + } + return + } + + commitWithActiveModal(portalKey, state, topMost, focusRoot) + } + + fun registerPortalTemplate(portalKey: String, root: ModalPortalRootNode) { + val previous = portalTemplates.put(portalKey, root) + if (previous != null && previous !== root && previous.parent == null) { + clearTemplateOwnedListeners(previous) + } + } + + fun portalHostRef(portalKey: String): RefTarget = + portalHostRefs.getOrPut(portalKey) { + RefTarget { handle -> + if (handle == null) { + forgetPortal(portalKey) + } + } + } + + fun commitPortal(portalKey: String, focusRoot: DOMNode) { + val state = states[portalKey] ?: return + onCommit(portalKey, state.currentModals, focusRoot) + } + + fun portalSnapshots(): List = + portalTemplates + .entries + .sortedBy { it.key } + .map { (portalKey, root) -> + PortalSnapshot( + portalKey = portalKey, + root = root, + topMostModal = states[portalKey]?.currentModals?.lastOrNull(), + ) + } + + fun shouldKeepPortalActive(portalKey: String): Boolean { + val template = portalTemplates[portalKey] ?: return false + val state = states[portalKey] + return template.children.any { it.key != modalLifecycleKey(portalKey) } || + state?.previousKeys?.isNotEmpty() == true || + state?.pendingRestoreFocusKey != null || + state?.pendingFocusDialogKey != null + } + + fun forgetPortal(portalKey: String) { + portalTemplates.remove(portalKey) + portalHostRefs.remove(portalKey) + states.remove(portalKey) + } + + fun dialogKey(portalKey: String, modalKey: String): String = "$portalKey.modal.$modalKey.dialog" +} + +private fun clearTemplateOwnedListeners(root: DOMNode) { + EventBus.run { + clearTemplateOwnedListeners( + root = root, + node = root, + ) + } +} + +private fun clearTemplateOwnedListeners(root: DOMNode, node: DOMNode) { + if (!isOwnedByTemplateRoot(root, node)) return + EventBus.run { node.clearOwnListeners() } + node.children.forEach { child -> clearTemplateOwnedListeners(root, child) } +} + +private fun isOwnedByTemplateRoot(root: DOMNode, node: DOMNode): Boolean { + if (node === root) return true + var current = node.parent + while (current != null) { + if (current === root) return true + current = current.parent + } + return false +} + +private fun commitWithoutActiveModal(state: ModalPortalState, focusRoot: DOMNode?): Boolean { + val restoreKey = state.pendingRestoreFocusKey + if (restoreKey != null) { + val restored = requestFocusByKey(restoreKey, focusRoot) + if (restored || focusRoot != null) { + state.pendingRestoreFocusKey = null + } + if (!restored && focusRoot != null) { + FocusManager.clearFocus() + } + } + return state.previousKeys.isEmpty() +} + +private fun commitWithActiveModal( + portalKey: String, + state: ModalPortalState, + topMost: ModalSpec, + focusRoot: DOMNode?, +) { + restorePendingFocus(state, focusRoot) + + val topDialogKey = ModalPortalSessionStore.dialogKey(portalKey, topMost.key) + val needsFocusOnTop = state.pendingFocusDialogKey == topDialogKey + val focusOutsideTop = !FocusManager.isFocusWithinSubtree(topDialogKey) + val shouldFocusTop = needsFocusOnTop || (topMost.trapFocus && focusOutsideTop) + val focusedTop = !shouldFocusTop || focusTopDialog(topDialogKey, focusRoot) + if (needsFocusOnTop && focusedTop) { + state.pendingFocusDialogKey = null + } +} + +private fun restorePendingFocus(state: ModalPortalState, focusRoot: DOMNode?) { + val restoreKey = state.pendingRestoreFocusKey ?: return + val restored = requestFocusByKey(restoreKey, focusRoot) + if (restored || focusRoot != null) { + state.pendingRestoreFocusKey = null + } +} + +private fun focusTopDialog(topDialogKey: String, focusRoot: DOMNode?): Boolean = + requestFocusFirstInSubtree(topDialogKey, focusRoot) || requestFocusByKey(topDialogKey, focusRoot) + +private fun requestFocusByKey(key: Any?, focusRoot: DOMNode?): Boolean = + if (focusRoot != null) { + FocusManager.requestFocusByKey(focusRoot, key) || FocusManager.requestFocusByKey(key) + } else { + FocusManager.requestFocusByKey(key) + } + +private fun requestFocusFirstInSubtree(rootKey: Any?, focusRoot: DOMNode?): Boolean = + if (focusRoot != null) { + FocusManager.requestFocusFirstInSubtree(focusRoot, rootKey) || + FocusManager.requestFocusFirstInSubtree(rootKey) + } else { + FocusManager.requestFocusFirstInSubtree(rootKey) + } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalRuntime.kt deleted file mode 100644 index 89a9d16..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/components/modal/internal/ModalRuntime.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.dreamfinity.dsgl.core.components.modal.internal - -import org.dreamfinity.dsgl.core.components.modal.ModalSpec -import org.dreamfinity.dsgl.core.event.FocusManager -import java.util.concurrent.ConcurrentHashMap - -internal object ModalRuntime { - private data class ModalMeta( - val restoreFocus: Boolean - ) - - private class HostState { - var previousKeys: List = emptyList() - var previousMetaByKey: Map = emptyMap() - val restoreFocusByModalKey: MutableMap = linkedMapOf() - var pendingRestoreFocusKey: Any? = null - var pendingFocusDialogKey: String? = null - } - - private val states: MutableMap = ConcurrentHashMap() - - fun onBuild(hostKey: String, modals: List) { - val state = states.getOrPut(hostKey) { HostState() } - val currentKeys = modals.map { it.key } - val previousKeys = state.previousKeys - - val openedKeys = currentKeys.filter { it !in previousKeys } - openedKeys.forEach { openedKey -> - state.restoreFocusByModalKey[openedKey] = FocusManager.focusedNode()?.key - } - - val currentKeySet = currentKeys.toHashSet() - val closedKeys = previousKeys.filter { it !in currentKeySet }.toHashSet() - if (closedKeys.isNotEmpty()) { - previousKeys.asReversed().forEach { closedKey -> - if (closedKey !in closedKeys) return@forEach - val previousMeta = state.previousMetaByKey[closedKey] - val restoreKey = state.restoreFocusByModalKey.remove(closedKey) - if (previousMeta?.restoreFocus == true && restoreKey != null) { - state.pendingRestoreFocusKey = restoreKey - return@forEach - } - } - closedKeys.forEach { key -> - state.restoreFocusByModalKey.remove(key) - } - } - - val previousTop = previousKeys.lastOrNull() - val currentTop = currentKeys.lastOrNull() - if (currentTop != null && currentTop != previousTop) { - state.pendingFocusDialogKey = dialogKey(hostKey, currentTop) - } - if (currentTop == null) { - state.pendingFocusDialogKey = null - } - - state.previousKeys = currentKeys - state.previousMetaByKey = modals.associate { spec -> - spec.key to ModalMeta(restoreFocus = spec.restoreFocus) - } - } - - fun onCommit(hostKey: String, modals: List) { - val state = states[hostKey] ?: return - val topMost = modals.lastOrNull() - - if (topMost == null) { - val restoreKey = state.pendingRestoreFocusKey - state.pendingRestoreFocusKey = null - if (restoreKey != null) { - if (!FocusManager.requestFocusByKey(restoreKey)) { - FocusManager.clearFocus() - } - } - if (state.previousKeys.isEmpty()) { - states.remove(hostKey) - } - return - } - - val topDialogKey = dialogKey(hostKey, topMost.key) - - val restoreKey = state.pendingRestoreFocusKey - if (restoreKey != null) { - state.pendingRestoreFocusKey = null - FocusManager.requestFocusByKey(restoreKey) - } - - val needsFocusOnTop = state.pendingFocusDialogKey == topDialogKey - val focusOutsideTop = !FocusManager.isFocusWithinSubtree(topDialogKey) - if ((needsFocusOnTop || (topMost.trapFocus && focusOutsideTop)) && - !FocusManager.requestFocusFirstInSubtree(topDialogKey) - ) { - FocusManager.requestFocusByKey(topDialogKey) - } - if (needsFocusOnTop) { - state.pendingFocusDialogKey = null - } - } - - fun dialogKey(hostKey: String, modalKey: String): String = "$hostKey.modal.$modalKey.dialog" -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuDsl.kt index 8005f5d..1af116c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuDsl.kt @@ -7,7 +7,7 @@ fun contextMenu( id: String? = null, fontId: String? = null, fontSize: Int? = null, - block: ContextMenuBuilder.() -> Unit + block: ContextMenuBuilder.() -> Unit, ): ContextMenuModel { val builder = ContextMenuBuilder() builder.block() @@ -15,7 +15,7 @@ fun contextMenu( id = id, entries = builder.buildEntries(), fontId = fontId, - fontSize = fontSize + fontSize = fontSize, ) } @@ -23,56 +23,42 @@ fun contextMenu( class ContextMenuBuilder { private val entries: MutableList = ArrayList() - fun item( - label: String, - id: String? = null, - block: ContextMenuItemBuilder.() -> Unit = {} - ) { + fun item(label: String, id: String? = null, block: ContextMenuItemBuilder.() -> Unit = {}) { item(labelProvider = { label }, id = id, block = block) } - fun item( - labelProvider: () -> String, - id: String? = null, - block: ContextMenuItemBuilder.() -> Unit = {} - ) { + fun item(labelProvider: () -> String, id: String? = null, block: ContextMenuItemBuilder.() -> Unit = {}) { val builder = ContextMenuItemBuilder() builder.block() - entries += MenuEntry.Item( - id = id, - labelProvider = labelProvider, - iconProvider = builder.iconProvider, - hintProvider = builder.hintProvider, - enabledProvider = builder.enabledProvider, - checkedProvider = builder.checkedProvider, - closeOnAction = builder.closeOnAction, - onClick = builder.onClick - ) - } - - fun submenu( - label: String, - id: String? = null, - block: ContextMenuSubmenuBuilder.() -> Unit - ) { + entries += + MenuEntry.Item( + id = id, + labelProvider = labelProvider, + iconProvider = builder.iconProvider, + hintProvider = builder.hintProvider, + enabledProvider = builder.enabledProvider, + checkedProvider = builder.checkedProvider, + closeOnAction = builder.closeOnAction, + onClick = builder.onClick, + ) + } + + fun submenu(label: String, id: String? = null, block: ContextMenuSubmenuBuilder.() -> Unit) { submenu(labelProvider = { label }, id = id, block = block) } - fun submenu( - labelProvider: () -> String, - id: String? = null, - block: ContextMenuSubmenuBuilder.() -> Unit - ) { + fun submenu(labelProvider: () -> String, id: String? = null, block: ContextMenuSubmenuBuilder.() -> Unit) { val builder = ContextMenuSubmenuBuilder() builder.block() - entries += MenuEntry.Submenu( - id = id, - labelProvider = labelProvider, - iconProvider = builder.iconProvider, - hintProvider = builder.hintProvider, - enabledProvider = builder.enabledProvider, - entries = builder.buildEntries() - ) + entries += + MenuEntry.Submenu( + id = id, + labelProvider = labelProvider, + iconProvider = builder.iconProvider, + hintProvider = builder.hintProvider, + enabledProvider = builder.enabledProvider, + entries = builder.buildEntries(), + ) } fun separator(id: String? = null) { @@ -113,56 +99,42 @@ class ContextMenuSubmenuBuilder { enabledProvider = predicate } - fun item( - label: String, - id: String? = null, - block: ContextMenuItemBuilder.() -> Unit = {} - ) { + fun item(label: String, id: String? = null, block: ContextMenuItemBuilder.() -> Unit = {}) { item(labelProvider = { label }, id = id, block = block) } - fun item( - labelProvider: () -> String, - id: String? = null, - block: ContextMenuItemBuilder.() -> Unit = {} - ) { + fun item(labelProvider: () -> String, id: String? = null, block: ContextMenuItemBuilder.() -> Unit = {}) { val builder = ContextMenuItemBuilder() builder.block() - entries += MenuEntry.Item( - id = id, - labelProvider = labelProvider, - iconProvider = builder.iconProvider, - hintProvider = builder.hintProvider, - enabledProvider = builder.enabledProvider, - checkedProvider = builder.checkedProvider, - closeOnAction = builder.closeOnAction, - onClick = builder.onClick - ) - } - - fun submenu( - label: String, - id: String? = null, - block: ContextMenuSubmenuBuilder.() -> Unit - ) { + entries += + MenuEntry.Item( + id = id, + labelProvider = labelProvider, + iconProvider = builder.iconProvider, + hintProvider = builder.hintProvider, + enabledProvider = builder.enabledProvider, + checkedProvider = builder.checkedProvider, + closeOnAction = builder.closeOnAction, + onClick = builder.onClick, + ) + } + + fun submenu(label: String, id: String? = null, block: ContextMenuSubmenuBuilder.() -> Unit) { submenu(labelProvider = { label }, id = id, block = block) } - fun submenu( - labelProvider: () -> String, - id: String? = null, - block: ContextMenuSubmenuBuilder.() -> Unit - ) { + fun submenu(labelProvider: () -> String, id: String? = null, block: ContextMenuSubmenuBuilder.() -> Unit) { val builder = ContextMenuSubmenuBuilder() builder.block() - entries += MenuEntry.Submenu( - id = id, - labelProvider = labelProvider, - iconProvider = builder.iconProvider, - hintProvider = builder.hintProvider, - enabledProvider = builder.enabledProvider, - entries = builder.buildEntries() - ) + entries += + MenuEntry.Submenu( + id = id, + labelProvider = labelProvider, + iconProvider = builder.iconProvider, + hintProvider = builder.hintProvider, + enabledProvider = builder.enabledProvider, + entries = builder.buildEntries(), + ) } fun separator(id: String? = null) { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngine.kt index b9a8639..2bc8ac8 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngine.kt @@ -1,17 +1,14 @@ package org.dreamfinity.dsgl.core.contextmenu -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.KeyCodes -import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.dom.layout.* +import org.dreamfinity.dsgl.core.event.* import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.* class ContextMenuEngine( private val clock: ContextMenuClock = SystemContextMenuClock, - private val measurementCache: ContextMenuMeasurementCache = ContextMenuMeasurementCache() -) : ContextMenuHost { + private val measurementCache: ContextMenuMeasurementCache = ContextMenuMeasurementCache(), +) : ContextMenuPortalService { private data class OpenLevel( val token: Long, val entries: List, @@ -25,24 +22,24 @@ class ContextMenuEngine( var hoveredIndex: Int = -1, var selectedIndex: Int = -1, var scrollOffset: Int = 0, - var measurement: ContextMenuMeasurementCache.Measurement? = null + var measurement: ContextMenuMeasurementCache.Measurement? = null, ) private data class PendingOpen( val parentLevel: Int, val parentEntryIndex: Int, - val dueMs: Long + val dueMs: Long, ) private data class PendingTrim( val fromLevel: Int, - val dueMs: Long + val dueMs: Long, ) data class StackSnapshot( val levelCount: Int, val hoveredIndices: List, - val selectedIndices: List + val selectedIndices: List, ) private val levels: MutableList = ArrayList(4) @@ -68,17 +65,14 @@ class ContextMenuEngine( fun measurementComputeCount(): Long = measurementCache.computeCount - fun snapshot(): StackSnapshot { - return StackSnapshot( + fun snapshot(): StackSnapshot = + StackSnapshot( levelCount = levels.size, hoveredIndices = levels.map { it.hoveredIndex }, - selectedIndices = levels.map { it.selectedIndex } + selectedIndices = levels.map { it.selectedIndex }, ) - } - fun debugPanelRect(levelIndex: Int): Rect? { - return levels.getOrNull(levelIndex)?.panelRect - } + fun debugPanelRect(levelIndex: Int): Rect? = levels.getOrNull(levelIndex)?.panelRect fun debugEntryRect(levelIndex: Int, entryIndex: Int): Rect? { val level = levels.getOrNull(levelIndex) ?: return null @@ -91,16 +85,17 @@ class ContextMenuEngine( return } levels.clear() - levels += OpenLevel( - token = model.token, - entries = model.entries, - placementMode = PLACEMENT_CURSOR, - fontId = model.fontId, - fontSize = model.fontSize, - anchorRect = Rect(x, y, 0, 0), - parentLevelIndex = -1, - parentEntryIndex = -1 - ) + levels += + OpenLevel( + token = model.token, + entries = model.entries, + placementMode = PLACEMENT_CURSOR, + fontId = model.fontId, + fontSize = model.fontSize, + anchorRect = Rect(x, y, 0, 0), + parentLevelIndex = -1, + parentEntryIndex = -1, + ) pendingOpen = null pendingTrim = null layoutDirty = true @@ -112,16 +107,17 @@ class ContextMenuEngine( return } levels.clear() - levels += OpenLevel( - token = model.token, - entries = model.entries, - placementMode = PLACEMENT_ANCHORED, - fontId = model.fontId, - fontSize = model.fontSize, - anchorRect = anchorRect, - parentLevelIndex = -1, - parentEntryIndex = -1 - ) + levels += + OpenLevel( + token = model.token, + entries = model.entries, + placementMode = PLACEMENT_ANCHORED, + fontId = model.fontId, + fontSize = model.fontSize, + anchorRect = anchorRect, + parentLevelIndex = -1, + parentEntryIndex = -1, + ) pendingOpen = null pendingTrim = null layoutDirty = true @@ -140,7 +136,7 @@ class ContextMenuEngine( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - viewportScale: Float = 1f + viewportScale: Float = 1f, ) { lastMeasureContext = measureContext if (this.viewportWidth != viewportWidth || this.viewportHeight != viewportHeight) { @@ -161,18 +157,18 @@ class ContextMenuEngine( ensureLayout() } - fun appendOverlayCommands( + fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - out: MutableList + out: MutableList, ) { if (!isOpen()) return onFrame( measureContext = measureContext, viewportWidth = viewportWidth, viewportHeight = viewportHeight, - viewportScale = viewportScale + viewportScale = viewportScale, ) if (!isOpen()) return @@ -212,74 +208,100 @@ class ContextMenuEngine( return@forEach } - val itemRect = Rect( - rowRect.x, - rowRect.y, - rowRect.width, - rowRect.height - ) + val itemRect = + Rect( + rowRect.x, + rowRect.y, + rowRect.width, + rowRect.height, + ) val isHovered = index == level.hoveredIndex val isSelected = index == level.selectedIndex - if (isHovered) { - out += RenderCommand.DrawRect(itemRect.x, itemRect.y, itemRect.width, itemRect.height, style.itemHoverBackgroundColor) - } else if (isSelected) { - out += RenderCommand.DrawRect(itemRect.x, itemRect.y, itemRect.width, itemRect.height, style.itemSelectedBackgroundColor) - } - + val nextCommand = + when { + isHovered -> + RenderCommand.DrawRect( + itemRect.x, + itemRect.y, + itemRect.width, + itemRect.height, + style.itemHoverBackgroundColor, + ) + + isSelected -> + RenderCommand.DrawRect( + itemRect.x, + itemRect.y, + itemRect.width, + itemRect.height, + style.itemSelectedBackgroundColor, + ) + + else -> null + } + nextCommand?.let { out += it } val textY = itemRect.y + ((itemRect.height - fontHeight).coerceAtLeast(0) / 2) val baseX = itemRect.x + style.rowPaddingX - val indicatorX = baseX - val labelX = indicatorX + measurement.indicatorWidth + style.contentSpacing + val labelX = baseX + measurement.indicatorWidth + style.contentSpacing val textColor = if (snapshot.enabled) style.itemTextColor else style.disabledTextColor - val indicatorText = when { - snapshot.checked -> ContextMenuGlyphs.CHECK_MARK - !snapshot.icon.isNullOrEmpty() -> snapshot.icon - else -> null - } + val indicatorText = + when { + snapshot.checked -> ContextMenuGlyphs.CHECK_MARK + !snapshot.icon.isNullOrEmpty() -> snapshot.icon + else -> null + } if (!indicatorText.isNullOrEmpty()) { val indicatorColor = if (snapshot.checked) style.checkMarkColor else textColor - out += RenderCommand.DrawText( - text = indicatorText, - x = indicatorX, + out += + RenderCommand.DrawText( + text = indicatorText, + x = baseX, + y = textY, + color = indicatorColor, + fontId = fontId, + fontSize = fontSize, + ) + } + + out += + RenderCommand.DrawText( + text = snapshot.label, + x = labelX, y = textY, - color = indicatorColor, + color = textColor, fontId = fontId, - fontSize = fontSize + fontSize = fontSize, ) - } - out += RenderCommand.DrawText( - text = snapshot.label, - x = labelX, - y = textY, - color = textColor, - fontId = fontId, - fontSize = fontSize - ) + val hintText = + when { + snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && + snapshot.hint.isNullOrEmpty() -> ContextMenuGlyphs.SUBMENU_ARROW - val hintText = when { - snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && snapshot.hint.isNullOrEmpty() -> ContextMenuGlyphs.SUBMENU_ARROW - else -> snapshot.hint - } + else -> snapshot.hint + } if (!hintText.isNullOrEmpty()) { val hintWidth = measureContext.measureText(hintText, fontId, fontSize) val hintX = itemRect.x + itemRect.width - style.rowPaddingX - hintWidth - val hintColor = when { - !snapshot.enabled -> style.disabledTextColor - snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && snapshot.hint.isNullOrEmpty() -> - style.submenuArrowColor - - else -> style.hintTextColor - } - out += RenderCommand.DrawText( - text = hintText, - x = hintX, - y = textY, - color = hintColor, - fontId = fontId, - fontSize = fontSize - ) + val hintColor = + when { + !snapshot.enabled -> style.disabledTextColor + snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && + snapshot.hint.isNullOrEmpty() -> + style.submenuArrowColor + + else -> style.hintTextColor + } + out += + RenderCommand.DrawText( + text = hintText, + x = hintX, + y = textY, + color = hintColor, + fontId = fontId, + fontSize = fontSize, + ) } } @@ -311,7 +333,10 @@ class ContextMenuEngine( hitLevel.selectedIndex = hit.entryIndex } - val snapshot = hitLevel.measurement?.snapshots?.getOrNull(hit.entryIndex) + val snapshot = + hitLevel.measurement + ?.snapshots + ?.getOrNull(hit.entryIndex) if (snapshot != null && snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && snapshot.enabled) { scheduleSubmenuOpen(hit.levelIndex, hit.entryIndex) } else { @@ -405,7 +430,10 @@ class ContextMenuEngine( KeyCodes.RIGHT -> { val index = normalizedSelection(level) if (index >= 0) { - val snapshot = level.measurement?.snapshots?.getOrNull(index) + val snapshot = + level.measurement + ?.snapshots + ?.getOrNull(index) if (snapshot != null && snapshot.kind == ContextMenuMeasurementCache.KIND_SUBMENU && snapshot.enabled @@ -439,6 +467,10 @@ class ContextMenuEngine( return false } + // Per-level layout pass mutates each level's measurement, panelRect, and scrollOffset in + // place; the two early-out `break`s (missing parent, missing anchor rect) trim the level + // stack before exiting, which a filter/map chain cannot express. + @Suppress("LoopWithTooManyJumpStatements") private fun ensureLayout() { val ctx = lastMeasureContext ?: return if (!isOpen()) return @@ -447,15 +479,16 @@ class ContextMenuEngine( var index = 0 while (index < levels.size) { val level = levels[index] - val measurement = measurementCache.measure( - menuToken = level.token, - entries = level.entries, - style = style, - fontId = level.fontId ?: style.fontId, - fontSize = level.fontSize ?: style.fontSize, - ctx = ctx, - dpiScale = viewportScale - ) + val measurement = + measurementCache.measure( + menuToken = level.token, + entries = level.entries, + style = style, + fontId = level.fontId ?: style.fontId, + fontSize = level.fontSize ?: style.fontSize, + ctx = ctx, + dpiScale = viewportScale, + ) level.measurement = measurement val panelWidth = measurement.panelWidth + style.panelPaddingX * 2 val maxAvailableHeight = (viewportHeight - style.viewportPadding * 2).coerceAtLeast(1) @@ -476,33 +509,36 @@ class ContextMenuEngine( level.anchorRect = parentEntryRect } - val preferredRect = when (level.placementMode) { - PLACEMENT_CURSOR -> { - Rect(level.anchorRect.x, level.anchorRect.y, panelWidth, panelHeight) - } + val preferredRect = + when (level.placementMode) { + PLACEMENT_CURSOR -> { + Rect(level.anchorRect.x, level.anchorRect.y, panelWidth, panelHeight) + } - PLACEMENT_ANCHORED -> { - Rect(level.anchorRect.x, level.anchorRect.y + level.anchorRect.height, panelWidth, panelHeight) - } + PLACEMENT_ANCHORED -> { + Rect(level.anchorRect.x, level.anchorRect.y + level.anchorRect.height, panelWidth, panelHeight) + } - else -> { - Rect(level.anchorRect.x + level.anchorRect.width, level.anchorRect.y, panelWidth, panelHeight) + else -> { + Rect(level.anchorRect.x + level.anchorRect.width, level.anchorRect.y, panelWidth, panelHeight) + } } - } - val flipCandidateX = if (level.placementMode == PLACEMENT_SUBMENU) { - level.anchorRect.x - panelWidth - } else { - null - } - val placement = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = preferredRect, - popupSize = Size(panelWidth, panelHeight), - viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), - padding = style.viewportPadding, - horizontalFlipX = flipCandidateX + val flipCandidateX = + if (level.placementMode == PLACEMENT_SUBMENU) { + level.anchorRect.x - panelWidth + } else { + null + } + val placement = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = preferredRect, + popupSize = Size(panelWidth, panelHeight), + viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + padding = style.viewportPadding, + horizontalFlipX = flipCandidateX, + ), ) - ) level.panelRect = placement.rect val maxScroll = maxScroll(level) if (level.scrollOffset > maxScroll) { @@ -522,9 +558,12 @@ class ContextMenuEngine( val measurement = level.measurement ?: return if (measurement.snapshots.isEmpty()) return - val start = if (level.selectedIndex >= 0) level.selectedIndex else { - if (direction >= 0) -1 else measurement.snapshots.size - } + val start = + if (level.selectedIndex >= 0) { + level.selectedIndex + } else { + if (direction >= 0) -1 else measurement.snapshots.size + } var index = start repeat(measurement.snapshots.size) { index += direction @@ -567,7 +606,10 @@ class ContextMenuEngine( private fun activate(levelIndex: Int, entryIndex: Int) { val level = levels.getOrNull(levelIndex) ?: return val entry = level.entries.getOrNull(entryIndex) ?: return - val snapshot = level.measurement?.snapshots?.getOrNull(entryIndex) ?: return + val snapshot = + level.measurement + ?.snapshots + ?.getOrNull(entryIndex) ?: return if (!snapshot.enabled || snapshot.kind == ContextMenuMeasurementCache.KIND_SEPARATOR) { return } @@ -604,16 +646,17 @@ class ContextMenuEngine( } trimLevels(parentLevelIndex + 1) } - levels += OpenLevel( - token = parentEntry.token, - entries = parentEntry.entries, - placementMode = PLACEMENT_SUBMENU, - fontId = parent.fontId, - fontSize = parent.fontSize, - anchorRect = parent.anchorRect, - parentLevelIndex = parentLevelIndex, - parentEntryIndex = parentEntryIndex - ) + levels += + OpenLevel( + token = parentEntry.token, + entries = parentEntry.entries, + placementMode = PLACEMENT_SUBMENU, + fontId = parent.fontId, + fontSize = parent.fontSize, + anchorRect = parent.anchorRect, + parentLevelIndex = parentLevelIndex, + parentEntryIndex = parentEntryIndex, + ) pendingOpen = null pendingTrim = null layoutDirty = true @@ -668,11 +711,12 @@ class ContextMenuEngine( } val due = clock.nowMs() + delayMs val current = pendingTrim - pendingTrim = if (current == null || fromLevel < current.fromLevel || due < current.dueMs) { - PendingTrim(fromLevel, due) - } else { - current - } + pendingTrim = + if (current == null || fromLevel < current.fromLevel || due < current.dueMs) { + PendingTrim(fromLevel, due) + } else { + current + } } private fun processTimers() { @@ -696,7 +740,7 @@ class ContextMenuEngine( private data class Hit( val levelIndex: Int, - val entryIndex: Int + val entryIndex: Int, ) private fun hitTest(mouseX: Int, mouseY: Int): Hit? { @@ -770,4 +814,3 @@ class ContextMenuEngine( private const val PLACEMENT_SUBMENU: Int = 3 } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCache.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCache.kt index 5bb5716..e76370b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCache.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCache.kt @@ -3,7 +3,7 @@ package org.dreamfinity.dsgl.core.contextmenu import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext class ContextMenuMeasurementCache( - private val maxEntries: Int = 192 + private val maxEntries: Int = 192, ) { data class EntrySnapshot( val id: String?, @@ -12,7 +12,7 @@ class ContextMenuMeasurementCache( val icon: String?, val hint: String?, val enabled: Boolean, - val checked: Boolean + val checked: Boolean, ) data class Measurement( @@ -25,7 +25,7 @@ class ContextMenuMeasurementCache( val maxLabelWidth: Int, val maxHintWidth: Int, val panelWidth: Int, - val indicatorWidth: Int + val indicatorWidth: Int, ) data class Key( @@ -33,14 +33,13 @@ class ContextMenuMeasurementCache( val styleHash: Int, val fontHash: Int, val dpiKey: Int, - val entriesHash: Int + val entriesHash: Int, ) private val cache: MutableMap = object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > maxEntries - } + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > maxEntries } var computeCount: Long = 0L @@ -53,24 +52,26 @@ class ContextMenuMeasurementCache( fontId: String?, fontSize: Int?, ctx: UiMeasureContext, - dpiScale: Float + dpiScale: Float, ): Measurement { val snapshots = buildSnapshots(entries) val fingerprint = fingerprint(snapshots) - val key = Key( - menuToken = menuToken, - styleHash = style.hashCode(), - fontHash = 31 * (fontId?.hashCode() ?: 0) + (fontSize ?: 0), - dpiKey = (dpiScale * 1000f).toInt(), - entriesHash = fingerprint - ) + val key = + Key( + menuToken = menuToken, + styleHash = style.hashCode(), + fontHash = 31 * (fontId?.hashCode() ?: 0) + (fontSize ?: 0), + dpiKey = (dpiScale * 1000f).toInt(), + entriesHash = fingerprint, + ) synchronized(cache) { cache[key]?.let { return it } } computeCount += 1 - val rowHeight = (ctx.fontHeight(fontId, fontSize) + style.rowPaddingY * 2) - .coerceAtLeast(14) + val rowHeight = + (ctx.fontHeight(fontId, fontSize) + style.rowPaddingY * 2) + .coerceAtLeast(14) val separatorHeight = style.separatorHeight.coerceAtLeast(2) val entryHeights = IntArray(snapshots.size) @@ -94,11 +95,12 @@ class ContextMenuMeasurementCache( maxLabelWidth = labelWidth } - val indicatorText = when { - snapshot.checked -> ContextMenuGlyphs.CHECK_MARK - !snapshot.icon.isNullOrEmpty() -> snapshot.icon - else -> null - } + val indicatorText = + when { + snapshot.checked -> ContextMenuGlyphs.CHECK_MARK + !snapshot.icon.isNullOrEmpty() -> snapshot.icon + else -> null + } if (!indicatorText.isNullOrEmpty()) { val indicatorWidth = ctx.measureText(indicatorText, fontId, fontSize) if (indicatorWidth > maxIndicatorWidth) { @@ -106,11 +108,12 @@ class ContextMenuMeasurementCache( } } - val hintText = when { - !snapshot.hint.isNullOrEmpty() -> snapshot.hint - snapshot.kind == KIND_SUBMENU -> ContextMenuGlyphs.SUBMENU_ARROW - else -> null - } + val hintText = + when { + !snapshot.hint.isNullOrEmpty() -> snapshot.hint + snapshot.kind == KIND_SUBMENU -> ContextMenuGlyphs.SUBMENU_ARROW + else -> null + } if (!hintText.isNullOrEmpty()) { val hintWidth = ctx.measureText(hintText, fontId, fontSize) if (hintWidth > maxHintWidth) { @@ -133,18 +136,19 @@ class ContextMenuMeasurementCache( style.rowPaddingX val panelWidth = measuredWidth.coerceAtLeast(style.minPanelWidth) - val measured = Measurement( - snapshots = snapshots, - rowHeight = rowHeight, - separatorHeight = separatorHeight, - entryHeights = entryHeights, - entryOffsets = entryOffsets, - totalContentHeight = offset, - maxLabelWidth = maxLabelWidth, - maxHintWidth = maxHintWidth, - panelWidth = panelWidth, - indicatorWidth = measuredIndicatorWidth - ) + val measured = + Measurement( + snapshots = snapshots, + rowHeight = rowHeight, + separatorHeight = separatorHeight, + entryHeights = entryHeights, + entryOffsets = entryOffsets, + totalContentHeight = offset, + maxLabelWidth = maxLabelWidth, + maxHintWidth = maxHintWidth, + panelWidth = panelWidth, + indicatorWidth = measuredIndicatorWidth, + ) synchronized(cache) { cache[key] = measured } @@ -157,39 +161,42 @@ class ContextMenuMeasurementCache( entries.forEach { entry -> when (entry) { is MenuEntry.Item -> { - out += EntrySnapshot( - id = entry.id, - kind = KIND_ITEM, - label = entry.labelProvider.invoke(), - icon = entry.iconProvider?.invoke(), - hint = entry.hintProvider?.invoke(), - enabled = entry.enabledProvider.invoke(), - checked = entry.checkedProvider?.invoke() == true - ) + out += + EntrySnapshot( + id = entry.id, + kind = KIND_ITEM, + label = entry.labelProvider.invoke(), + icon = entry.iconProvider?.invoke(), + hint = entry.hintProvider?.invoke(), + enabled = entry.enabledProvider.invoke(), + checked = entry.checkedProvider?.invoke() == true, + ) } is MenuEntry.Submenu -> { - out += EntrySnapshot( - id = entry.id, - kind = KIND_SUBMENU, - label = entry.labelProvider.invoke(), - icon = entry.iconProvider?.invoke(), - hint = entry.hintProvider?.invoke(), - enabled = entry.enabledProvider.invoke(), - checked = false - ) + out += + EntrySnapshot( + id = entry.id, + kind = KIND_SUBMENU, + label = entry.labelProvider.invoke(), + icon = entry.iconProvider?.invoke(), + hint = entry.hintProvider?.invoke(), + enabled = entry.enabledProvider.invoke(), + checked = false, + ) } is MenuEntry.Separator -> { - out += EntrySnapshot( - id = entry.id, - kind = KIND_SEPARATOR, - label = "", - icon = null, - hint = null, - enabled = false, - checked = false - ) + out += + EntrySnapshot( + id = entry.id, + kind = KIND_SEPARATOR, + label = "", + icon = null, + hint = null, + enabled = false, + checked = false, + ) } } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuModel.kt index b5a86fc..234a9a3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuModel.kt @@ -4,6 +4,7 @@ import java.util.concurrent.atomic.AtomicLong private object ContextMenuIds { private val nextToken = AtomicLong(1L) + fun next(): Long = nextToken.getAndIncrement() } @@ -12,11 +13,11 @@ data class ContextMenuModel( val entries: List, val fontId: String? = null, val fontSize: Int? = null, - internal val token: Long = ContextMenuIds.next() + internal val token: Long = ContextMenuIds.next(), ) sealed class MenuEntry( - open val id: String? + open val id: String?, ) { data class Item( override val id: String? = null, @@ -26,7 +27,7 @@ sealed class MenuEntry( val enabledProvider: () -> Boolean = { true }, val checkedProvider: (() -> Boolean)? = null, val closeOnAction: Boolean = true, - val onClick: (() -> Unit)? = null + val onClick: (() -> Unit)? = null, ) : MenuEntry(id) data class Submenu( @@ -36,10 +37,10 @@ sealed class MenuEntry( val hintProvider: (() -> String?)? = null, val enabledProvider: () -> Boolean = { true }, val entries: List, - internal val token: Long = ContextMenuIds.next() + internal val token: Long = ContextMenuIds.next(), ) : MenuEntry(id) data class Separator( - override val id: String? = null + override val id: String? = null, ) : MenuEntry(id) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuPortalRequest.kt similarity index 76% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuHost.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuPortalRequest.kt index e2e1712..c12f453 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuHost.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuPortalRequest.kt @@ -2,10 +2,13 @@ package org.dreamfinity.dsgl.core.contextmenu import org.dreamfinity.dsgl.core.dom.layout.Rect -interface ContextMenuHost { +interface ContextMenuPortalService { fun openAtCursor(model: ContextMenuModel, x: Int, y: Int) + fun openAnchored(model: ContextMenuModel, anchorRect: Rect) + fun closeAll() + fun isOpen(): Boolean } @@ -23,25 +26,25 @@ data class ContextMenuTriggerScope( val anchorRect: Rect?, private val inheritedFontId: String?, private val inheritedFontSize: Int?, - private val host: ContextMenuHost + private val portalService: ContextMenuPortalService, ) { fun openMenu(model: ContextMenuModel) { - host.openAtCursor(resolveModel(model), mouseX, mouseY) + portalService.openAtCursor(resolveModel(model), mouseX, mouseY) } fun openMenuAnchored(model: ContextMenuModel, anchor: Rect = anchorRect ?: Rect(mouseX, mouseY, 0, 0)) { - host.openAnchored(resolveModel(model), anchor) + portalService.openAnchored(resolveModel(model), anchor) } fun closeMenus() { - host.closeAll() + portalService.closeAll() } private fun resolveModel(model: ContextMenuModel): ContextMenuModel { if (model.fontId != null && model.fontSize != null) return model return model.copy( fontId = model.fontId ?: inheritedFontId, - fontSize = model.fontSize ?: inheritedFontSize + fontSize = model.fontSize ?: inheritedFontSize, ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuRuntime.kt deleted file mode 100644 index 8ce79de..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuRuntime.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.dreamfinity.dsgl.core.contextmenu - -object ContextMenuRuntime { - val engine: ContextMenuEngine = ContextMenuEngine() - val host: ContextMenuHost = engine -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuStyle.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuStyle.kt index e8e65d9..51b8fbb 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuStyle.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuStyle.kt @@ -31,5 +31,5 @@ data class ContextMenuStyle( val hintTextColor: Int = 0xFFB6C2CF.toInt(), val separatorColor: Int = 0xFF4D5D6E.toInt(), val checkMarkColor: Int = 0xFF8BE39A.toInt(), - val submenuArrowColor: Int = 0xFFC7D4E1.toInt() + val submenuArrowColor: Int = 0xFFC7D4E1.toInt(), ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacement.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacement.kt index 44612b5..e33647b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacement.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacement.kt @@ -8,13 +8,13 @@ data class PopupPlacementRequest( val popupSize: Size, val viewport: Rect, val padding: Int = 6, - val horizontalFlipX: Int? = null + val horizontalFlipX: Int? = null, ) data class PopupPlacementResult( val rect: Rect, val flippedHorizontally: Boolean, - val clampedVertically: Boolean + val clampedVertically: Boolean, ) object PopupPlacement { @@ -25,8 +25,12 @@ object PopupPlacement { val maxY = request.viewport.y + request.viewport.height - request.padding val widthLimit = (maxX - minX).coerceAtLeast(1) val heightLimit = (maxY - minY).coerceAtLeast(1) - val resolvedWidth = request.popupSize.width.coerceIn(1, widthLimit) - val resolvedHeight = request.popupSize.height.coerceIn(1, heightLimit) + val resolvedWidth = + request.popupSize.width + .coerceIn(1, widthLimit) + val resolvedHeight = + request.popupSize.height + .coerceIn(1, heightLimit) val maxPanelX = (maxX - resolvedWidth).coerceAtLeast(minX) val maxPanelY = (maxY - resolvedHeight).coerceAtLeast(minY) @@ -54,7 +58,7 @@ object PopupPlacement { return PopupPlacementResult( rect = Rect(x, clampedY, resolvedWidth, resolvedHeight), flippedHorizontally = flipped, - clampedVertically = verticallyClamped + clampedVertically = verticallyClamped, ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHosts.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHosts.kt new file mode 100644 index 0000000..9cf5860 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHosts.kt @@ -0,0 +1,528 @@ +package org.dreamfinity.dsgl.core.debug + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.TextNode +import org.dreamfinity.dsgl.core.dom.elements.TextSource +import org.dreamfinity.dsgl.core.dom.layout.Border +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.DomainSurfaceHost +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalFrameContext +import org.dreamfinity.dsgl.core.portal.PortalHost +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.StyleApplicationScope +import org.dreamfinity.dsgl.core.style.TextWrap +import java.util.Locale + +private const val MIN_VIEWPORT_SIZE = 1 +private const val PANEL_WIDTH = 300 +private const val PANEL_HEIGHT = 232 +private const val PANEL_MARGIN = 8 +private const val PANEL_MIN_WIDTH = 120 +private const val PANEL_MIN_HEIGHT = 96 +private const val PANEL_HORIZONTAL_PADDING = 10 +private const val TOGGLE_WIDTH = 56 +private const val TOGGLE_HEIGHT = 18 +private const val FIRST_ROW_OFFSET_Y = 34 +private const val ROW_STEP = 24 +private const val RESET_BOTTOM_OFFSET = 40 +private const val RESET_HEIGHT = 20 +private const val SHADOW_OFFSET = 2 +private const val TITLE_OFFSET_Y = 8 +private const val TITLE_HEIGHT = 18 +private const val STATUS_BOTTOM_OFFSET = 14 +private const val STATUS_HEIGHT = 14 +private const val LABEL_LEFT_PADDING_EXTRA = 6 +private const val BUTTON_FONT_SIZE = 14 +private const val TITLE_FONT_SIZE = 16 + +private const val COLOR_SHADOW = 0x5F000000 +private const val COLOR_PANEL = 0xEE1A2230.toInt() +private const val COLOR_PANEL_BORDER = 0xFF5A6B80.toInt() +private const val COLOR_TEXT_PRIMARY = 0xFFFFFFFF.toInt() +private const val COLOR_TEXT_MUTED = 0xFFBAC7D6.toInt() +private const val COLOR_LABEL = 0xFFE0E9F2.toInt() +private const val COLOR_RESET_BACKGROUND = 0xFF2E3A49.toInt() +private const val COLOR_RESET_BORDER = 0xFF6886A5.toInt() +private const val COLOR_TOGGLE_ON = 0xFF2F7D4E.toInt() +private const val COLOR_TOGGLE_OFF = 0xFF7A2E3A.toInt() +private const val COLOR_TOGGLE_BORDER = 0xFF9AB3C9.toInt() + +internal data class DebugDomainControlLayout( + val panelRect: Rect, + val appPortalRenderRect: Rect, + val appPortalTintRect: Rect, + val appPortalInputRect: Rect, + val systemPortalTintRect: Rect, + val systemPortalRenderRect: Rect, + val systemPortalInputRect: Rect, + val resetRect: Rect, +) + +private data class DebugDomainToggleSnapshot( + val applicationPortalRenderEnabled: Boolean, + val applicationPortalTintEnabled: Boolean, + val applicationPortalInputEnabled: Boolean, + val systemPortalRenderEnabled: Boolean, + val systemPortalTintEnabled: Boolean, + val systemPortalInputEnabled: Boolean, +) + +@Suppress("TooManyFunctions") +class DebugDomainRootHost( + private val state: DomainSurfaceDebugState = DomainSurfaceDebugState, +) : DomainSurfaceHost { + override val surface: ScreenDomainSurface = ScreenDomainSurfaces.DebugRoot + + private var viewportWidth: Int = MIN_VIEWPORT_SIZE + private var viewportHeight: Int = MIN_VIEWPORT_SIZE + private var layout: DebugDomainControlLayout? = null + private val rootNode: DebugDomainRootNode = DebugDomainRootNode(state) + private val tree: DomTree = + DomTree( + root = rootNode, + styleScope = StyleApplicationScope.Debug, + ) + private val domInputRouter: SurfaceDomInputRouter = SurfaceDomInputRouter { rootNode } + private var lastToggleSnapshot: DebugDomainToggleSnapshot? = null + + @Suppress("UnusedParameter") + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + viewportWidth = width.coerceAtLeast(MIN_VIEWPORT_SIZE) + viewportHeight = height.coerceAtLeast(MIN_VIEWPORT_SIZE) + if (!state.controlsEnabled) { + layout = null + lastToggleSnapshot = null + return + } + layout = buildLayout(viewportWidth, viewportHeight) + } + + override fun paint(ctx: UiMeasureContext): List { + val currentLayout = layout ?: return emptyList() + val snapshot = state.snapshot() + val toggleSnapshot = snapshot.toDebugToggleSnapshot() + if (lastToggleSnapshot != toggleSnapshot) { + tree.invalidateRenderCommandChunks() + lastToggleSnapshot = toggleSnapshot + } + rootNode.bind(currentLayout, snapshot) + tree.render(ctx, viewportWidth, viewportHeight) + return tree.paint(ctx, applyStyles = true) + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = domInputRouter.handleMouseMove(mouseX, mouseY) + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + domInputRouter.handleMouseDown(mouseX, mouseY, button) + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + domInputRouter.handleMouseUp(mouseX, mouseY, button) + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { + if (delta == 0) return false + return domInputRouter.handleMouseWheel(mouseX, mouseY, delta) + } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = domInputRouter.handleKeyDown(keyCode, keyChar) + + override fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = domInputRouter.handleKeyUp(keyCode, keyChar) + + override fun clearRefs() { + layout = null + lastToggleSnapshot = null + domInputRouter.clear() + tree.clearRefs() + } + + internal fun debugLayout(): DebugDomainControlLayout? = layout + + internal val debugStyleScope: StyleApplicationScope + get() = StyleApplicationScope.Debug + + private fun buildLayout(viewportWidth: Int, viewportHeight: Int): DebugDomainControlLayout { + val panelX = PANEL_MARGIN + val panelY = (viewportHeight - PANEL_HEIGHT - PANEL_MARGIN).coerceAtLeast(PANEL_MARGIN) + val panelRect = + Rect( + x = panelX, + y = panelY, + width = PANEL_WIDTH.coerceAtMost((viewportWidth - PANEL_MARGIN * 2).coerceAtLeast(PANEL_MIN_WIDTH)), + height = PANEL_HEIGHT.coerceAtMost((viewportHeight - PANEL_MARGIN * 2).coerceAtLeast(PANEL_MIN_HEIGHT)), + ) + val toggleX = panelRect.x + panelRect.width - TOGGLE_WIDTH - PANEL_HORIZONTAL_PADDING + val firstY = panelRect.y + FIRST_ROW_OFFSET_Y + var row = 0 + return DebugDomainControlLayout( + panelRect = panelRect, + appPortalRenderRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + appPortalTintRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + appPortalInputRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + systemPortalRenderRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + systemPortalTintRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + systemPortalInputRect = Rect(toggleX, firstY + ROW_STEP * row++, TOGGLE_WIDTH, TOGGLE_HEIGHT), + resetRect = + Rect( + x = panelRect.x + PANEL_HORIZONTAL_PADDING, + y = panelRect.y + panelRect.height - RESET_BOTTOM_OFFSET, + width = panelRect.width - PANEL_HORIZONTAL_PADDING * 2, + height = RESET_HEIGHT, + ), + ) + } +} + +@Suppress("TooManyFunctions") +class DebugDomainPortalHost : DomainSurfaceHost { + override val surface: ScreenDomainSurface = ScreenDomainSurfaces.DebugPortal + + private val portalHost: PortalHost = PortalHost(ScreenDomainSurfaces.DebugPortal) + private var viewportWidth: Int = MIN_VIEWPORT_SIZE + private var viewportHeight: Int = MIN_VIEWPORT_SIZE + + override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth.coerceAtLeast(MIN_VIEWPORT_SIZE) + this.viewportHeight = viewportHeight.coerceAtLeast(MIN_VIEWPORT_SIZE) + portalHost.onInputFrame(PortalFrameContext(Rect(0, 0, this.viewportWidth, this.viewportHeight))) + } + + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + viewportWidth = width.coerceAtLeast(MIN_VIEWPORT_SIZE) + viewportHeight = height.coerceAtLeast(MIN_VIEWPORT_SIZE) + portalHost.render(ctx, viewportWidth, viewportHeight) + } + + override fun paint(ctx: UiMeasureContext): List = portalHost.paint(ctx) + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + portalHost.dispatchInput { it.handleMouseMove(mouseX, mouseY) } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseDown(mouseX, mouseY, button) } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseUp(mouseX, mouseY, button) } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + portalHost.dispatchInput { it.handleMouseWheel(mouseX, mouseY, delta) } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyDown(keyCode, keyChar) } + + override fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyUp(keyCode, keyChar) } + + override fun clearRefs() { + portalHost.clearRefs() + } + + internal fun debugRegisterPortalEntryForTests(entry: PortalEntry) { + portalHost.register(entry) + } + + internal val debugActivePortalEntryIds: List + get() = portalHost.entriesInPaintOrder().map { it.state.id.value } +} + +private fun DomainSurfaceDebugSnapshot.toDebugToggleSnapshot(): DebugDomainToggleSnapshot = + DebugDomainToggleSnapshot( + applicationPortalRenderEnabled = applicationPortalRenderEnabled, + applicationPortalTintEnabled = applicationPortalTintEnabled, + applicationPortalInputEnabled = applicationPortalInputEnabled, + systemPortalRenderEnabled = systemPortalRenderEnabled, + systemPortalTintEnabled = systemPortalTintEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + ) + +private class DebugDomainRootNode( + private val state: DomainSurfaceDebugState, + key: Any? = "dsgl-debug-domain-root", +) : DOMNode(key) { + override val styleType: String = "dsgl-debug-domain-root" + + private val scope = UiScope(this) + private val shadowNode: ContainerNode = + scope.div({ + this.key = "dsgl-debug-domain-shadow" + }) + private val panelNode: ContainerNode = + scope.div({ + this.key = "dsgl-debug-domain-panel" + onMouseDown = { event -> + event.cancelled = true + } + onMouseWheel = { event -> + event.cancelled = true + } + }) + private val titleNode: TextNode = + scope.text(props = { + this.key = "dsgl-debug-domain-title" + source = TextSource.Static("Debug Domain") + style = { + textWrap = TextWrap.NoWrap + } + }) + + private val appRenderLabelNode: TextNode = labelNode("App Portal Render", "dsgl-debug-domain-label-app-render") + private val appTintLabelNode: TextNode = labelNode("App Portal Tint", "dsgl-debug-domain-label-app-tint") + private val appInputLabelNode: TextNode = labelNode("App Portal Input", "dsgl-debug-domain-label-app-input") + private val systemRenderLabelNode: TextNode = + labelNode("System Portal Render", "dsgl-debug-domain-label-system-render") + private val systemTintLabelNode: TextNode = labelNode("System Portal Tint", "dsgl-debug-domain-label-system-tint") + private val systemInputLabelNode: TextNode = + labelNode("System Portal Input", "dsgl-debug-domain-label-system-input") + + private val appRenderToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-app-render") { + state.applicationPortalRenderEnabled = !state.applicationPortalRenderEnabled + } + private val appTintToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-app-tint") { + state.applicationPortalTintEnabled = !state.applicationPortalTintEnabled + } + private val appInputToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-app-input") { + state.applicationPortalInputEnabled = !state.applicationPortalInputEnabled + } + private val systemRenderToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-system-render") { + state.systemPortalRenderEnabled = !state.systemPortalRenderEnabled + } + private val systemTintToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-system-tint") { + state.systemPortalTintEnabled = !state.systemPortalTintEnabled + } + private val systemInputToggleNode: ButtonNode = + toggleNode("dsgl-debug-domain-toggle-system-input") { + state.systemPortalInputEnabled = !state.systemPortalInputEnabled + } + + private val resetButtonNode: ButtonNode = + scope.button("Reset All", { + this.key = "dsgl-debug-domain-reset" + onMouseDown = { event -> + state.resetAll() + event.cancelled = true + } + style = { + textWrap = TextWrap.NoWrap + } + }) + + private val statusNode: TextNode = + scope.text(props = { + this.key = "dsgl-debug-domain-status" + source = TextSource.Static("") + style = { + textWrap = TextWrap.NoWrap + } + }) + + private var layout: DebugDomainControlLayout? = null + private var snapshot: DomainSurfaceDebugSnapshot = + DomainSurfaceDebugSnapshot( + applicationPortalRenderEnabled = true, + applicationPortalTintEnabled = false, + applicationPortalInputEnabled = true, + systemPortalRenderEnabled = true, + systemPortalTintEnabled = false, + systemPortalInputEnabled = true, + frameFps = 0, + frameTimeMs = 0f, + frameFpsWindow = 0, + frameTimeWindowMs = 0f, + ) + + fun bind(layout: DebugDomainControlLayout, snapshot: DomainSurfaceDebugSnapshot) { + this.layout = layout + this.snapshot = snapshot + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + val localLayout = + layout ?: run { + hideAll(ctx) + return + } + + val panelRect = localLayout.panelRect + val shadowRect = + Rect(panelRect.x + SHADOW_OFFSET, panelRect.y + SHADOW_OFFSET, panelRect.width, panelRect.height) + + shadowNode.backgroundColor = COLOR_SHADOW + shadowNode.border = Border.NONE + + panelNode.backgroundColor = COLOR_PANEL + panelNode.border = Border.all(1, COLOR_PANEL_BORDER) + + titleNode.color = COLOR_TEXT_PRIMARY + titleNode.fontSize = TITLE_FONT_SIZE + + applyLabelStyle(appRenderLabelNode) + applyLabelStyle(appTintLabelNode) + applyLabelStyle(appInputLabelNode) + applyLabelStyle(systemRenderLabelNode) + applyLabelStyle(systemTintLabelNode) + applyLabelStyle(systemInputLabelNode) + + configureToggle(appRenderToggleNode, snapshot.applicationPortalRenderEnabled) + configureToggle(appTintToggleNode, snapshot.applicationPortalTintEnabled) + configureToggle(appInputToggleNode, snapshot.applicationPortalInputEnabled) + configureToggle(systemRenderToggleNode, snapshot.systemPortalRenderEnabled) + configureToggle(systemTintToggleNode, snapshot.systemPortalTintEnabled) + configureToggle(systemInputToggleNode, snapshot.systemPortalInputEnabled) + + resetButtonNode.backgroundColor = COLOR_RESET_BACKGROUND + resetButtonNode.border = Border.all(1, COLOR_RESET_BORDER) + resetButtonNode.textColor = COLOR_TEXT_PRIMARY + resetButtonNode.fontSize = BUTTON_FONT_SIZE + + val rApp = if (snapshot.applicationPortalRenderEnabled) "A1" else "A0" + val rSys = if (snapshot.systemPortalRenderEnabled) "S1" else "S0" + val iApp = if (snapshot.applicationPortalInputEnabled) "A1" else "A0" + val iSys = if (snapshot.systemPortalInputEnabled) "S1" else "S0" + val statusTextValue = + "R:$rApp/$rSys I:$iApp/$iSys " + + "FPS:${snapshot.frameFps} (${String.format(Locale.US, "%.1f", snapshot.frameTimeMs)}ms) " + + "AvgFPS:${snapshot.frameFpsWindow} (${String.format(Locale.US, "%.1f", snapshot.frameTimeWindowMs)}ms)" + statusNode.setText(statusTextValue) + statusNode.color = COLOR_TEXT_MUTED + statusNode.fontSize = BUTTON_FONT_SIZE + + renderNode(ctx, shadowNode, shadowRect) + renderNode(ctx, panelNode, panelRect) + renderNode( + ctx, + titleNode, + Rect( + panelRect.x + PANEL_HORIZONTAL_PADDING, + panelRect.y + TITLE_OFFSET_Y, + (panelRect.width - PANEL_HORIZONTAL_PADDING * 2).coerceAtLeast(MIN_VIEWPORT_SIZE), + TITLE_HEIGHT, + ), + ) + + renderToggleRow(ctx, panelRect, localLayout.appPortalRenderRect, appRenderLabelNode, appRenderToggleNode) + renderToggleRow(ctx, panelRect, localLayout.appPortalTintRect, appTintLabelNode, appTintToggleNode) + renderToggleRow(ctx, panelRect, localLayout.appPortalInputRect, appInputLabelNode, appInputToggleNode) + renderToggleRow( + ctx, + panelRect, + localLayout.systemPortalRenderRect, + systemRenderLabelNode, + systemRenderToggleNode, + ) + renderToggleRow(ctx, panelRect, localLayout.systemPortalTintRect, systemTintLabelNode, systemTintToggleNode) + renderToggleRow(ctx, panelRect, localLayout.systemPortalInputRect, systemInputLabelNode, systemInputToggleNode) + + renderNode(ctx, resetButtonNode, localLayout.resetRect) + renderNode( + ctx, + statusNode, + Rect( + x = panelRect.x + PANEL_HORIZONTAL_PADDING, + y = panelRect.y + panelRect.height - STATUS_BOTTOM_OFFSET, + width = (panelRect.width - PANEL_HORIZONTAL_PADDING * 2).coerceAtLeast(MIN_VIEWPORT_SIZE), + height = STATUS_HEIGHT, + ), + ) + } + + private fun labelNode(text: String, key: Any): TextNode = + scope.text(props = { + this.key = key + source = TextSource.Static(text) + style = { + textWrap = TextWrap.NoWrap + } + }) + + private fun toggleNode(key: Any, onToggle: () -> Unit): ButtonNode = + scope.button("ON", { + this.key = key + onMouseDown = { event -> + onToggle() + event.cancelled = true + } + style = { + textWrap = TextWrap.NoWrap + } + }) + + private fun applyLabelStyle(node: TextNode) { + node.color = COLOR_LABEL + node.fontSize = BUTTON_FONT_SIZE + } + + private fun configureToggle(node: ButtonNode, enabled: Boolean) { + node.text = if (enabled) "ON" else "OFF" + node.backgroundColor = if (enabled) COLOR_TOGGLE_ON else COLOR_TOGGLE_OFF + node.border = Border.all(1, COLOR_TOGGLE_BORDER) + node.textColor = COLOR_TEXT_PRIMARY + node.fontSize = BUTTON_FONT_SIZE + } + + private fun renderToggleRow( + ctx: UiMeasureContext, + panelRect: Rect, + toggleRect: Rect, + labelNode: TextNode, + toggleNode: ButtonNode, + ) { + val labelRect = + Rect( + x = panelRect.x + PANEL_HORIZONTAL_PADDING, + y = toggleRect.y + SHADOW_OFFSET, + width = + ( + toggleRect.x - + (panelRect.x + PANEL_HORIZONTAL_PADDING + LABEL_LEFT_PADDING_EXTRA) + ).coerceAtLeast(MIN_VIEWPORT_SIZE), + height = (toggleRect.height - SHADOW_OFFSET).coerceAtLeast(MIN_VIEWPORT_SIZE), + ) + renderNode(ctx, labelNode, labelRect) + renderNode(ctx, toggleNode, toggleRect) + } + + private fun renderNode(ctx: UiMeasureContext, node: DOMNode, rect: Rect?) { + if (rect == null || rect.width <= 0 || rect.height <= 0) { + node.display = Display.None + node.render(ctx, 0, 0, 0, 0) + return + } + node.display = Display.Block + node.render(ctx, rect.x, rect.y, rect.width, rect.height) + } + + private fun hideAll(ctx: UiMeasureContext) { + children.forEach { child -> + child.display = Display.None + child.render(ctx, 0, 0, 0, 0) + } + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DomainSurfaceDebugState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DomainSurfaceDebugState.kt new file mode 100644 index 0000000..b96edad --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/DomainSurfaceDebugState.kt @@ -0,0 +1,153 @@ +package org.dreamfinity.dsgl.core.debug + +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces + +data class DomainSurfaceDebugSnapshot( + val applicationPortalRenderEnabled: Boolean, + val applicationPortalTintEnabled: Boolean, + val applicationPortalInputEnabled: Boolean, + val systemPortalRenderEnabled: Boolean, + val systemPortalTintEnabled: Boolean, + val systemPortalInputEnabled: Boolean, + val frameFps: Int, + val frameTimeMs: Float, + val frameFpsWindow: Int, + val frameTimeWindowMs: Float, +) + +object DomainSurfaceDebugState { + private const val FRAME_TIMING_WINDOW_SIZE: Int = 60 + private val frameTimeWindowSeconds: DoubleArray = DoubleArray(FRAME_TIMING_WINDOW_SIZE) + private var frameTimeWindowWriteIndex: Int = 0 + private var frameTimeWindowCount: Int = 0 + private var frameTimeWindowSumSeconds: Double = 0.0 + + @Volatile + var applicationPortalRenderEnabled: Boolean = true + + @Volatile + var applicationPortalTintEnabled: Boolean = false + + @Volatile + var applicationPortalInputEnabled: Boolean = true + + @Volatile + var systemPortalRenderEnabled: Boolean = true + + @Volatile + var systemPortalTintEnabled: Boolean = false + + @Volatile + var systemPortalInputEnabled: Boolean = true + + @Volatile + var frameFps: Int = 0 + + @Volatile + var frameTimeMs: Float = 0f + + @Volatile + var frameFpsWindow: Int = 0 + + @Volatile + var frameTimeWindowMs: Float = 0f + + val controlsEnabled: Boolean + get() { + return controlsEnabledOverride ?: java.lang.Boolean + .getBoolean("dsgl.domain.controls") + } + + fun isRenderEnabled(surface: ScreenDomainSurface): Boolean = + when { + surface == ScreenDomainSurfaces.ApplicationPortal -> applicationPortalRenderEnabled + surface == ScreenDomainSurfaces.SystemPortal -> systemPortalRenderEnabled + else -> true + } + + fun isTintEnabled(surface: ScreenDomainSurface): Boolean = + when { + surface == ScreenDomainSurfaces.ApplicationPortal -> applicationPortalTintEnabled + surface == ScreenDomainSurfaces.SystemPortal -> systemPortalTintEnabled + else -> true + } + + fun isInputEnabled(surface: ScreenDomainSurface): Boolean = + when { + surface == ScreenDomainSurfaces.ApplicationPortal -> applicationPortalInputEnabled + surface == ScreenDomainSurfaces.SystemPortal -> systemPortalInputEnabled + else -> true + } + + fun resetAll() { + applicationPortalRenderEnabled = true + applicationPortalTintEnabled = false + applicationPortalInputEnabled = true + systemPortalRenderEnabled = true + systemPortalTintEnabled = false + systemPortalInputEnabled = true + frameFps = 0 + frameTimeMs = 0f + frameFpsWindow = 0 + frameTimeWindowMs = 0f + frameTimeWindowWriteIndex = 0 + frameTimeWindowCount = 0 + frameTimeWindowSumSeconds = 0.0 + java.util.Arrays + .fill(frameTimeWindowSeconds, 0.0) + } + + @Synchronized + fun updateFrameTiming(dtSeconds: Double) { + val safeDt = dtSeconds.coerceAtLeast(1.0 / 1000.0) + frameTimeMs = (safeDt * 1000.0).toFloat() + frameFps = + kotlin.math + .round(1.0 / safeDt) + .toInt() + .coerceAtLeast(0) + + if (frameTimeWindowCount == FRAME_TIMING_WINDOW_SIZE) { + frameTimeWindowSumSeconds -= frameTimeWindowSeconds[frameTimeWindowWriteIndex] + } else { + frameTimeWindowCount += 1 + } + frameTimeWindowSeconds[frameTimeWindowWriteIndex] = safeDt + frameTimeWindowSumSeconds += safeDt + frameTimeWindowWriteIndex = (frameTimeWindowWriteIndex + 1) % FRAME_TIMING_WINDOW_SIZE + + val averageDt = + if (frameTimeWindowCount > 0) { + frameTimeWindowSumSeconds / frameTimeWindowCount.toDouble() + } else { + safeDt + } + frameTimeWindowMs = (averageDt * 1000.0).toFloat() + frameFpsWindow = + kotlin.math + .round(1.0 / averageDt) + .toInt() + .coerceAtLeast(0) + } + + fun snapshot(): DomainSurfaceDebugSnapshot = + DomainSurfaceDebugSnapshot( + applicationPortalRenderEnabled = applicationPortalRenderEnabled, + applicationPortalTintEnabled = applicationPortalTintEnabled, + applicationPortalInputEnabled = applicationPortalInputEnabled, + systemPortalRenderEnabled = systemPortalRenderEnabled, + systemPortalTintEnabled = systemPortalTintEnabled, + systemPortalInputEnabled = systemPortalInputEnabled, + frameFps = frameFps, + frameTimeMs = frameTimeMs, + frameFpsWindow = frameFpsWindow, + frameTimeWindowMs = frameTimeWindowMs, + ) + + private var controlsEnabledOverride: Boolean? = null + + internal fun setControlsEnabledTestOverride(value: Boolean?) { + controlsEnabledOverride = value + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHost.kt deleted file mode 100644 index 4305232..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHost.kt +++ /dev/null @@ -1,402 +0,0 @@ -package org.dreamfinity.dsgl.core.debug - -import org.dreamfinity.dsgl.core.DomTree -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.elements.ButtonNode -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.elements.TextNode -import org.dreamfinity.dsgl.core.dom.elements.TextSource -import org.dreamfinity.dsgl.core.dom.layout.Border -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.button -import org.dreamfinity.dsgl.core.dsl.div -import org.dreamfinity.dsgl.core.dsl.text -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.StyleApplicationScope -import org.dreamfinity.dsgl.core.style.TextWrap -import java.util.Locale - -internal data class OverlayDebugControlLayout( - val panelRect: Rect, - val appOverlayRenderRect: Rect, - val appOverlayTintRect: Rect, - val appOverlayInputRect: Rect, - val systemOverlayTintRect: Rect, - val systemOverlayRenderRect: Rect, - val systemOverlayInputRect: Rect, - val resetRect: Rect -) - -class OverlayDebugControlHost( - private val state: OverlayLayerDebugState = OverlayLayerDebugState -) { - private data class ToggleSnapshot( - val applicationOverlayRenderEnabled: Boolean, - val applicationOverlayTintEnabled: Boolean, - val applicationOverlayInputEnabled: Boolean, - val systemOverlayRenderEnabled: Boolean, - val systemOverlayTintEnabled: Boolean, - val systemOverlayInputEnabled: Boolean - ) - - private var viewportWidth: Int = 1 - private var viewportHeight: Int = 1 - private var layout: OverlayDebugControlLayout? = null - private val rootNode: OverlayDebugControlRootNode = OverlayDebugControlRootNode() - private val tree: DomTree = DomTree( - root = rootNode, - styleScope = StyleApplicationScope.SystemOverlay - ) - private var lastToggleSnapshot: ToggleSnapshot? = null - - fun render(viewportWidth: Int, viewportHeight: Int) { - this.viewportWidth = viewportWidth.coerceAtLeast(1) - this.viewportHeight = viewportHeight.coerceAtLeast(1) - if (!state.controlsEnabled) { - layout = null - lastToggleSnapshot = null - return - } - layout = buildLayout(this.viewportWidth, this.viewportHeight) - } - - fun paint(ctx: UiMeasureContext): List { - val currentLayout = layout ?: return emptyList() - val snapshot = state.snapshot() - val toggleSnapshot = snapshot.toggleSnapshot() - if (lastToggleSnapshot != toggleSnapshot) { - tree.invalidateRenderCommandChunks() - lastToggleSnapshot = toggleSnapshot - } - rootNode.bind(currentLayout, snapshot) - tree.render(ctx, viewportWidth, viewportHeight) - return tree.paint(ctx, applyStyles = true) - } - - fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { - val currentLayout = layout ?: return false - return currentLayout.panelRect.contains(mouseX, mouseY) - } - - fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - val currentLayout = layout ?: return false - if (!currentLayout.panelRect.contains(mouseX, mouseY)) { - return false - } - if (button != MouseButton.LEFT) { - return true - } - when { - currentLayout.appOverlayRenderRect.contains(mouseX, mouseY) -> { - state.applicationOverlayRenderEnabled = !state.applicationOverlayRenderEnabled - } - - currentLayout.appOverlayTintRect.contains(mouseX, mouseY) -> { - state.applicationOverlayTintEnabled = !state.applicationOverlayTintEnabled - } - - currentLayout.appOverlayInputRect.contains(mouseX, mouseY) -> { - state.applicationOverlayInputEnabled = !state.applicationOverlayInputEnabled - } - - currentLayout.systemOverlayRenderRect.contains(mouseX, mouseY) -> { - state.systemOverlayRenderEnabled = !state.systemOverlayRenderEnabled - } - - currentLayout.systemOverlayTintRect.contains(mouseX, mouseY) -> { - state.systemOverlayTintEnabled = !state.systemOverlayTintEnabled - } - - currentLayout.systemOverlayInputRect.contains(mouseX, mouseY) -> { - state.systemOverlayInputEnabled = !state.systemOverlayInputEnabled - } - - currentLayout.resetRect.contains(mouseX, mouseY) -> { - state.resetAll() - } - } - return true - } - - fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - val currentLayout = layout ?: return false - if (button != MouseButton.LEFT) return false - return currentLayout.panelRect.contains(mouseX, mouseY) - } - - fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { - val currentLayout = layout ?: return false - if (delta == 0) return false - return currentLayout.panelRect.contains(mouseX, mouseY) - } - - fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false - - fun clearRefs() { - layout = null - lastToggleSnapshot = null - tree.clearRefs() - } - - internal fun debugLayout(): OverlayDebugControlLayout? = layout - - private fun buildLayout(viewportWidth: Int, viewportHeight: Int): OverlayDebugControlLayout { - val panelWidth = 300 - val panelHeight = 176 + 56 - val panelX = 8 - val panelY = (viewportHeight - panelHeight - 8).coerceAtLeast(8) - val panelRect = Rect( - x = panelX, - y = panelY, - width = panelWidth.coerceAtMost((viewportWidth - 16).coerceAtLeast(120)), - height = panelHeight.coerceAtMost((viewportHeight - 16).coerceAtLeast(96)) - ) - val toggleWidth = 56 - val toggleHeight = 18 - val toggleX = panelRect.x + panelRect.width - toggleWidth - 10 - val firstY = panelRect.y + 34 - val rowStep = 24 - var row = 0 - return OverlayDebugControlLayout( - panelRect = panelRect, - appOverlayRenderRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - appOverlayTintRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - appOverlayInputRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - systemOverlayRenderRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - systemOverlayTintRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - systemOverlayInputRect = Rect(toggleX, firstY + rowStep * row++, toggleWidth, toggleHeight), - resetRect = Rect( - x = panelRect.x + 10, - y = panelRect.y + panelRect.height - 40, - width = panelRect.width - 20, - height = 20 - ) - ) - } - - private fun OverlayLayerDebugSnapshot.toggleSnapshot(): ToggleSnapshot { - return ToggleSnapshot( - applicationOverlayRenderEnabled = applicationOverlayRenderEnabled, - applicationOverlayTintEnabled = applicationOverlayTintEnabled, - applicationOverlayInputEnabled = applicationOverlayInputEnabled, - systemOverlayRenderEnabled = systemOverlayRenderEnabled, - systemOverlayTintEnabled = systemOverlayTintEnabled, - systemOverlayInputEnabled = systemOverlayInputEnabled - ) - } -} - -private class OverlayDebugControlRootNode( - key: Any? = "dsgl-overlay-debug-root" -) : DOMNode(key) { - override val styleType: String = "dsgl-overlay-debug-root" - - private val scope = UiScope(this) - private val shadowNode: ContainerNode = scope.div({ - this.key = "dsgl-overlay-debug-shadow" - }) - private val panelNode: ContainerNode = scope.div({ - this.key = "dsgl-overlay-debug-panel" - }) - private val titleNode: TextNode = scope.text(props = { - this.key = "dsgl-overlay-debug-title" - source = TextSource.Static("Overlay Debug") - style = { - textWrap = TextWrap.NoWrap - } - }) - - private val appRenderLabelNode: TextNode = labelNode("App Overlay Render", "dsgl-overlay-debug-label-app-render") - private val appTintLabelNode: TextNode = labelNode("App Overlay Tint", "dsgl-overlay-debug-label-app-tint") - private val appInputLabelNode: TextNode = labelNode("App Overlay Input", "dsgl-overlay-debug-label-app-input") - private val systemRenderLabelNode: TextNode = labelNode("System Overlay Render", "dsgl-overlay-debug-label-system-render") - private val systemTintLabelNode: TextNode = labelNode("System Overlay Tint", "dsgl-overlay-debug-label-system-tint") - private val systemInputLabelNode: TextNode = labelNode("System Overlay Input", "dsgl-overlay-debug-label-system-input") - - private val appRenderToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-app-render") - private val appTintToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-app-tint") - private val appInputToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-app-input") - private val systemRenderToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-system-render") - private val systemTintToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-system-tint") - private val systemInputToggleNode: ButtonNode = toggleNode("dsgl-overlay-debug-toggle-system-input") - - private val resetButtonNode: ButtonNode = scope.button("Reset All", { - this.key = "dsgl-overlay-debug-reset" - style = { - textWrap = TextWrap.NoWrap - } - }) - - private val statusNode: TextNode = scope.text(props = { - this.key = "dsgl-overlay-debug-status" - source = TextSource.Static("") - style = { - textWrap = TextWrap.NoWrap - } - }) - - private var layout: OverlayDebugControlLayout? = null - private var snapshot: OverlayLayerDebugSnapshot = OverlayLayerDebugSnapshot( - applicationOverlayRenderEnabled = true, - applicationOverlayTintEnabled = false, - applicationOverlayInputEnabled = true, - systemOverlayRenderEnabled = true, - systemOverlayTintEnabled = false, - systemOverlayInputEnabled = true, - frameFps = 0, - frameTimeMs = 0f, - frameFpsWindow = 0, - frameTimeWindowMs = 0f - ) - - fun bind(layout: OverlayDebugControlLayout, snapshot: OverlayLayerDebugSnapshot) { - this.layout = layout - this.snapshot = snapshot - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - val localLayout = layout ?: run { - hideAll(ctx) - return - } - - val panelRect = localLayout.panelRect - val shadowRect = Rect(panelRect.x + 2, panelRect.y + 2, panelRect.width, panelRect.height) - - shadowNode.backgroundColor = 0x5F000000 - shadowNode.border = Border.NONE - - panelNode.backgroundColor = 0xEE1A2230.toInt() - panelNode.border = Border.all(1, 0xFF5A6B80.toInt()) - - titleNode.color = 0xFFFFFFFF.toInt() - titleNode.fontSize = 16 - - applyLabelStyle(appRenderLabelNode) - applyLabelStyle(appTintLabelNode) - applyLabelStyle(appInputLabelNode) - applyLabelStyle(systemRenderLabelNode) - applyLabelStyle(systemTintLabelNode) - applyLabelStyle(systemInputLabelNode) - - configureToggle(appRenderToggleNode, snapshot.applicationOverlayRenderEnabled) - configureToggle(appTintToggleNode, snapshot.applicationOverlayTintEnabled) - configureToggle(appInputToggleNode, snapshot.applicationOverlayInputEnabled) - configureToggle(systemRenderToggleNode, snapshot.systemOverlayRenderEnabled) - configureToggle(systemTintToggleNode, snapshot.systemOverlayTintEnabled) - configureToggle(systemInputToggleNode, snapshot.systemOverlayInputEnabled) - - resetButtonNode.backgroundColor = 0xFF2E3A49.toInt() - resetButtonNode.border = Border.all(1, 0xFF6886A5.toInt()) - resetButtonNode.textColor = 0xFFFFFFFF.toInt() - resetButtonNode.fontSize = 14 - - val statusTextValue = - "R:${if (snapshot.applicationOverlayRenderEnabled) "A1" else "A0"}/${if (snapshot.systemOverlayRenderEnabled) "S1" else "S0"} " + - "I:${if (snapshot.applicationOverlayInputEnabled) "A1" else "A0"}/${if (snapshot.systemOverlayInputEnabled) "S1" else "S0"} " + - "FPS:${snapshot.frameFps} (${String.format(Locale.US, "%.1f", snapshot.frameTimeMs)}ms) " + - "AvgFPS:${snapshot.frameFpsWindow} (${String.format(Locale.US, "%.1f", snapshot.frameTimeWindowMs)}ms)" - statusNode.setText(statusTextValue) - statusNode.color = 0xFFBAC7D6.toInt() - statusNode.fontSize = 14 - - renderNode(ctx, shadowNode, shadowRect) - renderNode(ctx, panelNode, panelRect) - renderNode(ctx, titleNode, Rect(panelRect.x + 10, panelRect.y + 8, (panelRect.width - 20).coerceAtLeast(1), 18)) - - renderToggleRow(ctx, panelRect, localLayout.appOverlayRenderRect, appRenderLabelNode, appRenderToggleNode) - renderToggleRow(ctx, panelRect, localLayout.appOverlayTintRect, appTintLabelNode, appTintToggleNode) - renderToggleRow(ctx, panelRect, localLayout.appOverlayInputRect, appInputLabelNode, appInputToggleNode) - renderToggleRow(ctx, panelRect, localLayout.systemOverlayRenderRect, systemRenderLabelNode, systemRenderToggleNode) - renderToggleRow(ctx, panelRect, localLayout.systemOverlayTintRect, systemTintLabelNode, systemTintToggleNode) - renderToggleRow(ctx, panelRect, localLayout.systemOverlayInputRect, systemInputLabelNode, systemInputToggleNode) - - renderNode(ctx, resetButtonNode, localLayout.resetRect) - renderNode( - ctx, - statusNode, - Rect( - x = panelRect.x + 10, - y = panelRect.y + panelRect.height - 14, - width = (panelRect.width - 20).coerceAtLeast(1), - height = 14 - ) - ) - } - - private fun labelNode(text: String, key: Any): TextNode { - return scope.text(props = { - this.key = key - source = TextSource.Static(text) - style = { - textWrap = TextWrap.NoWrap - } - }) - } - - private fun toggleNode(key: Any): ButtonNode { - return scope.button("ON", { - this.key = key - style = { - textWrap = TextWrap.NoWrap - } - }) - } - - private fun applyLabelStyle(node: TextNode) { - node.color = 0xFFE0E9F2.toInt() - node.fontSize = 14 - } - - private fun configureToggle(node: ButtonNode, enabled: Boolean) { - node.text = if (enabled) "ON" else "OFF" - node.backgroundColor = if (enabled) 0xFF2F7D4E.toInt() else 0xFF7A2E3A.toInt() - node.border = Border.all(1, 0xFF9AB3C9.toInt()) - node.textColor = 0xFFFFFFFF.toInt() - node.fontSize = 14 - } - - private fun renderToggleRow( - ctx: UiMeasureContext, - panelRect: Rect, - toggleRect: Rect, - labelNode: TextNode, - toggleNode: ButtonNode - ) { - val labelRect = Rect( - x = panelRect.x + 10, - y = toggleRect.y + 2, - width = (toggleRect.x - (panelRect.x + 16)).coerceAtLeast(1), - height = (toggleRect.height - 2).coerceAtLeast(1) - ) - renderNode(ctx, labelNode, labelRect) - renderNode(ctx, toggleNode, toggleRect) - } - - private fun renderNode(ctx: UiMeasureContext, node: DOMNode, rect: Rect?) { - if (rect == null || rect.width <= 0 || rect.height <= 0) { - node.display = Display.None - node.render(ctx, 0, 0, 0, 0) - return - } - node.display = Display.Block - node.render(ctx, rect.x, rect.y, rect.width, rect.height) - } - - private fun hideAll(ctx: UiMeasureContext) { - children.forEach { child -> - child.display = Display.None - child.render(ctx, 0, 0, 0, 0) - } - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayLayerDebugState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayLayerDebugState.kt deleted file mode 100644 index 57b1d15..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/OverlayLayerDebugState.kt +++ /dev/null @@ -1,147 +0,0 @@ -package org.dreamfinity.dsgl.core.debug - -import org.dreamfinity.dsgl.core.overlay.UiLayerId - -data class OverlayLayerDebugSnapshot( - val applicationOverlayRenderEnabled: Boolean, - val applicationOverlayTintEnabled: Boolean, - val applicationOverlayInputEnabled: Boolean, - val systemOverlayRenderEnabled: Boolean, - val systemOverlayTintEnabled: Boolean, - val systemOverlayInputEnabled: Boolean, - val frameFps: Int, - val frameTimeMs: Float, - val frameFpsWindow: Int, - val frameTimeWindowMs: Float -) - -object OverlayLayerDebugState { - private const val FRAME_TIMING_WINDOW_SIZE: Int = 60 - private val frameTimeWindowSeconds: DoubleArray = DoubleArray(FRAME_TIMING_WINDOW_SIZE) - private var frameTimeWindowWriteIndex: Int = 0 - private var frameTimeWindowCount: Int = 0 - private var frameTimeWindowSumSeconds: Double = 0.0 - - @Volatile - var applicationOverlayRenderEnabled: Boolean = true - @Volatile - var applicationOverlayTintEnabled: Boolean = false - - @Volatile - var applicationOverlayInputEnabled: Boolean = true - - @Volatile - var systemOverlayRenderEnabled: Boolean = true - - @Volatile - var systemOverlayTintEnabled: Boolean = false - - @Volatile - var systemOverlayInputEnabled: Boolean = true - - @Volatile - var frameFps: Int = 0 - - @Volatile - var frameTimeMs: Float = 0f - - @Volatile - var frameFpsWindow: Int = 0 - - @Volatile - var frameTimeWindowMs: Float = 0f - - val controlsEnabled: Boolean - get() { - return controlsEnabledOverride ?: java.lang.Boolean.getBoolean("dsgl.overlay.controls") - } - - fun isRenderEnabled(layer: UiLayerId): Boolean { - return when (layer) { - UiLayerId.ApplicationOverlay -> applicationOverlayRenderEnabled - UiLayerId.SystemOverlay -> systemOverlayRenderEnabled - UiLayerId.Debug -> true - UiLayerId.ApplicationRoot -> true - } - } - - fun isTintEnabled(layer: UiLayerId): Boolean { - return when (layer) { - UiLayerId.ApplicationOverlay -> applicationOverlayTintEnabled - UiLayerId.SystemOverlay -> systemOverlayTintEnabled - UiLayerId.Debug -> true - UiLayerId.ApplicationRoot -> true - } - } - - fun isInputEnabled(layer: UiLayerId): Boolean { - return when (layer) { - UiLayerId.ApplicationOverlay -> applicationOverlayInputEnabled - UiLayerId.SystemOverlay -> systemOverlayInputEnabled - UiLayerId.Debug -> true - UiLayerId.ApplicationRoot -> true - } - } - - fun resetAll() { - applicationOverlayRenderEnabled = true - applicationOverlayTintEnabled = false - applicationOverlayInputEnabled = true - systemOverlayRenderEnabled = true - systemOverlayTintEnabled = false - systemOverlayInputEnabled = true - frameFps = 0 - frameTimeMs = 0f - frameFpsWindow = 0 - frameTimeWindowMs = 0f - frameTimeWindowWriteIndex = 0 - frameTimeWindowCount = 0 - frameTimeWindowSumSeconds = 0.0 - java.util.Arrays.fill(frameTimeWindowSeconds, 0.0) - } - - @Synchronized - fun updateFrameTiming(dtSeconds: Double) { - val safeDt = dtSeconds.coerceAtLeast(1.0 / 1000.0) - frameTimeMs = (safeDt * 1000.0).toFloat() - frameFps = kotlin.math.round(1.0 / safeDt).toInt().coerceAtLeast(0) - - if (frameTimeWindowCount == FRAME_TIMING_WINDOW_SIZE) { - frameTimeWindowSumSeconds -= frameTimeWindowSeconds[frameTimeWindowWriteIndex] - } else { - frameTimeWindowCount += 1 - } - frameTimeWindowSeconds[frameTimeWindowWriteIndex] = safeDt - frameTimeWindowSumSeconds += safeDt - frameTimeWindowWriteIndex = (frameTimeWindowWriteIndex + 1) % FRAME_TIMING_WINDOW_SIZE - - val averageDt = if (frameTimeWindowCount > 0) { - frameTimeWindowSumSeconds / frameTimeWindowCount.toDouble() - } else { - safeDt - } - frameTimeWindowMs = (averageDt * 1000.0).toFloat() - frameFpsWindow = kotlin.math.round(1.0 / averageDt).toInt().coerceAtLeast(0) - } - - fun snapshot(): OverlayLayerDebugSnapshot { - return OverlayLayerDebugSnapshot( - applicationOverlayRenderEnabled = applicationOverlayRenderEnabled, - applicationOverlayTintEnabled = applicationOverlayTintEnabled, - applicationOverlayInputEnabled = applicationOverlayInputEnabled, - systemOverlayRenderEnabled = systemOverlayRenderEnabled, - systemOverlayTintEnabled = systemOverlayTintEnabled, - systemOverlayInputEnabled = systemOverlayInputEnabled, - frameFps = frameFps, - frameTimeMs = frameTimeMs, - frameFpsWindow = frameFpsWindow, - frameTimeWindowMs = frameTimeWindowMs - ) - } - - private var controlsEnabledOverride: Boolean? = null - - internal fun setControlsEnabledTestOverride(value: Boolean?) { - controlsEnabledOverride = value - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/ScrollPerformanceCounters.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/ScrollPerformanceCounters.kt index 10bbaa4..9a99a1e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/ScrollPerformanceCounters.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/debug/ScrollPerformanceCounters.kt @@ -22,7 +22,7 @@ data class ScrollPerformanceSnapshot( val paintTotalNanos: Long, val styleApplyNanos: Long, val fullRerenderLayoutNanos: Long, - val chunkRebuildNanos: Long + val chunkRebuildNanos: Long, ) object ScrollPerformanceCounters { @@ -115,8 +115,8 @@ object ScrollPerformanceCounters { chunkRebuildNanos.addAndGet(durationNanos.coerceAtLeast(0L)) } - fun snapshot(): ScrollPerformanceSnapshot { - return ScrollPerformanceSnapshot( + fun snapshot(): ScrollPerformanceSnapshot = + ScrollPerformanceSnapshot( paintCalls = paintCalls.get(), guardedScrollVisualFastPathRuns = guardedScrollVisualFastPathRuns.get(), scrollVisualGeometryRefreshRuns = scrollVisualGeometryRefreshRuns.get(), @@ -136,9 +136,8 @@ object ScrollPerformanceCounters { paintTotalNanos = paintTotalNanos.get(), styleApplyNanos = styleApplyNanos.get(), fullRerenderLayoutNanos = fullRerenderLayoutNanos.get(), - chunkRebuildNanos = chunkRebuildNanos.get() + chunkRebuildNanos = chunkRebuildNanos.get(), ) - } fun resetForTests() { paintCalls.set(0L) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndDescriptors.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndDescriptors.kt index b6e15d2..fc98438 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndDescriptors.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndDescriptors.kt @@ -15,7 +15,7 @@ data class Draggable( val previewMode: DragPreviewMode, val hideSourceWhileDragging: Boolean, val renderPreview: (DragPreviewScope.() -> Unit)?, - val renderPlaceholder: (PlaceholderScope.() -> Unit)? + val renderPlaceholder: (PlaceholderScope.() -> Unit)?, ) data class Droppable( @@ -24,7 +24,7 @@ data class Droppable( val isOver: Boolean, val active: ActiveDrag?, val listeners: DndListeners, - val setNodeRef: RefTarget + val setNodeRef: RefTarget, ) data class Sortable( @@ -36,5 +36,5 @@ data class Sortable( val isOver: Boolean, val overId: String?, val projection: SortableProjection, - val listeners: DndListeners -) \ No newline at end of file + val listeners: DndListeners, +) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooks.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooks.kt index e6e6bc2..6c1e86a 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooks.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooks.kt @@ -2,42 +2,44 @@ package org.dreamfinity.dsgl.core.dnd import org.dreamfinity.dsgl.core.DsglWindow import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.hooks.useEffect import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle import org.dreamfinity.dsgl.core.hooks.ref.useRef +import org.dreamfinity.dsgl.core.hooks.useEffect import java.util.WeakHashMap private data class SortableState( var activeId: String? = null, var overId: String? = null, - var insertPosition: InsertPosition = InsertPosition.APPEND + var insertPosition: InsertPosition = InsertPosition.APPEND, ) private data class SortableContainerRecord( val state: SortableState, - var activeHookCount: Int = 0 + var activeHookCount: Int = 0, ) private data class SortableWindowState( - val byContainerId: MutableMap = linkedMapOf() + val byContainerId: MutableMap = linkedMapOf(), ) private data class SortableBindingKey( val containerId: String, - val nodeKey: Any + val nodeKey: Any, ) private val sortableStateByWindow: WeakHashMap = WeakHashMap() -private fun sortableWindowState(window: DsglWindow): SortableWindowState { - return sortableStateByWindow.getOrPut(window) { SortableWindowState() } -} +private fun sortableWindowState(window: DsglWindow): SortableWindowState = + sortableStateByWindow.getOrPut(window) { + SortableWindowState() + } private fun sortableState(window: DsglWindow, containerId: String): SortableState { val state = sortableWindowState(window) - val record = state.byContainerId.getOrPut(containerId) { - SortableContainerRecord(state = SortableState()) - } + val record = + state.byContainerId.getOrPut(containerId) { + SortableContainerRecord(state = SortableState()) + } pruneUnboundSortableContainers(state = state, keepContainerId = containerId) return record.state } @@ -46,10 +48,11 @@ private fun retainSortableContainer(window: DsglWindow, containerId: String, ren val state = sortableWindowState(window) val existing = state.byContainerId[containerId] if (existing == null) { - state.byContainerId[containerId] = SortableContainerRecord( - state = renderState, - activeHookCount = 1 - ) + state.byContainerId[containerId] = + SortableContainerRecord( + state = renderState, + activeHookCount = 1, + ) } else { existing.activeHookCount += 1 } @@ -70,7 +73,9 @@ private fun releaseSortableContainer(window: DsglWindow, containerId: String) { } private fun pruneUnboundSortableContainers(state: SortableWindowState, keepContainerId: String?) { - val iterator = state.byContainerId.entries.iterator() + val iterator = + state.byContainerId.entries + .iterator() while (iterator.hasNext()) { val entry = iterator.next() if (entry.key == keepContainerId) { @@ -93,9 +98,9 @@ fun UiScope.useDraggable( renderPlaceholder: (PlaceholderScope.() -> Unit)? = null, onDragStart: ((DragStartEvent) -> Unit)? = null, onDrag: ((DragEvent) -> Unit)? = null, - onDragEnd: ((DragEndEvent) -> Unit)? = null -): Draggable { - return requireHookOwnerWindow().useDraggable( + onDragEnd: ((DragEndEvent) -> Unit)? = null, +): Draggable = + requireHookOwnerWindow().useDraggable( id = id, nodeKey = nodeKey, type = type, @@ -106,9 +111,8 @@ fun UiScope.useDraggable( renderPlaceholder = renderPlaceholder, onDragStart = onDragStart, onDrag = onDrag, - onDragEnd = onDragEnd + onDragEnd = onDragEnd, ) -} internal fun DsglWindow.useDraggable( id: String, @@ -121,39 +125,42 @@ internal fun DsglWindow.useDraggable( renderPlaceholder: (PlaceholderScope.() -> Unit)? = null, onDragStart: ((DragStartEvent) -> Unit)? = null, onDrag: ((DragEvent) -> Unit)? = null, - onDragEnd: ((DragEndEvent) -> Unit)? = null -): Draggable { - return hookRuntime().withComponentInstance(componentName = "useDraggable", key = nodeKey) { + onDragEnd: ((DragEndEvent) -> Unit)? = null, +): Draggable = + hookRuntime().withComponentInstance(componentName = "useDraggable", key = nodeKey) { DndSystem.registerPayload(id, data) val ref by useRef() val monitor = DndSystem.monitor(nodeKey) val isDragging = monitor.isDragging && monitor.sourceKey == nodeKey - val transform = if (isDragging) { - Transform( - x = monitor.previewX - monitor.cursorX.toDouble(), - y = monitor.previewY - monitor.cursorY.toDouble() - ) - } else { - null - } + val transform = + if (isDragging) { + Transform( + x = monitor.previewX - monitor.cursorX.toDouble(), + y = monitor.previewY - monitor.cursorY.toDouble(), + ) + } else { + null + } - val listeners = DndListeners( - onDragStart = { event -> - event.dataTransfer.setData(DND_DATA_ID_MIME, id) - event.dataTransfer.setData(DND_DATA_TYPE_MIME, type) - onDragStart?.invoke(event) - }, - onDrag = onDrag, - onDragEnd = onDragEnd - ) + val listeners = + DndListeners( + onDragStart = { event -> + event.dataTransfer.setData(DND_DATA_ID_MIME, id) + event.dataTransfer.setData(DND_DATA_TYPE_MIME, type) + onDragStart?.invoke(event) + }, + onDrag = onDrag, + onDragEnd = onDragEnd, + ) Draggable( id = id, nodeKey = nodeKey, - attributes = mapOf( - "data-dnd-id" to id, - "data-dnd-type" to type - ), + attributes = + mapOf( + "data-dnd-id" to id, + "data-dnd-type" to type, + ), listeners = listeners, isDragging = isDragging, activeTransform = transform, @@ -162,10 +169,9 @@ internal fun DsglWindow.useDraggable( previewMode = previewMode, hideSourceWhileDragging = hideSourceWhileDragging, renderPreview = renderPreview, - renderPlaceholder = renderPlaceholder + renderPlaceholder = renderPlaceholder, ) } -} fun UiScope.useDroppable( id: String, @@ -174,18 +180,17 @@ fun UiScope.useDroppable( onDragOver: ((DragOverEvent, ActiveDrag?) -> Unit)? = null, onDrop: ((DropEvent, ActiveDrag?) -> Unit)? = null, onDragEnter: ((DragEnterEvent, ActiveDrag?) -> Unit)? = null, - onDragLeave: ((DragLeaveEvent, ActiveDrag?) -> Unit)? = null -): Droppable { - return requireHookOwnerWindow().useDroppable( + onDragLeave: ((DragLeaveEvent, ActiveDrag?) -> Unit)? = null, +): Droppable = + requireHookOwnerWindow().useDroppable( id = id, nodeKey = nodeKey, accepts = accepts, onDragOver = onDragOver, onDrop = onDrop, onDragEnter = onDragEnter, - onDragLeave = onDragLeave + onDragLeave = onDragLeave, ) -} internal fun DsglWindow.useDroppable( id: String, @@ -194,43 +199,43 @@ internal fun DsglWindow.useDroppable( onDragOver: ((DragOverEvent, ActiveDrag?) -> Unit)? = null, onDrop: ((DropEvent, ActiveDrag?) -> Unit)? = null, onDragEnter: ((DragEnterEvent, ActiveDrag?) -> Unit)? = null, - onDragLeave: ((DragLeaveEvent, ActiveDrag?) -> Unit)? = null -): Droppable { - return hookRuntime().withComponentInstance(componentName = "useDroppable", key = nodeKey) { + onDragLeave: ((DragLeaveEvent, ActiveDrag?) -> Unit)? = null, +): Droppable = + hookRuntime().withComponentInstance(componentName = "useDroppable", key = nodeKey) { val ref by useRef() val active = DndSystem.activeDrag() val isOver = active?.overKey == nodeKey - val listeners = DndListeners( - onDragEnter = { event -> - val snapshot = activeFromEvent(event) - onDragEnter?.invoke(event, snapshot) - }, - onDragOver = { event -> - val snapshot = activeFromEvent(event) - if (accepts(snapshot)) { - event.acceptDrop(snapshot.dropEffect) - } - onDragOver?.invoke(event, snapshot) - }, - onDragLeave = { event -> - val snapshot = activeFromEvent(event) - onDragLeave?.invoke(event, snapshot) - }, - onDrop = { event -> - val snapshot = activeFromEvent(event) - onDrop?.invoke(event, snapshot) - } - ) + val listeners = + DndListeners( + onDragEnter = { event -> + val snapshot = activeFromEvent(event) + onDragEnter?.invoke(event, snapshot) + }, + onDragOver = { event -> + val snapshot = activeFromEvent(event) + if (accepts(snapshot)) { + event.acceptDrop(snapshot.dropEffect) + } + onDragOver?.invoke(event, snapshot) + }, + onDragLeave = { event -> + val snapshot = activeFromEvent(event) + onDragLeave?.invoke(event, snapshot) + }, + onDrop = { event -> + val snapshot = activeFromEvent(event) + onDrop?.invoke(event, snapshot) + }, + ) Droppable( id = id, nodeKey = nodeKey, isOver = isOver, active = active, listeners = listeners, - setNodeRef = { value -> ref.current = value } + setNodeRef = { value -> ref.current = value }, ) } -} fun UiScope.useSortable( id: String, @@ -239,18 +244,17 @@ fun UiScope.useSortable( items: List, data: Any? = null, previewMode: DragPreviewMode = DragPreviewMode.ORIGINAL, - hideSourceWhileDragging: Boolean = true -): Sortable { - return requireHookOwnerWindow().useSortable( + hideSourceWhileDragging: Boolean = true, +): Sortable = + requireHookOwnerWindow().useSortable( id = id, nodeKey = nodeKey, containerId = containerId, items = items, data = data, previewMode = previewMode, - hideSourceWhileDragging = hideSourceWhileDragging + hideSourceWhileDragging = hideSourceWhileDragging, ) -} internal fun DsglWindow.useSortable( id: String, @@ -259,18 +263,18 @@ internal fun DsglWindow.useSortable( items: List, data: Any? = null, previewMode: DragPreviewMode = DragPreviewMode.ORIGINAL, - hideSourceWhileDragging: Boolean = true + hideSourceWhileDragging: Boolean = true, ): Sortable { val state = sortableState(this, containerId) hookRuntime().withComponentInstance( componentName = "useSortableBinding", - key = SortableBindingKey(containerId = containerId, nodeKey = nodeKey) + key = SortableBindingKey(containerId = containerId, nodeKey = nodeKey), ) { useEffect { retainSortableContainer( window = this@useSortable, containerId = containerId, - renderState = state + renderState = state, ) onDispose { releaseSortableContainer(window = this@useSortable, containerId = containerId) @@ -278,54 +282,65 @@ internal fun DsglWindow.useSortable( } } val sortableType = "sortable:$containerId" - val draggable = useDraggable( - id = id, - nodeKey = nodeKey, - type = sortableType, - data = data, - previewMode = previewMode, - hideSourceWhileDragging = hideSourceWhileDragging, - onDragStart = { - state.activeId = id - state.overId = null - state.insertPosition = InsertPosition.APPEND - }, - onDragEnd = { - state.activeId = null - state.overId = null - state.insertPosition = InsertPosition.APPEND - } - ) - val droppable = useDroppable( - id = id, - nodeKey = nodeKey, - accepts = { active -> active.type == sortableType && active.id != id }, - onDragOver = { event, active -> - if (active == null || active.id == id) return@useDroppable - state.overId = id - state.insertPosition = if (event.mouseY >= event.target!!.bounds.y + event.target!!.bounds.height / 2) { - InsertPosition.AFTER - } else { - InsertPosition.BEFORE - } - }, - onDrop = { event, active -> - if (active == null || active.id == id) return@useDroppable - state.overId = id - state.insertPosition = if (event.mouseY >= event.target!!.bounds.y + event.target!!.bounds.height / 2) { - InsertPosition.AFTER - } else { - InsertPosition.BEFORE - } - } - ) + val draggable = + useDraggable( + id = id, + nodeKey = nodeKey, + type = sortableType, + data = data, + previewMode = previewMode, + hideSourceWhileDragging = hideSourceWhileDragging, + onDragStart = { + state.activeId = id + state.overId = null + state.insertPosition = InsertPosition.APPEND + }, + onDragEnd = { + state.activeId = null + state.overId = null + state.insertPosition = InsertPosition.APPEND + }, + ) + val droppable = + useDroppable( + id = id, + nodeKey = nodeKey, + accepts = { active -> active.type == sortableType && active.id != id }, + onDragOver = { event, active -> + if (active == null || active.id == id) return@useDroppable + state.overId = id + state.insertPosition = + if (event.mouseY >= event.target!! + .bounds.y + event.target!! + .bounds.height / 2 + ) { + InsertPosition.AFTER + } else { + InsertPosition.BEFORE + } + }, + onDrop = { event, active -> + if (active == null || active.id == id) return@useDroppable + state.overId = id + state.insertPosition = + if (event.mouseY >= event.target!! + .bounds.y + event.target!! + .bounds.height / 2 + ) { + InsertPosition.AFTER + } else { + InsertPosition.BEFORE + } + }, + ) - val projected = SortableProjection( - activeId = state.activeId, - overId = state.overId, - insertPosition = state.insertPosition, - newIndex = projectedIndex(items, state.activeId, state.overId, state.insertPosition) - ) + val projected = + SortableProjection( + activeId = state.activeId, + overId = state.overId, + insertPosition = state.insertPosition, + newIndex = projectedIndex(items, state.activeId, state.overId, state.insertPosition), + ) val mergedListeners = mergeDndListeners(draggable.listeners, droppable.listeners) return Sortable( id = id, @@ -336,7 +351,7 @@ internal fun DsglWindow.useSortable( isOver = droppable.isOver, overId = state.overId, projection = projected, - listeners = mergedListeners + listeners = mergedListeners, ) } @@ -345,27 +360,40 @@ fun UiScope.useDragDropMonitor(callbacks: DragDropMonitorCallbacks) { val callbackRef by useRef(callbacks) callbackRef.current = callbacks useEffect { - val subscription = DndRuntime.engine.subscribe(object : DndMonitorListener { - override fun onDragStart(active: ActiveDrag) { - callbackRef.current?.onDragStart?.invoke(active) - } + val subscription = + DndRuntime.engine.subscribe( + object : DndMonitorListener { + override fun onDragStart(active: ActiveDrag) { + callbackRef.current + ?.onDragStart + ?.invoke(active) + } - override fun onDragMove(active: ActiveDrag, over: Any?) { - callbackRef.current?.onDragMove?.invoke(active, over) - } + override fun onDragMove(active: ActiveDrag, over: Any?) { + callbackRef.current + ?.onDragMove + ?.invoke(active, over) + } - override fun onDragOver(active: ActiveDrag, over: Any?) { - callbackRef.current?.onDragOver?.invoke(active, over) - } + override fun onDragOver(active: ActiveDrag, over: Any?) { + callbackRef.current + ?.onDragOver + ?.invoke(active, over) + } - override fun onDragEnd(active: ActiveDrag, over: Any?, dropEffect: DropEffect) { - callbackRef.current?.onDragEnd?.invoke(active, over, dropEffect) - } + override fun onDragEnd(active: ActiveDrag, over: Any?, dropEffect: DropEffect) { + callbackRef.current + ?.onDragEnd + ?.invoke(active, over, dropEffect) + } - override fun onDragCancel(active: ActiveDrag) { - callbackRef.current?.onDragCancel?.invoke(active) - } - }) + override fun onDragCancel(active: ActiveDrag) { + callbackRef.current + ?.onDragCancel + ?.invoke(active) + } + }, + ) onDispose { subscription.close() } @@ -387,7 +415,7 @@ private fun activeFromEvent(event: DragDropEvent): ActiveDrag { cursorY = event.mouseY, transform = Transform(0.0, 0.0), dropEffect = event.dataTransfer.dropEffect, - dataTransfer = event.dataTransfer + dataTransfer = event.dataTransfer, ) } @@ -395,14 +423,15 @@ private fun projectedIndex( items: List, activeId: String?, overId: String?, - insertPosition: InsertPosition + insertPosition: InsertPosition, ): Int? { if (activeId == null) return null - val moved = reorderByDnD( - items = items, - activeId = activeId, - overId = overId, - insertPosition = insertPosition - ) { value -> value } + val moved = + reorderByDnD( + items = items, + activeId = activeId, + overId = overId, + insertPosition = insertPosition, + ) { value -> value } return moved.indexOf(activeId).takeIf { it >= 0 } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndInterfaces.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndInterfaces.kt index d70ef6e..072f026 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndInterfaces.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndInterfaces.kt @@ -16,7 +16,7 @@ data class DndMonitorState( val previewY: Double, val mode: DragPreviewMode?, val collisionCandidates: Int, - val sourceExcludedFromHitTest: Boolean + val sourceExcludedFromHitTest: Boolean, ) interface DndMonitorRegistry { @@ -25,14 +25,15 @@ interface DndMonitorRegistry { interface DndHitTester -interface DndOverlayRenderer { +interface DndPortalRenderer { fun appendPlaceholderCommands(out: MutableList) - fun appendOverlayCommands( + + fun appendPortalCommands( root: DOMNode, ctx: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - out: MutableList + out: MutableList, ) } @@ -40,20 +41,30 @@ interface DndClock { fun onFrame(root: DOMNode, dtSeconds: Double) } -interface DndEngine : DndMonitorRegistry, DndOverlayRenderer, DndClock { +interface DndEngine : + DndMonitorRegistry, + DndPortalRenderer, + DndClock { val isDragging: Boolean val isPointerCaptured: Boolean fun monitor(nodeKey: Any? = null): DndMonitorState + fun activeDrag(): ActiveDrag? fun setSmoothingFactor(value: Double) + fun getSmoothingFactor(): Double + fun isDraggingNode(nodeKey: Any?): Boolean fun onMouseDown(root: DOMNode, target: DOMNode?, event: MouseDownEvent) + fun onMouseMove(root: DOMNode, mouseX: Int, mouseY: Int) + fun onMouseUp(root: DOMNode, event: MouseUpEvent): Boolean + fun rebindAfterReconcile(root: DOMNode) + fun cancelActiveDrag() } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndBindings.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndListeners.kt similarity index 86% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndBindings.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndListeners.kt index e90a80d..9975c53 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndBindings.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndListeners.kt @@ -9,7 +9,7 @@ data class DndListeners( val onDragEnter: ((DragEnterEvent) -> Unit)? = null, val onDragOver: ((DragOverEvent) -> Unit)? = null, val onDragLeave: ((DragLeaveEvent) -> Unit)? = null, - val onDrop: ((DropEvent) -> Unit)? = null + val onDrop: ((DropEvent) -> Unit)? = null, ) fun ComponentProps.applyDndListeners(listeners: DndListeners) { @@ -22,26 +22,22 @@ fun ComponentProps.applyDndListeners(listeners: DndListeners) { onDrop = chain(onDrop, listeners.onDrop) } -fun mergeDndListeners(first: DndListeners, second: DndListeners): DndListeners { - return DndListeners( +fun mergeDndListeners(first: DndListeners, second: DndListeners): DndListeners = + DndListeners( onDragStart = chain(first.onDragStart, second.onDragStart), onDrag = chain(first.onDrag, second.onDrag), onDragEnd = chain(first.onDragEnd, second.onDragEnd), onDragEnter = chain(first.onDragEnter, second.onDragEnter), onDragOver = chain(first.onDragOver, second.onDragOver), onDragLeave = chain(first.onDragLeave, second.onDragLeave), - onDrop = chain(first.onDrop, second.onDrop) + onDrop = chain(first.onDrop, second.onDrop), ) -} -private fun chain( - first: ((T) -> Unit)?, - second: ((T) -> Unit)? -): ((T) -> Unit)? { +private fun chain(first: ((T) -> Unit)?, second: ((T) -> Unit)?): ((T) -> Unit)? { if (first == null) return second if (second == null) return first return { event -> first(event) second(event) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndModels.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndModels.kt index 5c946c2..c798f43 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndModels.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndModels.kt @@ -5,7 +5,7 @@ const val DND_DATA_TYPE_MIME: String = "application/x-dsgl-dnd-type" data class Transform( val x: Double, - val y: Double + val y: Double, ) data class ActiveDrag( @@ -18,18 +18,18 @@ data class ActiveDrag( val cursorY: Int, val transform: Transform, val dropEffect: DropEffect, - val dataTransfer: DataTransfer + val dataTransfer: DataTransfer, ) enum class InsertPosition { BEFORE, AFTER, - APPEND + APPEND, } data class SortableProjection( val activeId: String?, val overId: String?, val insertPosition: InsertPosition, - val newIndex: Int? -) \ No newline at end of file + val newIndex: Int?, +) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndMonitor.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndMonitor.kt index 7df91c9..b3801ce 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndMonitor.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndMonitor.kt @@ -2,9 +2,13 @@ package org.dreamfinity.dsgl.core.dnd interface DndMonitorListener { fun onDragStart(active: ActiveDrag) {} + fun onDragMove(active: ActiveDrag, over: Any?) {} + fun onDragOver(active: ActiveDrag, over: Any?) {} + fun onDragEnd(active: ActiveDrag, over: Any?, dropEffect: DropEffect) {} + fun onDragCancel(active: ActiveDrag) {} } @@ -13,5 +17,5 @@ data class DragDropMonitorCallbacks( val onDragMove: ((ActiveDrag, Any?) -> Unit)? = null, val onDragOver: ((ActiveDrag, Any?) -> Unit)? = null, val onDragEnd: ((ActiveDrag, Any?, DropEffect) -> Unit)? = null, - val onDragCancel: ((ActiveDrag) -> Unit)? = null -) \ No newline at end of file + val onDragCancel: ((ActiveDrag) -> Unit)? = null, +) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndProps.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndProps.kt index 0c4d61c..73d74f9 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndProps.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndProps.kt @@ -22,4 +22,4 @@ fun ComponentProps.applySortable(descriptor: Sortable) { applyDraggable(descriptor.draggable) applyDroppable(descriptor.droppable) applyDndListeners(descriptor.listeners) -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndReorder.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndReorder.kt index a7997f9..119a990 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndReorder.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndReorder.kt @@ -5,7 +5,7 @@ fun reorderByDnD( activeId: String?, overId: String?, insertPosition: InsertPosition, - idOf: (T) -> String + idOf: (T) -> String, ): List { if (activeId.isNullOrBlank()) return items val fromIndex = items.indexOfFirst { idOf(it) == activeId } @@ -13,19 +13,20 @@ fun reorderByDnD( val mutable = items.toMutableList() val moved = mutable.removeAt(fromIndex) - val targetIndex = when { - overId.isNullOrBlank() || insertPosition == InsertPosition.APPEND -> mutable.size - else -> { - val overIndex = mutable.indexOfFirst { idOf(it) == overId } - if (overIndex < 0) { - mutable.size - } else if (insertPosition == InsertPosition.AFTER) { - overIndex + 1 - } else { - overIndex + val targetIndex = + when { + overId.isNullOrBlank() || insertPosition == InsertPosition.APPEND -> mutable.size + else -> { + val overIndex = mutable.indexOfFirst { idOf(it) == overId } + if (overIndex < 0) { + mutable.size + } else if (insertPosition == InsertPosition.AFTER) { + overIndex + 1 + } else { + overIndex + } } - } - }.coerceIn(0, mutable.size) + }.coerceIn(0, mutable.size) mutable.add(targetIndex, moved) return if (items.sameOrderAs(mutable, idOf)) items else mutable diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndRuntime.kt index 2ef4d80..2735ab3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndRuntime.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DndRuntime.kt @@ -32,4 +32,4 @@ object DndSystem { } fun getSmoothingFactor(): Double = DndRuntime.engine.getSmoothingFactor() -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDataTransfer.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDataTransfer.kt index 27394ba..d11bf98 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDataTransfer.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDataTransfer.kt @@ -8,20 +8,20 @@ enum class EffectAllowed { COPY_LINK, COPY_MOVE, LINK_MOVE, - ALL + ALL, } enum class DropEffect { NONE, COPY, MOVE, - LINK + LINK, } data class DragImageSpec( val nodeKey: Any, val offsetX: Int = 0, - val offsetY: Int = 0 + val offsetY: Int = 0, ) /** @@ -43,9 +43,7 @@ class DataTransfer { dataByType[normalized] = value } - fun getData(type: String): String? { - return dataByType[type.trim()] - } + fun getData(type: String): String? = dataByType[type.trim()] fun clearData(type: String? = null) { if (type == null) { @@ -72,4 +70,4 @@ class DataTransfer { } internal fun currentDragImageSpec(): DragImageSpec? = dragImageSpec -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDropEvents.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDropEvents.kt index cdf67e8..3eba835 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDropEvents.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragDropEvents.kt @@ -7,14 +7,14 @@ abstract class DragDropEvent( override var mouseX: Int, override var mouseY: Int, val sourceKey: Any?, - val dataTransfer: DataTransfer + val dataTransfer: DataTransfer, ) : MouseEvent(mouseX, mouseY) data class DragStartEvent( private val x: Int, private val y: Int, private val dragSourceKey: Any?, - private val transfer: DataTransfer + private val transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGSTART @@ -24,7 +24,7 @@ data class DragEvent( private val x: Int, private val y: Int, private val dragSourceKey: Any?, - private val transfer: DataTransfer + private val transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGGING @@ -37,7 +37,7 @@ data class DragEndEvent( private val transfer: DataTransfer, val didDrop: Boolean, val finalDropEffect: DropEffect, - val dropTargetKey: Any? + val dropTargetKey: Any?, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGEND @@ -47,7 +47,7 @@ data class DragEnterEvent( private val x: Int, private val y: Int, private val dragSourceKey: Any?, - private val transfer: DataTransfer + private val transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGENTER @@ -57,7 +57,7 @@ class DragOverEvent( x: Int, y: Int, dragSourceKey: Any?, - transfer: DataTransfer + transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGOVER @@ -81,7 +81,7 @@ data class DragLeaveEvent( private val x: Int, private val y: Int, private val dragSourceKey: Any?, - private val transfer: DataTransfer + private val transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DRAGLEAVE @@ -91,7 +91,7 @@ class DropEvent( x: Int, y: Int, dragSourceKey: Any?, - transfer: DataTransfer + transfer: DataTransfer, ) : DragDropEvent(x, y, dragSourceKey, transfer) { override val type: Events get() = Events.DROP @@ -109,4 +109,4 @@ class DropEvent( dataTransfer.dropEffect = effect } } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragPresentation.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragPresentation.kt index 687cfcc..8733fa1 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragPresentation.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/DragPresentation.kt @@ -6,7 +6,7 @@ import org.dreamfinity.dsgl.core.render.RenderCommand enum class DragPreviewMode { ORIGINAL, - GHOST + GHOST, } class PlaceholderScope internal constructor() { @@ -30,8 +30,8 @@ class PlaceholderScope internal constructor() { bounds.y, bounds.width, bounds.height, - color - ) + color, + ), ) } val border = borderColor @@ -44,8 +44,8 @@ class PlaceholderScope internal constructor() { bounds.y + bounds.height - width, bounds.width, width, - border - ) + border, + ), ) out.add(RenderCommand.DrawRect(bounds.x, bounds.y, width, bounds.height, border)) out.add( @@ -54,8 +54,8 @@ class PlaceholderScope internal constructor() { bounds.y, width, bounds.height, - border - ) + border, + ), ) } return out @@ -66,35 +66,52 @@ class DragPreviewScope internal constructor( val dataTransfer: DataTransfer, val sourceBounds: Rect, private val anchorX: Int, - private val anchorY: Int + private val anchorY: Int, ) { private val commands: MutableList = ArrayList(8) - fun rect(x: Int, y: Int, width: Int, height: Int, color: Int) { + fun rect( + x: Int, + y: Int, + width: Int, + height: Int, + color: Int, + ) { commands.add( RenderCommand.DrawRect( anchorX + x, anchorY + y, width, height, - color - ) + color, + ), ) } - fun text(value: String, x: Int, y: Int, color: Int = DsglColors.WHITE) { + fun text( + value: String, + x: Int, + y: Int, + color: Int = DsglColors.WHITE, + ) { commands.add(RenderCommand.DrawText(value, anchorX + x, anchorY + y, color)) } - fun image(resource: String, x: Int, y: Int, width: Int, height: Int) { + fun image( + resource: String, + x: Int, + y: Int, + width: Int, + height: Int, + ) { commands.add( RenderCommand.DrawImage( resource, anchorX + x, anchorY + y, width, - height - ) + height, + ), ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngine.kt index 1e30088..9352b11 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngine.kt @@ -25,7 +25,7 @@ object DefaultDndEngine : DndEngine { val sourceKey: Any?, val sourceClass: Class, val startX: Int, - val startY: Int + val startY: Int, ) private data class ActiveSession( @@ -52,7 +52,7 @@ object DefaultDndEngine : DndEngine { var collisionCandidateCount: Int = 0, var sourceExcludedFromHitTest: Boolean = true, var dragTickAccum: Double = 0.0, - var overTickAccum: Double = 0.0 + var overTickAccum: Double = 0.0, ) private var pendingDrag: PendingDrag? = null @@ -74,18 +74,19 @@ object DefaultDndEngine : DndEngine { override fun getSmoothingFactor(): Double = smoothingFactor override fun monitor(nodeKey: Any?): DndMonitorState { - val active = activeDrag ?: return DndMonitorState( - isDragging = false, - sourceKey = null, - cursorX = 0, - cursorY = 0, - previewX = 0.0, - previewY = 0.0, - mode = null, - overKey = null, - collisionCandidates = 0, - sourceExcludedFromHitTest = true - ) + val active = + activeDrag ?: return DndMonitorState( + isDragging = false, + sourceKey = null, + cursorX = 0, + cursorY = 0, + previewX = 0.0, + previewY = 0.0, + mode = null, + overKey = null, + collisionCandidates = 0, + sourceExcludedFromHitTest = true, + ) val draggingThisSource = nodeKey != null && nodeKey == active.sourceKey return DndMonitorState( isDragging = nodeKey == null || draggingThisSource, @@ -97,7 +98,7 @@ object DefaultDndEngine : DndEngine { mode = active.previewMode, overKey = active.dropTargetKey, collisionCandidates = active.collisionCandidateCount, - sourceExcludedFromHitTest = active.sourceExcludedFromHitTest + sourceExcludedFromHitTest = active.sourceExcludedFromHitTest, ) } @@ -121,20 +122,25 @@ object DefaultDndEngine : DndEngine { } override fun onMouseDown(_root: DOMNode, target: DOMNode?, event: MouseDownEvent) { + if (event.cancelled) { + pendingDrag = null + return + } if (event.mouseButton != MouseButton.LEFT) { pendingDrag = null return } if (activeDrag != null) return - pendingDrag = resolveDraggableSource(target)?.let { source -> - PendingDrag( - sourceNode = source, - sourceKey = source.key, - sourceClass = source.javaClass, - startX = event.mouseX, - startY = event.mouseY - ) - } + pendingDrag = + resolveDraggableSource(target)?.let { source -> + PendingDrag( + sourceNode = source, + sourceKey = source.key, + sourceClass = source.javaClass, + startX = event.mouseX, + startY = event.mouseY, + ) + } } override fun onMouseMove(root: DOMNode, mouseX: Int, mouseY: Int) { @@ -175,12 +181,13 @@ object DefaultDndEngine : DndEngine { var didDrop = false var dropTargetKey: Any? = null if (acceptedTarget != null) { - val dropEvent = DropEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer - ) + val dropEvent = + DropEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + ) dropEvent.target = acceptedTarget EventBus.post(dropEvent) dropTargetKey = acceptedTarget.key @@ -199,11 +206,12 @@ object DefaultDndEngine : DndEngine { rebindAfterReconcile(root) val active = activeDrag ?: return - val alpha = if (smoothingFactor <= 0.0) { - 1.0 - } else { - 1.0 - exp(-smoothingFactor * safeDt) - } + val alpha = + if (smoothingFactor <= 0.0) { + 1.0 + } else { + 1.0 - exp(-smoothingFactor * safeDt) + } active.previewX += (active.cursorX.toDouble() - active.previewX) * alpha active.previewY += (active.cursorY.toDouble() - active.previewY) * alpha @@ -234,12 +242,12 @@ object DefaultDndEngine : DndEngine { out.addAll(scope.buildCommands(bounds)) } - override fun appendOverlayCommands( + override fun appendPortalCommands( root: DOMNode, ctx: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - out: MutableList + out: MutableList, ) { val active = activeDrag ?: return if (viewportWidth <= 0 || viewportHeight <= 0) return @@ -271,11 +279,12 @@ object DefaultDndEngine : DndEngine { } val dropClass = active.dropTargetClass - val reboundTarget = if (active.dropTargetKey != null && dropClass != null) { - findByKeyAndClass(root, active.dropTargetKey, dropClass) - } else { - null - } + val reboundTarget = + if (active.dropTargetKey != null && dropClass != null) { + findByKeyAndClass(root, active.dropTargetKey, dropClass) + } else { + null + } active.dropTargetNode = reboundTarget if (reboundTarget == null) { active.dropTargetKey = null @@ -286,23 +295,30 @@ object DefaultDndEngine : DndEngine { } override fun cancelActiveDrag() { - val active = activeDrag ?: run { - pendingDrag = null - return - } + val active = + activeDrag ?: run { + pendingDrag = null + return + } notifyDragCancel(active) finishDrag(active, didDrop = false, dropTargetKey = null) } - private fun tryStartDrag(root: DOMNode, pending: PendingDrag, mouseX: Int, mouseY: Int) { + private fun tryStartDrag( + root: DOMNode, + pending: PendingDrag, + mouseX: Int, + mouseY: Int, + ) { val source = findByKeyAndClass(root, pending.sourceKey, pending.sourceClass) ?: pending.sourceNode val transfer = DataTransfer() - val startEvent = DragStartEvent( - x = mouseX, - y = mouseY, - dragSourceKey = source.key, - transfer = transfer - ) + val startEvent = + DragStartEvent( + x = mouseX, + y = mouseY, + dragSourceKey = source.key, + transfer = transfer, + ) startEvent.target = source EventBus.post(startEvent) if (startEvent.cancelled) { @@ -318,24 +334,25 @@ object DefaultDndEngine : DndEngine { val previewOffsetY = previewSpec?.offsetY ?: defaultOffsetY val hideSource = source.dragPreviewMode == DragPreviewMode.ORIGINAL || source.hideSourceWhileDragging - val startedDrag = ActiveSession( - sourceNode = source, - sourceKey = source.key, - sourceClass = source.javaClass, - dataTransfer = transfer, - previewMode = source.dragPreviewMode, - sourceHiddenDuringDrag = hideSource, - placeholderWidth = sourceBounds.width, - placeholderHeight = sourceBounds.height, - placeholderBuilder = source.dragPlaceholderBuilder, - previewBuilder = source.dragPreviewBuilder, - cursorX = mouseX, - cursorY = mouseY, - previewX = mouseX.toDouble(), - previewY = mouseY.toDouble(), - previewOffsetX = previewOffsetX, - previewOffsetY = previewOffsetY - ) + val startedDrag = + ActiveSession( + sourceNode = source, + sourceKey = source.key, + sourceClass = source.javaClass, + dataTransfer = transfer, + previewMode = source.dragPreviewMode, + sourceHiddenDuringDrag = hideSource, + placeholderWidth = sourceBounds.width, + placeholderHeight = sourceBounds.height, + placeholderBuilder = source.dragPlaceholderBuilder, + previewBuilder = source.dragPreviewBuilder, + cursorX = mouseX, + cursorY = mouseY, + previewX = mouseX.toDouble(), + previewY = mouseY.toDouble(), + previewOffsetX = previewOffsetX, + previewOffsetY = previewOffsetY, + ) activeDrag = startedDrag pendingDrag = null applySourceHiddenFlags(startedDrag) @@ -357,12 +374,13 @@ object DefaultDndEngine : DndEngine { val resolvedTarget = resolveDropTarget(root, active, active.cursorX, active.cursorY) if (!isSameNode(active.dropTargetNode, resolvedTarget)) { active.dropTargetNode?.let { prev -> - val leaveEvent = DragLeaveEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer - ) + val leaveEvent = + DragLeaveEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + ) leaveEvent.target = prev EventBus.post(leaveEvent) } @@ -372,47 +390,60 @@ object DefaultDndEngine : DndEngine { active.dropAccepted = false active.dataTransfer.dropEffect = DropEffect.NONE resolvedTarget?.let { next -> - val enterEvent = DragEnterEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer - ) + val enterEvent = + DragEnterEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + ) enterEvent.target = next EventBus.post(enterEvent) } } if (!dispatchOver) return - val currentTarget = active.dropTargetNode ?: run { - active.dropAccepted = false - active.dataTransfer.dropEffect = DropEffect.NONE - return - } + val currentTarget = + active.dropTargetNode ?: run { + active.dropAccepted = false + active.dataTransfer.dropEffect = DropEffect.NONE + return + } - val overEvent = DragOverEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer - ) + val overEvent = + DragOverEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + ) overEvent.target = currentTarget EventBus.post(overEvent) val accepted = overEvent.dropAccepted || overEvent.cancelled active.dropAccepted = accepted if (accepted) { - active.dataTransfer.dropEffect = normalizeDropEffect( - requested = active.dataTransfer.dropEffect, - allowed = active.dataTransfer.effectAllowed - ) + active.dataTransfer.dropEffect = + normalizeDropEffect( + requested = active.dataTransfer.dropEffect, + allowed = active.dataTransfer.effectAllowed, + ) } else { active.dataTransfer.dropEffect = DropEffect.NONE } notifyDragOver(active) } - private fun resolveDropTarget(root: DOMNode, active: ActiveSession, mouseX: Int, mouseY: Int): DOMNode? { + // Single pass that both collects drop candidates and records whether the drag source + // appeared in the hover chain; folding this into a filter+any pair would walk the chain + // twice and split closely coupled state across two expressions. + @Suppress("LoopWithTooManyJumpStatements") + private fun resolveDropTarget( + root: DOMNode, + active: ActiveSession, + mouseX: Int, + mouseY: Int, + ): DOMNode? { val chain = collectHoverChain(root, mouseX, mouseY) val candidates = ArrayList(chain.size) var excludedSource = false @@ -447,27 +478,29 @@ object DefaultDndEngine : DndEngine { private fun dispatchDragEvent(active: ActiveSession) { val source = active.sourceNode - val dragEvent = DragEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer - ) + val dragEvent = + DragEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + ) dragEvent.target = source EventBus.post(dragEvent) } private fun dispatchDragEnd(active: ActiveSession, didDrop: Boolean, dropTargetKey: Any?) { val source = active.sourceNode - val event = DragEndEvent( - x = active.cursorX, - y = active.cursorY, - dragSourceKey = active.sourceKey, - transfer = active.dataTransfer, - didDrop = didDrop, - finalDropEffect = if (didDrop) active.dataTransfer.dropEffect else DropEffect.NONE, - dropTargetKey = dropTargetKey - ) + val event = + DragEndEvent( + x = active.cursorX, + y = active.cursorY, + dragSourceKey = active.sourceKey, + transfer = active.dataTransfer, + didDrop = didDrop, + finalDropEffect = if (didDrop) active.dataTransfer.dropEffect else DropEffect.NONE, + dropTargetKey = dropTargetKey, + ) event.target = source EventBus.post(event) } @@ -495,7 +528,7 @@ object DefaultDndEngine : DndEngine { private fun appendOriginalPreviewCommands( active: ActiveSession, ctx: UiMeasureContext, - out: MutableList + out: MutableList, ) { val source = active.sourceNode val dx = (active.previewX - active.previewOffsetX - source.bounds.x).toInt() @@ -511,7 +544,7 @@ object DefaultDndEngine : DndEngine { root: DOMNode, active: ActiveSession, ctx: UiMeasureContext, - out: MutableList + out: MutableList, ) { if (!active.dataTransfer.ghostVisible) return val anchorX = (active.previewX - active.previewOffsetX).toInt() @@ -520,21 +553,23 @@ object DefaultDndEngine : DndEngine { val customBuilder = active.previewBuilder if (customBuilder != null) { - val scope = DragPreviewScope( - dataTransfer = active.dataTransfer, - sourceBounds = sourceBounds, - anchorX = anchorX, - anchorY = anchorY - ) + val scope = + DragPreviewScope( + dataTransfer = active.dataTransfer, + sourceBounds = sourceBounds, + anchorX = anchorX, + anchorY = anchorY, + ) customBuilder.invoke(scope) out.addAll(scope.build()) return } val previewSpec = active.dataTransfer.currentDragImageSpec() - val previewNode = previewSpec?.let { spec -> - findByKey(root, spec.nodeKey) - } + val previewNode = + previewSpec?.let { spec -> + findByKey(root, spec.nodeKey) + } if (previewNode != null && previewSpec != null) { val dx = (active.previewX - previewSpec.offsetX - previewNode.bounds.x).toInt() val dy = (active.previewY - previewSpec.offsetY - previewNode.bounds.y).toInt() @@ -565,8 +600,8 @@ object DefaultDndEngine : DndEngine { } } - private fun isDropEffectAllowed(effect: DropEffect, allowed: EffectAllowed): Boolean { - return when (allowed) { + private fun isDropEffectAllowed(effect: DropEffect, allowed: EffectAllowed): Boolean = + when (allowed) { EffectAllowed.NONE -> false EffectAllowed.COPY -> effect == DropEffect.COPY EffectAllowed.MOVE -> effect == DropEffect.MOVE @@ -576,13 +611,8 @@ object DefaultDndEngine : DndEngine { EffectAllowed.LINK_MOVE -> effect == DropEffect.LINK || effect == DropEffect.MOVE EffectAllowed.ALL -> effect != DropEffect.NONE } - } - private fun drawDefaultGhost( - active: ActiveSession, - ctx: UiMeasureContext, - out: MutableList - ) { + private fun drawDefaultGhost(active: ActiveSession, ctx: UiMeasureContext, out: MutableList) { val label = active.dataTransfer.getData("text/plain") ?: "drag" val x = (active.previewX - active.previewOffsetX).toInt() val y = (active.previewY - active.previewOffsetY).toInt() @@ -596,73 +626,84 @@ object DefaultDndEngine : DndEngine { out.add(RenderCommand.DrawText(label, x + 6, y + 4, DsglColors.WHITE)) } - private fun shiftCommand(command: RenderCommand, dx: Int, dy: Int): RenderCommand { - return when (command) { - is RenderCommand.DrawRect -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + private fun shiftCommand(command: RenderCommand, dx: Int, dy: Int): RenderCommand = + when (command) { + is RenderCommand.DrawRect -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawColorField -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawColorField -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawHueBar -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawHueBar -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawAlphaBar -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawAlphaBar -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawCheckerboard -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawCheckerboard -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawText -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawText -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawImage -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawImage -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.CaptureScreenRegion -> command.copy( - sourceX = command.sourceX + dx, - sourceY = command.sourceY + dy - ) + is RenderCommand.CaptureScreenRegion -> + command.copy( + sourceX = command.sourceX + dx, + sourceY = command.sourceY + dy, + ) - is RenderCommand.DrawCapturedScreenRegion -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawCapturedScreenRegion -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.DrawItemStack -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.DrawItemStack -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) - is RenderCommand.PushClip -> command.copy( - x = command.x + dx, - y = command.y + dy - ) + is RenderCommand.PushClip -> + command.copy( + x = command.x + dx, + y = command.y + dy, + ) RenderCommand.PopClip -> RenderCommand.PopClip - is RenderCommand.PushTransform -> command.copy( - originX = command.originX + dx, - originY = command.originY + dy - ) + is RenderCommand.PushTransform -> + command.copy( + originX = command.originX + dx, + originY = command.originY + dy, + ) RenderCommand.PopTransform -> RenderCommand.PopTransform is RenderCommand.PushOpacity -> command RenderCommand.PopOpacity -> RenderCommand.PopOpacity } - } private fun isSameNode(prev: DOMNode?, current: DOMNode?): Boolean { if (prev === current) return true @@ -671,9 +712,9 @@ object DefaultDndEngine : DndEngine { val currKey = current.key if (prevKey != null || currKey != null) { return prevKey != null && - currKey != null && - prevKey == currKey && - prev.javaClass == current.javaClass + currKey != null && + prevKey == currKey && + prev.javaClass == current.javaClass } return false } @@ -748,13 +789,13 @@ object DefaultDndEngine : DndEngine { data = DndSystem.payload(id), cursorX = active.cursorX, cursorY = active.cursorY, - transform = Transform( - x = active.previewX - active.cursorX.toDouble(), - y = active.previewY - active.cursorY.toDouble() - ), + transform = + Transform( + x = active.previewX - active.cursorX.toDouble(), + y = active.previewY - active.cursorY.toDouble(), + ), dropEffect = active.dataTransfer.dropEffect, - dataTransfer = active.dataTransfer + dataTransfer = active.dataTransfer, ) } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEvents.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEvents.kt index a13c30d..c141241 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEvents.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEvents.kt @@ -1,14 +1,14 @@ package org.dreamfinity.dsgl.core.dom -import org.dreamfinity.dsgl.core.contextmenu.ContextMenuHost import org.dreamfinity.dsgl.core.contextmenu.ContextMenuModel -import org.dreamfinity.dsgl.core.contextmenu.ContextMenuRuntime +import org.dreamfinity.dsgl.core.contextmenu.ContextMenuPortalService import org.dreamfinity.dsgl.core.contextmenu.ContextMenuTriggerScope import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.DomainPortalServices fun DOMNode.onContextMenu( - host: ContextMenuHost = ContextMenuRuntime.host, - handler: ContextMenuTriggerScope.() -> Unit + portalService: ContextMenuPortalService = DomainPortalServices.applicationContextMenuEngine, + handler: ContextMenuTriggerScope.() -> Unit, ) { val previous = onMouseDown onMouseDown = { event -> @@ -24,8 +24,8 @@ fun DOMNode.onContextMenu( anchorRect = anchor, inheritedFontId = sourceStyle?.fontId ?: sourceNode.fontId, inheritedFontSize = sourceStyle?.fontSize ?: sourceNode.fontSize, - host = host - ) + portalService = portalService, + ), ) event.cancelled = true } @@ -33,8 +33,8 @@ fun DOMNode.onContextMenu( } fun DOMNode.onContextMenuModel( - host: ContextMenuHost = ContextMenuRuntime.host, - modelProvider: () -> ContextMenuModel + portalService: ContextMenuPortalService = DomainPortalServices.applicationContextMenuEngine, + modelProvider: () -> ContextMenuModel, ) { - onContextMenu(host = host, handler = { openMenu(modelProvider()) }) + onContextMenu(portalService = portalService, handler = { openMenu(modelProvider()) }) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DOMNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DOMNode.kt index dc7fc63..168a75d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DOMNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DOMNode.kt @@ -1,30 +1,24 @@ package org.dreamfinity.dsgl.core.dom -import org.dreamfinity.dsgl.core.dsl.ComponentProps import org.dreamfinity.dsgl.core.DsglColors -import org.dreamfinity.dsgl.core.dsl.StyleScope -import org.dreamfinity.dsgl.core.animation.AnimationSpec -import org.dreamfinity.dsgl.core.animation.StyleAnimationEngine -import org.dreamfinity.dsgl.core.animation.TransitionSpec +import org.dreamfinity.dsgl.core.animation.* import org.dreamfinity.dsgl.core.debug.ScrollPerformanceCounters import org.dreamfinity.dsgl.core.dnd.* import org.dreamfinity.dsgl.core.dom.layout.* +import org.dreamfinity.dsgl.core.dom.text.ResolvedTextMetrics +import org.dreamfinity.dsgl.core.dsl.* import org.dreamfinity.dsgl.core.event.* -import org.dreamfinity.dsgl.core.font.FontRegistry +import org.dreamfinity.dsgl.core.font.* import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle import org.dreamfinity.dsgl.core.hooks.ref.RefTarget import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.* -import org.dreamfinity.dsgl.core.text.MinecraftFormattingParser -import org.dreamfinity.dsgl.core.text.ParsedText -import org.dreamfinity.dsgl.core.text.TextStyleFlags -import org.dreamfinity.dsgl.core.text.TextStyleMetrics -import org.dreamfinity.dsgl.core.dom.text.ResolvedTextMetrics +import org.dreamfinity.dsgl.core.text.* import kotlin.math.roundToInt data class NodeStyleApplyResult( val visualDirty: Boolean, - val layoutDirty: Boolean + val layoutDirty: Boolean, ) data class ScrollAxisState( @@ -32,7 +26,7 @@ data class ScrollAxisState( val scrollContainer: Boolean, val clipsToViewport: Boolean, val scrollbarPresent: Boolean, - val scrollbarGutter: Int + val scrollbarGutter: Int, ) data class ScrollContainerState( @@ -46,7 +40,7 @@ data class ScrollContainerState( val horizontalScrollbarGutter: Int, val verticalScrollbarGutter: Int, val axisX: ScrollAxisState, - val axisY: ScrollAxisState + val axisY: ScrollAxisState, ) data class ScrollAnimationDebugState( @@ -55,7 +49,7 @@ data class ScrollAnimationDebugState( val displayedX: Double, val displayedY: Double, val resolvedX: Int, - val resolvedY: Int + val resolvedY: Int, ) data class ScrollSessionSnapshot( @@ -65,20 +59,21 @@ data class ScrollSessionSnapshot( val displayedY: Double, val resolvedX: Int, val resolvedY: Int, - val dragSession: ScrollbarDragSessionDebugState? + val dragSession: ScrollbarDragSessionDebugState?, ) data class ScrollInvalidationState( val layoutDirty: Boolean, val visualDirty: Boolean, - val interactionDirty: Boolean + val interactionDirty: Boolean, ) { companion object { - val CLEAN = ScrollInvalidationState( - layoutDirty = false, - visualDirty = false, - interactionDirty = false - ) + val CLEAN = + ScrollInvalidationState( + layoutDirty = false, + visualDirty = false, + interactionDirty = false, + ) } val anyDirty: Boolean @@ -93,19 +88,19 @@ data class ScrollbarDragSessionDebugState( val maxThumbTravelPx: Int, val maxScroll: Int, val grabOffsetPx: Int, - val initialResolvedScroll: Int + val initialResolvedScroll: Int, ) data class ScrollbarVisualAxis( val trackRect: Rect, val thumbRect: Rect, val maxScroll: Int, - val scrollOffset: Int + val scrollOffset: Int, ) data class ScrollbarVisualState( val horizontal: ScrollbarVisualAxis?, - val vertical: ScrollbarVisualAxis? + val vertical: ScrollbarVisualAxis?, ) private data class ScrollbarResolution( @@ -114,13 +109,13 @@ private data class ScrollbarResolution( val horizontalGutter: Int, val verticalGutter: Int, val viewportWidth: Int, - val viewportHeight: Int + val viewportHeight: Int, ) private data class NativeFontMetricsPx( val lineHeightPx: Int, val ascenderPx: Float, - val descenderPx: Float + val descenderPx: Float, ) private data class ScrollbarDragSession( @@ -131,12 +126,12 @@ private data class ScrollbarDragSession( val maxThumbTravelPx: Int, val maxScroll: Int, val grabOffsetPx: Int, - val initialResolvedScroll: Int + val initialResolvedScroll: Int, ) private enum class ScrollbarAxis { Horizontal, - Vertical + Vertical, } /** @@ -145,7 +140,7 @@ private enum class ScrollbarAxis { * Nodes are measured, laid out, and then converted into [RenderCommand]s by the host. */ abstract class DOMNode( - var key: Any? = null + var key: Any? = null, ) { companion object { const val NORMAL_LINE_HEIGHT_MULTIPLIER: Float = 1.2f @@ -338,7 +333,7 @@ abstract class DOMNode( private var stickyVisualOffsetXPx: Int = 0 private var stickyVisualOffsetYPx: Int = 0 private var stickyVisualOffsetsDirty: Boolean = true - private var stickyVisualRefreshSubtreeDirty: Boolean = true + private var stickyVisualRefreshSubtreeDirty: Boolean = true private var gapStyleValue: CssLength = CssLength.px(gap) private var flexBasisStyleValue: CssLength? = null private var borderColorStyleValue: Int = border.color @@ -552,76 +547,85 @@ abstract class DOMNode( } /** Measures the node's desired size. */ - internal fun resolveLayoutStyleValues( - ctx: UiMeasureContext, - parentContentWidth: Int?, - parentContentHeight: Int? - ) { + @Suppress("UnusedParameter") + internal fun resolveLayoutStyleValues(ctx: UiMeasureContext, parentContentWidth: Int?, parentContentHeight: Int?) { if (appliedComputedStyle == null) { return } - val context = lengthResolveContext(ctx, parentContentWidth, parentContentHeight) + val context = lengthResolveContext(parentContentWidth, parentContentHeight) margin = marginStyleValue.resolveToInsets(context) padding = paddingStyleValue.resolveToInsets(context) - val borderWidthPx = borderWidthStyleValue - .resolvePx(context, LengthPercentBase.ContainerWidth) - .roundToInt() - .coerceAtLeast(0) + val borderWidthPx = + borderWidthStyleValue + .resolvePx(context, LengthPercentBase.ContainerWidth) + .roundToInt() + .coerceAtLeast(0) border = Border.all(borderWidthPx, borderColorStyleValue) - borderRadius = borderRadiusStyleValue - .resolvePx(context, LengthPercentBase.ContainerWidth) - .roundToInt() - .coerceAtLeast(0) - val resolvedWidth = widthStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerWidth) - ?.roundToInt() - ?.coerceAtLeast(0) - val resolvedHeight = heightStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerHeight) - ?.roundToInt() - ?.coerceAtLeast(0) - val resolvedMinWidth = minWidthStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerWidth) - ?.roundToInt() - ?.coerceAtLeast(0) - val resolvedMinHeight = minHeightStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerHeight) - ?.roundToInt() - ?.coerceAtLeast(0) - val resolvedMaxWidth = maxWidthStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerWidth) - ?.roundToInt() - ?.coerceAtLeast(0) - val resolvedMaxHeight = maxHeightStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerHeight) - ?.roundToInt() - ?.coerceAtLeast(0) + borderRadius = + borderRadiusStyleValue + .resolvePx(context, LengthPercentBase.ContainerWidth) + .roundToInt() + .coerceAtLeast(0) + val resolvedWidth = + widthStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerWidth) + ?.roundToInt() + ?.coerceAtLeast(0) + val resolvedHeight = + heightStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerHeight) + ?.roundToInt() + ?.coerceAtLeast(0) + val resolvedMinWidth = + minWidthStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerWidth) + ?.roundToInt() + ?.coerceAtLeast(0) + val resolvedMinHeight = + minHeightStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerHeight) + ?.roundToInt() + ?.coerceAtLeast(0) + val resolvedMaxWidth = + maxWidthStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerWidth) + ?.roundToInt() + ?.coerceAtLeast(0) + val resolvedMaxHeight = + maxHeightStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerHeight) + ?.roundToInt() + ?.coerceAtLeast(0) minWidth = resolvedMinWidth minHeight = resolvedMinHeight maxWidth = resolvedMaxWidth maxHeight = resolvedMaxHeight - width = resolvedWidth?.let { - clampContentLengthToConstraints( - length = it, - minConstraint = resolvedMinWidth, - maxConstraint = resolvedMaxWidth - ) - } - height = resolvedHeight?.let { - clampContentLengthToConstraints( - length = it, - minConstraint = resolvedMinHeight, - maxConstraint = resolvedMaxHeight - ) - } - gap = gapStyleValue - .resolvePx(context, LengthPercentBase.ContainerWidth) - .roundToInt() - .coerceAtLeast(0) - flexBasis = flexBasisStyleValue - ?.resolvePx(context, LengthPercentBase.ContainerWidth) - ?.roundToInt() - ?.coerceAtLeast(0) + width = + resolvedWidth?.let { + clampContentLengthToConstraints( + length = it, + minConstraint = resolvedMinWidth, + maxConstraint = resolvedMaxWidth, + ) + } + height = + resolvedHeight?.let { + clampContentLengthToConstraints( + length = it, + minConstraint = resolvedMinHeight, + maxConstraint = resolvedMaxHeight, + ) + } + gap = + gapStyleValue + .resolvePx(context, LengthPercentBase.ContainerWidth) + .roundToInt() + .coerceAtLeast(0) + flexBasis = + flexBasisStyleValue + ?.resolvePx(context, LengthPercentBase.ContainerWidth) + ?.roundToInt() + ?.coerceAtLeast(0) val resolvedRelativeOffsetX = resolveRelativeVisualOffsetXPx(context) val resolvedRelativeOffsetY = resolveRelativeVisualOffsetYPx(context) @@ -668,55 +672,62 @@ abstract class DOMNode( val verticalActive = verticalInset.active if (!horizontalActive && !verticalActive) return 0 to 0 - val references = StickyLayoutModel.nearestStickyScrollContainers( - node = this, - resolveHorizontal = horizontalActive, - resolveVertical = verticalActive - ) + val references = + StickyLayoutModel.nearestStickyScrollContainers( + node = this, + resolveHorizontal = horizontalActive, + resolveVertical = verticalActive, + ) val containingBlockRect = stickyContainingBlockForPositioningRect() val baseRect = visualBoundsWithPendingScrollTranslation(this) val referenceStates = HashMap(2) + fun viewportRect(reference: DOMNode): Rect { - val state = referenceStates.getOrPut(reference) { - reference.scrollContainerState() - } + val state = + referenceStates.getOrPut(reference) { + reference.scrollContainerState() + } return stickyReferenceViewportRect(reference, state) } - val offsetX = if (horizontalActive) { - val viewportRect = viewportRect(references.horizontal) - val offsetContext = positioningOffsetResolveContext(viewportRect) - val insetLength = horizontalInset.value - ?: error("Sticky horizontal inset must be present when horizontal sticky axis is active") - val insetPx = insetLength.resolvePx(offsetContext, LengthPercentBase.ContainerWidth).roundToInt() - StickyLayoutModel.resolveHorizontalVisualOffsetPx( - baseX = baseRect.x, - nodeWidth = baseRect.width.coerceAtLeast(0), - viewportRect = viewportRect, - containingBlockRect = containingBlockRect, - insetResolution = horizontalInset, - insetPx = insetPx - ) - } else { - 0 - } - val offsetY = if (verticalActive) { - val viewportRect = viewportRect(references.vertical) - val offsetContext = positioningOffsetResolveContext(viewportRect) - val insetLength = verticalInset.value - ?: error("Sticky vertical inset must be present when vertical sticky axis is active") - val insetPx = insetLength.resolvePx(offsetContext, LengthPercentBase.ContainerHeight).roundToInt() - StickyLayoutModel.resolveVerticalVisualOffsetPx( - baseY = baseRect.y, - nodeHeight = baseRect.height.coerceAtLeast(0), - viewportRect = viewportRect, - containingBlockRect = containingBlockRect, - insetResolution = verticalInset, - insetPx = insetPx - ) - } else { - 0 - } + val offsetX = + if (horizontalActive) { + val viewportRect = viewportRect(references.horizontal) + val offsetContext = positioningOffsetResolveContext(viewportRect) + val insetLength = + horizontalInset.value + ?: error("Sticky horizontal inset must be present when horizontal sticky axis is active") + val insetPx = insetLength.resolvePx(offsetContext, LengthPercentBase.ContainerWidth).roundToInt() + StickyLayoutModel.resolveHorizontalVisualOffsetPx( + baseX = baseRect.x, + nodeWidth = baseRect.width.coerceAtLeast(0), + viewportRect = viewportRect, + containingBlockRect = containingBlockRect, + insetResolution = horizontalInset, + insetPx = insetPx, + ) + } else { + 0 + } + val offsetY = + if (verticalActive) { + val viewportRect = viewportRect(references.vertical) + val offsetContext = positioningOffsetResolveContext(viewportRect) + val insetLength = + verticalInset.value + ?: error("Sticky vertical inset must be present when vertical sticky axis is active") + val insetPx = insetLength.resolvePx(offsetContext, LengthPercentBase.ContainerHeight).roundToInt() + StickyLayoutModel.resolveVerticalVisualOffsetPx( + baseY = baseRect.y, + nodeHeight = baseRect.height.coerceAtLeast(0), + viewportRect = viewportRect, + containingBlockRect = containingBlockRect, + insetResolution = verticalInset, + insetPx = insetPx, + ) + } else { + 0 + } return offsetX to offsetY } @@ -738,7 +749,7 @@ abstract class DOMNode( private fun stickyReferenceViewportRect( referenceScrollContainer: DOMNode, - referenceState: ScrollContainerState? = null + referenceState: ScrollContainerState? = null, ): Rect { val viewportRect = (referenceState ?: referenceScrollContainer.scrollContainerState()).viewportRect val adjustedBounds = visualBoundsWithPendingScrollTranslation(referenceScrollContainer) @@ -749,7 +760,7 @@ abstract class DOMNode( x = viewportRect.x + shiftX, y = viewportRect.y + shiftY, width = viewportRect.width, - height = viewportRect.height + height = viewportRect.height, ) } @@ -760,7 +771,7 @@ abstract class DOMNode( x = adjustedBounds.x, y = adjustedBounds.y, width = adjustedBounds.width.coerceAtLeast(0), - height = adjustedBounds.height.coerceAtLeast(0) + height = adjustedBounds.height.coerceAtLeast(0), ) } @@ -773,7 +784,7 @@ abstract class DOMNode( x = node.bounds.x - pendingDelta.first, y = node.bounds.y - pendingDelta.second, width = node.bounds.width, - height = node.bounds.height + height = node.bounds.height, ) } @@ -796,34 +807,33 @@ abstract class DOMNode( return deltaX to deltaY } + @Suppress("UnusedParameter") internal fun resolveFlexBasisForAxis( ctx: UiMeasureContext, parentContentWidth: Int?, parentContentHeight: Int?, - axis: FlexDirection + axis: FlexDirection, ): Int? { - val context = lengthResolveContext(ctx, parentContentWidth, parentContentHeight) - val percentBase = if (axis == FlexDirection.Row) { - LengthPercentBase.ContainerWidth - } else { - LengthPercentBase.ContainerHeight - } + val context = lengthResolveContext(parentContentWidth, parentContentHeight) + val percentBase = + if (axis == FlexDirection.Row) { + LengthPercentBase.ContainerWidth + } else { + LengthPercentBase.ContainerHeight + } return flexBasisStyleValue ?.resolvePx(context, percentBase) ?.roundToInt() ?.coerceAtLeast(0) } - private fun lengthResolveContext( - ctx: UiMeasureContext, - parentContentWidth: Int?, - parentContentHeight: Int? - ): LengthResolveContext { + private fun lengthResolveContext(parentContentWidth: Int?, parentContentHeight: Int?): LengthResolveContext { val rootFontSizePx = rootNode().resolveComputedFontSizePx().toFloat() - val inheritedFontSizePx = ( + val inheritedFontSizePx = + ( parent?.resolveComputedFontSizePx() ?: resolveComputedFontSizePx() - ).toFloat() + ).toFloat() val currentFontSizePx = resolveComputedFontSizePx().toFloat() return LengthResolveContext( viewportWidthPx = StyleEngine.viewportWidthPx().toFloat(), @@ -832,7 +842,7 @@ abstract class DOMNode( containingBlockHeightPx = parentContentHeight?.toFloat(), rootFontSizePx = rootFontSizePx, currentFontSizePx = currentFontSizePx, - inheritedFontSizePx = inheritedFontSizePx + inheritedFontSizePx = inheritedFontSizePx, ) } @@ -844,104 +854,87 @@ abstract class DOMNode( return current } + internal fun participatesInPositionedOrderingModel(): Boolean = PositionedLayoutModel.isPositioned(this) - internal fun participatesInPositionedOrderingModel(): Boolean { - return PositionedLayoutModel.isPositioned(this) - } - - internal fun rootStackingScopeForPositioning(): DOMNode { - return PositionedLayoutModel.rootStackingScope(this) - } + internal fun rootStackingScopeForPositioning(): DOMNode = PositionedLayoutModel.rootStackingScope(this) - internal fun sharesRootStackingScopeForPositioning(other: DOMNode): Boolean { - return PositionedLayoutModel.sharesRootStackingScope(this, other) - } + internal fun sharesRootStackingScopeForPositioning(other: DOMNode): Boolean = + PositionedLayoutModel.sharesRootStackingScope(this, other) - internal fun rootStackingContextIdentityForPositioning(): PositionedLayoutModel.RootStackingContextId { - return PositionedLayoutModel.rootStackingContextId(this) - } + internal fun rootStackingContextIdentityForPositioning(): PositionedLayoutModel.RootStackingContextId = + PositionedLayoutModel.rootStackingContextId(this) - internal fun stackingContextScaffoldForTraversalOwner(): PositionedLayoutModel.StackingContext { - return PositionedLayoutModel.stackingContextScaffold(this) - } + internal fun stackingContextScaffoldForTraversalOwner(): PositionedLayoutModel.StackingContext = + PositionedLayoutModel.stackingContextScaffold(this) - internal fun containingBlockForAbsolutePositioning(): DOMNode { - return PositionedLayoutModel.containingBlockForAbsolute(this) - } + internal fun containingBlockForAbsolutePositioning(): DOMNode = + PositionedLayoutModel.containingBlockForAbsolute(this) - internal fun fixedViewportRootForPositioning(): DOMNode { - return PositionedLayoutModel.fixedViewportRoot(this) - } + internal fun fixedViewportRootForPositioning(): DOMNode = PositionedLayoutModel.fixedViewportRoot(this) - internal fun stickyReferenceScrollContainerVertical(): DOMNode { - return StickyLayoutModel.nearestStickyScrollContainerVertical(this) - } + internal fun stickyReferenceScrollContainerVertical(): DOMNode = + StickyLayoutModel.nearestStickyScrollContainerVertical(this) - internal fun stickyReferenceScrollContainerHorizontal(): DOMNode { - return StickyLayoutModel.nearestStickyScrollContainerHorizontal(this) - } + internal fun stickyReferenceScrollContainerHorizontal(): DOMNode = + StickyLayoutModel.nearestStickyScrollContainerHorizontal(this) - internal fun stickyContainingBlockForPositioning(): DOMNode { - return StickyLayoutModel.stickyContainingBlock(this) - } + internal fun stickyContainingBlockForPositioning(): DOMNode = StickyLayoutModel.stickyContainingBlock(this) - internal fun stickyHorizontalInsetResolutionContract(): StickyLayoutModel.StickyHorizontalInsetResolution { - return StickyLayoutModel.resolveHorizontalInsets( + internal fun stickyHorizontalInsetResolutionContract(): StickyLayoutModel.StickyHorizontalInsetResolution = + StickyLayoutModel.resolveHorizontalInsets( left = leftStyleValue, - right = rightStyleValue + right = rightStyleValue, ) - } - internal fun stickyVerticalInsetResolutionContract(): StickyLayoutModel.StickyInsetResolution { - return StickyLayoutModel.resolveVerticalInsets( + internal fun stickyVerticalInsetResolutionContract(): StickyLayoutModel.StickyInsetResolution = + StickyLayoutModel.resolveVerticalInsets( top = topStyleValue, - bottom = bottomStyleValue + bottom = bottomStyleValue, ) - } - internal fun stickyPositionedGeometryIntegrationPoint(): StickyLayoutModel.PositionedGeometryIntegrationPoint { - return StickyLayoutModel.positionedGeometryIntegrationPoint() - } + internal fun stickyPositionedGeometryIntegrationPoint(): StickyLayoutModel.PositionedGeometryIntegrationPoint = + StickyLayoutModel.positionedGeometryIntegrationPoint() - internal fun isRemovedFromNormalFlowForPositioning(): Boolean { - return position == PositionMode.Absolute || position == PositionMode.Fixed - } + internal fun isRemovedFromNormalFlowForPositioning(): Boolean = + position == PositionMode.Absolute || position == PositionMode.Fixed internal fun resolveAbsoluteLayoutRect( ctx: UiMeasureContext, desiredX: Int, desiredY: Int, desiredWidth: Int, - desiredHeight: Int + desiredHeight: Int, ): Rect { if (position != PositionMode.Absolute) { return Rect( x = desiredX, y = desiredY, width = desiredWidth.coerceAtLeast(0), - height = desiredHeight.coerceAtLeast(0) + height = desiredHeight.coerceAtLeast(0), ) } val containingBlockRect = absoluteContainingBlockRect() val offsetContext = positioningOffsetResolveContext(ctx, containingBlockRect) - val resolvedX = resolvePositionedX( - context = offsetContext, - containerRect = containingBlockRect, - desiredX = desiredX, - desiredWidth = desiredWidth - ) - val resolvedY = resolvePositionedY( - context = offsetContext, - containerRect = containingBlockRect, - desiredY = desiredY, - desiredHeight = desiredHeight - ) + val resolvedX = + resolvePositionedX( + context = offsetContext, + containerRect = containingBlockRect, + desiredX = desiredX, + desiredWidth = desiredWidth, + ) + val resolvedY = + resolvePositionedY( + context = offsetContext, + containerRect = containingBlockRect, + desiredY = desiredY, + desiredHeight = desiredHeight, + ) return Rect( x = resolvedX, y = resolvedY, width = desiredWidth.coerceAtLeast(0), - height = desiredHeight.coerceAtLeast(0) + height = desiredHeight.coerceAtLeast(0), ) } @@ -950,79 +943,77 @@ abstract class DOMNode( desiredX: Int, desiredY: Int, desiredWidth: Int, - desiredHeight: Int + desiredHeight: Int, ): Rect { if (position != PositionMode.Fixed) { return Rect( x = desiredX, y = desiredY, width = desiredWidth.coerceAtLeast(0), - height = desiredHeight.coerceAtLeast(0) + height = desiredHeight.coerceAtLeast(0), ) } val viewportRect = fixedViewportAnchorRect() val offsetContext = positioningOffsetResolveContext(ctx, viewportRect) - val resolvedX = resolvePositionedX( - context = offsetContext, - containerRect = viewportRect, - desiredX = desiredX, - desiredWidth = desiredWidth - ) - val resolvedY = resolvePositionedY( - context = offsetContext, - containerRect = viewportRect, - desiredY = desiredY, - desiredHeight = desiredHeight - ) + val resolvedX = + resolvePositionedX( + context = offsetContext, + containerRect = viewportRect, + desiredX = desiredX, + desiredWidth = desiredWidth, + ) + val resolvedY = + resolvePositionedY( + context = offsetContext, + containerRect = viewportRect, + desiredY = desiredY, + desiredHeight = desiredHeight, + ) return Rect( x = resolvedX, y = resolvedY, width = desiredWidth.coerceAtLeast(0), - height = desiredHeight.coerceAtLeast(0) + height = desiredHeight.coerceAtLeast(0), ) } - internal fun orderedChildrenForPaintTraversal(): List { - return PositionedLayoutModel.orderedChildrenForPaint(this) - } + internal fun orderedChildrenForPaintTraversal(): List = PositionedLayoutModel.orderedChildrenForPaint(this) - internal fun orderedChildrenForHitTestingTraversal(): List { - return PositionedLayoutModel.orderedChildrenForHitTesting(this) - } + internal fun orderedChildrenForHitTestingTraversal(): List = + PositionedLayoutModel.orderedChildrenForHitTesting(this) internal fun clampMeasuredOuterSize(size: Size): Size { val extrasWidth = (padding.horizontal + border.horizontal).coerceAtLeast(0) val extrasHeight = (padding.vertical + border.vertical).coerceAtLeast(0) val contentWidth = (size.width - extrasWidth).coerceAtLeast(0) val contentHeight = (size.height - extrasHeight).coerceAtLeast(0) - val clampedContentWidth = clampContentLengthToConstraints( - length = contentWidth, - minConstraint = minWidth, - maxConstraint = maxWidth - ) - val clampedContentHeight = clampContentLengthToConstraints( - length = contentHeight, - minConstraint = minHeight, - maxConstraint = maxHeight - ) + val clampedContentWidth = + clampContentLengthToConstraints( + length = contentWidth, + minConstraint = minWidth, + maxConstraint = maxWidth, + ) + val clampedContentHeight = + clampContentLengthToConstraints( + length = contentHeight, + minConstraint = minHeight, + maxConstraint = maxHeight, + ) return Size( width = clampedContentWidth + extrasWidth, - height = clampedContentHeight + extrasHeight + height = clampedContentHeight + extrasHeight, ) } - private fun clampContentLengthToConstraints( - length: Int, - minConstraint: Int?, - maxConstraint: Int? - ): Int { + private fun clampContentLengthToConstraints(length: Int, minConstraint: Int?, maxConstraint: Int?): Int { val normalizedMin = minConstraint?.coerceAtLeast(0) val normalizedMax = maxConstraint?.coerceAtLeast(0) - val effectiveMax = when { - normalizedMin != null && normalizedMax != null -> normalizedMax.coerceAtLeast(normalizedMin) - else -> normalizedMax - } + val effectiveMax = + when { + normalizedMin != null && normalizedMax != null -> normalizedMax.coerceAtLeast(normalizedMin) + else -> normalizedMax + } var result = length.coerceAtLeast(0) if (normalizedMin != null && result < normalizedMin) { result = normalizedMin @@ -1039,8 +1030,12 @@ abstract class DOMNode( return Rect( x = state.viewportRect.x - state.scrollX, y = state.viewportRect.y - state.scrollY, - width = state.viewportRect.width.coerceAtLeast(0), - height = state.viewportRect.height.coerceAtLeast(0) + width = + state.viewportRect.width + .coerceAtLeast(0), + height = + state.viewportRect.height + .coerceAtLeast(0), ) } @@ -1050,21 +1045,21 @@ abstract class DOMNode( return Rect( x = state.viewportRect.x, y = state.viewportRect.y, - width = state.viewportRect.width.coerceAtLeast(0), - height = state.viewportRect.height.coerceAtLeast(0) + width = + state.viewportRect.width + .coerceAtLeast(0), + height = + state.viewportRect.height + .coerceAtLeast(0), ) } - private fun fixedViewportAnchorRect(): Rect { - return fixedViewportClipRectForPromotedParticipation() - } + private fun fixedViewportAnchorRect(): Rect = fixedViewportClipRectForPromotedParticipation() private fun positioningOffsetResolveContext( @Suppress("UNUSED_PARAMETER") ctx: UiMeasureContext, - containerRect: Rect - ): LengthResolveContext { - return positioningOffsetResolveContext(containerRect) - } + containerRect: Rect, + ): LengthResolveContext = positioningOffsetResolveContext(containerRect) private fun positioningOffsetResolveContext(containerRect: Rect): LengthResolveContext { val rootFontSizePx = rootNode().resolveComputedFontSizePx().toFloat() @@ -1073,11 +1068,17 @@ abstract class DOMNode( return LengthResolveContext( viewportWidthPx = StyleEngine.viewportWidthPx().toFloat(), viewportHeightPx = StyleEngine.viewportHeightPx().toFloat(), - containingBlockWidthPx = containerRect.width.coerceAtLeast(0).toFloat(), - containingBlockHeightPx = containerRect.height.coerceAtLeast(0).toFloat(), + containingBlockWidthPx = + containerRect.width + .coerceAtLeast(0) + .toFloat(), + containingBlockHeightPx = + containerRect.height + .coerceAtLeast(0) + .toFloat(), rootFontSizePx = rootFontSizePx, currentFontSizePx = currentFontSizePx, - inheritedFontSizePx = inheritedFontSizePx + inheritedFontSizePx = inheritedFontSizePx, ) } @@ -1085,7 +1086,7 @@ abstract class DOMNode( context: LengthResolveContext, containerRect: Rect, desiredX: Int, - desiredWidth: Int + desiredWidth: Int, ): Int { val resolution = PositionedLayoutModel.resolveHorizontalOffset(left = leftStyleValue, right = rightStyleValue) val value = resolution.value ?: return desiredX @@ -1101,7 +1102,7 @@ abstract class DOMNode( context: LengthResolveContext, containerRect: Rect, desiredY: Int, - desiredHeight: Int + desiredHeight: Int, ): Int { val resolution = PositionedLayoutModel.resolveVerticalOffset(top = topStyleValue, bottom = bottomStyleValue) val value = resolution.value ?: return desiredY @@ -1114,9 +1115,7 @@ abstract class DOMNode( } /** Measures the node's desired size. */ - internal open fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measure(ctx) - } + internal open fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = measure(ctx) /** Measures the node's desired size. */ open fun measure(ctx: UiMeasureContext): Size { @@ -1131,7 +1130,13 @@ abstract class DOMNode( } /** Lays out this node and its children for the given bounds. */ - open fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + open fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { if (display == Display.None) { val next = Rect(x, y, 0, 0) if (bounds != next) { @@ -1159,7 +1164,7 @@ abstract class DOMNode( child.resolveLayoutStyleValues( ctx = ctx, parentContentWidth = availableOuterWidth, - parentContentHeight = availableOuterHeight + parentContentHeight = availableOuterHeight, ) val childSize = child.clampMeasuredOuterSize(child.measureForLayout(ctx, availableOuterWidth)) val childX = layoutContentX + child.margin.left @@ -1177,20 +1182,22 @@ abstract class DOMNode( val localClipRect = overflowViewportRect() val inheritedClipRect = currentInheritedChildRenderClipRect() if (localClipRect != null) { - val effectiveClipRect = if (inheritedClipRect != null) { - inheritedClipRect.intersection(localClipRect) ?: return - } else { - localClipRect - } + val effectiveClipRect = + if (inheritedClipRect != null) { + inheritedClipRect.intersection(localClipRect) ?: return + } else { + localClipRect + } if (effectiveClipRect.width <= 0 || effectiveClipRect.height <= 0) { return } - out += RenderCommand.PushClip( - x = effectiveClipRect.x, - y = effectiveClipRect.y, - width = effectiveClipRect.width.coerceAtLeast(0), - height = effectiveClipRect.height.coerceAtLeast(0) - ) + out += + RenderCommand.PushClip( + x = effectiveClipRect.x, + y = effectiveClipRect.y, + width = effectiveClipRect.width.coerceAtLeast(0), + height = effectiveClipRect.height.coerceAtLeast(0), + ) withInheritedChildRenderClipRect(effectiveClipRect) { orderedChildrenForPaintTraversal().forEach { child -> child.appendRenderCommands(ctx, out) @@ -1217,15 +1224,16 @@ abstract class DOMNode( if (transformPushed) { val ox = bounds.x + bounds.width * transformOrigin.originX val oy = bounds.y + bounds.height * transformOrigin.originY - out += RenderCommand.PushTransform( - originX = ox, - originY = oy, - translateX = activeTransform.translateX, - translateY = activeTransform.translateY, - scaleX = activeTransform.scaleX, - scaleY = activeTransform.scaleY, - rotateDeg = activeTransform.rotateDeg - ) + out += + RenderCommand.PushTransform( + originX = ox, + originY = oy, + translateX = activeTransform.translateX, + translateY = activeTransform.translateY, + scaleX = activeTransform.scaleX, + scaleY = activeTransform.scaleY, + rotateDeg = activeTransform.rotateDeg, + ) } if (opacityPushed) { out += RenderCommand.PushOpacity(activeOpacity) @@ -1243,23 +1251,18 @@ abstract class DOMNode( open fun handleClick(event: MouseClickEvent): Boolean = false /** Dispatches a click through this node and its subtree. */ - fun dispatchClick(event: MouseClickEvent): Boolean { - return dispatchClickInternal( + fun dispatchClick(event: MouseClickEvent): Boolean = + dispatchClickInternal( element = this, event = event, parentTransform = AffineTransform2D.IDENTITY, - parentInputClipRect = null + parentInputClipRect = null, ) - } /** Returns true if the mouse event is within current bounds. */ - fun hovered(event: MouseEvent): Boolean { - return isHitTestVisible() && containsGlobalPoint(event.mouseX, event.mouseY) - } + fun hovered(event: MouseEvent): Boolean = isHitTestVisible() && containsGlobalPoint(event.mouseX, event.mouseY) - fun isHitTestVisible(): Boolean { - return !dragHitTestHidden && display != Display.None - } + fun isHitTestVisible(): Boolean = !dragHitTestHidden && display != Display.None /** Applies event handlers from [ComponentProps] to this node. */ fun applyHandlers(props: ComponentProps) { @@ -1275,10 +1278,10 @@ abstract class DOMNode( this@DOMNode.styleDisabled = props.disabled this@DOMNode.draggable = props.draggable this@DOMNode.droppable = props.droppable || - props.onDragEnter != null || - props.onDragOver != null || - props.onDragLeave != null || - props.onDrop != null + props.onDragEnter != null || + props.onDragOver != null || + props.onDragLeave != null || + props.onDrop != null this@DOMNode.dragPreviewMode = props.dragPreviewMode this@DOMNode.hideSourceWhileDragging = props.hideSourceWhileDragging this@DOMNode.dragPreviewBuilder = props.dragPreview @@ -1379,7 +1382,7 @@ abstract class DOMNode( mouseY: Int, mouseDX: Int, mouseDY: Int, - button: MouseButton + button: MouseButton, ) { if (styleDisabled || button != MouseButton.LEFT) return updateScrollbarPointerDrag(mouseX, mouseY) @@ -1572,62 +1575,71 @@ abstract class DOMNode( markRenderCommandsDirty() } + /** + * Optional node-specific reconcile sync hook. + * + * Called by reconciler after [syncBaseFrom] only when class and key already match. + * Default behavior is no-op. + */ + internal open fun syncCustomFrom(template: DOMNode) = Unit + internal fun captureStyleDefaults(): ComputedStyleDefaults { val existing = styleDefaultsSnapshot if (existing != null) return existing val defaultFontSize = defaultFontSize() - val computed = ComputedStyleDefaults( - margin = LengthInsets.fromInsets(margin), - padding = LengthInsets.fromInsets(padding), - backgroundColor = defaultBackgroundColor(), - backgroundImage = defaultBackgroundImage(), - borderColor = border.color, - borderWidth = CssLength.px(maxOf(border.top, border.right, border.bottom, border.left)), - borderRadius = CssLength.px(borderRadius), - foregroundColor = defaultForegroundColor(), - fontId = defaultFontId(), - fontSize = defaultFontSize, - fontSizeValue = defaultFontSize?.let { CssLength.px(it) }, - fontWeight = fontWeight, - fontStyle = fontStyle, - textDecoration = textDecoration, - obfuscated = textObfuscated, - width = width?.let { CssLength.px(it) }, - height = height?.let { CssLength.px(it) }, - minWidth = minWidth?.let { CssLength.px(it) }, - minHeight = minHeight?.let { CssLength.px(it) }, - maxWidth = maxWidth?.let { CssLength.px(it) }, - maxHeight = maxHeight?.let { CssLength.px(it) }, - align = align, - display = display, - position = position, - left = leftStyleValue, - top = topStyleValue, - right = rightStyleValue, - bottom = bottomStyleValue, - zIndex = zIndex, - flexDirection = flexDirection, - justifyContent = justifyContent, - alignItems = alignItems, - justifyItems = justifyItems, - gap = CssLength.px(gap), - flexGrow = flexGrow, - flexShrink = flexShrink, - flexBasis = flexBasis?.let { CssLength.px(it) }, - gridColumns = gridColumns, - gridRows = gridRows, - gridAutoFlow = gridAutoFlow, - gridColumnSpan = gridColumnSpan, - gridRowSpan = gridRowSpan, - overflow = overflow, - overflowX = overflowX, - overflowY = overflowY, - textWrap = textWrap, - textFormatting = textFormatting, - transform = transform, - transformOrigin = transformOrigin, - opacity = opacity - ) + val computed = + ComputedStyleDefaults( + margin = LengthInsets.fromInsets(margin), + padding = LengthInsets.fromInsets(padding), + backgroundColor = defaultBackgroundColor(), + backgroundImage = defaultBackgroundImage(), + borderColor = border.color, + borderWidth = CssLength.px(maxOf(border.top, border.right, border.bottom, border.left)), + borderRadius = CssLength.px(borderRadius), + foregroundColor = defaultForegroundColor(), + fontId = defaultFontId(), + fontSize = defaultFontSize, + fontSizeValue = defaultFontSize?.let { CssLength.px(it) }, + fontWeight = fontWeight, + fontStyle = fontStyle, + textDecoration = textDecoration, + obfuscated = textObfuscated, + width = width?.let { CssLength.px(it) }, + height = height?.let { CssLength.px(it) }, + minWidth = minWidth?.let { CssLength.px(it) }, + minHeight = minHeight?.let { CssLength.px(it) }, + maxWidth = maxWidth?.let { CssLength.px(it) }, + maxHeight = maxHeight?.let { CssLength.px(it) }, + align = align, + display = display, + position = position, + left = leftStyleValue, + top = topStyleValue, + right = rightStyleValue, + bottom = bottomStyleValue, + zIndex = zIndex, + flexDirection = flexDirection, + justifyContent = justifyContent, + alignItems = alignItems, + justifyItems = justifyItems, + gap = CssLength.px(gap), + flexGrow = flexGrow, + flexShrink = flexShrink, + flexBasis = flexBasis?.let { CssLength.px(it) }, + gridColumns = gridColumns, + gridRows = gridRows, + gridAutoFlow = gridAutoFlow, + gridColumnSpan = gridColumnSpan, + gridRowSpan = gridRowSpan, + overflow = overflow, + overflowX = overflowX, + overflowY = overflowY, + textWrap = textWrap, + textFormatting = textFormatting, + transform = transform, + transformOrigin = transformOrigin, + opacity = opacity, + ) styleDefaultsSnapshot = computed return computed } @@ -1638,7 +1650,7 @@ abstract class DOMNode( StyleAnimationEngine.onComputedStyleApplied(this, previous, style) return NodeStyleApplyResult( visualDirty = false, - layoutDirty = false + layoutDirty = false, ) } marginStyleValue = style.margin @@ -1700,10 +1712,11 @@ abstract class DOMNode( if (previous == null) { return NodeStyleApplyResult( visualDirty = true, - layoutDirty = true + layoutDirty = true, ) } - val layoutDirty = previous.margin != style.margin || + val layoutDirty = + previous.margin != style.margin || previous.padding != style.padding || previous.borderWidth != style.borderWidth || previous.borderColor != style.borderColor || @@ -1747,7 +1760,7 @@ abstract class DOMNode( previous.lineHeight != style.lineHeight return NodeStyleApplyResult( visualDirty = true, - layoutDirty = layoutDirty + layoutDirty = layoutDirty, ) } @@ -1757,8 +1770,8 @@ abstract class DOMNode( val normalizedOpacity = opacity?.coerceIn(0f, 1f) val changed = animatedTransform != transform || - animatedOpacity != normalizedOpacity || - animatedColor != color + animatedOpacity != normalizedOpacity || + animatedColor != color animatedTransform = transform animatedOpacity = normalizedOpacity animatedColor = color @@ -1784,7 +1797,10 @@ abstract class DOMNode( result = 31L * result + scrollOffsetResolvedX.toLong() result = 31L * result + scrollOffsetResolvedY.toLong() result = 31L * result + effectiveTransform().hashCode().toLong() - result = 31L * result + java.lang.Float.floatToIntBits(effectiveOpacity()).toLong() + result = 31L * result + + java.lang.Float + .floatToIntBits(effectiveOpacity()) + .toLong() result = 31L * result + volatileRenderCommandsSignature(nowMs) return result } @@ -1795,6 +1811,10 @@ abstract class DOMNode( renderCommandsRevision += 1L } + internal fun requestRenderCommandsInvalidation() { + markRenderCommandsDirty() + } + fun effectiveTransform(): UiTransform { val base = animatedTransform ?: transform val relativeOffsetX = if (position == PositionMode.Relative) relativeVisualOffsetXPx else 0 @@ -1814,7 +1834,7 @@ abstract class DOMNode( } return base.copy( translateX = base.translateX + relativeOffsetX.toFloat() + stickyOffsetX.toFloat(), - translateY = base.translateY + relativeOffsetY.toFloat() + stickyOffsetY.toFloat() + translateY = base.translateY + relativeOffsetY.toFloat() + stickyOffsetY.toFloat(), ) } @@ -1833,7 +1853,11 @@ abstract class DOMNode( val translate = AffineTransform2D.translation(active.translateX, active.translateY) val rotate = AffineTransform2D.rotation(active.rotateDeg) val scale = AffineTransform2D.scale(active.scaleX, active.scaleY) - return translate.times(origin).times(rotate).times(scale).times(originBack) + return translate + .times(origin) + .times(rotate) + .times(scale) + .times(originBack) } fun worldTransformMatrix(): AffineTransform2D { @@ -1882,54 +1906,50 @@ abstract class DOMNode( protected open fun applyFontSize(value: Int?) {} - protected fun resolveFontSize(ctx: UiMeasureContext): Int { - return ctx.fontHeight(fontId, fontSize).coerceAtLeast(1) - } + protected fun resolveFontSize(ctx: UiMeasureContext): Int = ctx.fontHeight(fontId, fontSize).coerceAtLeast(1) - protected fun resolveComputedFontSizePx(): Int { - return (appliedComputedStyleSnapshot()?.fontSize ?: fontSize ?: 16).coerceAtLeast(1) - } + protected fun resolveComputedFontSizePx(): Int = + (appliedComputedStyleSnapshot()?.fontSize ?: fontSize ?: 16).coerceAtLeast(1) - protected fun resolveEffectiveLineHeight(ctx: UiMeasureContext): Int { - return resolveTextMetrics(ctx).lineHeightPx - } + protected fun resolveEffectiveLineHeight(ctx: UiMeasureContext): Int = resolveTextMetrics(ctx).lineHeightPx - protected fun resolveEffectiveLineTopLeading(ctx: UiMeasureContext): Int { - return resolveTextMetrics(ctx).topLeadingPx.roundToInt().coerceAtLeast(0) - } + protected fun resolveEffectiveLineTopLeading(ctx: UiMeasureContext): Int = + resolveTextMetrics(ctx) + .topLeadingPx + .roundToInt() + .coerceAtLeast(0) - protected fun resolveEffectiveAscenderPx(ctx: UiMeasureContext): Float { - return resolveTextMetrics(ctx).ascenderPx - } + protected fun resolveEffectiveAscenderPx(ctx: UiMeasureContext): Float = resolveTextMetrics(ctx).ascenderPx - protected fun resolveEffectiveDescenderPx(ctx: UiMeasureContext): Float { - return resolveTextMetrics(ctx).descenderPx - } + protected fun resolveEffectiveDescenderPx(ctx: UiMeasureContext): Float = resolveTextMetrics(ctx).descenderPx protected fun resolveTextMetrics(ctx: UiMeasureContext): ResolvedTextMetrics { val fontSizePx = resolveComputedFontSizePx().coerceAtLeast(1) val nativeMetrics = resolveNativeFontMetrics(ctx, fontSizePx) val fallbackFontHeightPx = resolveFontSize(ctx).coerceAtLeast(1) - val fallbackNormalLineHeightPx = (fallbackFontHeightPx * NORMAL_LINE_HEIGHT_MULTIPLIER) - .roundToInt() - .coerceAtLeast(fallbackFontHeightPx) - .coerceAtLeast(1) + val fallbackNormalLineHeightPx = + (fallbackFontHeightPx * NORMAL_LINE_HEIGHT_MULTIPLIER) + .roundToInt() + .coerceAtLeast(fallbackFontHeightPx) + .coerceAtLeast(1) val nativeLineHeightPx = nativeMetrics?.lineHeightPx ?: fallbackNormalLineHeightPx val ascenderPx = nativeMetrics?.ascenderPx ?: (fallbackFontHeightPx * 0.8f) - val descenderPx = nativeMetrics?.descenderPx - ?: (nativeLineHeightPx - ascenderPx).coerceAtLeast(0f) + val descenderPx = + nativeMetrics?.descenderPx + ?: (nativeLineHeightPx - ascenderPx).coerceAtLeast(0f) val computedLineHeight = when (val computedLineHeight = appliedComputedStyleSnapshot()?.lineHeight ?: LineHeightValue.Normal) { LineHeightValue.Normal -> nativeLineHeightPx is LineHeightValue.Length -> { val currentFontSizePx = fontSizePx.toFloat() - val context = LengthResolveContext( - rootFontSizePx = currentFontSizePx, - currentFontSizePx = currentFontSizePx, - inheritedFontSizePx = currentFontSizePx - ) + val context = + LengthResolveContext( + rootFontSizePx = currentFontSizePx, + currentFontSizePx = currentFontSizePx, + inheritedFontSizePx = currentFontSizePx, + ) computedLineHeight.value .resolvePx(context, LengthPercentBase.CurrentFontSize) .roundToInt() @@ -1947,7 +1967,7 @@ abstract class DOMNode( ascenderPx = ascenderPx, descenderPx = descenderPx, topLeadingPx = topLeadingPx, - bottomLeadingPx = bottomLeadingPx + bottomLeadingPx = bottomLeadingPx, ) } @@ -1955,42 +1975,47 @@ abstract class DOMNode( val metrics = ctx.fontLineMetrics(fontId, fontSizePx) ?: return null if (metrics.emSize <= 0f || metrics.lineHeightEm <= 0f) return null val scalePx = fontSizePx / metrics.emSize - val lineHeightPx = kotlin.math.ceil(metrics.lineHeightEm * scalePx).toInt().coerceAtLeast(1) + val lineHeightPx = + kotlin.math + .ceil(metrics.lineHeightEm * scalePx) + .toInt() + .coerceAtLeast(1) val ascenderPx = (metrics.ascenderEm * scalePx).coerceAtLeast(0f) val descenderPx = kotlin.math.abs(metrics.descenderEm * scalePx) return NativeFontMetricsPx( lineHeightPx = lineHeightPx, ascenderPx = ascenderPx, - descenderPx = descenderPx + descenderPx = descenderPx, ) } - protected fun parseTextForFormatting(rawText: String): ParsedText { - return MinecraftFormattingParser.parse(rawText, textFormatting) - } + protected fun parseTextForFormatting(rawText: String): ParsedText = + MinecraftFormattingParser.parse(rawText, textFormatting) - protected fun baseTextStyleFlags(): TextStyleFlags { - return TextStyleFlags( + protected fun baseTextStyleFlags(): TextStyleFlags = + TextStyleFlags( bold = fontWeight == FontWeight.Bold, italic = fontStyle == FontStyle.Italic, - underline = textDecoration == TextDecoration.Underline || + underline = + textDecoration == TextDecoration.Underline || textDecoration == TextDecoration.UnderlineStrikethrough, - strikethrough = textDecoration == TextDecoration.Strikethrough || + strikethrough = + textDecoration == TextDecoration.Strikethrough || textDecoration == TextDecoration.UnderlineStrikethrough, - obfuscated = textObfuscated + obfuscated = textObfuscated, ) - } protected fun measureText(ctx: UiMeasureContext, text: String): Int { val metrics = resolveTextMetrics(ctx) val parsed = parseTextForFormatting(text) val plainText = parsed.plainText val base = ctx.measureText(plainText, fontId, metrics.fontSizePx) - val extraBold = TextStyleMetrics.boldExtraPxForRange( - plainText = plainText, - spans = parsed.spans, - baseFlags = baseTextStyleFlags() - ) + val extraBold = + TextStyleMetrics.boldExtraPxForRange( + plainText = plainText, + spans = parsed.spans, + baseFlags = baseTextStyleFlags(), + ) return base + extraBold } @@ -2000,7 +2025,7 @@ abstract class DOMNode( x: Int, y: Int, color: Int, - styleSpans: List = emptyList() + styleSpans: List = emptyList(), ): RenderCommand.DrawText { val metrics = resolveTextMetrics(ctx) val baseFlags = baseTextStyleFlags() @@ -2018,7 +2043,7 @@ abstract class DOMNode( strikethrough = baseFlags.strikethrough, obfuscated = baseFlags.obfuscated, textStyleSpans = styleSpans, - sourceKey = key?.toString() + sourceKey = key?.toString(), ) } @@ -2026,27 +2051,17 @@ abstract class DOMNode( protected fun contentY(): Int = bounds.y + border.top + padding.top - protected fun contentWidth(): Int = - (bounds.width - border.horizontal - padding.horizontal).coerceAtLeast(0) + protected fun contentWidth(): Int = (bounds.width - border.horizontal - padding.horizontal).coerceAtLeast(0) - protected fun contentHeight(): Int = - (bounds.height - border.vertical - padding.vertical).coerceAtLeast(0) + protected fun contentHeight(): Int = (bounds.height - border.vertical - padding.vertical).coerceAtLeast(0) - protected fun viewportContentX(): Int { - return scrollContainerState().viewportRect.x - } + protected fun viewportContentX(): Int = scrollContainerState().viewportRect.x - protected fun viewportContentY(): Int { - return scrollContainerState().viewportRect.y - } + protected fun viewportContentY(): Int = scrollContainerState().viewportRect.y - protected fun viewportContentWidth(): Int { - return scrollContainerState().viewportRect.width - } + protected fun viewportContentWidth(): Int = scrollContainerState().viewportRect.width - protected fun viewportContentHeight(): Int { - return scrollContainerState().viewportRect.height - } + protected fun viewportContentHeight(): Int = scrollContainerState().viewportRect.height protected fun setContentLayoutScroll(scrollX: Int, scrollY: Int) { contentLayoutScrollX = scrollX @@ -2102,13 +2117,11 @@ abstract class DOMNode( return ScrollInvalidationState( layoutDirty = layoutDirty, visualDirty = visualDirty, - interactionDirty = interactionDirty + interactionDirty = interactionDirty, ) } - internal fun consumeScrollLayoutDirtyRecursively(): Boolean { - return consumeScrollInvalidationRecursively().layoutDirty - } + internal fun consumeScrollLayoutDirtyRecursively(): Boolean = consumeScrollInvalidationRecursively().layoutDirty internal fun invalidateStickyVisualOffsetsRecursively() { ScrollPerformanceCounters.incrementStickyVisualInvalidateRuns() @@ -2202,12 +2215,13 @@ abstract class DOMNode( return 0 } var translated = 0 - val next = Rect( - x = bounds.x + deltaX, - y = bounds.y + deltaY, - width = bounds.width, - height = bounds.height - ) + val next = + Rect( + x = bounds.x + deltaX, + y = bounds.y + deltaY, + width = bounds.width, + height = bounds.height, + ) if (next != bounds) { bounds = next markRenderCommandsDirty() @@ -2219,16 +2233,15 @@ abstract class DOMNode( return translated } - internal fun debugScrollAnimationState(): ScrollAnimationDebugState { - return ScrollAnimationDebugState( + internal fun debugScrollAnimationState(): ScrollAnimationDebugState = + ScrollAnimationDebugState( targetX = scrollOffsetTargetX, targetY = scrollOffsetTargetY, displayedX = scrollOffsetDisplayedX, displayedY = scrollOffsetDisplayedY, resolvedX = scrollOffsetResolvedX, - resolvedY = scrollOffsetResolvedY + resolvedY = scrollOffsetResolvedY, ) - } internal fun captureScrollSessionSnapshot(): ScrollSessionSnapshot { val animation = debugScrollAnimationState() @@ -2239,7 +2252,7 @@ abstract class DOMNode( displayedY = if (animation.displayedY.isFinite()) animation.displayedY.coerceAtLeast(0.0) else 0.0, resolvedX = animation.resolvedX.coerceAtLeast(0), resolvedY = animation.resolvedY.coerceAtLeast(0), - dragSession = debugScrollbarDragSession() + dragSession = debugScrollbarDragSession(), ) } @@ -2299,16 +2312,17 @@ abstract class DOMNode( } } else { val nextAxis = if (drag.verticalAxis) ScrollbarAxis.Vertical else ScrollbarAxis.Horizontal - val nextSession = ScrollbarDragSession( - axis = nextAxis, - trackStartPx = drag.trackStartPx, - trackLengthPx = drag.trackLengthPx, - thumbLengthPx = drag.thumbLengthPx, - maxThumbTravelPx = drag.maxThumbTravelPx, - maxScroll = drag.maxScroll.coerceAtLeast(0), - grabOffsetPx = drag.grabOffsetPx.coerceAtLeast(0), - initialResolvedScroll = drag.initialResolvedScroll.coerceAtLeast(0) - ) + val nextSession = + ScrollbarDragSession( + axis = nextAxis, + trackStartPx = drag.trackStartPx, + trackLengthPx = drag.trackLengthPx, + thumbLengthPx = drag.thumbLengthPx, + maxThumbTravelPx = drag.maxThumbTravelPx, + maxScroll = drag.maxScroll.coerceAtLeast(0), + grabOffsetPx = drag.grabOffsetPx.coerceAtLeast(0), + initialResolvedScroll = drag.initialResolvedScroll.coerceAtLeast(0), + ) if (activeScrollbarDragAxis != nextAxis || scrollbarDragSession != nextSession) { activeScrollbarDragAxis = nextAxis scrollbarDragSession = nextSession @@ -2320,17 +2334,13 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = layoutChanged, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } } - private fun markScrollInvalidation( - layoutDirty: Boolean, - visualDirty: Boolean, - interactionDirty: Boolean - ) { + private fun markScrollInvalidation(layoutDirty: Boolean, visualDirty: Boolean, interactionDirty: Boolean) { if (layoutDirty) { scrollLayoutDirty = true } @@ -2371,7 +2381,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } @@ -2385,14 +2395,16 @@ abstract class DOMNode( scrollContainer = state.axisX.scrollContainer, maxScroll = state.maxScrollX, vertical = false, - dtSeconds = normalizedDt - ) || changed + dtSeconds = normalizedDt, + ) || + changed changed = advanceScrollAnimationAxis( scrollContainer = state.axisY.scrollContainer, maxScroll = state.maxScrollY, vertical = true, - dtSeconds = normalizedDt - ) || changed + dtSeconds = normalizedDt, + ) || + changed return changed } @@ -2400,7 +2412,7 @@ abstract class DOMNode( scrollContainer: Boolean, maxScroll: Int, vertical: Boolean, - dtSeconds: Double + dtSeconds: Double, ): Boolean { if (!scrollContainer) { return normalizeScrollAxisForNonScrollable(vertical) @@ -2429,7 +2441,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) } return changed @@ -2444,18 +2456,19 @@ abstract class DOMNode( } val target = clampedTarget.toDouble() - val nextDisplayed = if (dtSeconds <= 0.0) { - normalizedDisplayed - } else { - val delta = target - normalizedDisplayed - if (kotlin.math.abs(delta) <= wheelScrollSnapThresholdPx()) { - target + val nextDisplayed = + if (dtSeconds <= 0.0) { + normalizedDisplayed } else { - val alpha = 1.0 - kotlin.math.exp(-wheelScrollSmoothingPerSecond() * dtSeconds) - val eased = normalizedDisplayed + delta * alpha.coerceIn(0.0, 1.0) - if (kotlin.math.abs(target - eased) <= wheelScrollSnapThresholdPx()) target else eased - } - }.coerceIn(0.0, maxScroll.toDouble()) + val delta = target - normalizedDisplayed + if (kotlin.math.abs(delta) <= wheelScrollSnapThresholdPx()) { + target + } else { + val alpha = 1.0 - kotlin.math.exp(-wheelScrollSmoothingPerSecond() * dtSeconds) + val eased = normalizedDisplayed + delta * alpha.coerceIn(0.0, 1.0) + if (kotlin.math.abs(target - eased) <= wheelScrollSnapThresholdPx()) target else eased + } + }.coerceIn(0.0, maxScroll.toDouble()) if (nextDisplayed != displayedScrollAxis(vertical)) { setDisplayedScrollAxis(vertical, nextDisplayed) changed = true @@ -2471,7 +2484,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) } return changed @@ -2495,32 +2508,27 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } return changed } - private fun targetScrollAxis(vertical: Boolean): Int { - return if (vertical) scrollOffsetTargetY else scrollOffsetTargetX - } + private fun targetScrollAxis(vertical: Boolean): Int = if (vertical) scrollOffsetTargetY else scrollOffsetTargetX - private fun isScrollbarAxisActivelyDragged(vertical: Boolean): Boolean { - return when (activeScrollbarDragAxis) { + private fun isScrollbarAxisActivelyDragged(vertical: Boolean): Boolean = + when (activeScrollbarDragAxis) { ScrollbarAxis.Vertical -> vertical ScrollbarAxis.Horizontal -> !vertical null -> false } - } - private fun displayedScrollAxis(vertical: Boolean): Double { - return if (vertical) scrollOffsetDisplayedY else scrollOffsetDisplayedX - } + private fun displayedScrollAxis(vertical: Boolean): Double = + if (vertical) scrollOffsetDisplayedY else scrollOffsetDisplayedX - private fun resolvedScrollAxis(vertical: Boolean): Int { - return if (vertical) scrollOffsetResolvedY else scrollOffsetResolvedX - } + private fun resolvedScrollAxis(vertical: Boolean): Int = + if (vertical) scrollOffsetResolvedY else scrollOffsetResolvedX private fun setTargetScrollAxis(vertical: Boolean, value: Int) { if (vertical) { @@ -2548,51 +2556,59 @@ abstract class DOMNode( fun scrollContainerState(): ScrollContainerState { ScrollPerformanceCounters.incrementScrollContainerStateCalls() - val baseViewportRect = Rect( - x = contentX(), - y = contentY(), - width = contentWidth(), - height = contentHeight() - ) - val contentExtent = computeContentExtent( - contentOriginX = baseViewportRect.x, - contentOriginY = baseViewportRect.y, - layoutScrollX = contentLayoutScrollX, - layoutScrollY = contentLayoutScrollY - ) - val scrollbarResolution = resolveScrollbarResolution( - overflowX = overflowX, - overflowY = overflowY, - contentExtent = contentExtent, - baseViewportWidth = baseViewportRect.width, - baseViewportHeight = baseViewportRect.height - ) - val viewportRect = Rect( - x = baseViewportRect.x, - y = baseViewportRect.y, - width = scrollbarResolution.viewportWidth, - height = scrollbarResolution.viewportHeight - ) - val axisX = axisStateForOverflow( - overflowMode = overflowX, - scrollbarPresent = scrollbarResolution.horizontalPresent, - scrollbarGutter = scrollbarResolution.horizontalGutter - ) - val axisY = axisStateForOverflow( - overflowMode = overflowY, - scrollbarPresent = scrollbarResolution.verticalPresent, - scrollbarGutter = scrollbarResolution.verticalGutter - ) - val maxScrollX = if (axisX.scrollContainer) { - (contentExtent.width - viewportRect.width).coerceAtLeast(0) - } else { - 0 - } - val maxScrollY = if (axisY.scrollContainer) { - (contentExtent.height - viewportRect.height).coerceAtLeast(0) - } else { - 0 - } + val baseViewportRect = + Rect( + x = contentX(), + y = contentY(), + width = contentWidth(), + height = contentHeight(), + ) + val contentExtent = + computeContentExtent( + contentOriginX = baseViewportRect.x, + contentOriginY = baseViewportRect.y, + layoutScrollX = contentLayoutScrollX, + layoutScrollY = contentLayoutScrollY, + ) + val scrollbarResolution = + resolveScrollbarResolution( + overflowX = overflowX, + overflowY = overflowY, + contentExtent = contentExtent, + baseViewportWidth = baseViewportRect.width, + baseViewportHeight = baseViewportRect.height, + ) + val viewportRect = + Rect( + x = baseViewportRect.x, + y = baseViewportRect.y, + width = scrollbarResolution.viewportWidth, + height = scrollbarResolution.viewportHeight, + ) + val axisX = + axisStateForOverflow( + overflowMode = overflowX, + scrollbarPresent = scrollbarResolution.horizontalPresent, + scrollbarGutter = scrollbarResolution.horizontalGutter, + ) + val axisY = + axisStateForOverflow( + overflowMode = overflowY, + scrollbarPresent = scrollbarResolution.verticalPresent, + scrollbarGutter = scrollbarResolution.verticalGutter, + ) + val maxScrollX = + if (axisX.scrollContainer) { + (contentExtent.width - viewportRect.width).coerceAtLeast(0) + } else { + 0 + } + val maxScrollY = + if (axisY.scrollContainer) { + (contentExtent.height - viewportRect.height).coerceAtLeast(0) + } else { + 0 + } if (axisX.scrollContainer) { var axisChanged = false val axisDragged = isScrollbarAxisActivelyDragged(vertical = false) @@ -2617,7 +2633,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } @@ -2648,7 +2664,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } @@ -2666,14 +2682,14 @@ abstract class DOMNode( horizontalScrollbarGutter = scrollbarResolution.horizontalGutter, verticalScrollbarGutter = scrollbarResolution.verticalGutter, axisX = axisX, - axisY = axisY + axisY = axisY, ) } private fun axisStateForOverflow( overflowMode: Overflow, scrollbarPresent: Boolean, - scrollbarGutter: Int + scrollbarGutter: Int, ): ScrollAxisState { val scrollContainer = overflowMode != Overflow.Visible val supportsScrollbar = overflowMode == Overflow.Scroll || overflowMode == Overflow.Auto @@ -2682,7 +2698,7 @@ abstract class DOMNode( scrollContainer = scrollContainer, clipsToViewport = scrollContainer, scrollbarPresent = supportsScrollbar && scrollbarPresent, - scrollbarGutter = if (supportsScrollbar && scrollbarPresent) scrollbarGutter.coerceAtLeast(0) else 0 + scrollbarGutter = if (supportsScrollbar && scrollbarPresent) scrollbarGutter.coerceAtLeast(0) else 0, ) } @@ -2707,7 +2723,7 @@ abstract class DOMNode( overflowY: Overflow, contentExtent: Size, baseViewportWidth: Int, - baseViewportHeight: Int + baseViewportHeight: Int, ): ScrollbarResolution { val thickness = scrollbarThicknessPx().coerceAtLeast(0) var horizontalPresent = overflowX == Overflow.Scroll @@ -2715,16 +2731,18 @@ abstract class DOMNode( repeat(3) { val viewportWidth = (baseViewportWidth - if (verticalPresent) thickness else 0).coerceAtLeast(0) val viewportHeight = (baseViewportHeight - if (horizontalPresent) thickness else 0).coerceAtLeast(0) - val nextHorizontal = when (overflowX) { - Overflow.Visible, Overflow.Hidden -> false - Overflow.Scroll -> true - Overflow.Auto -> contentExtent.width > viewportWidth - } - val nextVertical = when (overflowY) { - Overflow.Visible, Overflow.Hidden -> false - Overflow.Scroll -> true - Overflow.Auto -> contentExtent.height > viewportHeight - } + val nextHorizontal = + when (overflowX) { + Overflow.Visible, Overflow.Hidden -> false + Overflow.Scroll -> true + Overflow.Auto -> contentExtent.width > viewportWidth + } + val nextVertical = + when (overflowY) { + Overflow.Visible, Overflow.Hidden -> false + Overflow.Scroll -> true + Overflow.Auto -> contentExtent.height > viewportHeight + } if (nextHorizontal == horizontalPresent && nextVertical == verticalPresent) { return ScrollbarResolution( horizontalPresent = nextHorizontal, @@ -2732,7 +2750,7 @@ abstract class DOMNode( horizontalGutter = if (nextHorizontal) thickness else 0, verticalGutter = if (nextVertical) thickness else 0, viewportWidth = viewportWidth, - viewportHeight = viewportHeight + viewportHeight = viewportHeight, ) } horizontalPresent = nextHorizontal @@ -2746,50 +2764,58 @@ abstract class DOMNode( horizontalGutter = if (horizontalPresent) thickness else 0, verticalGutter = if (verticalPresent) thickness else 0, viewportWidth = viewportWidth, - viewportHeight = viewportHeight + viewportHeight = viewportHeight, ) } private fun scrollbarVisualState(state: ScrollContainerState = scrollContainerState()): ScrollbarVisualState { - val verticalTrack = if (state.axisY.scrollbarPresent && state.verticalScrollbarGutter > 0) { - Rect( - x = state.viewportRect.x + state.viewportRect.width, - y = state.viewportRect.y, - width = state.verticalScrollbarGutter.coerceAtLeast(1), - height = state.viewportRect.height.coerceAtLeast(0) + val verticalTrack = + if (state.axisY.scrollbarPresent && state.verticalScrollbarGutter > 0) { + Rect( + x = state.viewportRect.x + state.viewportRect.width, + y = state.viewportRect.y, + width = state.verticalScrollbarGutter.coerceAtLeast(1), + height = + state.viewportRect.height + .coerceAtLeast(0), + ) + } else { + null + } + val horizontalTrack = + if (state.axisX.scrollbarPresent && state.horizontalScrollbarGutter > 0) { + Rect( + x = state.viewportRect.x, + y = state.viewportRect.y + state.viewportRect.height, + width = + state.viewportRect.width + .coerceAtLeast(0), + height = state.horizontalScrollbarGutter.coerceAtLeast(1), + ) + } else { + null + } + val vertical = + buildScrollbarVisualAxis( + trackRect = verticalTrack, + viewportExtent = state.viewportRect.height, + contentExtent = state.contentExtent.height, + scrollOffset = displayedScrollAxis(vertical = true).coerceIn(0.0, state.maxScrollY.toDouble()), + maxScroll = state.maxScrollY, + verticalAxis = true, ) - } else { - null - } - val horizontalTrack = if (state.axisX.scrollbarPresent && state.horizontalScrollbarGutter > 0) { - Rect( - x = state.viewportRect.x, - y = state.viewportRect.y + state.viewportRect.height, - width = state.viewportRect.width.coerceAtLeast(0), - height = state.horizontalScrollbarGutter.coerceAtLeast(1) + val horizontal = + buildScrollbarVisualAxis( + trackRect = horizontalTrack, + viewportExtent = state.viewportRect.width, + contentExtent = state.contentExtent.width, + scrollOffset = displayedScrollAxis(vertical = false).coerceIn(0.0, state.maxScrollX.toDouble()), + maxScroll = state.maxScrollX, + verticalAxis = false, ) - } else { - null - } - val vertical = buildScrollbarVisualAxis( - trackRect = verticalTrack, - viewportExtent = state.viewportRect.height, - contentExtent = state.contentExtent.height, - scrollOffset = displayedScrollAxis(vertical = true).coerceIn(0.0, state.maxScrollY.toDouble()), - maxScroll = state.maxScrollY, - verticalAxis = true - ) - val horizontal = buildScrollbarVisualAxis( - trackRect = horizontalTrack, - viewportExtent = state.viewportRect.width, - contentExtent = state.contentExtent.width, - scrollOffset = displayedScrollAxis(vertical = false).coerceIn(0.0, state.maxScrollX.toDouble()), - maxScroll = state.maxScrollX, - verticalAxis = false - ) return ScrollbarVisualState( horizontal = horizontal, - vertical = vertical + vertical = vertical, ) } @@ -2799,93 +2825,108 @@ abstract class DOMNode( contentExtent: Int, scrollOffset: Double, maxScroll: Int, - verticalAxis: Boolean + verticalAxis: Boolean, ): ScrollbarVisualAxis? { val track = trackRect ?: return null val trackExtent = if (verticalAxis) track.height else track.width if (trackExtent <= 0) return null val minThumb = minScrollbarThumbSizePx().coerceAtLeast(1) - val rawThumbExtent = if (contentExtent <= 0 || viewportExtent <= 0) { - trackExtent - } else { - val ratio = viewportExtent.toFloat() / contentExtent.toFloat() - (trackExtent.toFloat() * ratio).roundToInt() - } + val rawThumbExtent = + if (contentExtent <= 0 || viewportExtent <= 0) { + trackExtent + } else { + val ratio = viewportExtent.toFloat() / contentExtent.toFloat() + (trackExtent.toFloat() * ratio).roundToInt() + } val thumbExtent = rawThumbExtent.coerceIn(minThumb.coerceAtMost(trackExtent), trackExtent) val thumbTravel = (trackExtent - thumbExtent).coerceAtLeast(0) - val thumbOffset = if (maxScroll <= 0 || thumbTravel <= 0) { - 0 - } else { - val ratio = (scrollOffset.coerceIn(0.0, maxScroll.toDouble()) / maxScroll.toDouble()).toFloat() - (ratio * thumbTravel.toFloat()).roundToInt().coerceIn(0, thumbTravel) - } - val thumbRect = if (verticalAxis) { - Rect(track.x, track.y + thumbOffset, track.width, thumbExtent) - } else { - Rect(track.x + thumbOffset, track.y, thumbExtent, track.height) - } + val thumbOffset = + if (maxScroll <= 0 || thumbTravel <= 0) { + 0 + } else { + val ratio = (scrollOffset.coerceIn(0.0, maxScroll.toDouble()) / maxScroll.toDouble()).toFloat() + (ratio * thumbTravel.toFloat()).roundToInt().coerceIn(0, thumbTravel) + } + val thumbRect = + if (verticalAxis) { + Rect(track.x, track.y + thumbOffset, track.width, thumbExtent) + } else { + Rect(track.x + thumbOffset, track.y, thumbExtent, track.height) + } return ScrollbarVisualAxis( trackRect = track, thumbRect = thumbRect, maxScroll = maxScroll.coerceAtLeast(0), - scrollOffset = scrollOffset.roundToInt().coerceAtLeast(0) + scrollOffset = scrollOffset.roundToInt().coerceAtLeast(0), ) } private fun appendScrollbarCommands(out: MutableList, state: ScrollContainerState) { val visuals = scrollbarVisualState(state) visuals.vertical?.let { axis -> - out += RenderCommand.DrawRect( - x = axis.trackRect.x, - y = axis.trackRect.y, - width = axis.trackRect.width, - height = axis.trackRect.height, - color = scrollbarTrackColor() - ) - val thumbColor = if (activeScrollbarDragAxis == ScrollbarAxis.Vertical) { - scrollbarThumbActiveColor() - } else { - scrollbarThumbColor() - } - out += RenderCommand.DrawRect( - x = axis.thumbRect.x, - y = axis.thumbRect.y, - width = axis.thumbRect.width, - height = axis.thumbRect.height, - color = thumbColor - ) + out += + RenderCommand.DrawRect( + x = axis.trackRect.x, + y = axis.trackRect.y, + width = axis.trackRect.width, + height = axis.trackRect.height, + color = scrollbarTrackColor(), + ) + val thumbColor = + if (activeScrollbarDragAxis == ScrollbarAxis.Vertical) { + scrollbarThumbActiveColor() + } else { + scrollbarThumbColor() + } + out += + RenderCommand.DrawRect( + x = axis.thumbRect.x, + y = axis.thumbRect.y, + width = axis.thumbRect.width, + height = axis.thumbRect.height, + color = thumbColor, + ) } visuals.horizontal?.let { axis -> - out += RenderCommand.DrawRect( - x = axis.trackRect.x, - y = axis.trackRect.y, - width = axis.trackRect.width, - height = axis.trackRect.height, - color = scrollbarTrackColor() - ) - val thumbColor = if (activeScrollbarDragAxis == ScrollbarAxis.Horizontal) { - scrollbarThumbActiveColor() - } else { - scrollbarThumbColor() - } - out += RenderCommand.DrawRect( - x = axis.thumbRect.x, - y = axis.thumbRect.y, - width = axis.thumbRect.width, - height = axis.thumbRect.height, - color = thumbColor - ) + out += + RenderCommand.DrawRect( + x = axis.trackRect.x, + y = axis.trackRect.y, + width = axis.trackRect.width, + height = axis.trackRect.height, + color = scrollbarTrackColor(), + ) + val thumbColor = + if (activeScrollbarDragAxis == ScrollbarAxis.Horizontal) { + scrollbarThumbActiveColor() + } else { + scrollbarThumbColor() + } + out += + RenderCommand.DrawRect( + x = axis.thumbRect.x, + y = axis.thumbRect.y, + width = axis.thumbRect.width, + height = axis.thumbRect.height, + color = thumbColor, + ) } } private fun resolveScrollbarDragAxisAt(mouseX: Int, mouseY: Int): ScrollbarAxis? { if (!containsGlobalPoint(mouseX, mouseY)) return null val visuals = scrollbarVisualState() - if (visuals.vertical?.trackRect?.contains(mouseX, mouseY) == true) { + if (visuals.vertical + ?.trackRect + ?.contains(mouseX, mouseY) == true + ) { return ScrollbarAxis.Vertical } - if (visuals.horizontal?.trackRect?.contains(mouseX, mouseY) == true) { + if (visuals.horizontal + ?.trackRect + ?.contains(mouseX, mouseY) == true + ) { return ScrollbarAxis.Horizontal } return null @@ -2896,12 +2937,13 @@ abstract class DOMNode( val vertical = visuals.vertical if (vertical != null && vertical.trackRect.contains(mouseX, mouseY)) { activeScrollbarDragAxis = ScrollbarAxis.Vertical - scrollbarDragSession = beginScrollbarDragSession( - axis = ScrollbarAxis.Vertical, - visual = vertical, - pointerPx = mouseY, - pointerInsideThumb = vertical.thumbRect.contains(mouseX, mouseY) - ) + scrollbarDragSession = + beginScrollbarDragSession( + axis = ScrollbarAxis.Vertical, + visual = vertical, + pointerPx = mouseY, + pointerInsideThumb = vertical.thumbRect.contains(mouseX, mouseY), + ) markRenderCommandsDirty() updateScrollbarPointerDrag(mouseX, mouseY) return true @@ -2910,12 +2952,13 @@ abstract class DOMNode( val horizontal = visuals.horizontal if (horizontal != null && horizontal.trackRect.contains(mouseX, mouseY)) { activeScrollbarDragAxis = ScrollbarAxis.Horizontal - scrollbarDragSession = beginScrollbarDragSession( - axis = ScrollbarAxis.Horizontal, - visual = horizontal, - pointerPx = mouseX, - pointerInsideThumb = horizontal.thumbRect.contains(mouseX, mouseY) - ) + scrollbarDragSession = + beginScrollbarDragSession( + axis = ScrollbarAxis.Horizontal, + visual = horizontal, + pointerPx = mouseX, + pointerInsideThumb = horizontal.thumbRect.contains(mouseX, mouseY), + ) markRenderCommandsDirty() updateScrollbarPointerDrag(mouseX, mouseY) return true @@ -2927,33 +2970,37 @@ abstract class DOMNode( axis: ScrollbarAxis, visual: ScrollbarVisualAxis, pointerPx: Int, - pointerInsideThumb: Boolean + pointerInsideThumb: Boolean, ): ScrollbarDragSession { val track = visual.trackRect val thumb = visual.thumbRect - val trackLengthPx = if (axis == ScrollbarAxis.Vertical) { - track.height.coerceAtLeast(0) - } else { - track.width.coerceAtLeast(0) - } - val thumbLengthPx = if (axis == ScrollbarAxis.Vertical) { - thumb.height.coerceAtLeast(1) - } else { - thumb.width.coerceAtLeast(1) - } + val trackLengthPx = + if (axis == ScrollbarAxis.Vertical) { + track.height.coerceAtLeast(0) + } else { + track.width.coerceAtLeast(0) + } + val thumbLengthPx = + if (axis == ScrollbarAxis.Vertical) { + thumb.height.coerceAtLeast(1) + } else { + thumb.width.coerceAtLeast(1) + } val trackStartPx = if (axis == ScrollbarAxis.Vertical) track.y else track.x val thumbStartPx = if (axis == ScrollbarAxis.Vertical) thumb.y else thumb.x val maxThumbTravelPx = (trackLengthPx - thumbLengthPx).coerceAtLeast(0) - val grabOffsetPx = if (pointerInsideThumb) { - (pointerPx - thumbStartPx).coerceIn(0, (thumbLengthPx - 1).coerceAtLeast(0)) - } else { - (thumbLengthPx / 2).coerceAtLeast(0) - } - val initialResolvedScroll = if (axis == ScrollbarAxis.Vertical) { - resolvedScrollAxis(vertical = true) - } else { - resolvedScrollAxis(vertical = false) - } + val grabOffsetPx = + if (pointerInsideThumb) { + (pointerPx - thumbStartPx).coerceIn(0, (thumbLengthPx - 1).coerceAtLeast(0)) + } else { + (thumbLengthPx / 2).coerceAtLeast(0) + } + val initialResolvedScroll = + if (axis == ScrollbarAxis.Vertical) { + resolvedScrollAxis(vertical = true) + } else { + resolvedScrollAxis(vertical = false) + } return ScrollbarDragSession( axis = axis, trackStartPx = trackStartPx, @@ -2962,24 +3009,26 @@ abstract class DOMNode( maxThumbTravelPx = maxThumbTravelPx, maxScroll = visual.maxScroll.coerceAtLeast(0), grabOffsetPx = grabOffsetPx, - initialResolvedScroll = initialResolvedScroll + initialResolvedScroll = initialResolvedScroll, ) } private fun updateScrollbarPointerDrag(mouseX: Int, mouseY: Int) { val session = scrollbarDragSession ?: return val pointerAxisPx = if (session.axis == ScrollbarAxis.Vertical) mouseY else mouseX - val desiredThumbStartPx = if (session.maxThumbTravelPx <= 0) { - 0 - } else { - (pointerAxisPx - session.trackStartPx - session.grabOffsetPx).coerceIn(0, session.maxThumbTravelPx) - } - val desiredScroll = if (session.maxThumbTravelPx <= 0 || session.maxScroll <= 0) { - 0 - } else { - val ratio = desiredThumbStartPx.toDouble() / session.maxThumbTravelPx.toDouble() - (ratio * session.maxScroll.toDouble()).roundToInt().coerceIn(0, session.maxScroll) - } + val desiredThumbStartPx = + if (session.maxThumbTravelPx <= 0) { + 0 + } else { + (pointerAxisPx - session.trackStartPx - session.grabOffsetPx).coerceIn(0, session.maxThumbTravelPx) + } + val desiredScroll = + if (session.maxThumbTravelPx <= 0 || session.maxScroll <= 0) { + 0 + } else { + val ratio = desiredThumbStartPx.toDouble() / session.maxThumbTravelPx.toDouble() + (ratio * session.maxScroll.toDouble()).roundToInt().coerceIn(0, session.maxScroll) + } var nextScrollX = resolvedScrollAxis(vertical = false) var nextScrollY = resolvedScrollAxis(vertical = true) @@ -2990,14 +3039,11 @@ abstract class DOMNode( } applyDragScrollOffsets( scrollX = nextScrollX, - scrollY = nextScrollY + scrollY = nextScrollY, ) } - private fun applyDragScrollOffsets( - scrollX: Int, - scrollY: Int - ) { + private fun applyDragScrollOffsets(scrollX: Int, scrollY: Int) { val resolvedX = scrollX.coerceAtLeast(0) val resolvedY = scrollY.coerceAtLeast(0) val clampedX = resolvedX.toDouble() @@ -3031,7 +3077,7 @@ abstract class DOMNode( markScrollInvalidation( layoutDirty = false, visualDirty = true, - interactionDirty = true + interactionDirty = true, ) markRenderCommandsDirty() } @@ -3041,7 +3087,7 @@ abstract class DOMNode( contentOriginX: Int, contentOriginY: Int, layoutScrollX: Int, - layoutScrollY: Int + layoutScrollY: Int, ): Size { var maxWidth = 0 var maxHeight = 0 @@ -3069,8 +3115,22 @@ abstract class DOMNode( return Rect( x = if (clipX) state.viewportRect.x else root.bounds.x, y = if (clipY) state.viewportRect.y else root.bounds.y, - width = if (clipX) state.viewportRect.width.coerceAtLeast(0) else root.bounds.width.coerceAtLeast(0), - height = if (clipY) state.viewportRect.height.coerceAtLeast(0) else root.bounds.height.coerceAtLeast(0) + width = + if (clipX) { + state.viewportRect.width + .coerceAtLeast(0) + } else { + root.bounds.width + .coerceAtLeast(0) + }, + height = + if (clipY) { + state.viewportRect.height + .coerceAtLeast(0) + } else { + root.bounds.height + .coerceAtLeast(0) + }, ) } @@ -3114,11 +3174,12 @@ abstract class DOMNode( while (current != null) { val clipRect = current.overflowViewportRect() if (clipRect != null) { - effectiveRect = if (effectiveRect == null) { - clipRect - } else { - effectiveRect.intersection(clipRect) ?: return Rect(0, 0, 0, 0) - } + effectiveRect = + if (effectiveRect == null) { + clipRect + } else { + effectiveRect.intersection(clipRect) ?: return Rect(0, 0, 0, 0) + } } current = current.parent } @@ -3134,9 +3195,7 @@ abstract class DOMNode( } } - internal fun debugScrollbarVisualState(): ScrollbarVisualState { - return scrollbarVisualState() - } + internal fun debugScrollbarVisualState(): ScrollbarVisualState = scrollbarVisualState() internal fun debugScrollbarDragSession(): ScrollbarDragSessionDebugState? { val session = scrollbarDragSession ?: return null @@ -3148,7 +3207,7 @@ abstract class DOMNode( maxThumbTravelPx = session.maxThumbTravelPx, maxScroll = session.maxScroll, grabOffsetPx = session.grabOffsetPx, - initialResolvedScroll = session.initialResolvedScroll + initialResolvedScroll = session.initialResolvedScroll, ) } @@ -3253,21 +3312,23 @@ abstract class DOMNode( } } - private fun copyStyleDecls(source: StyleDeclarations): StyleDeclarations { - return StyleDeclarations( - values = linkedMapOf().apply { - putAll(source.values) - }, - importantProperties = linkedSetOf().apply { - addAll(source.importantProperties) - } + private fun copyStyleDecls(source: StyleDeclarations): StyleDeclarations = + StyleDeclarations( + values = + linkedMapOf().apply { + putAll(source.values) + }, + importantProperties = + linkedSetOf().apply { + addAll(source.importantProperties) + }, ) - } } private fun parseClassNames(value: String): Set { if (value.isBlank()) return emptySet() - return value.trim() + return value + .trim() .split(Regex("\\s+")) .filter { it.isNotBlank() } .toSet() @@ -3283,4 +3344,3 @@ fun T.applyParent(parent: DOMNode?): T { } return this } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DomElementEvents.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DomElementEvents.kt index 237496a..562a6ec 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DomElementEvents.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/DomElementEvents.kt @@ -117,4 +117,4 @@ fun DOMNode.onDragLeave(handler: (DragLeaveEvent) -> Unit) { /** Registers a drop handler for this node. */ fun DOMNode.onDrop(handler: (DropEvent) -> Unit) { EventBus.run { this@onDrop.addEventListener(Events.DROP, handler) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModel.kt index 1c7825f..2c2fba6 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModel.kt @@ -5,33 +5,34 @@ import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleProperty internal object PositionedLayoutModel { - private fun isLayoutRuntimePositionedMode(mode: PositionMode): Boolean { - return when (mode) { + private fun isLayoutRuntimePositionedMode(mode: PositionMode): Boolean = + when (mode) { PositionMode.Relative, PositionMode.Absolute, - PositionMode.Fixed -> true + PositionMode.Fixed, + -> true PositionMode.Static, - PositionMode.Sticky -> false + PositionMode.Sticky, + -> false } - } - private fun isOrderingPositionedMode(mode: PositionMode): Boolean { - return when (mode) { + private fun isOrderingPositionedMode(mode: PositionMode): Boolean = + when (mode) { PositionMode.Static -> false PositionMode.Relative, PositionMode.Absolute, PositionMode.Fixed, - PositionMode.Sticky -> true + PositionMode.Sticky, + -> true } - } data class RootStackingContextId( - val rootNode: DOMNode + val rootNode: DOMNode, ) enum class StackingParticipantKind { LocalNode, - ChildContext + ChildContext, } data class StackingParticipant( @@ -41,47 +42,42 @@ internal object PositionedLayoutModel { val priority: OrderingPriority, val kind: StackingParticipantKind, val createsChildContextHint: Boolean, - val rootContextPromotionTarget: RootStackingContextId? + val rootContextPromotionTarget: RootStackingContextId?, ) data class StackingContext( val id: RootStackingContextId, val ownerNode: DOMNode, val rootNode: DOMNode, - val participants: List + val participants: List, ) data class OffsetPrecedenceResolution( val sourceProperty: StyleProperty?, - val value: CssLength? + val value: CssLength?, ) data class OrderingPriority( val positionedBucket: Int, val zIndex: Int, - val domOrder: Int + val domOrder: Int, ) data class ChildEntry( val node: DOMNode, - val priority: OrderingPriority + val priority: OrderingPriority, ) - fun isPositioned(node: DOMNode): Boolean { - return isOrderingPositionedMode(node.position) - } + fun isPositioned(node: DOMNode): Boolean = isOrderingPositionedMode(node.position) - private fun effectiveOrderingZIndex(node: DOMNode): Int { - return if (isPositioned(node)) node.zIndex else 0 - } + private fun effectiveOrderingZIndex(node: DOMNode): Int = if (isPositioned(node)) node.zIndex else 0 - fun orderingPriority(node: DOMNode, domOrder: Int): OrderingPriority { - return OrderingPriority( + fun orderingPriority(node: DOMNode, domOrder: Int): OrderingPriority = + OrderingPriority( positionedBucket = if (isPositioned(node)) 1 else 0, zIndex = effectiveOrderingZIndex(node), - domOrder = domOrder + domOrder = domOrder, ) - } fun rootStackingScope(node: DOMNode): DOMNode { var current = node @@ -91,31 +87,28 @@ internal object PositionedLayoutModel { return current } - fun sharesRootStackingScope(first: DOMNode, second: DOMNode): Boolean { - return rootStackingScope(first) === rootStackingScope(second) - } + fun sharesRootStackingScope(first: DOMNode, second: DOMNode): Boolean = + rootStackingScope(first) === rootStackingScope(second) - fun rootStackingContextId(node: DOMNode): RootStackingContextId { - return RootStackingContextId(rootNode = rootStackingScope(node)) - } + fun rootStackingContextId(node: DOMNode): RootStackingContextId = + RootStackingContextId(rootNode = rootStackingScope(node)) - fun matchesChildContextTrigger(node: DOMNode): Boolean { - return isOrderingPositionedMode(node.position) && node.zIndex != 0 - } + fun matchesChildContextTrigger(node: DOMNode): Boolean = isOrderingPositionedMode(node.position) && node.zIndex != 0 fun stackingContextScaffold(owner: DOMNode): StackingContext { val root = rootStackingScope(owner) val contextId = RootStackingContextId(rootNode = root) - val participants = if (owner.parent == null) { - rootContextParticipants(owner, contextId) - } else { - localContextParticipants(owner) - } + val participants = + if (owner.parent == null) { + rootContextParticipants(owner, contextId) + } else { + localContextParticipants(owner) + } return StackingContext( id = contextId, ownerNode = owner, rootNode = root, - participants = participants + participants = participants, ) } @@ -130,9 +123,7 @@ internal object PositionedLayoutModel { return rootStackingScope(node) } - fun fixedViewportRoot(node: DOMNode): DOMNode { - return rootStackingScope(node) - } + fun fixedViewportRoot(node: DOMNode): DOMNode = rootStackingScope(node) private fun createsChildContextForLocalParticipation(node: DOMNode): Boolean { if (node.position == PositionMode.Fixed) { @@ -142,67 +133,78 @@ internal object PositionedLayoutModel { } private fun localContextParticipants(owner: DOMNode): List { - return owner.children.withIndex() - .filter { indexed -> indexed.value.position != PositionMode.Fixed } - .map { indexed -> - val child = indexed.value - val createsChildContextHint = createsChildContextForLocalParticipation(child) + val children = owner.children + val participants = ArrayList(children.size) + for ((index, element) in children.withIndex()) { + val child = element + if (child.position == PositionMode.Fixed) { + continue + } + val createsChildContextHint = createsChildContextForLocalParticipation(child) + participants += StackingParticipant( node = child, logicalParent = owner, - sourceDomOrder = indexed.index, - priority = orderingPriority(child, indexed.index), - kind = if (createsChildContextHint) { - StackingParticipantKind.ChildContext - } else { - StackingParticipantKind.LocalNode - }, + sourceDomOrder = index, + priority = orderingPriority(child, index), + kind = + if (createsChildContextHint) { + StackingParticipantKind.ChildContext + } else { + StackingParticipantKind.LocalNode + }, createsChildContextHint = createsChildContextHint, - rootContextPromotionTarget = null + rootContextPromotionTarget = null, ) - } + } + return participants } private fun rootContextParticipants(root: DOMNode, contextId: RootStackingContextId): List { val globalDomOrder = buildGlobalDomOrderMap(root) - val localParticipants = root.children.withIndex() - .filter { indexed -> indexed.value.position != PositionMode.Fixed } - .map { indexed -> - val child = indexed.value - val domOrder = globalDomOrder[child] ?: indexed.index - val createsChildContextHint = createsChildContextForLocalParticipation(child) - StackingParticipant( - node = child, - logicalParent = root, - sourceDomOrder = domOrder, - priority = orderingPriority(child, domOrder), - kind = if (createsChildContextHint) { - StackingParticipantKind.ChildContext - } else { - StackingParticipantKind.LocalNode - }, - createsChildContextHint = createsChildContextHint, - rootContextPromotionTarget = null - ) - } - val promotedFixedParticipants = collectPromotedFixedNodes(root) - .map { fixed -> - val domOrder = globalDomOrder[fixed] ?: Int.MAX_VALUE - StackingParticipant( - node = fixed, - logicalParent = fixed.parent ?: root, - sourceDomOrder = domOrder, - priority = orderingPriority(fixed, domOrder), - kind = StackingParticipantKind.ChildContext, - createsChildContextHint = true, - rootContextPromotionTarget = contextId - ) - } + val localParticipants = + root.children + .withIndex() + .filter { indexed -> indexed.value.position != PositionMode.Fixed } + .map { indexed -> + val child = indexed.value + val domOrder = globalDomOrder[child] ?: indexed.index + val createsChildContextHint = createsChildContextForLocalParticipation(child) + StackingParticipant( + node = child, + logicalParent = root, + sourceDomOrder = domOrder, + priority = orderingPriority(child, domOrder), + kind = + if (createsChildContextHint) { + StackingParticipantKind.ChildContext + } else { + StackingParticipantKind.LocalNode + }, + createsChildContextHint = createsChildContextHint, + rootContextPromotionTarget = null, + ) + } + val promotedFixedParticipants = + collectPromotedFixedNodes(root) + .map { fixed -> + val domOrder = globalDomOrder[fixed] ?: Int.MAX_VALUE + StackingParticipant( + node = fixed, + logicalParent = fixed.parent ?: root, + sourceDomOrder = domOrder, + priority = orderingPriority(fixed, domOrder), + kind = StackingParticipantKind.ChildContext, + createsChildContextHint = true, + rootContextPromotionTarget = contextId, + ) + } return localParticipants + promotedFixedParticipants } private fun collectPromotedFixedNodes(root: DOMNode): List { val out = ArrayList() + fun visit(node: DOMNode) { node.children.forEach { child -> if (child.position == PositionMode.Fixed) { @@ -218,6 +220,7 @@ internal object PositionedLayoutModel { private fun buildGlobalDomOrderMap(root: DOMNode): Map { val order = LinkedHashMap() var cursor = 0 + fun visit(node: DOMNode) { node.children.forEach { child -> order[child] = cursor @@ -229,50 +232,72 @@ internal object PositionedLayoutModel { return order } - fun resolveHorizontalOffset(left: CssLength?, right: CssLength?): OffsetPrecedenceResolution { - return when { + fun resolveHorizontalOffset(left: CssLength?, right: CssLength?): OffsetPrecedenceResolution = + when { left != null -> OffsetPrecedenceResolution(StyleProperty.LEFT, left) right != null -> OffsetPrecedenceResolution(StyleProperty.RIGHT, right) else -> OffsetPrecedenceResolution(null, null) } - } - fun resolveVerticalOffset(top: CssLength?, bottom: CssLength?): OffsetPrecedenceResolution { - return when { + fun resolveVerticalOffset(top: CssLength?, bottom: CssLength?): OffsetPrecedenceResolution = + when { top != null -> OffsetPrecedenceResolution(StyleProperty.TOP, top) bottom != null -> OffsetPrecedenceResolution(StyleProperty.BOTTOM, bottom) else -> OffsetPrecedenceResolution(null, null) } - } + + private val paintOrderComparator: Comparator = + compareBy( + { it.priority.positionedBucket }, + { it.priority.zIndex }, + { it.priority.domOrder }, + ) fun orderedParticipantsForPaint(owner: DOMNode): List { val participants = stackingContextScaffold(owner).participants if (participants.size <= 1) { return participants } - val hasPositioned = participants.any { isPositioned(it.node) } + var hasPositioned = false + for (index in participants.indices) { + if (isPositioned(participants[index].node)) { + hasPositioned = true + break + } + } if (!hasPositioned) { - return participants.sortedBy { it.priority.domOrder } + // Participants are built in DOM order; without positioned nodes the sort is a no-op. + return participants } - return participants.sortedWith( - compareBy( - { it.priority.positionedBucket }, - { it.priority.zIndex }, - { it.priority.domOrder } - ) - ) + return participants.sortedWith(paintOrderComparator) } - fun orderedParticipantsForHitTesting(owner: DOMNode): List { - return orderedParticipantsForPaint(owner).asReversed() - } + fun orderedParticipantsForHitTesting(owner: DOMNode): List = + orderedParticipantsForPaint(owner).asReversed() fun orderedChildrenForPaint(parent: DOMNode): List { + // Fast path for the per-frame chunk traversal: non-root owners with no positioned + // children paint in plain DOM order, and Fixed children (the only ones filtered out + // of local participation) are positioned by definition. Callers iterate read-only. + if (parent.parent != null && !hasPositionedChild(parent)) { + return parent.children + } return orderedParticipantsForPaint(parent).map { it.node } } - fun orderedChildrenForHitTesting(parent: DOMNode): List { - return orderedParticipantsForHitTesting(parent).map { it.node } + private fun hasPositionedChild(parent: DOMNode): Boolean { + val children = parent.children + for (index in children.indices) { + if (isPositioned(children[index])) { + return true + } + } + return false } + + fun orderedChildrenForHitTesting(parent: DOMNode): List = + orderedParticipantsForHitTesting(parent).map { + it.node + } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModel.kt index c90136b..b43e265 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModel.kt @@ -6,25 +6,25 @@ import org.dreamfinity.dsgl.core.style.StyleProperty internal object StickyLayoutModel { enum class PositionedGeometryIntegrationPoint { - SharedUsedGeometryTransform + SharedUsedGeometryTransform, } enum class StickyInsetAxisMode { Inactive, Top, - Bottom + Bottom, } enum class StickyHorizontalInsetAxisMode { Inactive, Left, - Right + Right, } data class StickyInsetResolution( val mode: StickyInsetAxisMode, val sourceProperty: StyleProperty?, - val value: CssLength? + val value: CssLength?, ) { val active: Boolean get() = mode != StickyInsetAxisMode.Inactive && sourceProperty != null && value != null @@ -33,7 +33,7 @@ internal object StickyLayoutModel { data class StickyHorizontalInsetResolution( val mode: StickyHorizontalInsetAxisMode, val sourceProperty: StyleProperty?, - val value: CssLength? + val value: CssLength?, ) { val active: Boolean get() = mode != StickyHorizontalInsetAxisMode.Inactive && sourceProperty != null && value != null @@ -41,13 +41,13 @@ internal object StickyLayoutModel { data class StickyReferenceScrollContainers( val horizontal: DOMNode, - val vertical: DOMNode + val vertical: DOMNode, ) fun nearestStickyScrollContainers( node: DOMNode, resolveHorizontal: Boolean = true, - resolveVertical: Boolean = true + resolveVertical: Boolean = true, ): StickyReferenceScrollContainers { val root = PositionedLayoutModel.rootStackingScope(node) var horizontal: DOMNode? = if (resolveHorizontal) null else root @@ -65,77 +65,76 @@ internal object StickyLayoutModel { } return StickyReferenceScrollContainers( horizontal = horizontal ?: root, - vertical = vertical ?: root + vertical = vertical ?: root, ) } - fun nearestStickyScrollContainerHorizontal(node: DOMNode): DOMNode { - return nearestStickyScrollContainers( + fun nearestStickyScrollContainerHorizontal(node: DOMNode): DOMNode = + nearestStickyScrollContainers( node = node, resolveHorizontal = true, - resolveVertical = false + resolveVertical = false, ).horizontal - } - fun nearestStickyScrollContainerVertical(node: DOMNode): DOMNode { - return nearestStickyScrollContainers( + fun nearestStickyScrollContainerVertical(node: DOMNode): DOMNode = + nearestStickyScrollContainers( node = node, resolveHorizontal = false, - resolveVertical = true + resolveVertical = true, ).vertical - } - - fun stickyContainingBlock(node: DOMNode): DOMNode { - return node.parent ?: PositionedLayoutModel.rootStackingScope(node) - } - fun resolveHorizontalInsets(left: CssLength?, right: CssLength?): StickyHorizontalInsetResolution { - return when { - left != null -> StickyHorizontalInsetResolution( - mode = StickyHorizontalInsetAxisMode.Left, - sourceProperty = StyleProperty.LEFT, - value = left - ) - - right != null -> StickyHorizontalInsetResolution( - mode = StickyHorizontalInsetAxisMode.Right, - sourceProperty = StyleProperty.RIGHT, - value = right - ) - - else -> StickyHorizontalInsetResolution( - mode = StickyHorizontalInsetAxisMode.Inactive, - sourceProperty = null, - value = null - ) + fun stickyContainingBlock(node: DOMNode): DOMNode = node.parent ?: PositionedLayoutModel.rootStackingScope(node) + + fun resolveHorizontalInsets(left: CssLength?, right: CssLength?): StickyHorizontalInsetResolution = + when { + left != null -> + StickyHorizontalInsetResolution( + mode = StickyHorizontalInsetAxisMode.Left, + sourceProperty = StyleProperty.LEFT, + value = left, + ) + + right != null -> + StickyHorizontalInsetResolution( + mode = StickyHorizontalInsetAxisMode.Right, + sourceProperty = StyleProperty.RIGHT, + value = right, + ) + + else -> + StickyHorizontalInsetResolution( + mode = StickyHorizontalInsetAxisMode.Inactive, + sourceProperty = null, + value = null, + ) } - } - fun resolveVerticalInsets(top: CssLength?, bottom: CssLength?): StickyInsetResolution { - return when { - top != null -> StickyInsetResolution( - mode = StickyInsetAxisMode.Top, - sourceProperty = StyleProperty.TOP, - value = top - ) - - bottom != null -> StickyInsetResolution( - mode = StickyInsetAxisMode.Bottom, - sourceProperty = StyleProperty.BOTTOM, - value = bottom - ) - - else -> StickyInsetResolution( - mode = StickyInsetAxisMode.Inactive, - sourceProperty = null, - value = null - ) + fun resolveVerticalInsets(top: CssLength?, bottom: CssLength?): StickyInsetResolution = + when { + top != null -> + StickyInsetResolution( + mode = StickyInsetAxisMode.Top, + sourceProperty = StyleProperty.TOP, + value = top, + ) + + bottom != null -> + StickyInsetResolution( + mode = StickyInsetAxisMode.Bottom, + sourceProperty = StyleProperty.BOTTOM, + value = bottom, + ) + + else -> + StickyInsetResolution( + mode = StickyInsetAxisMode.Inactive, + sourceProperty = null, + value = null, + ) } - } - fun positionedGeometryIntegrationPoint(): PositionedGeometryIntegrationPoint { - return PositionedGeometryIntegrationPoint.SharedUsedGeometryTransform - } + fun positionedGeometryIntegrationPoint(): PositionedGeometryIntegrationPoint = + PositionedGeometryIntegrationPoint.SharedUsedGeometryTransform fun resolveVerticalVisualOffsetPx( baseY: Int, @@ -143,25 +142,26 @@ internal object StickyLayoutModel { viewportRect: Rect, containingBlockRect: Rect, insetResolution: StickyInsetResolution, - insetPx: Int + insetPx: Int, ): Int { if (!insetResolution.active) return 0 val minY = containingBlockRect.y val maxY = (containingBlockRect.y + containingBlockRect.height - nodeHeight).coerceAtLeast(minY) - val targetY = when (insetResolution.mode) { - StickyInsetAxisMode.Top -> { - val thresholdY = viewportRect.y + insetPx - maxOf(baseY, thresholdY) + val targetY = + when (insetResolution.mode) { + StickyInsetAxisMode.Top -> { + val thresholdY = viewportRect.y + insetPx + maxOf(baseY, thresholdY) + } + + StickyInsetAxisMode.Bottom -> { + val thresholdY = viewportRect.y + viewportRect.height - insetPx - nodeHeight + minOf(baseY, thresholdY) + } + + StickyInsetAxisMode.Inactive -> baseY } - - StickyInsetAxisMode.Bottom -> { - val thresholdY = viewportRect.y + viewportRect.height - insetPx - nodeHeight - minOf(baseY, thresholdY) - } - - StickyInsetAxisMode.Inactive -> baseY - } val finalY = targetY.coerceIn(minY, maxY) return finalY - baseY } @@ -172,25 +172,26 @@ internal object StickyLayoutModel { viewportRect: Rect, containingBlockRect: Rect, insetResolution: StickyHorizontalInsetResolution, - insetPx: Int + insetPx: Int, ): Int { if (!insetResolution.active) return 0 val minX = containingBlockRect.x val maxX = (containingBlockRect.x + containingBlockRect.width - nodeWidth).coerceAtLeast(minX) - val targetX = when (insetResolution.mode) { - StickyHorizontalInsetAxisMode.Left -> { - val thresholdX = viewportRect.x + insetPx - maxOf(baseX, thresholdX) + val targetX = + when (insetResolution.mode) { + StickyHorizontalInsetAxisMode.Left -> { + val thresholdX = viewportRect.x + insetPx + maxOf(baseX, thresholdX) + } + + StickyHorizontalInsetAxisMode.Right -> { + val thresholdX = viewportRect.x + viewportRect.width - insetPx - nodeWidth + minOf(baseX, thresholdX) + } + + StickyHorizontalInsetAxisMode.Inactive -> baseX } - - StickyHorizontalInsetAxisMode.Right -> { - val thresholdX = viewportRect.x + viewportRect.width - insetPx - nodeWidth - minOf(baseX, thresholdX) - } - - StickyHorizontalInsetAxisMode.Inactive -> baseX - } val finalX = targetX.coerceIn(minX, maxX) return finalX - baseX } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/UsedInteractionGeometryResolver.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/UsedInteractionGeometryResolver.kt index d41c4ae..6af99d5 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/UsedInteractionGeometryResolver.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/UsedInteractionGeometryResolver.kt @@ -8,13 +8,13 @@ internal data class UsedInteractionProjection( val worldTransform: AffineTransform2D, val childInputClipRect: Rect?, val canTraverseChildren: Boolean, - val selfContainsPoint: Boolean + val selfContainsPoint: Boolean, ) internal data class UsedInteractionNodeGeometry( val usedBorderRect: Rect, val usedClipRect: Rect?, - val visibleBorderRect: Rect? + val visibleBorderRect: Rect?, ) internal object UsedInteractionGeometryResolver { @@ -23,7 +23,7 @@ internal object UsedInteractionGeometryResolver { mouseX: Int, mouseY: Int, parentTransform: AffineTransform2D, - parentInputClipRect: Rect? + parentInputClipRect: Rect?, ): UsedInteractionProjection? { val worldTransform = parentTransform.times(node.localTransformMatrix()) val inverse = worldTransform.inverseOrNull() ?: return null @@ -42,38 +42,33 @@ internal object UsedInteractionGeometryResolver { worldTransform = worldTransform, childInputClipRect = childInputClipRect, canTraverseChildren = canTraverseChildren, - selfContainsPoint = selfContainsPoint + selfContainsPoint = selfContainsPoint, ) } - fun orderedChildrenForHitTraversal(node: DOMNode): List { - return node.orderedChildrenForHitTestingTraversal() - } + fun orderedChildrenForHitTraversal(node: DOMNode): List = node.orderedChildrenForHitTestingTraversal() fun resolveNodeGeometry(node: DOMNode): UsedInteractionNodeGeometry { val usedClipRect = resolveNodeSelfInputClipRect(node) val usedBorderRect = resolveUsedBorderRect(node) - val visibleBorderRect = when (usedClipRect) { - null -> usedBorderRect - else -> usedBorderRect.intersection(usedClipRect) - } + val visibleBorderRect = + when (usedClipRect) { + null -> usedBorderRect + else -> usedBorderRect.intersection(usedClipRect) + } return UsedInteractionNodeGeometry( usedBorderRect = usedBorderRect, usedClipRect = usedClipRect, - visibleBorderRect = visibleBorderRect + visibleBorderRect = visibleBorderRect, ) } - private fun resolveSelfInputClipRect( - node: DOMNode, - parentInputClipRect: Rect? - ): Rect? { - return if (node.position == PositionMode.Fixed) { + private fun resolveSelfInputClipRect(node: DOMNode, parentInputClipRect: Rect?): Rect? = + if (node.position == PositionMode.Fixed) { node.fixedViewportClipRectForPromotedParticipation() } else { parentInputClipRect } - } private fun resolveNodeSelfInputClipRect(node: DOMNode): Rect? { val chain = ArrayList(8) @@ -109,10 +104,24 @@ internal object UsedInteractionGeometryResolver { val minY = minOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) val maxY = maxOf(topLeft.second, topRight.second, bottomLeft.second, bottomRight.second) - val x = kotlin.math.floor(minX.toDouble()).toInt() - val y = kotlin.math.floor(minY.toDouble()).toInt() - val width = kotlin.math.ceil((maxX - minX).toDouble()).toInt().coerceAtLeast(0) - val height = kotlin.math.ceil((maxY - minY).toDouble()).toInt().coerceAtLeast(0) + val x = + kotlin.math + .floor(minX.toDouble()) + .toInt() + val y = + kotlin.math + .floor(minY.toDouble()) + .toInt() + val width = + kotlin.math + .ceil((maxX - minX).toDouble()) + .toInt() + .coerceAtLeast(0) + val height = + kotlin.math + .ceil((maxY - minY).toDouble()) + .toInt() + .coerceAtLeast(0) return Rect(x, y, width, height) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/debug/LayoutValidator.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/debug/LayoutValidator.kt index af1b318..4434549 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/debug/LayoutValidator.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/debug/LayoutValidator.kt @@ -16,7 +16,7 @@ data class LayoutViolation( val parentKey: Any?, val message: String, val nodeBounds: Rect, - val parentContentBounds: Rect? + val parentContentBounds: Rect?, ) object LayoutValidator { @@ -29,19 +29,19 @@ object LayoutValidator { out.forEach { violation -> println( "[DSGL-Layout] ${violation.code} key=${violation.nodeKey} parent=${violation.parentKey} " + - "node=${rectLabel(violation.nodeBounds)} parentContent=${rectLabel(violation.parentContentBounds)} :: " + - violation.message + "node=${ + rectLabel( + violation.nodeBounds, + ) + } parentContent=${rectLabel(violation.parentContentBounds)} :: " + + violation.message, ) } } return out } - fun appendDebugCommands( - root: DOMNode, - violations: List, - out: MutableList - ) { + fun appendDebugCommands(root: DOMNode, violations: List, out: MutableList) { if (!LayoutDebug.drawBounds) return val violatingKeys = violations.mapNotNull { it.nodeKey }.toSet() walkDraw(root, violatingKeys, out) @@ -52,7 +52,7 @@ object LayoutValidator { parent: DOMNode?, parentContent: Rect?, ctx: UiMeasureContext, - out: MutableList + out: MutableList, ) { if (node.display == Display.None) return validateRectShape(node = node, parent = parent, out = out) @@ -68,36 +68,46 @@ object LayoutValidator { parent = node, parentContent = content, ctx = ctx, - out = out + out = out, ) } } private fun validateRectShape(node: DOMNode, parent: DOMNode?, out: MutableList) { if (node.bounds.width < 0 || node.bounds.height < 0) { - out += LayoutViolation( - code = "NEGATIVE_SIZE", - nodeKey = node.key, - parentKey = parent?.key, - message = "Negative bounds size is invalid.", - nodeBounds = node.bounds, - parentContentBounds = parent?.let { contentRect(it) } - ) + out += + LayoutViolation( + code = "NEGATIVE_SIZE", + nodeKey = node.key, + parentKey = parent?.key, + message = "Negative bounds size is invalid.", + nodeBounds = node.bounds, + parentContentBounds = parent?.let { contentRect(it) }, + ) } - val right = node.bounds.x.toLong() + node.bounds.width.toLong() - val bottom = node.bounds.y.toLong() + node.bounds.height.toLong() + val right = + node.bounds.x + .toLong() + + node.bounds.width + .toLong() + val bottom = + node.bounds.y + .toLong() + + node.bounds.height + .toLong() if (right !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() || bottom !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() ) { - out += LayoutViolation( - code = "INT_OVERFLOW", - nodeKey = node.key, - parentKey = parent?.key, - message = "Bounds exceed Int range.", - nodeBounds = node.bounds, - parentContentBounds = parent?.let { contentRect(it) } - ) + out += + LayoutViolation( + code = "INT_OVERFLOW", + nodeKey = node.key, + parentKey = parent?.key, + message = "Bounds exceed Int range.", + nodeBounds = node.bounds, + parentContentBounds = parent?.let { contentRect(it) }, + ) } } @@ -105,18 +115,19 @@ object LayoutValidator { node: DOMNode, parent: DOMNode?, parentContent: Rect, - out: MutableList + out: MutableList, ) { val outer = outerRect(node) if (!containsRect(parentContent, outer)) { - out += LayoutViolation( - code = "CHILD_OUTSIDE_PARENT_CONTENT", - nodeKey = node.key, - parentKey = parent?.key, - message = "Child outer rect escapes parent content box.", - nodeBounds = outer, - parentContentBounds = parentContent - ) + out += + LayoutViolation( + code = "CHILD_OUTSIDE_PARENT_CONTENT", + nodeKey = node.key, + parentKey = parent?.key, + message = "Child outer rect escapes parent content box.", + nodeBounds = outer, + parentContentBounds = parentContent, + ) } } @@ -124,28 +135,30 @@ object LayoutValidator { node: DOMNode, ctx: UiMeasureContext, parent: DOMNode?, - out: MutableList + out: MutableList, ) { when (node) { - is TextNode -> validateLineStack( - text = node.text, - wrap = node.textWrap, - rect = contentRect(node), - ctx = ctx, - node = node, - parent = parent, - out = out - ) + is TextNode -> + validateLineStack( + text = node.text, + wrap = node.textWrap, + rect = contentRect(node), + ctx = ctx, + node = node, + parent = parent, + out = out, + ) - is ButtonNode -> validateLineStack( - text = node.text, - wrap = node.textWrap, - rect = contentRect(node), - ctx = ctx, - node = node, - parent = parent, - out = out - ) + is ButtonNode -> + validateLineStack( + text = node.text, + wrap = node.textWrap, + rect = contentRect(node), + ctx = ctx, + node = node, + parent = parent, + out = out, + ) } } @@ -156,44 +169,49 @@ object LayoutValidator { ctx: UiMeasureContext, node: DOMNode, parent: DOMNode?, - out: MutableList + out: MutableList, ) { val maxWidth = if (wrap == TextWrap.Wrap) rect.width.coerceAtLeast(0) else null val lineHeight = ctx.fontHeight(node.fontId, node.fontSize).coerceAtLeast(1) - val layout = TextLayoutEngine.layout( - text = text, - maxWidth = maxWidth, - wrap = wrap, - fontHeight = lineHeight, - measureText = { value -> ctx.measureText(value, node.fontId, node.fontSize) } - ) + val layout = + TextLayoutEngine.layout( + text = text, + maxWidth = maxWidth, + wrap = wrap, + fontHeight = lineHeight, + measureText = { value -> ctx.measureText(value, node.fontId, node.fontSize) }, + ) var previousBottom = 0 layout.lines.forEachIndexed { index, _ -> val top = index * layout.lineHeight if (top < previousBottom) { - out += LayoutViolation( - code = "TEXT_LINE_COLLISION", - nodeKey = node.key, - parentKey = parent?.key, - message = "Wrapped line boxes overlap.", - nodeBounds = node.bounds, - parentContentBounds = parent?.let { contentRect(it) } - ) + out += + LayoutViolation( + code = "TEXT_LINE_COLLISION", + nodeKey = node.key, + parentKey = parent?.key, + message = "Wrapped line boxes overlap.", + nodeBounds = node.bounds, + parentContentBounds = parent?.let { contentRect(it) }, + ) return } previousBottom = top + layout.lineHeight } if (rect.height < layout.totalHeight) { - out += LayoutViolation( - code = "TEXT_HEIGHT_UNDERSIZED", - nodeKey = node.key, - parentKey = parent?.key, - message = "Measured content height (${rect.height}) is smaller than line stack (${layout.totalHeight}).", - nodeBounds = node.bounds, - parentContentBounds = parent?.let { contentRect(it) } - ) + out += + LayoutViolation( + code = "TEXT_HEIGHT_UNDERSIZED", + nodeKey = node.key, + parentKey = parent?.key, + message = + "Measured content height (${rect.height}) is smaller " + + "than line stack (${layout.totalHeight}).", + nodeBounds = node.bounds, + parentContentBounds = parent?.let { contentRect(it) }, + ) } } @@ -255,4 +273,3 @@ object LayoutValidator { return "(${rect.x},${rect.y},${rect.width},${rect.height})" } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ButtonNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ButtonNode.kt index 045fc43..88f3196 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ButtonNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ButtonNode.kt @@ -22,7 +22,7 @@ class ButtonNode( var textColor: Int = DsglColors.TEXT, var backgroundColor: Int = DsglColors.BUTTON, padding: Int = 4, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "button" private var onClickHandler: ((MouseClickEvent) -> Unit)? = null @@ -37,13 +37,10 @@ class ButtonNode( } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val textMetrics = resolveTextMetrics(ctx) @@ -51,25 +48,27 @@ class ButtonNode( val parsed = parseTextForFormatting(text) val plainText = parsed.plainText val baseFlags = baseTextStyleFlags() - val measuredRanges = MeasuredTextRangeWidthSource( - plainText = plainText, - fontId = fontId, - fontSizePx = textMetrics.fontSizePx, - baseFlags = baseFlags, - spans = parsed.spans, - ctx = ctx - ) + val measuredRanges = + MeasuredTextRangeWidthSource( + plainText = plainText, + fontId = fontId, + fontSizePx = textMetrics.fontSizePx, + baseFlags = baseFlags, + spans = parsed.spans, + ctx = ctx, + ) val contentLimit = resolvedContentLimit(availableOuterWidth) val wrapWidth = if (textWrap == TextWrap.Wrap) contentLimit else null - val layout = TextLayoutEngine.layout( - text = plainText, - maxWidth = wrapWidth, - wrap = textWrap, - fontHeight = lineHeight, - measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, - measureRange = measuredRanges::measureRange, - measureRangeCacheKey = measuredRanges.cacheKey - ) + val layout = + TextLayoutEngine.layout( + text = plainText, + maxWidth = wrapWidth, + wrap = textWrap, + fontHeight = lineHeight, + measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, + measureRange = measuredRanges::measureRange, + measureRangeCacheKey = measuredRanges.cacheKey, + ) val naturalContentWidth = width ?: layout.maxLineWidth val contentWidth = contentLimit?.let { minOf(it, naturalContentWidth) } ?: naturalContentWidth val contentHeight = height ?: layout.totalHeight @@ -96,52 +95,56 @@ class ButtonNode( val parsed = parseTextForFormatting(text) val plainText = parsed.plainText val baseFlags = baseTextStyleFlags() - val measuredRanges = MeasuredTextRangeWidthSource( - plainText = plainText, - fontId = fontId, - fontSizePx = textMetrics.fontSizePx, - baseFlags = baseFlags, - spans = parsed.spans, - ctx = ctx - ) + val measuredRanges = + MeasuredTextRangeWidthSource( + plainText = plainText, + fontId = fontId, + fontSizePx = textMetrics.fontSizePx, + baseFlags = baseFlags, + spans = parsed.spans, + ctx = ctx, + ) out.add(RenderCommand.DrawRect(bounds.x, bounds.y, bounds.width, bounds.height, backgroundColor)) addBackgroundImageCommand(out) addBorderCommands(out) val contentWidth = contentWidth() val contentHeight = contentHeight() val wrapWidth = if (textWrap == TextWrap.Wrap) contentWidth else null - val layout = TextLayoutEngine.layout( - text = plainText, - maxWidth = wrapWidth, - wrap = textWrap, - fontHeight = lineHeight, - measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, - measureRange = measuredRanges::measureRange, - measureRangeCacheKey = measuredRanges.cacheKey - ) + val layout = + TextLayoutEngine.layout( + text = plainText, + maxWidth = wrapWidth, + wrap = textWrap, + fontHeight = lineHeight, + measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, + measureRange = measuredRanges::measureRange, + measureRangeCacheKey = measuredRanges.cacheKey, + ) val textBlockHeight = layout.totalHeight val blockY = contentY() + (contentHeight - textBlockHeight) / 2 layout.lines.forEachIndexed { index, line -> val lineX = contentX() + (contentWidth - line.width) / 2 val lineY = blockY + index * layout.lineHeight + lineTopLeading - val spans = MinecraftFormattingParser.resolveStyleSpans( - parsed = parsed, - baseColor = textColor, - baseFlags = baseFlags, - rangeStart = line.startIndex, - rangeEnd = line.endIndexExclusive - ).map { span -> - RenderCommand.TextStyleSpan( - start = span.start, - end = span.end, - color = span.color, - bold = span.flags.bold, - italic = span.flags.italic, - underline = span.flags.underline, - strikethrough = span.flags.strikethrough, - obfuscated = span.flags.obfuscated - ) - } + val spans = + MinecraftFormattingParser + .resolveStyleSpans( + parsed = parsed, + baseColor = textColor, + baseFlags = baseFlags, + rangeStart = line.startIndex, + rangeEnd = line.endIndexExclusive, + ).map { span -> + RenderCommand.TextStyleSpan( + start = span.start, + end = span.end, + color = span.color, + bold = span.flags.bold, + italic = span.flags.italic, + underline = span.flags.underline, + strikethrough = span.flags.strikethrough, + obfuscated = span.flags.obfuscated, + ) + } out.add(drawTextCommand(ctx, line.text, lineX, lineY, textColor, spans)) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/CheckboxGroupNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/CheckboxGroupNode.kt index ee5676c..01be7b4 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/CheckboxGroupNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/CheckboxGroupNode.kt @@ -16,7 +16,7 @@ class CheckboxGroupNode( selected: Set = emptySet(), var minSelected: Int? = null, var maxSelected: Int? = null, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "input" override val focusable: Boolean = true @@ -48,13 +48,10 @@ class CheckboxGroupNode( fun selected(): Set = selectedIds.toSet() - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val fontHeight = resolveFontSize(ctx) @@ -81,7 +78,13 @@ class CheckboxGroupNode( } } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) val fontHeight = resolveFontSize(ctx) boxSize = maxOf(10, fontHeight - 2) @@ -132,9 +135,11 @@ class CheckboxGroupNode( } } - private fun valueString(): String { - return selectedIds.toList().sorted().joinToString(",") - } + private fun valueString(): String = + selectedIds + .toList() + .sorted() + .joinToString(",") override fun defaultForegroundColor(): Int = textColor diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerInlineNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerInlineNode.kt index 6e8960e..4a8f8b0 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerInlineNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerInlineNode.kt @@ -15,7 +15,7 @@ class ColorPickerInlineNode( previousValue: RgbaColor? = null, mode: ColorFormatMode = ColorFormatMode.HEX, alphaEnabled: Boolean = true, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "color-picker" override val focusable: Boolean = true @@ -36,16 +36,18 @@ class ColorPickerInlineNode( private var uncontrolledValue: RgbaColor = value ?: defaultValue private var uncontrolledPrevious: RgbaColor = previousValue ?: uncontrolledValue private val recentHistory: ColorRecentHistory = ColorRecentHistory(64) - private val controller: ColorPickerController = ColorPickerController( - initial = ColorPickerState( - color = effectiveColor(), - previous = effectivePreviousColor(), - mode = mode, - alphaEnabled = alphaEnabled, - closeOnSelect = closeOnSelect - ), - recentHistory = recentHistory - ) + private val controller: ColorPickerController = + ColorPickerController( + initial = + ColorPickerState( + color = effectiveColor(), + previous = effectivePreviousColor(), + mode = mode, + alphaEnabled = alphaEnabled, + closeOnSelect = closeOnSelect, + ), + recentHistory = recentHistory, + ) private var layout: ColorPickerLayout? = null private var dragCaptured: Boolean = false private var syncedColorArgb: Int = Int.MIN_VALUE @@ -85,6 +87,7 @@ class ColorPickerInlineNode( } } this@ColorPickerInlineNode.addEventListener(Events.MOUSEMOVE) { event: MouseMoveEvent -> + if (!shouldAcceptPointerHoverEvent(event.target)) return@addEventListener val currentLayout = layout ?: return@addEventListener val moved = controller.handleMouseMove(event.mouseX, event.mouseY, currentLayout) if (moved) { @@ -104,11 +107,17 @@ class ColorPickerInlineNode( } } this@ColorPickerInlineNode.addEventListener(Events.MOUSEOVER) { event: MouseOverEvent -> + if (!shouldAcceptPointerHoverEvent(event.target)) return@addEventListener val currentLayout = layout ?: return@addEventListener if (controller.handleMouseMove(event.mouseX, event.mouseY, currentLayout)) { markRenderCommandsDirty() } } + this@ColorPickerInlineNode.addEventListener(Events.MOUSELEAVE) { _: MouseLeaveEvent -> + if (controller.clearHover()) { + markRenderCommandsDirty() + } + } this@ColorPickerInlineNode.addEventListener(Events.KEYDOWN) { event: KeyboardKeyDownEvent -> if (!FocusManager.isFocused(this@ColorPickerInlineNode)) return@addEventListener if (controller.handleKeyDown(event.keyCode, event.keyChar)) { @@ -120,35 +129,39 @@ class ColorPickerInlineNode( } } - override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean { - return dragCaptured || containsGlobalPoint(mouseX, mouseY) + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean = + dragCaptured || containsGlobalPoint(mouseX, mouseY) + + private fun shouldAcceptPointerHoverEvent(target: DOMNode?): Boolean { + if (dragCaptured || controller.isEyedropperActive()) return true + var current = target ?: return false + while (true) { + if (current === this) return true + current = current.parent ?: return false + } } fun wantsGlobalPointerInput(): Boolean = controller.isEyedropperActive() - fun appendEyedropperOverlayCommands( - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { - controller.appendEyedropperOverlay( + fun appendEyedropperPortalCommands(viewportWidth: Int, viewportHeight: Int, out: MutableList) { + controller.appendEyedropperPreview( viewportWidth = viewportWidth.coerceAtLeast(1), viewportHeight = viewportHeight.coerceAtLeast(1), - out = out + out = out, ) } fun captureEyedropperSample() { - controller.sampleEyedropperAtHover() + if (controller.sampleEyedropperAtHoverAndReportChange()) { + refreshLayout() + markRenderCommandsDirty() + } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(null) private fun measureWithConstraint(availableOuterWidth: Int?): Size { val minWidth = controller.style().minWidth @@ -171,7 +184,13 @@ class ColorPickerInlineNode( } } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) syncControllerStateIfNeeded() refreshLayout() @@ -234,8 +253,8 @@ class ColorPickerInlineNode( previous = previous, mode = mode, alphaEnabled = alphaEnabled, - closeOnSelect = closeOnSelect - ) + closeOnSelect = closeOnSelect, + ), ) bindController() } @@ -268,31 +287,30 @@ class ColorPickerInlineNode( } } - private fun effectiveColor(): RgbaColor { - return if (controlled) { + private fun effectiveColor(): RgbaColor = + if (controlled) { (controlledValue ?: defaultValue).normalized() } else { uncontrolledValue.normalized() } - } - private fun effectivePreviousColor(): RgbaColor { - return if (controlled) { + private fun effectivePreviousColor(): RgbaColor = + if (controlled) { (previousValue ?: controlledValue ?: defaultValue).normalized() } else { (previousValue ?: uncontrolledPrevious).normalized() } - } private fun refreshLayout() { if (bounds.width <= 0 || bounds.height <= 0) return - layout = controller.buildLayout( - Rect( - contentX(), - contentY(), - contentWidth().coerceAtLeast(1), - contentHeight().coerceAtLeast(1) + layout = + controller.buildLayout( + Rect( + contentX(), + contentY(), + contentWidth().coerceAtLeast(1), + contentHeight().coerceAtLeast(1), + ), ) - ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerPopupPaneNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerPopupPaneNode.kt index b5413d6..1aa759d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerPopupPaneNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ColorPickerPopupPaneNode.kt @@ -7,6 +7,7 @@ import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.* +import org.dreamfinity.dsgl.core.portal.DomainPortalServices import org.dreamfinity.dsgl.core.render.RenderCommand class ColorPickerPopupPaneNode( @@ -16,7 +17,7 @@ class ColorPickerPopupPaneNode( previousValue: RgbaColor? = null, mode: ColorFormatMode = ColorFormatMode.HEX, alphaEnabled: Boolean = true, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "color-picker-popup" override val focusable: Boolean = true @@ -50,10 +51,16 @@ class ColorPickerPopupPaneNode( this@ColorPickerPopupPaneNode.addEventListener(Events.MOUSEDOWN) { event: MouseDownEvent -> if (this@ColorPickerPopupPaneNode.styleDisabled) return@addEventListener if (event.mouseButton != MouseButton.LEFT) return@addEventListener - if (!this@ColorPickerPopupPaneNode.containsGlobalPoint(event.mouseX, event.mouseY)) return@addEventListener + if (!this@ColorPickerPopupPaneNode.containsGlobalPoint( + event.mouseX, + event.mouseY, + ) + ) { + return@addEventListener + } FocusManager.requestFocus(this@ColorPickerPopupPaneNode) - if (ColorPickerRuntime.host.isOpenFor(ownerToken)) { - ColorPickerRuntime.host.close(ownerToken) + if (DomainPortalServices.applicationColorPickerEngine.isOpenFor(ownerToken)) { + DomainPortalServices.applicationColorPickerEngine.close(ownerToken) } else { openPopup() } @@ -62,13 +69,10 @@ class ColorPickerPopupPaneNode( } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val label = ColorTextCodec.format(effectiveColor(), mode, alphaEnabled) @@ -99,35 +103,46 @@ class ColorPickerPopupPaneNode( addBorderCommands(out) val swatchSize = (rect.height - 8).coerceAtLeast(10) val swatchRect = Rect(rect.x + 4, rect.y + ((rect.height - swatchSize) / 2), swatchSize, swatchSize) - out += RenderCommand.DrawRect(swatchRect.x, swatchRect.y, swatchRect.width, swatchRect.height, effectiveColor().toArgbInt()) + out += + RenderCommand.DrawRect( + swatchRect.x, + swatchRect.y, + swatchRect.width, + swatchRect.height, + effectiveColor().toArgbInt(), + ) out += RenderCommand.DrawRect(swatchRect.x, swatchRect.y, swatchRect.width, 1, borderColor) - out += RenderCommand.DrawRect(swatchRect.x, swatchRect.y + swatchRect.height - 1, swatchRect.width, 1, borderColor) + out += + RenderCommand.DrawRect(swatchRect.x, swatchRect.y + swatchRect.height - 1, swatchRect.width, 1, borderColor) out += RenderCommand.DrawRect(swatchRect.x, swatchRect.y, 1, swatchRect.height, borderColor) - out += RenderCommand.DrawRect(swatchRect.x + swatchRect.width - 1, swatchRect.y, 1, swatchRect.height, borderColor) - out += drawTextCommand( - ctx, - text = ColorTextCodec.format(effectiveColor(), mode, alphaEnabled), - x = swatchRect.x + swatchRect.width + 6, - y = rect.y + 3, - color = textColor - ) - out += if (ColorPickerRuntime.host.isOpenFor(ownerToken)) { + out += + RenderCommand.DrawRect(swatchRect.x + swatchRect.width - 1, swatchRect.y, 1, swatchRect.height, borderColor) + out += drawTextCommand( ctx, - text = "^", - x = rect.x + rect.width - 14, + text = ColorTextCodec.format(effectiveColor(), mode, alphaEnabled), + x = swatchRect.x + swatchRect.width + 6, y = rect.y + 3, - color = textColor + color = textColor, ) - } else { - drawTextCommand( - ctx, - text = "v", - x = rect.x + rect.width - 14, - y = rect.y + 3, - color = textColor - ) - } + out += + if (DomainPortalServices.applicationColorPickerEngine.isOpenFor(ownerToken)) { + drawTextCommand( + ctx, + text = "^", + x = rect.x + rect.width - 14, + y = rect.y + 3, + color = textColor, + ) + } else { + drawTextCommand( + ctx, + text = "v", + x = rect.x + rect.width - 14, + y = rect.y + 3, + color = textColor, + ) + } } internal fun syncFrom(template: ColorPickerPopupPaneNode) { @@ -169,28 +184,29 @@ class ColorPickerPopupPaneNode( } private fun syncPopupIfOpen() { - if (!ColorPickerRuntime.host.isOpenFor(ownerToken)) return - ColorPickerRuntime.engine.sync(openRequest()) + if (!DomainPortalServices.applicationColorPickerEngine.isOpenFor(ownerToken)) return + DomainPortalServices.applicationColorPickerEngine.sync(openRequest()) setOpenState(true) } private fun openPopup() { - ColorPickerRuntime.host.open(openRequest()) + DomainPortalServices.applicationColorPickerEngine.open(openRequest()) setOpenState(true) } - private fun openRequest(): ColorPickerPopupRequest { - return ColorPickerPopupRequest( + private fun openRequest(): ColorPickerPopupRequest = + ColorPickerPopupRequest( owner = ownerToken, anchorRect = bounds, title = popupTitle, - state = ColorPickerState( - color = effectiveColor(), - previous = effectivePreviousColor(), - mode = mode, - alphaEnabled = alphaEnabled, - closeOnSelect = closeOnSelect - ), + state = + ColorPickerState( + color = effectiveColor(), + previous = effectivePreviousColor(), + mode = mode, + alphaEnabled = alphaEnabled, + closeOnSelect = closeOnSelect, + ), width = popupWidth, draggable = popupDraggable, closeOnOutsideClick = popupCloseOnOutsideClick, @@ -218,23 +234,20 @@ class ColorPickerPopupPaneNode( }, onClose = { setOpenState(false) - } + }, ) - } - private fun effectiveColor(): RgbaColor { - return if (controlled) { + private fun effectiveColor(): RgbaColor = + if (controlled) { (controlledValue ?: defaultValue).normalized() } else { uncontrolledValue.normalized() } - } - private fun effectivePreviousColor(): RgbaColor { - return if (controlled) { + private fun effectivePreviousColor(): RgbaColor = + if (controlled) { (previousValue ?: controlledValue ?: defaultValue).normalized() } else { (previousValue ?: uncontrolledPrevious).normalized() } - } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ContainerNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ContainerNode.kt index 9b822f9..3b3936a 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ContainerNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ContainerNode.kt @@ -2,10 +2,7 @@ package org.dreamfinity.dsgl.core.dom.elements import org.dreamfinity.dsgl.core.debug.ScrollPerformanceCounters import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.layout.Insets -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dom.layout.* import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.* import kotlin.math.roundToInt @@ -18,7 +15,7 @@ class ContainerNode( gap: Int = 0, var backgroundColor: Int? = null, var stackLayout: Boolean = false, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "div" @@ -27,16 +24,15 @@ class ContainerNode( this.gap = gap.coerceAtLeast(0) } - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - val constrainedContentWidth = if (availableOuterWidth != null) { - constrainContentWidthForOuterLimit(availableOuterWidth, this@ContainerNode) - } else { - null - } + val constrainedContentWidth = + if (availableOuterWidth != null) { + constrainContentWidthForOuterLimit(availableOuterWidth, this@ContainerNode) + } else { + null + } return measureWithConstraint(ctx, constrainedContentWidth) } @@ -47,38 +43,47 @@ class ContainerNode( val visibleChildren = visibleChildren() val inFlowChildren = inFlowChildren(visibleChildren) val resolvedWrapWidth = resolvedContentLimit(constrainedContentWidth) - val boundedExplicitWidth = width?.let { explicit -> - resolvedWrapWidth?.let { minOf(explicit, it) } ?: explicit - } + val boundedExplicitWidth = + width?.let { explicit -> + resolvedWrapWidth?.let { minOf(explicit, it) } ?: explicit + } if (inFlowChildren.isEmpty()) { val contentWidth = boundedExplicitWidth ?: 0 val contentHeight = height ?: 0 return Size( contentWidth + padding.horizontal + border.horizontal, - contentHeight + padding.vertical + border.vertical + contentHeight + padding.vertical + border.vertical, ) } - val contentSize = when { - stackLayout -> measureStack(ctx, inFlowChildren) - display == Display.Flex -> measureFlex(ctx, inFlowChildren, resolvedWrapWidth) - display == Display.Grid -> measureGrid(ctx, inFlowChildren, resolvedWrapWidth) - display == Display.Inline -> measureInline(ctx, inFlowChildren, resolvedWrapWidth) - else -> measureBlock(ctx, inFlowChildren, resolvedWrapWidth) - } - val contentWidth = when { - boundedExplicitWidth != null -> boundedExplicitWidth - resolvedWrapWidth != null -> minOf(contentSize.width, resolvedWrapWidth) - else -> contentSize.width - } + val contentSize = + when { + stackLayout -> measureStack(ctx, inFlowChildren) + display == Display.Flex -> measureFlex(ctx, inFlowChildren, resolvedWrapWidth) + display == Display.Grid -> measureGrid(ctx, inFlowChildren, resolvedWrapWidth) + display == Display.Inline -> measureInline(ctx, inFlowChildren, resolvedWrapWidth) + else -> measureBlock(ctx, inFlowChildren, resolvedWrapWidth) + } + val contentWidth = + when { + boundedExplicitWidth != null -> boundedExplicitWidth + resolvedWrapWidth != null -> minOf(contentSize.width, resolvedWrapWidth) + else -> contentSize.width + } val contentHeight = height ?: contentSize.height return Size( contentWidth + padding.horizontal + border.horizontal, - contentHeight + padding.vertical + border.vertical + contentHeight + padding.vertical + border.vertical, ) } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { if (display == Display.None) { bounds = Rect(x, y, 0, 0) resetContentLayoutScroll() @@ -121,17 +126,17 @@ class ContainerNode( backgroundColor = value } - private fun visibleChildren(): List { - return children.filter { it.display != Display.None } - } + private fun visibleChildren(): List = children.filter { it.display != Display.None } - private fun inFlowChildren(children: List): List { - return children.filter { !it.isRemovedFromNormalFlowForPositioning() } - } + private fun inFlowChildren(children: List): List = + children.filter { + !it.isRemovedFromNormalFlowForPositioning() + } - private fun outOfFlowChildren(children: List): List { - return children.filter { it.isRemovedFromNormalFlowForPositioning() } - } + private fun outOfFlowChildren(children: List): List = + children.filter { + it.isRemovedFromNormalFlowForPositioning() + } private fun renderOutOfFlowChildren(ctx: UiMeasureContext, children: List) { val cx = childContentOriginX() @@ -139,12 +144,13 @@ class ContainerNode( val cw = viewportContentWidth() val ch = viewportContentHeight() children.forEach { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = cw, - availableOuterHeight = ch - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = cw, + availableOuterHeight = ch, + ) val childX = cx + child.margin.left val childY = cy + child.margin.top renderContainedChild( @@ -157,7 +163,7 @@ class ContainerNode( desiredX = childX, desiredY = childY, desiredWidth = measured.width, - desiredHeight = measured.height + desiredHeight = measured.height, ) } } @@ -166,12 +172,13 @@ class ContainerNode( var maxWidth = 0 var maxHeight = 0 children.forEach { child -> - val size = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = null, - availableOuterHeight = null - ) + val size = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = null, + availableOuterHeight = null, + ) maxWidth = maxOf(maxWidth, size.width + child.margin.horizontal) maxHeight = maxOf(maxHeight, size.height + child.margin.vertical) } @@ -184,12 +191,13 @@ class ContainerNode( val cw = viewportContentWidth() val ch = viewportContentHeight() children.forEach { child -> - val size = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = cw, - availableOuterHeight = ch - ) + val size = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = cw, + availableOuterHeight = ch, + ) val childWidth = size.width val childHeight = size.height val childX = alignedChildX(child, cx, cw, childWidth) @@ -219,11 +227,12 @@ class ContainerNode( } children.forEach { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = wrapWidth - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = wrapWidth, + ) val outerWidth = measured.width + child.margin.horizontal val outerHeight = measured.height + child.margin.vertical if (isInlineFormattingChild(child)) { @@ -240,12 +249,14 @@ class ContainerNode( } else { flushInlineLine() if (hasRows) totalHeight += gap - val blockWidth = if (wrapWidth != null && child.width == null) { - val stretchedOuterWidth = (wrapWidth - child.margin.horizontal).coerceAtLeast(0) - child.clampMeasuredOuterSize(Size(stretchedOuterWidth, measured.height)).width + child.margin.horizontal - } else { - outerWidth - } + val blockWidth = + if (wrapWidth != null && child.width == null) { + val stretchedOuterWidth = (wrapWidth - child.margin.horizontal).coerceAtLeast(0) + child.clampMeasuredOuterSize(Size(stretchedOuterWidth, measured.height)).width + + child.margin.horizontal + } else { + outerWidth + } maxWidth = maxOf(maxWidth, blockWidth) totalHeight += outerHeight hasRows = true @@ -262,7 +273,7 @@ class ContainerNode( val width: Int, val height: Int, val relX: Int, - val outerHeight: Int + val outerHeight: Int, ) private fun renderBlock(ctx: UiMeasureContext, children: List) { @@ -295,12 +306,13 @@ class ContainerNode( } children.forEach { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = cw, - availableOuterHeight = ch - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = cw, + availableOuterHeight = ch, + ) if (isInlineFormattingChild(child)) { val outerWidth = measured.width + child.margin.horizontal val outerHeight = measured.height + child.margin.vertical @@ -316,20 +328,21 @@ class ContainerNode( width = measured.width, height = measured.height, relX = relX, - outerHeight = outerHeight - ) + outerHeight = outerHeight, + ), ) lineWidth = relX + outerWidth lineHeight = maxOf(lineHeight, outerHeight, inlineLineBoxHeight) } else { flushInlineLine() if (hasRows) cursorY += gap - val widthToRender = if (child.width == null) { - val stretchedOuterWidth = (cw - child.margin.horizontal).coerceAtLeast(0) - child.clampMeasuredOuterSize(Size(stretchedOuterWidth, measured.height)).width - } else { - measured.width - } + val widthToRender = + if (child.width == null) { + val stretchedOuterWidth = (cw - child.margin.horizontal).coerceAtLeast(0) + child.clampMeasuredOuterSize(Size(stretchedOuterWidth, measured.height)).width + } else { + measured.width + } val heightToRender = measured.height val childX = alignedChildX(child, cx, cw, widthToRender) val childY = cursorY + child.margin.top @@ -360,11 +373,12 @@ class ContainerNode( } children.forEach { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = wrapWidth - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = wrapWidth, + ) val outerWidth = measured.width + child.margin.horizontal val outerHeight = measured.height + child.margin.vertical val spacing = if (lineHasItems) gap else 0 @@ -379,11 +393,12 @@ class ContainerNode( } flushLine() - val finalWidth = if (wrapWidth != null) { - minOf(maxLineWidth, wrapWidth.coerceAtLeast(0)) - } else { - maxLineWidth - } + val finalWidth = + if (wrapWidth != null) { + minOf(maxLineWidth, wrapWidth.coerceAtLeast(0)) + } else { + maxLineWidth + } return Size(finalWidth.coerceAtLeast(0), totalHeight.coerceAtLeast(0)) } @@ -399,12 +414,13 @@ class ContainerNode( val inlineLineBoxHeight = resolveEffectiveLineHeight(ctx) children.forEach { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = cw, - availableOuterHeight = ch - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = cw, + availableOuterHeight = ch, + ) val outerWidth = measured.width + child.margin.horizontal val outerHeight = measured.height + child.margin.vertical if (lineHasItems && cursorX + gap + outerWidth > cx + cw) { @@ -423,9 +439,7 @@ class ContainerNode( } } - private fun isInlineFormattingChild(child: DOMNode): Boolean { - return child.display == Display.Inline || child is TextNode - } + private fun isInlineFormattingChild(child: DOMNode): Boolean = child.display == Display.Inline || child is TextNode private data class FlexItem( val child: DOMNode, @@ -437,7 +451,7 @@ class ContainerNode( val crossMarginEnd: Int, val explicitMain: Int?, val explicitCross: Int?, - val resolvedFlexBasis: Int? + val resolvedFlexBasis: Int?, ) private fun measureFlex(ctx: UiMeasureContext, children: List, wrapWidth: Int?): Size { @@ -472,31 +486,34 @@ class ContainerNode( if (children.isEmpty()) return - val items = children.map { child -> - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = availableOuterWidth, - availableOuterHeight = availableOuterHeight - ) - FlexItem( - child = child, - measuredMain = if (isRow) measured.width else measured.height, - measuredCross = if (isRow) measured.height else measured.width, - mainMarginStart = if (isRow) child.margin.left else child.margin.top, - mainMarginEnd = if (isRow) child.margin.right else child.margin.bottom, - crossMarginStart = if (isRow) child.margin.top else child.margin.left, - crossMarginEnd = if (isRow) child.margin.bottom else child.margin.right, - explicitMain = if (isRow) child.width else child.height, - explicitCross = if (isRow) child.height else child.width, - resolvedFlexBasis = child.resolveFlexBasisForAxis( - ctx = ctx, - parentContentWidth = availableOuterWidth, - parentContentHeight = availableOuterHeight, - axis = flexDirection - ), - ) - } + val items = + children.map { child -> + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = availableOuterWidth, + availableOuterHeight = availableOuterHeight, + ) + FlexItem( + child = child, + measuredMain = if (isRow) measured.width else measured.height, + measuredCross = if (isRow) measured.height else measured.width, + mainMarginStart = if (isRow) child.margin.left else child.margin.top, + mainMarginEnd = if (isRow) child.margin.right else child.margin.bottom, + crossMarginStart = if (isRow) child.margin.top else child.margin.left, + crossMarginEnd = if (isRow) child.margin.bottom else child.margin.right, + explicitMain = if (isRow) child.width else child.height, + explicitCross = if (isRow) child.height else child.width, + resolvedFlexBasis = + child.resolveFlexBasisForAxis( + ctx = ctx, + parentContentWidth = availableOuterWidth, + parentContentHeight = availableOuterHeight, + axis = flexDirection, + ), + ) + } val baseMain = DoubleArray(items.size) var totalOuterBaseMain = 0.0 @@ -506,8 +523,15 @@ class ContainerNode( val base = (item.explicitMain ?: item.resolvedFlexBasis ?: item.measuredMain).coerceAtLeast(0) baseMain[index] = base.toDouble() totalOuterBaseMain += base + item.mainMarginStart + item.mainMarginEnd - totalGrow += item.child.flexGrow.coerceAtLeast(0f).toDouble() - totalShrinkWeight += (item.child.flexShrink.coerceAtLeast(0f) * base).toDouble() + totalGrow += + item.child.flexGrow + .coerceAtLeast(0f) + .toDouble() + totalShrinkWeight += + ( + item.child.flexShrink + .coerceAtLeast(0f) * base + ).toDouble() } val gapTotal = gap * (items.size - 1).coerceAtLeast(0) val freeSpace = availableMain - totalOuterBaseMain - gapTotal @@ -517,22 +541,30 @@ class ContainerNode( val finalMain = DoubleArray(items.size) { baseMain[it] } if (freeSpace > 0.0 && totalGrow > 0.0) { items.forEachIndexed { index, item -> - val grow = item.child.flexGrow.coerceAtLeast(0f).toDouble() + val grow = + item.child.flexGrow + .coerceAtLeast(0f) + .toDouble() finalMain[index] += freeSpace * (grow / totalGrow) } } else if (allowMainAxisShrink && freeSpace < 0.0 && totalShrinkWeight > 0.0) { items.forEachIndexed { index, item -> - val weight = (item.child.flexShrink.coerceAtLeast(0f) * baseMain[index].toFloat()).toDouble() + val weight = + ( + item.child.flexShrink + .coerceAtLeast(0f) * baseMain[index].toFloat() + ).toDouble() finalMain[index] += freeSpace * (weight / totalShrinkWeight) if (finalMain[index] < 0.0) finalMain[index] = 0.0 } } - val usedMain = finalMain.indices.sumOf { index -> - finalMain[index] + + val usedMain = + finalMain.indices.sumOf { index -> + finalMain[index] + items[index].mainMarginStart + items[index].mainMarginEnd - } + gapTotal + } + gapTotal val extra = (availableMain - usedMain).coerceAtLeast(0.0) val (mainStartOffset, spacing) = justifyOffsets(justifyContent, items.size, extra, gap.toDouble()) @@ -542,40 +574,49 @@ class ContainerNode( cursorMain += item.mainMarginStart val mainSize = finalMain[index].roundToInt().coerceAtLeast(0) val crossAvailable = (availableCross - item.crossMarginStart - item.crossMarginEnd).coerceAtLeast(0) - val crossSize = when { - item.explicitCross != null -> item.explicitCross - alignItems == AlignItems.Stretch -> crossAvailable - else -> item.measuredCross.coerceAtMost(crossAvailable) - }.coerceAtLeast(0) + val crossSize = + when { + item.explicitCross != null -> item.explicitCross + alignItems == AlignItems.Stretch -> crossAvailable + else -> item.measuredCross.coerceAtMost(crossAvailable) + }.coerceAtLeast(0) val candidateWidth = if (isRow) mainSize else crossSize val candidateHeight = if (isRow) crossSize else mainSize - val resolvedSize = item.child.clampMeasuredOuterSize( - Size( - width = candidateWidth, - height = candidateHeight + val resolvedSize = + item.child.clampMeasuredOuterSize( + Size( + width = candidateWidth, + height = candidateHeight, + ), ) - ) val childWidth = resolvedSize.width val childHeight = resolvedSize.height val resolvedCross = if (isRow) childHeight else childWidth val resolvedMain = if (isRow) childWidth else childHeight - val crossRoom = (availableCross - resolvedCross - item.crossMarginStart - item.crossMarginEnd).coerceAtLeast(0) - val crossOffset = when (alignItems) { - AlignItems.Start, AlignItems.Stretch -> 0 - AlignItems.Center -> crossRoom / 2 - AlignItems.End -> crossRoom - } + val crossRoom = + (availableCross - resolvedCross - item.crossMarginStart - item.crossMarginEnd) + .coerceAtLeast( + 0, + ) + val crossOffset = + when (alignItems) { + AlignItems.Start, AlignItems.Stretch -> 0 + AlignItems.Center -> crossRoom / 2 + AlignItems.End -> crossRoom + } - val childX = if (isRow) { - cx + cursorMain.roundToInt() - } else { - cx + item.crossMarginStart + crossOffset - } - val childY = if (isRow) { - cy + item.crossMarginStart + crossOffset - } else { - cy + cursorMain.roundToInt() - } + val childX = + if (isRow) { + cx + cursorMain.roundToInt() + } else { + cx + item.crossMarginStart + crossOffset + } + val childY = + if (isRow) { + cy + item.crossMarginStart + crossOffset + } else { + cy + cursorMain.roundToInt() + } renderContainedChild( ctx = ctx, child = item.child, @@ -586,7 +627,7 @@ class ContainerNode( desiredX = childX, desiredY = childY, desiredWidth = childWidth, - desiredHeight = childHeight + desiredHeight = childHeight, ) cursorMain += resolvedMain + item.mainMarginEnd @@ -597,7 +638,7 @@ class ContainerNode( justify: JustifyContent, count: Int, extra: Double, - baseGap: Double + baseGap: Double, ): Pair { if (count <= 0) return 0.0 to baseGap return when (justify) { @@ -626,7 +667,7 @@ class ContainerNode( val row: Int, val column: Int, val rowSpan: Int, - val columnSpan: Int + val columnSpan: Int, ) private fun measureGrid(ctx: UiMeasureContext, children: List, fixedWidth: Int?): Size { @@ -635,16 +676,18 @@ class ContainerNode( val placements = computeGridPlacements(children, columns) val rowCount = resolveGridRowCount(placements) - val colWidth = when { - fixedWidth != null -> ((fixedWidth - gap * (columns - 1)).coerceAtLeast(0)) / columns - else -> placements.maxOfOrNull { placement -> - val child = placement.child - val outerWidth = measureChildForLayout(ctx, child, null).width + child.margin.horizontal - val totalGapWithinSpan = gap * (placement.columnSpan - 1).coerceAtLeast(0) - ((outerWidth - totalGapWithinSpan).coerceAtLeast(0) + placement.columnSpan - 1) / - placement.columnSpan.coerceAtLeast(1) - } ?: 0 - } + val colWidth = + when { + fixedWidth != null -> ((fixedWidth - gap * (columns - 1)).coerceAtLeast(0)) / columns + else -> + placements.maxOfOrNull { placement -> + val child = placement.child + val outerWidth = measureChildForLayout(ctx, child, null).width + child.margin.horizontal + val totalGapWithinSpan = gap * (placement.columnSpan - 1).coerceAtLeast(0) + ((outerWidth - totalGapWithinSpan).coerceAtLeast(0) + placement.columnSpan - 1) / + placement.columnSpan.coerceAtLeast(1) + } ?: 0 + } val rowHeights = computeGridRowHeights(ctx, placements, rowCount, colWidth) val measuredWidth = fixedWidth ?: (columns * colWidth + gap * (columns - 1)) val measuredHeight = rowHeights.sum() + gap * (rowHeights.size - 1).coerceAtLeast(0) @@ -678,44 +721,50 @@ class ContainerNode( val availableCellWidth = (cellWidth - child.margin.horizontal).coerceAtLeast(0) val availableCellHeight = (cellHeight - child.margin.vertical).coerceAtLeast(0) - val measured = measureChildForLayout( - ctx = ctx, - child = child, - availableOuterWidth = cellWidth.coerceAtLeast(0), - availableOuterHeight = cellHeight.coerceAtLeast(0) - ) + val measured = + measureChildForLayout( + ctx = ctx, + child = child, + availableOuterWidth = cellWidth.coerceAtLeast(0), + availableOuterHeight = cellHeight.coerceAtLeast(0), + ) - val requestedChildWidth = when { - child.width != null -> child.width!!.coerceAtMost(availableCellWidth) - justifyItems == JustifyItems.Stretch -> availableCellWidth - else -> measured.width.coerceAtMost(availableCellWidth) - }.coerceAtLeast(0) - val requestedChildHeight = when { - child.height != null -> child.height!!.coerceAtMost(availableCellHeight) - alignItems == AlignItems.Stretch -> availableCellHeight - else -> measured.height.coerceAtMost(availableCellHeight) - }.coerceAtLeast(0) - val resolvedSize = child.clampMeasuredOuterSize( - Size( - width = requestedChildWidth, - height = requestedChildHeight + val requestedChildWidth = + when { + child.width != null -> child.width!!.coerceAtMost(availableCellWidth) + justifyItems == JustifyItems.Stretch -> availableCellWidth + else -> measured.width.coerceAtMost(availableCellWidth) + }.coerceAtLeast(0) + val requestedChildHeight = + when { + child.height != null -> child.height!!.coerceAtMost(availableCellHeight) + alignItems == AlignItems.Stretch -> availableCellHeight + else -> measured.height.coerceAtMost(availableCellHeight) + }.coerceAtLeast(0) + val resolvedSize = + child.clampMeasuredOuterSize( + Size( + width = requestedChildWidth, + height = requestedChildHeight, + ), ) - ) val childWidth = resolvedSize.width val childHeight = resolvedSize.height val xSpace = (availableCellWidth - childWidth).coerceAtLeast(0) val ySpace = (availableCellHeight - childHeight).coerceAtLeast(0) - val xOffset = when (justifyItems) { - JustifyItems.Start, JustifyItems.Stretch -> 0 - JustifyItems.Center -> xSpace / 2 - JustifyItems.End -> xSpace - } - val yOffset = when (alignItems) { - AlignItems.Start, AlignItems.Stretch -> 0 - AlignItems.Center -> ySpace / 2 - AlignItems.End -> ySpace - } + val xOffset = + when (justifyItems) { + JustifyItems.Start, JustifyItems.Stretch -> 0 + JustifyItems.Center -> xSpace / 2 + JustifyItems.End -> xSpace + } + val yOffset = + when (alignItems) { + AlignItems.Start, AlignItems.Stretch -> 0 + AlignItems.Center -> ySpace / 2 + AlignItems.End -> ySpace + } val childX = cellX + child.margin.left + xOffset val childY = cellY + child.margin.top + yOffset @@ -729,7 +778,7 @@ class ContainerNode( childX, childY, childWidth, - childHeight + childHeight, ) } } @@ -741,7 +790,10 @@ class ContainerNode( var searchRows = (gridRows ?: 1).coerceAtLeast(1) children.forEach { child -> - val colSpan = child.gridColumnSpan.coerceAtLeast(1).coerceAtMost(columns) + val colSpan = + child.gridColumnSpan + .coerceAtLeast(1) + .coerceAtMost(columns) val rowSpan = child.gridRowSpan.coerceAtLeast(1) var placed: GridPlacement? = null var safety = 0 @@ -750,13 +802,14 @@ class ContainerNode( for ((row, col) in candidates) { if (canPlaceGridCell(row, col, rowSpan, colSpan, columns, occupied)) { markGridCell(row, col, rowSpan, colSpan, occupied) - placed = GridPlacement( - child = child, - row = row, - column = col, - rowSpan = rowSpan, - columnSpan = colSpan - ) + placed = + GridPlacement( + child = child, + row = row, + column = col, + rowSpan = rowSpan, + columnSpan = colSpan, + ) break } } @@ -771,14 +824,14 @@ class ContainerNode( row = placements.size / columns, column = placements.size % columns, rowSpan = rowSpan, - columnSpan = colSpan + columnSpan = colSpan, ) } return placements } - private fun candidateGridCells(columns: Int, rows: Int): Sequence> { - return sequence { + private fun candidateGridCells(columns: Int, rows: Int): Sequence> = + sequence { val maxRows = rows.coerceAtLeast(1) if (gridAutoFlow == GridAutoFlow.Column) { for (col in 0 until columns) { @@ -794,7 +847,6 @@ class ContainerNode( } } } - } private fun canPlaceGridCell( row: Int, @@ -802,7 +854,7 @@ class ContainerNode( rowSpan: Int, colSpan: Int, columns: Int, - occupied: Set + occupied: Set, ): Boolean { if (column + colSpan > columns) return false for (r in row until row + rowSpan) { @@ -818,7 +870,7 @@ class ContainerNode( column: Int, rowSpan: Int, colSpan: Int, - occupied: MutableSet + occupied: MutableSet, ) { for (r in row until row + rowSpan) { for (c in column until column + colSpan) { @@ -827,9 +879,8 @@ class ContainerNode( } } - private fun encodeGridCell(row: Int, column: Int): Long { - return (row.toLong() shl 32) or (column.toLong() and 0xFFFF_FFFFL) - } + private fun encodeGridCell(row: Int, column: Int): Long = + (row.toLong() shl 32) or (column.toLong() and 0xFFFF_FFFFL) private fun resolveGridRowCount(placements: List): Int { val placedRows = placements.maxOfOrNull { it.row + it.rowSpan } ?: 0 @@ -840,7 +891,7 @@ class ContainerNode( ctx: UiMeasureContext, placements: List, rowCount: Int, - colWidth: Int + colWidth: Int, ): IntArray { val rowHeights = IntArray(rowCount) placements.forEach { placement -> @@ -869,13 +920,13 @@ class ContainerNode( ctx: UiMeasureContext, child: DOMNode, availableOuterWidth: Int?, - availableOuterHeight: Int? = null + availableOuterHeight: Int? = null, ): Size { ScrollPerformanceCounters.incrementMeasureChildForLayoutCalls() child.resolveLayoutStyleValues( ctx = ctx, parentContentWidth = availableOuterWidth, - parentContentHeight = availableOuterHeight + parentContentHeight = availableOuterHeight, ) return child.clampMeasuredOuterSize(child.measureForLayout(ctx, availableOuterWidth)) } @@ -908,26 +959,39 @@ class ContainerNode( return total } - private fun alignedChildX(child: DOMNode, contentX: Int, availableWidth: Int, childWidth: Int): Int { + private fun alignedChildX( + child: DOMNode, + contentX: Int, + availableWidth: Int, + childWidth: Int, + ): Int { val interactableWidth = (availableWidth - child.margin.horizontal).coerceAtLeast(0) - val horizontalOffset = when (child.align) { - StyleAlign.START -> 0 - StyleAlign.CENTER -> (interactableWidth - childWidth) / 2 - StyleAlign.END -> interactableWidth - childWidth - } + val horizontalOffset = + when (child.align) { + StyleAlign.START -> 0 + StyleAlign.CENTER -> (interactableWidth - childWidth) / 2 + StyleAlign.END -> interactableWidth - childWidth + } return contentX + child.margin.left + horizontalOffset.coerceAtLeast(0) } - private fun alignedChildY(child: DOMNode, contentY: Int, availableHeight: Int, childHeight: Int): Int { + private fun alignedChildY( + child: DOMNode, + contentY: Int, + availableHeight: Int, + childHeight: Int, + ): Int { val interactableHeight = (availableHeight - child.margin.vertical).coerceAtLeast(0) - val verticalOffset = when (child.align) { - StyleAlign.START -> 0 - StyleAlign.CENTER -> (interactableHeight - childHeight) / 2 - StyleAlign.END -> interactableHeight - childHeight - } + val verticalOffset = + when (child.align) { + StyleAlign.START -> 0 + StyleAlign.CENTER -> (interactableHeight - childHeight) / 2 + StyleAlign.END -> interactableHeight - childHeight + } return contentY + child.margin.top + verticalOffset.coerceAtLeast(0) } + @Suppress("UnusedParameter") private fun renderContainedChild( ctx: UiMeasureContext, child: DOMNode, @@ -938,39 +1002,42 @@ class ContainerNode( desiredX: Int, desiredY: Int, desiredWidth: Int, - desiredHeight: Int + desiredHeight: Int, ) { - val positionedRect = when (child.position) { - PositionMode.Absolute -> child.resolveAbsoluteLayoutRect( - ctx = ctx, - desiredX = desiredX, - desiredY = desiredY, - desiredWidth = desiredWidth, - desiredHeight = desiredHeight - ) + val positionedRect = + when (child.position) { + PositionMode.Absolute -> + child.resolveAbsoluteLayoutRect( + ctx = ctx, + desiredX = desiredX, + desiredY = desiredY, + desiredWidth = desiredWidth, + desiredHeight = desiredHeight, + ) - PositionMode.Fixed -> child.resolveFixedLayoutRect( - ctx = ctx, - desiredX = desiredX, - desiredY = desiredY, - desiredWidth = desiredWidth, - desiredHeight = desiredHeight - ) + PositionMode.Fixed -> + child.resolveFixedLayoutRect( + ctx = ctx, + desiredX = desiredX, + desiredY = desiredY, + desiredWidth = desiredWidth, + desiredHeight = desiredHeight, + ) - else -> Rect( - x = desiredX, - y = desiredY, - width = desiredWidth.coerceAtLeast(0), - height = desiredHeight.coerceAtLeast(0) - ) - } + else -> + Rect( + x = desiredX, + y = desiredY, + width = desiredWidth.coerceAtLeast(0), + height = desiredHeight.coerceAtLeast(0), + ) + } child.render( ctx = ctx, x = positionedRect.x, y = positionedRect.y, width = positionedRect.width.coerceAtLeast(0), - height = positionedRect.height.coerceAtLeast(0) + height = positionedRect.height.coerceAtLeast(0), ) } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/DateInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/DateInputNode.kt index 81fff09..950fd55 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/DateInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/DateInputNode.kt @@ -14,7 +14,7 @@ class DateInputNode( value: Instant, zoneId: ZoneId, placeholder: String = "dd.MM.yyyy HH:mm", - key: Any? = null + key: Any? = null, ) : SingleLineInputNode("", placeholder, key) { private val initialValue: Instant = value private val initialZoneId: ZoneId = zoneId @@ -63,25 +63,25 @@ class DateInputNode( return formatter.format(dateTime) } - private fun parseInstant(text: String): Instant? { - return try { + private fun parseInstant(text: String): Instant? = + try { val dateTime = LocalDateTime.parse(text, formatter) dateTime.atZone(this.zoneId).toInstant() - } catch (ex: Exception) { + } catch (_: java.time.format.DateTimeParseException) { null } - } private fun isValidPrefix(text: String): Boolean { if (text.isEmpty()) return true for (i in text.indices) { val ch = text[i] - val expected = when (i) { - 2, 5 -> '.' - 10 -> ' ' - 13 -> ':' - else -> null - } + val expected = + when (i) { + 2, 5 -> '.' + 10 -> ' ' + 13 -> ':' + else -> null + } if (expected != null) { if (ch != expected) return false } else if (!ch.isDigit()) { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ImageNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ImageNode.kt index 0fbdf5b..ca61920 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ImageNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ImageNode.kt @@ -12,17 +12,14 @@ class ImageNode( var url: String, var imageWidth: Int = 0, var imageHeight: Int = 0, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "img" - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(null) private fun measureWithConstraint(availableOuterWidth: Int?): Size { val contentLimit = resolvedContentLimit(availableOuterWidth) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputOption.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputOption.kt index 401749c..d2c32a3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputOption.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputOption.kt @@ -5,5 +5,5 @@ package org.dreamfinity.dsgl.core.dom.elements */ data class InputOption( val id: String, - val label: String = id -) \ No newline at end of file + val label: String = id, +) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputType.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputType.kt index 105e9e3..1b77234 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputType.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/InputType.kt @@ -13,7 +13,7 @@ sealed class InputType { val placeholder: String = "", val allowedChars: String? = null, val minLength: Int? = null, - val maxLength: Int? = null + val maxLength: Int? = null, ) : InputType() /** Password input rendered with masking. */ @@ -21,7 +21,7 @@ sealed class InputType { val value: String = "", val placeholder: String = "", val minLength: Int? = null, - val maxLength: Int? = null + val maxLength: Int? = null, ) : InputType() /** Numeric input. */ @@ -29,7 +29,7 @@ sealed class InputType { val value: Long = 0L, val placeholder: String = "", val min: Long? = null, - val max: Long? = null + val max: Long? = null, ) : InputType() /** Range slider input. */ @@ -37,7 +37,7 @@ sealed class InputType { val value: Long = 0L, val min: Long = 0L, val max: Long = 100L, - val step: Long? = null + val step: Long? = null, ) : InputType() /** Checkbox group input. */ @@ -45,19 +45,19 @@ sealed class InputType { val variants: List, val selected: Set = emptySet(), val minSelected: Int? = null, - val maxSelected: Int? = null + val maxSelected: Int? = null, ) : InputType() /** Radio group input. */ data class Radio( val variants: List, - val selected: String? = null + val selected: String? = null, ) : InputType() /** Date/time input. */ data class Date( val value: Instant? = null, val zoneId: ZoneId? = null, - val placeholder: String = "dd.MM.yyyy HH:mm" + val placeholder: String = "dd.MM.yyyy HH:mm", ) : InputType() -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ItemStackNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ItemStackNode.kt index 0742db3..01bfb09 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ItemStackNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ItemStackNode.kt @@ -14,17 +14,14 @@ class ItemStackNode( var size: Int = 18, var rotYDeg: Double = 160.0, var rotXDeg: Double = -11.0, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "itemstack" - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val contentLimit = resolvedContentLimit(availableOuterWidth) @@ -58,8 +55,8 @@ class ItemStackNode( contentWidth(), size, rotYDeg, - rotXDeg - ) + rotXDeg, + ), ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/NumberInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/NumberInputNode.kt index badf5bd..97b740f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/NumberInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/NumberInputNode.kt @@ -13,7 +13,7 @@ class NumberInputNode( placeholder: String = "", var min: Long? = null, var max: Long? = null, - key: Any? = null + key: Any? = null, ) : SingleLineInputNode(value.toString(), placeholder, key) { private val initialValue: Long = value var value: Long = initialValue diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/PasswordInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/PasswordInputNode.kt index 320c87a..b00ea3b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/PasswordInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/PasswordInputNode.kt @@ -8,18 +8,16 @@ class PasswordInputNode( placeholder: String = "", minLength: Int? = null, maxLength: Int? = null, - key: Any? = null + key: Any? = null, ) : SingleLineInputNode(text, placeholder, key) { init { this.minLength = minLength this.maxLength = maxLength } - override fun displayText(): String { - return if (text.isEmpty()) "" else "*".repeat(text.length) - } + override fun displayText(): String = if (text.isEmpty()) "" else "*".repeat(text.length) override fun allowClipboardCopy(): Boolean = false override fun allowClipboardCut(): Boolean = false -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RadioGroupNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RadioGroupNode.kt index 3b028cb..be54fe8 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RadioGroupNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RadioGroupNode.kt @@ -14,7 +14,7 @@ import org.dreamfinity.dsgl.core.render.RenderCommand class RadioGroupNode( var variants: List, var selectedId: String? = null, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "input" override val focusable: Boolean = true @@ -42,13 +42,10 @@ class RadioGroupNode( } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val fontHeight = resolveFontSize(ctx) @@ -75,7 +72,13 @@ class RadioGroupNode( } } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) val fontHeight = resolveFontSize(ctx) boxSize = maxOf(10, fontHeight - 2) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RangeInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RangeInputNode.kt index 9d1d996..59909f6 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RangeInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/RangeInputNode.kt @@ -17,11 +17,11 @@ class RangeInputNode( var min: Long = 0L, var max: Long = 100L, var step: Long? = null, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { private data class SliderGeometry( val trackRect: Rect, - val knobSize: Int + val knobSize: Int, ) companion object { @@ -49,56 +49,30 @@ class RangeInputNode( if (this@RangeInputNode.styleDisabled) return@addEventListener if (event.mouseButton != MouseButton.LEFT) return@addEventListener if (!containsGlobalPoint(event.mouseX, event.mouseY)) return@addEventListener - activeDragIdentity = dragIdentity() - activeDragStartValue = this@RangeInputNode.value - val before = this@RangeInputNode.value - updateFromMouse(event.mouseX) - if (this@RangeInputNode.value != before) { - postInput( - this@RangeInputNode, - this@RangeInputNode.value.toString(), - this@RangeInputNode.value - ) - } + beginActiveDrag(event.mouseX) + event.cancelled = true } this@RangeInputNode.addEventListener(Events.MOUSEUP) { event: MouseUpEvent -> if (this@RangeInputNode.styleDisabled) return@addEventListener if (event.mouseButton != MouseButton.LEFT) return@addEventListener if (!isActiveDragTarget()) return@addEventListener - val start = activeDragStartValue ?: this@RangeInputNode.value - if (this@RangeInputNode.value != start) { - postChange( - this@RangeInputNode, - this@RangeInputNode.value.toString(), - this@RangeInputNode.value - ) - } - clearActiveDrag() + completeActiveDrag() + event.cancelled = true } this@RangeInputNode.addEventListener(Events.DRAG) { event: MouseDragEvent -> if (this@RangeInputNode.styleDisabled) return@addEventListener if (!isActiveDragTarget()) return@addEventListener val currentX = event.lastMouseX + event.dx - val before = this@RangeInputNode.value - updateFromMouse(currentX) - if (this@RangeInputNode.value != before) { - postInput( - this@RangeInputNode, - this@RangeInputNode.value.toString(), - this@RangeInputNode.value - ) - } + updateActiveDrag(currentX) + event.cancelled = true } } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(null) private fun measureWithConstraint(availableOuterWidth: Int?): Size { val contentLimit = resolvedContentLimit(availableOuterWidth) @@ -121,10 +95,47 @@ class RangeInputNode( } } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) } + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean = + !styleDisabled && containsGlobalPoint(mouseX, mouseY) + + override fun beginPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT || styleDisabled) return + if (isActiveDragTarget()) return + beginActiveDrag(mouseX) + } + + override fun continuePointerCapture( + mouseX: Int, + mouseY: Int, + mouseDX: Int, + mouseDY: Int, + button: MouseButton, + ) { + if (button != MouseButton.LEFT || styleDisabled || !isActiveDragTarget()) return + updateActiveDrag(mouseX) + } + + override fun endPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT || !isActiveDragTarget()) return + completeActiveDrag() + } + + override fun cancelPointerCapture() { + if (isActiveDragTarget()) { + clearActiveDrag() + } + } + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { val geometry = sliderGeometry() addBackgroundImageCommand(out) @@ -135,8 +146,8 @@ class RangeInputNode( geometry.trackRect.y, geometry.trackRect.width, geometry.trackRect.height, - trackColor - ) + trackColor, + ), ) val knobX = valueToX(geometry) val knobY = geometry.trackRect.y + (geometry.trackRect.height - geometry.knobSize) / 2 @@ -160,13 +171,44 @@ class RangeInputNode( setValue(next) } + private fun beginActiveDrag(mouseX: Int) { + activeDragIdentity = dragIdentity() + activeDragStartValue = this@RangeInputNode.value + updateActiveDrag(mouseX) + } + + private fun updateActiveDrag(mouseX: Int) { + val before = this@RangeInputNode.value + updateFromMouse(mouseX) + if (this@RangeInputNode.value != before) { + postInput( + this@RangeInputNode, + this@RangeInputNode.value.toString(), + this@RangeInputNode.value, + ) + } + } + + private fun completeActiveDrag() { + val start = activeDragStartValue ?: this@RangeInputNode.value + if (this@RangeInputNode.value != start) { + postChange( + this@RangeInputNode, + this@RangeInputNode.value.toString(), + this@RangeInputNode.value, + ) + } + clearActiveDrag() + } + private fun valueToX(geometry: SliderGeometry): Int { val trackRect = geometry.trackRect val knobSize = geometry.knobSize val trackWidth = trackRect.width.coerceAtLeast(1) if (max == min) return trackRect.x val ratio = (value - min).toDouble() / (max - min).toDouble() - return (trackRect.x + ratio * trackWidth - knobSize / 2.0).toInt() + return (trackRect.x + ratio * trackWidth - knobSize / 2.0) + .toInt() .coerceIn(trackRect.x, trackRect.x + trackWidth - knobSize) } @@ -174,15 +216,16 @@ class RangeInputNode( val contentHeight = contentHeight() val resolvedTrackHeight = maxOf(2, contentHeight / 3) val resolvedKnobSize = maxOf(resolvedTrackHeight * 2, 8) - val trackRect = Rect( - x = contentX(), - y = contentY() + (contentHeight - resolvedTrackHeight) / 2, - width = contentWidth(), - height = resolvedTrackHeight - ) + val trackRect = + Rect( + x = contentX(), + y = contentY() + (contentHeight - resolvedTrackHeight) / 2, + width = contentWidth(), + height = resolvedTrackHeight, + ) return SliderGeometry( trackRect = trackRect, - knobSize = resolvedKnobSize + knobSize = resolvedKnobSize, ) } @@ -193,9 +236,7 @@ class RangeInputNode( value = clamped } - private fun dragIdentity(): Any { - return key ?: this - } + private fun dragIdentity(): Any = key ?: this private fun isActiveDragTarget(): Boolean { val active = activeDragIdentity ?: return false diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SelectNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SelectNode.kt index 5359536..4efa121 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SelectNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SelectNode.kt @@ -7,11 +7,12 @@ import org.dreamfinity.dsgl.core.dom.layout.Insets import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.* +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.select.SelectEntry import org.dreamfinity.dsgl.core.select.SelectModel import org.dreamfinity.dsgl.core.select.SelectOpenRequest -import org.dreamfinity.dsgl.core.select.SelectRuntime class SelectNode( model: SelectModel, @@ -19,7 +20,8 @@ class SelectNode( value: String? = null, defaultValue: String? = null, closeOnSelect: Boolean = true, - key: Any? = null + ownerDomain: ScreenDomainId = ScreenDomainId.Application, + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "select" override val focusable: Boolean = true @@ -56,12 +58,16 @@ class SelectNode( markRenderCommandsDirty() } var closeOnSelect: Boolean = closeOnSelect + var ownerDomain: ScreenDomainId = ownerDomain var textColor: Int = DsglColors.TEXT var placeholderColor: Int = 0xFF8A8A8A.toInt() var backgroundColor: Int = 0xFF2E2E33.toInt() var disabledTextColor: Int = 0xFF8E8E8E.toInt() var minContentWidth: Int = 92 - var arrowGlyph: String = SelectRuntime.engine.currentStyle().arrowGlyph + var arrowGlyph: String = + DomainPortalServices.applicationSelectEngine + .currentStyle() + .arrowGlyph var arrowSpacing: Int = 8 private var uncontrolledValue: String? = defaultValue @@ -75,8 +81,11 @@ class SelectNode( if (event.mouseButton != MouseButton.LEFT) return@addEventListener if (!this@SelectNode.containsGlobalPoint(event.mouseX, event.mouseY)) return@addEventListener FocusManager.requestFocus(this@SelectNode) - if (SelectRuntime.host.isOpenFor(ownerToken)) { - SelectRuntime.host.close(ownerToken) + if ( + DomainPortalServices.isSelectOpenFor(ownerToken) && + !DomainPortalServices.isSelectClosingFor(ownerToken) + ) { + DomainPortalServices.closeSelect(ownerToken) } else { openPopup() } @@ -85,7 +94,7 @@ class SelectNode( this@SelectNode.addEventListener(Events.KEYDOWN) { event: KeyboardKeyDownEvent -> if (this@SelectNode.styleDisabled) return@addEventListener if (!FocusManager.isFocused(this@SelectNode)) return@addEventListener - if (SelectRuntime.host.isOpenFor(ownerToken)) return@addEventListener + if (DomainPortalServices.isSelectOpenFor(ownerToken)) return@addEventListener when (event.keyCode) { KeyCodes.ENTER, KeyCodes.SPACE -> { openPopup() @@ -94,32 +103,29 @@ class SelectNode( KeyCodes.DOWN -> { openPopup() - SelectRuntime.engine.moveHighlight(ownerToken, 1) + DomainPortalServices.selectEngineFor(ownerDomain).moveHighlight(ownerToken, 1) event.cancelled = true } KeyCodes.UP -> { openPopup() - SelectRuntime.engine.moveHighlight(ownerToken, -1) + DomainPortalServices.selectEngineFor(ownerDomain).moveHighlight(ownerToken, -1) event.cancelled = true } } } this@SelectNode.addEventListener(Events.BLUR) { _: FocusLoseEvent -> - if (SelectRuntime.host.isOpenFor(ownerToken)) { - SelectRuntime.host.close(ownerToken) + if (DomainPortalServices.isSelectOpenFor(ownerToken)) { + DomainPortalServices.closeSelect(ownerToken) } } } } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val lineHeight = resolveFontSize(ctx) @@ -147,17 +153,18 @@ class SelectNode( } override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { - if (styleDisabled && SelectRuntime.host.isOpenFor(ownerToken)) { - SelectRuntime.host.close(ownerToken) + if (styleDisabled && DomainPortalServices.isSelectOpenFor(ownerToken)) { + DomainPortalServices.closeSelect(ownerToken) } syncPopup() val isFocused = FocusManager.isFocused(this) && !styleDisabled val textValue = selectedLabelOrPlaceholder() - val drawColor = when { - styleDisabled -> disabledTextColor - selectedOptionId() == null -> placeholderColor - else -> textColor - } + val drawColor = + when { + styleDisabled -> disabledTextColor + selectedOptionId() == null -> placeholderColor + else -> textColor + } val arrowWidth = if (arrowGlyph.isEmpty()) 0 else measureText(ctx, arrowGlyph) val lineHeight = resolveFontSize(ctx) out += RenderCommand.DrawRect(bounds.x, bounds.y, bounds.width, bounds.height, backgroundColor) @@ -174,34 +181,35 @@ class SelectNode( out += RenderCommand.PushClip(innerX, innerY, textClipWidth, innerHeight.coerceAtLeast(1)) if (textValue.isNotEmpty()) { - out += drawTextCommand( - ctx, - text = textValue, - x = innerX, - y = textY, - color = drawColor - ) + out += + drawTextCommand( + ctx, + text = textValue, + x = innerX, + y = textY, + color = drawColor, + ) } out += RenderCommand.PopClip if (arrowGlyph.isNotEmpty()) { val arrowColor = if (styleDisabled) disabledTextColor else textColor - out += drawTextCommand( - ctx, - text = arrowGlyph, - x = arrowX, - y = textY, - color = arrowColor - ) + out += + drawTextCommand( + ctx, + text = arrowGlyph, + x = arrowX, + y = textY, + color = arrowColor, + ) } - } override fun volatileRenderCommandsSignature(nowMs: Long): Long { var hash = 1L hash = 31L * hash + selectedLabelOrPlaceholder().hashCode() hash = 31L * hash + (selectedOptionId()?.hashCode() ?: 0) - hash = 31L * hash + if (SelectRuntime.host.isOpenFor(ownerToken)) 1L else 0L + hash = 31L * hash + if (DomainPortalServices.isSelectOpenFor(ownerToken)) 1L else 0L return hash } @@ -211,6 +219,7 @@ class SelectNode( controlledValue = template.controlledValue defaultValue = template.defaultValue closeOnSelect = template.closeOnSelect + ownerDomain = template.ownerDomain textColor = template.textColor placeholderColor = template.placeholderColor backgroundColor = template.backgroundColor @@ -240,15 +249,15 @@ class SelectNode( private fun openPopup() { if (!hasEnabledOption()) return - SelectRuntime.host.open(openRequest()) + DomainPortalServices.openSelect(openRequest()) setOpenState(true) } private fun syncPopup() { - val open = SelectRuntime.host.isOpenFor(ownerToken) + val open = DomainPortalServices.isSelectOpenFor(ownerToken) setOpenState(open) if (open) { - SelectRuntime.engine.sync(openRequest()) + DomainPortalServices.selectEngineFor(ownerDomain).sync(openRequest()) } } @@ -265,7 +274,8 @@ class SelectNode( onSelect = { selected -> applySelection(selected) }, onClose = { setOpenState(false) }, fontId = fontId, - fontSize = fontSize + fontSize = fontSize, + ownerDomain = ownerDomain, ) } @@ -294,7 +304,9 @@ class SelectNode( return option.labelProvider.invoke() } } - return model.placeholderProvider?.invoke().orEmpty() + return model.placeholderProvider + ?.invoke() + .orEmpty() } private fun reconcileSelection() { @@ -305,17 +317,16 @@ class SelectNode( if (controlled) { return } - uncontrolledValue = when { - !defaultValue.isNullOrEmpty() && optionExists(defaultValue!!) -> defaultValue - else -> firstEnabledOptionId() - } + uncontrolledValue = + when { + !defaultValue.isNullOrEmpty() && optionExists(defaultValue!!) -> defaultValue + else -> firstEnabledOptionId() + } } private fun hasEnabledOption(): Boolean = firstEnabledOptionId() != null - private fun firstEnabledOptionId(): String? { - return firstEnabledOptionId(model.entries) - } + private fun firstEnabledOptionId(): String? = firstEnabledOptionId(model.entries) private fun firstEnabledOptionId(entries: List): String? { entries.forEach { entry -> @@ -332,13 +343,9 @@ class SelectNode( return null } - private fun optionExists(optionId: String): Boolean { - return findOption(optionId) != null - } + private fun optionExists(optionId: String): Boolean = findOption(optionId) != null - private fun findOption(optionId: String): SelectEntry.Option? { - return findOption(model.entries, optionId) - } + private fun findOption(optionId: String): SelectEntry.Option? = findOption(model.entries, optionId) private fun findOption(entries: List, optionId: String): SelectEntry.Option? { entries.forEach { entry -> diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SingleLineInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SingleLineInputNode.kt index cc73e4c..5ad1a60 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SingleLineInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/SingleLineInputNode.kt @@ -16,20 +16,20 @@ import org.dreamfinity.dsgl.core.style.TextWrap open class SingleLineInputNode( text: String = "", var placeholder: String = "", - key: Any? = null + key: Any? = null, ) : DOMNode(key) { companion object { private data class UndoSnapshot( val text: String, val caretIndex: Int, - val selectionAnchor: Int? + val selectionAnchor: Int?, ) private data class PersistedState( val caretIndex: Int, val selectionAnchor: Int?, val undoHistory: List, - val redoHistory: List + val redoHistory: List, ) private val persistedByKey: KeyedStateStore = KeyedStateStore() @@ -37,7 +37,6 @@ open class SingleLineInputNode( fun clearActiveDrag() { activeSelectionDragIdentity = null - } } @@ -76,12 +75,14 @@ open class SingleLineInputNode( if (event.mouseButton != MouseButton.LEFT) return@addEventListener if (!this@SingleLineInputNode.containsGlobalPoint(event.mouseX, event.mouseY)) return@addEventListener handlePointerDown(event.mouseX) + event.cancelled = true } this@SingleLineInputNode.addEventListener(Events.DRAG) { event: MouseDragEvent -> if (this@SingleLineInputNode.styleDisabled) return@addEventListener if (!isActiveSelectionDragTarget()) return@addEventListener val currentX = event.lastMouseX + event.dx updateSelectionFromPointerDrag(currentX) + event.cancelled = true } this@SingleLineInputNode.addEventListener(Events.MOUSEUP) { event: MouseUpEvent -> if (event.mouseButton != MouseButton.LEFT) return@addEventListener @@ -92,6 +93,7 @@ open class SingleLineInputNode( } editState.resetBlinkClock() persistState() + event.cancelled = true } this@SingleLineInputNode.addEventListener(Events.KEYDOWN) { event: KeyboardKeyDownEvent -> if (this@SingleLineInputNode.styleDisabled) return@addEventListener @@ -110,15 +112,18 @@ open class SingleLineInputNode( } protected open fun displayText(): String = this.text + protected open fun currentEventValue(): String = this.text + protected open fun currentParsedValue(): Any? = this.text + protected open fun allowClipboardCopy(): Boolean = true + protected open fun allowClipboardCut(): Boolean = true + protected open fun allowClipboardPaste(): Boolean = true - protected open fun sanitizePastedText(raw: String): String { - return raw.replace("\r", "").replace("\n", "") - } + protected open fun sanitizePastedText(raw: String): String = raw.replace("\r", "").replace("\n", "") protected open fun handleKey(event: KeyboardKeyDownEvent) { editState.clampToLength(text.length) @@ -148,13 +153,9 @@ open class SingleLineInputNode( } } - protected fun isPrintable(ch: Char): Boolean { - return TextEditOps.isPrintable(ch) - } + protected fun isPrintable(ch: Char): Boolean = TextEditOps.isPrintable(ch) - protected open fun canAcceptText(next: String): Boolean { - return !(maxLength != null && next.length > maxLength!!) - } + protected open fun canAcceptText(next: String): Boolean = !(maxLength != null && next.length > maxLength!!) protected open fun applyText(next: String) { text = next @@ -173,13 +174,10 @@ open class SingleLineInputNode( postInput(this, current, currentParsedValue()) } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val lineHeight = resolveFontSize(ctx) @@ -252,7 +250,6 @@ open class SingleLineInputNode( if (innerWidth > 0 && innerHeight > 0) { out.add(RenderCommand.PopClip) } - } fun shouldCaptureTextSelectionDrag(mouseX: Int, mouseY: Int): Boolean { @@ -260,6 +257,42 @@ open class SingleLineInputNode( return containsGlobalPoint(mouseX, mouseY) } + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean = + shouldCaptureTextSelectionDrag(mouseX, mouseY) || super.shouldCapturePointerDrag(mouseX, mouseY) + + override fun beginPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT || styleDisabled) return + if (isActiveSelectionDragTarget()) return + handlePointerDown(mouseX) + } + + override fun continuePointerCapture( + mouseX: Int, + mouseY: Int, + mouseDX: Int, + mouseDY: Int, + button: MouseButton, + ) { + if (button != MouseButton.LEFT || styleDisabled || !isActiveSelectionDragTarget()) return + updateSelectionFromPointerDrag(mouseX) + } + + override fun endPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT || !isActiveSelectionDragTarget()) return + clearActiveDrag() + if (!editState.hasSelection()) { + editState.clearSelection() + } + editState.resetBlinkClock() + persistState() + } + + override fun cancelPointerCapture() { + if (isActiveSelectionDragTarget()) { + clearActiveDrag() + } + } + private fun handlePointerDown(mouseX: Int) { resetTypingUndoGroup() val target = caretIndexFromMouseX(mouseX) @@ -345,7 +378,12 @@ open class SingleLineInputNode( return replaceRange(start, end, insert, recordUndo) } - private fun replaceRange(start: Int, end: Int, insert: String, recordUndo: Boolean = false): Boolean { + private fun replaceRange( + start: Int, + end: Int, + insert: String, + recordUndo: Boolean = false, + ): Boolean { val safeStart = start.coerceIn(0, text.length) val safeEnd = end.coerceIn(safeStart, text.length) val previous = currentEventValue() @@ -365,8 +403,8 @@ open class SingleLineInputNode( return true } - private fun handleClipboardShortcut(event: KeyboardKeyDownEvent): Boolean { - return TextEditShortcutDispatcher.dispatch( + private fun handleClipboardShortcut(event: KeyboardKeyDownEvent): Boolean = + TextEditShortcutDispatcher.dispatch( event, TextShortcutCallbacks( canCopy = allowClipboardCopy(), @@ -391,28 +429,26 @@ open class SingleLineInputNode( if (action != TextShortcutAction.COPY) { persistState() } - } - ) + }, + ), ) - } private fun pushUndoSnapshot() { history.pushUndo( UndoSnapshot( text = text, caretIndex = editState.caretIndex, - selectionAnchor = editState.selectionAnchor - ) + selectionAnchor = editState.selectionAnchor, + ), ) } - private fun currentSnapshot(): UndoSnapshot { - return UndoSnapshot( + private fun currentSnapshot(): UndoSnapshot = + UndoSnapshot( text = text, caretIndex = editState.caretIndex, - selectionAnchor = editState.selectionAnchor + selectionAnchor = editState.selectionAnchor, ) - } private fun undoLastEdit(): Boolean { val snapshot = history.undo(currentSnapshot()) ?: return false @@ -436,17 +472,14 @@ open class SingleLineInputNode( return true } - private fun shouldRecordTypingUndo(ch: Char): Boolean { - return typingUndoGrouping.shouldRecord(ch, editState.hasSelection()) - } + private fun shouldRecordTypingUndo(ch: Char): Boolean = + typingUndoGrouping.shouldRecord(ch, editState.hasSelection()) private fun resetTypingUndoGroup() { typingUndoGrouping.reset() } - private fun selectedText(): String { - return TextEditOps.selectedText(text, editState) - } + private fun selectedText(): String = TextEditOps.selectedText(text, editState) private fun caretIndexFromMouseX(mouseX: Int): Int { val rendered = displayText() @@ -469,9 +502,7 @@ open class SingleLineInputNode( return rendered.length } - private fun dragIdentity(): Any { - return TextEditOps.dragIdentity(key, this) - } + private fun dragIdentity(): Any = TextEditOps.dragIdentity(key, this) private fun isActiveSelectionDragTarget(): Boolean { val active = activeSelectionDragIdentity ?: return false @@ -486,8 +517,8 @@ open class SingleLineInputNode( caretIndex = editState.caretIndex, selectionAnchor = editState.selectionAnchor, undoHistory = history.undoHistory(), - redoHistory = history.redoHistory() - ) + redoHistory = history.redoHistory(), + ), ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextAreaNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextAreaNode.kt index a77a235..65c658d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextAreaNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextAreaNode.kt @@ -17,7 +17,7 @@ import org.dreamfinity.dsgl.core.style.TextWrap class TextAreaNode( var text: String = "", var placeholder: String = "", - key: Any? = null + key: Any? = null, ) : DOMNode(key) { companion object { private data class UndoSnapshot( @@ -25,7 +25,7 @@ class TextAreaNode( val caretIndex: Int, val selectionAnchor: Int?, val scrollY: Int, - val preferredColumn: Int? + val preferredColumn: Int?, ) private data class PersistedState( @@ -34,7 +34,7 @@ class TextAreaNode( val preferredColumn: Int?, val selectionAnchor: Int?, val undoHistory: List, - val redoHistory: List + val redoHistory: List, ) private val persistedByKey: KeyedStateStore = KeyedStateStore() @@ -96,6 +96,7 @@ class TextAreaNode( } if (!this@TextAreaNode.containsGlobalPoint(event.mouseX, event.mouseY)) return@addEventListener handleTextPointerDown(event.mouseX, event.mouseY) + event.cancelled = true } this@TextAreaNode.addEventListener(Events.DRAG) { event: MouseDragEvent -> if (this@TextAreaNode.styleDisabled) return@addEventListener @@ -165,13 +166,10 @@ class TextAreaNode( } } - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val lineHeight = resolveFontSize(ctx) @@ -180,17 +178,19 @@ class TextAreaNode( lastLineHeight = lineHeight val display = text.ifEmpty { placeholder } val contentLimit = resolvedContentLimit(availableOuterWidth) - val naturalContentWidth = width ?: maxOf( - layoutForText(display, null, lineHeight, measure).maxLineWidth, - minContentWidth - ) + val naturalContentWidth = + width ?: maxOf( + layoutForText(display, null, lineHeight, measure).maxLineWidth, + minContentWidth, + ) val contentWidth = contentLimit?.let { minOf(it, naturalContentWidth) } ?: naturalContentWidth - val textLayout = layoutForText( - source = display, - contentWidth = if (textWrap == TextWrap.Wrap) contentWidth else null, - fontHeight = lineHeight, - measureText = measure - ) + val textLayout = + layoutForText( + source = display, + contentWidth = if (textWrap == TextWrap.Wrap) contentWidth else null, + fontHeight = lineHeight, + measureText = measure, + ) val contentHeight = height ?: maxOf(textLayout.totalHeight, minContentHeight) val totalWidth = contentWidth + padding.horizontal + border.horizontal val totalHeight = contentHeight + padding.vertical + border.vertical @@ -243,16 +243,18 @@ class TextAreaNode( clampScroll() val showPlaceholder = text.isEmpty() && !focused && placeholder.isNotEmpty() - val drawLayout = if (showPlaceholder) { - layoutForText(placeholder, textInnerWidth, lineHeight, measure) - } else { - textLayout - } + val drawLayout = + if (showPlaceholder) { + layoutForText(placeholder, textInnerWidth, lineHeight, measure) + } else { + textLayout + } val color = if (showPlaceholder) placeholderColor else textColor val effectiveScroll = if (showPlaceholder) 0 else editState.scrollY.coerceIn(0, maxScroll) val firstVisibleLine = (effectiveScroll / lastLineHeight).coerceIn(0, drawLayout.lines.lastIndex) - val lastVisibleLine = ((effectiveScroll + innerHeight) / lastLineHeight + 1) - .coerceIn(0, drawLayout.lines.lastIndex) + val lastVisibleLine = + ((effectiveScroll + innerHeight) / lastLineHeight + 1) + .coerceIn(0, drawLayout.lines.lastIndex) if (textInnerWidth > 0 && innerHeight > 0) { out.add(RenderCommand.PushClip(innerX, innerY, textInnerWidth, innerHeight)) @@ -332,14 +334,70 @@ class TextAreaNode( return containsGlobalPoint(mouseX, mouseY) } - fun shouldCaptureAnyDrag(mouseX: Int, mouseY: Int): Boolean { - return shouldCaptureScrollbarDrag(mouseX, mouseY) || shouldCaptureTextSelectionDrag(mouseX, mouseY) + fun shouldCaptureAnyDrag(mouseX: Int, mouseY: Int): Boolean = + shouldCaptureScrollbarDrag(mouseX, mouseY) || shouldCaptureTextSelectionDrag(mouseX, mouseY) + + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean = + shouldCaptureAnyDrag(mouseX, mouseY) || super.shouldCapturePointerDrag(mouseX, mouseY) + + override fun beginPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT || styleDisabled) return + if (isActiveScrollbarDragTarget() || isActiveSelectionDragTarget()) return + if (handleScrollbarMouseDown(mouseX, mouseY)) return + if (containsGlobalPoint(mouseX, mouseY)) { + handleTextPointerDown(mouseX, mouseY) + } + } + + override fun continuePointerCapture( + mouseX: Int, + mouseY: Int, + mouseDX: Int, + mouseDY: Int, + button: MouseButton, + ) { + if (button != MouseButton.LEFT || styleDisabled) return + when { + isActiveScrollbarDragTarget() -> { + updateScrollbarFromDrag(mouseY) + persistState() + } + + isActiveSelectionDragTarget() -> { + updateSelectionFromPointerDrag(mouseX, mouseY) + persistState() + } + } } - override fun inspectorScrollOffset(): Pair? { - return 0 to editState.scrollY + override fun endPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT) return + var handled = false + if (isActiveScrollbarDragTarget()) { + activeScrollbarDragIdentity = null + handled = true + } + if (isActiveSelectionDragTarget()) { + activeSelectionDragIdentity = null + if (!editState.hasSelection()) { + editState.clearSelection() + } + handled = true + } + if (handled) { + editState.resetBlinkClock() + persistState() + } } + override fun cancelPointerCapture() { + if (isActiveScrollbarDragTarget() || isActiveSelectionDragTarget()) { + clearActiveDrag() + } + } + + override fun inspectorScrollOffset(): Pair? = 0 to editState.scrollY + private fun handleKey(event: KeyboardKeyDownEvent) { editState.clampToLength(text.length) if (handleClipboardShortcut(event)) return @@ -370,8 +428,8 @@ class TextAreaNode( } } - private fun handleClipboardShortcut(event: KeyboardKeyDownEvent): Boolean { - return TextEditShortcutDispatcher.dispatch( + private fun handleClipboardShortcut(event: KeyboardKeyDownEvent): Boolean = + TextEditShortcutDispatcher.dispatch( event, TextShortcutCallbacks( hasSelection = { editState.hasSelection() }, @@ -393,10 +451,9 @@ class TextAreaNode( if (action != TextShortcutAction.COPY) { persistState() } - } - ) + }, + ), ) - } private fun pushUndoSnapshot() { history.pushUndo( @@ -405,20 +462,19 @@ class TextAreaNode( caretIndex = editState.caretIndex, selectionAnchor = editState.selectionAnchor, scrollY = editState.scrollY, - preferredColumn = preferredColumn - ) + preferredColumn = preferredColumn, + ), ) } - private fun currentSnapshot(): UndoSnapshot { - return UndoSnapshot( + private fun currentSnapshot(): UndoSnapshot = + UndoSnapshot( text = text, caretIndex = editState.caretIndex, selectionAnchor = editState.selectionAnchor, scrollY = editState.scrollY, - preferredColumn = preferredColumn + preferredColumn = preferredColumn, ) - } private fun undoLastEdit(): Boolean { val snapshot = history.undo(currentSnapshot()) ?: return false @@ -454,9 +510,8 @@ class TextAreaNode( return true } - private fun shouldRecordTypingUndo(ch: Char): Boolean { - return typingUndoGrouping.shouldRecord(ch, editState.hasSelection()) - } + private fun shouldRecordTypingUndo(ch: Char): Boolean = + typingUndoGrouping.shouldRecord(ch, editState.hasSelection()) private fun resetTypingUndoGroup() { typingUndoGrouping.reset() @@ -534,11 +589,12 @@ class TextAreaNode( val layout = currentTextLayout() val current = caretLineAndColumn(editState.caretIndex, layout) val line = layout.lines[current.first] - val next = if (start) { - line.startIndex - } else { - line.endIndexExclusive - } + val next = + if (start) { + line.startIndex + } else { + line.endIndexExclusive + } moveCaretTo(next, extend) preferredColumn = null ensureCaretVisible() @@ -574,7 +630,12 @@ class TextAreaNode( return replaceRange(start, end, insert, recordUndo) } - private fun replaceRange(start: Int, end: Int, insert: String, recordUndo: Boolean = false): Boolean { + private fun replaceRange( + start: Int, + end: Int, + insert: String, + recordUndo: Boolean = false, + ): Boolean { val safeStart = start.coerceIn(0, text.length) val safeEnd = end.coerceIn(safeStart, text.length) val previous = text @@ -636,9 +697,12 @@ class TextAreaNode( private fun maxScrollFor(source: String): Int { val visibleHeight = lastVisibleHeight.coerceAtLeast(lastLineHeight) - val layout = if (source == text) currentTextLayout() else { - layoutForText(source, textContentWrapWidth(), lastLineHeight, currentMeasure()) - } + val layout = + if (source == text) { + currentTextLayout() + } else { + layoutForText(source, textContentWrapWidth(), lastLineHeight, currentMeasure()) + } val totalHeight = layout.totalHeight return (totalHeight - visibleHeight).coerceAtLeast(0) } @@ -649,7 +713,7 @@ class TextAreaNode( innerY: Int, innerWidth: Int, innerHeight: Int, - focused: Boolean + focused: Boolean, ) { val trackX = innerX + innerWidth - scrollbarWidth val trackWidth = scrollbarWidth.coerceAtLeast(1) @@ -662,17 +726,18 @@ class TextAreaNode( trackRect.y, trackRect.width, trackRect.height, - scrollbarTrackColor - ) + scrollbarTrackColor, + ), ) val thumbHeight = computeThumbHeight(trackHeight) val thumbTravel = (trackHeight - thumbHeight).coerceAtLeast(0) - val thumbOffset = if (lastMaxScroll <= 0 || thumbTravel <= 0) { - 0 - } else { - ((editState.scrollY.toDouble() / lastMaxScroll.toDouble()) * thumbTravel.toDouble()).toInt() - } + val thumbOffset = + if (lastMaxScroll <= 0 || thumbTravel <= 0) { + 0 + } else { + ((editState.scrollY.toDouble() / lastMaxScroll.toDouble()) * thumbTravel.toDouble()).toInt() + } val thumbY = innerY + thumbOffset val thumbRect = Rect(trackX, thumbY, trackWidth, thumbHeight) scrollbarThumbRect = thumbRect @@ -687,7 +752,7 @@ class TextAreaNode( lastVisibleLine: Int, innerX: Int, innerY: Int, - effectiveScroll: Int + effectiveScroll: Int, ) { if (!editState.hasSelection()) return val startIndex = editState.selectionStart().coerceIn(0, text.length) @@ -732,11 +797,12 @@ class TextAreaNode( FocusManager.requestFocus(this) activeScrollbarDragIdentity = dragIdentity() val thumb = scrollbarThumbRect - scrollbarDragAnchorY = if (thumb != null && thumb.contains(mouseX, mouseY)) { - (mouseY - thumb.y).coerceIn(0, thumb.height.coerceAtLeast(1)) - } else { - computeThumbHeight(scrollbarTrackRect?.height ?: 0) / 2 - } + scrollbarDragAnchorY = + if (thumb != null && thumb.contains(mouseX, mouseY)) { + (mouseY - thumb.y).coerceIn(0, thumb.height.coerceAtLeast(1)) + } else { + computeThumbHeight(scrollbarTrackRect?.height ?: 0) / 2 + } updateScrollbarFromDrag(mouseY) editState.resetBlinkClock() persistState() @@ -756,8 +822,10 @@ class TextAreaNode( return } val desiredTop = (mouseY - track.y - scrollbarDragAnchorY).coerceIn(0, thumbTravel) - editState.scrollY = ((desiredTop.toDouble() / thumbTravel.toDouble()) * lastMaxScroll.toDouble()).toInt() - .coerceIn(0, lastMaxScroll) + editState.scrollY = + ((desiredTop.toDouble() / thumbTravel.toDouble()) * lastMaxScroll.toDouble()) + .toInt() + .coerceIn(0, lastMaxScroll) } private fun caretIndexFromClick(mouseX: Int, mouseY: Int, scrollOffsetY: Int): Int { @@ -792,17 +860,11 @@ class TextAreaNode( return lineText.length } - private fun selectedText(): String { - return TextEditOps.selectedText(text, editState) - } + private fun selectedText(): String = TextEditOps.selectedText(text, editState) - private fun isPrintable(ch: Char): Boolean { - return TextEditOps.isPrintable(ch) - } + private fun isPrintable(ch: Char): Boolean = TextEditOps.isPrintable(ch) - private fun dragIdentity(): Any { - return TextEditOps.dragIdentity(key, this) - } + private fun dragIdentity(): Any = TextEditOps.dragIdentity(key, this) private fun isActiveScrollbarDragTarget(): Boolean { val active = activeScrollbarDragIdentity ?: return false @@ -829,8 +891,8 @@ class TextAreaNode( preferredColumn = preferredColumn, selectionAnchor = editState.selectionAnchor, undoHistory = history.undoHistory(), - redoHistory = history.redoHistory() - ) + redoHistory = history.redoHistory(), + ), ) } @@ -865,15 +927,13 @@ class TextAreaNode( } } - private fun currentMeasure(): (String) -> Int { - return lastMeasureText ?: { value: String -> value.length * 6 } - } + private fun currentMeasure(): (String) -> Int = lastMeasureText ?: { value: String -> value.length * 6 } private fun layoutForText( source: String, contentWidth: Int?, fontHeight: Int, - measureText: (String) -> Int + measureText: (String) -> Int, ): TextLayoutEngine.Layout { val wrapMode = if (this@TextAreaNode.textWrap == TextWrap.Wrap) TextWrap.Wrap else TextWrap.NoWrap val maxWidth = if (wrapMode == TextWrap.Wrap) contentWidth else null @@ -882,7 +942,7 @@ class TextAreaNode( maxWidth = maxWidth, wrap = wrapMode, fontHeight = fontHeight.coerceAtLeast(1), - measureText = measureText + measureText = measureText, ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextEditState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextEditState.kt index 0831743..74a0917 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextEditState.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextEditState.kt @@ -7,7 +7,7 @@ data class TextEditState( var caretIndex: Int = 0, var selectionAnchor: Int? = null, var scrollY: Int = 0, - var lastInteractionAtMs: Long = System.currentTimeMillis() + var lastInteractionAtMs: Long = System.currentTimeMillis(), ) { fun clampToLength(length: Int) { caretIndex = caretIndex.coerceIn(0, length) @@ -42,4 +42,4 @@ data class TextEditState( val ticks = ((now - lastInteractionAtMs).coerceAtLeast(0L) / blinkPeriodMs) return ticks % 2L == 0L } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextInputNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextInputNode.kt index 7c08699..f149f04 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextInputNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextInputNode.kt @@ -9,11 +9,11 @@ class TextInputNode( allowedChars: String? = null, minLength: Int? = null, maxLength: Int? = null, - key: Any? = null + key: Any? = null, ) : SingleLineInputNode(text, placeholder, key) { init { this.allowedChars = allowedChars this.minLength = minLength this.maxLength = maxLength } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextNode.kt index 71d0d54..fd36e47 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextNode.kt @@ -16,7 +16,7 @@ import org.dreamfinity.dsgl.core.text.MinecraftFormattingParser class TextNode( private var textSource: TextSource, var color: Int = DsglColors.TEXT, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { companion object { const val NORMAL_LINE_HEIGHT_MULTIPLIER: Float = DOMNode.NORMAL_LINE_HEIGHT_MULTIPLIER @@ -27,13 +27,10 @@ class TextNode( var text: String = textSource.resolve() private set - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(ctx, availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(ctx, availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(ctx, null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(ctx, null) private fun measureWithConstraint(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { val textMetrics = resolveTextMetrics(ctx) @@ -41,25 +38,27 @@ class TextNode( val parsed = parseTextForFormatting(this@TextNode.text) val plainText = parsed.plainText val baseFlags = baseTextStyleFlags() - val measuredRanges = MeasuredTextRangeWidthSource( - plainText = plainText, - fontId = fontId, - fontSizePx = textMetrics.fontSizePx, - baseFlags = baseFlags, - spans = parsed.spans, - ctx = ctx - ) + val measuredRanges = + MeasuredTextRangeWidthSource( + plainText = plainText, + fontId = fontId, + fontSizePx = textMetrics.fontSizePx, + baseFlags = baseFlags, + spans = parsed.spans, + ctx = ctx, + ) val contentLimit = resolvedContentLimit(availableOuterWidth) val wrapWidth = if (textWrap == TextWrap.Wrap) contentLimit else null - val layout = TextLayoutEngine.layout( - text = plainText, - maxWidth = wrapWidth, - wrap = textWrap, - fontHeight = lineHeight, - measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, - measureRange = measuredRanges::measureRange, - measureRangeCacheKey = measuredRanges.cacheKey - ) + val layout = + TextLayoutEngine.layout( + text = plainText, + maxWidth = wrapWidth, + wrap = textWrap, + fontHeight = lineHeight, + measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, + measureRange = measuredRanges::measureRange, + measureRangeCacheKey = measuredRanges.cacheKey, + ) val naturalContentWidth = width ?: layout.maxLineWidth val contentWidth = contentLimit?.let { minOf(it, naturalContentWidth) } ?: naturalContentWidth val contentHeight = height ?: layout.totalHeight @@ -86,46 +85,50 @@ class TextNode( val parsed = parseTextForFormatting(this@TextNode.text) val plainText = parsed.plainText val baseFlags = baseTextStyleFlags() - val measuredRanges = MeasuredTextRangeWidthSource( - plainText = plainText, - fontId = fontId, - fontSizePx = textMetrics.fontSizePx, - baseFlags = baseFlags, - spans = parsed.spans, - ctx = ctx - ) + val measuredRanges = + MeasuredTextRangeWidthSource( + plainText = plainText, + fontId = fontId, + fontSizePx = textMetrics.fontSizePx, + baseFlags = baseFlags, + spans = parsed.spans, + ctx = ctx, + ) addBorderCommands(out) val wrapWidth = if (textWrap == TextWrap.Wrap) contentWidth() else null - val layout = TextLayoutEngine.layout( - text = plainText, - maxWidth = wrapWidth, - wrap = textWrap, - fontHeight = lineHeight, - measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, - measureRange = measuredRanges::measureRange, - measureRangeCacheKey = measuredRanges.cacheKey - ) + val layout = + TextLayoutEngine.layout( + text = plainText, + maxWidth = wrapWidth, + wrap = textWrap, + fontHeight = lineHeight, + measureText = { value -> ctx.measureText(value, fontId, textMetrics.fontSizePx) }, + measureRange = measuredRanges::measureRange, + measureRangeCacheKey = measuredRanges.cacheKey, + ) val baseX = contentX() var lineY = contentY() layout.lines.forEach { line -> - val spans = MinecraftFormattingParser.resolveStyleSpans( - parsed = parsed, - baseColor = color, - baseFlags = baseFlags, - rangeStart = line.startIndex, - rangeEnd = line.endIndexExclusive - ).map { span -> - RenderCommand.TextStyleSpan( - start = span.start, - end = span.end, - color = span.color, - bold = span.flags.bold, - italic = span.flags.italic, - underline = span.flags.underline, - strikethrough = span.flags.strikethrough, - obfuscated = span.flags.obfuscated - ) - } + val spans = + MinecraftFormattingParser + .resolveStyleSpans( + parsed = parsed, + baseColor = color, + baseFlags = baseFlags, + rangeStart = line.startIndex, + rangeEnd = line.endIndexExclusive, + ).map { span -> + RenderCommand.TextStyleSpan( + start = span.start, + end = span.end, + color = span.color, + bold = span.flags.bold, + italic = span.flags.italic, + underline = span.flags.underline, + strikethrough = span.flags.strikethrough, + obfuscated = span.flags.obfuscated, + ) + } out.add(drawTextCommand(ctx, line.text, baseX, lineY + lineTopLeading, color, spans)) lineY += layout.lineHeight } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextSource.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextSource.kt index 71fec66..5d4b3a5 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextSource.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextSource.kt @@ -3,11 +3,15 @@ package org.dreamfinity.dsgl.core.dom.elements sealed interface TextSource { fun resolve(): String - data class Static(private val value: String) : TextSource { + data class Static( + private val value: String, + ) : TextSource { override fun resolve(): String = value } - data class Dynamic(private val supplier: () -> String) : TextSource { + data class Dynamic( + private val supplier: () -> String, + ) : TextSource { override fun resolve(): String = supplier() } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNode.kt index 68493ed..60fc9ba 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNode.kt @@ -20,7 +20,7 @@ class ToggleNode( controlled: Boolean = false, checked: Boolean = false, defaultChecked: Boolean = false, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "toggle" override val focusable: Boolean = true @@ -87,9 +87,7 @@ class ToggleNode( } } - fun isChecked(): Boolean { - return if (controlled) controlledChecked else uncontrolledChecked - } + fun isChecked(): Boolean = if (controlled) controlledChecked else uncontrolledChecked internal fun syncFrom(template: ToggleNode) { val wasControlled = controlled @@ -111,13 +109,10 @@ class ToggleNode( markRenderCommandsDirty() } - internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size { - return measureWithConstraint(availableOuterWidth) - } + internal override fun measureForLayout(ctx: UiMeasureContext, availableOuterWidth: Int?): Size = + measureWithConstraint(availableOuterWidth) - override fun measure(ctx: UiMeasureContext): Size { - return measureWithConstraint(null) - } + override fun measure(ctx: UiMeasureContext): Size = measureWithConstraint(null) private fun measureWithConstraint(availableOuterWidth: Int?): Size { val contentLimit = resolvedContentLimit(availableOuterWidth) @@ -140,7 +135,13 @@ class ToggleNode( } } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) val tx = contentX() val ty = contentY() @@ -158,11 +159,12 @@ class ToggleNode( override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { addBackgroundImageCommand(out) - val trackColor = when { - styleDisabled -> trackDisabledColor - isChecked() -> trackOnColor - else -> trackOffColor - } + val trackColor = + when { + styleDisabled -> trackDisabledColor + isChecked() -> trackOnColor + else -> trackOffColor + } out += RenderCommand.DrawRect(trackRect.x, trackRect.y, trackRect.width, trackRect.height, trackColor) addBorderCommands(out) val thumb = if (styleDisabled) thumbDisabledColor else thumbColor diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/KeyedStateStore.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/KeyedStateStore.kt index 35a9cbb..4c6d716 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/KeyedStateStore.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/KeyedStateStore.kt @@ -4,7 +4,7 @@ package org.dreamfinity.dsgl.core.dom.elements.support * Small keyed state store with bounded growth for node-persisted editor state. */ internal class KeyedStateStore( - private val maxEntries: Int = 1024 + private val maxEntries: Int = 1024, ) { private val states: LinkedHashMap = LinkedHashMap() diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/MeasuredTextRangeWidthSource.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/MeasuredTextRangeWidthSource.kt index 0fe8281..bbc39aa 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/MeasuredTextRangeWidthSource.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/MeasuredTextRangeWidthSource.kt @@ -12,12 +12,12 @@ internal class MeasuredTextRangeWidthSource( private val fontSizePx: Int, private val baseFlags: TextStyleFlags, private val spans: List, - private val ctx: UiMeasureContext + private val ctx: UiMeasureContext, ) { data class SpanWidthKey( val start: Int, val end: Int, - val flagsMask: Int + val flagsMask: Int, ) data class CacheKey( @@ -25,25 +25,27 @@ internal class MeasuredTextRangeWidthSource( val fontId: String?, val fontSizePx: Int, val baseFlagsMask: Int, - val spanWidthKey: List + val spanWidthKey: List, ) private val rangeWidthCache: MutableMap = HashMap() private val hasBoldContribution: Boolean = baseFlags.bold || spans.any { it.flags.bold } - val cacheKey: CacheKey = CacheKey( - backendFingerprint = backendFingerprint(ctx, fontId, fontSizePx), - fontId = fontId, - fontSizePx = fontSizePx, - baseFlagsMask = baseFlags.mask(), - spanWidthKey = spans.map { span -> - SpanWidthKey( - start = span.start, - end = span.end, - flagsMask = span.flags.mask() - ) - } - ) + val cacheKey: CacheKey = + CacheKey( + backendFingerprint = backendFingerprint(ctx, fontId, fontSizePx), + fontId = fontId, + fontSizePx = fontSizePx, + baseFlagsMask = baseFlags.mask(), + spanWidthKey = + spans.map { span -> + SpanWidthKey( + start = span.start, + end = span.end, + flagsMask = span.flags.mask(), + ) + }, + ) fun measureRange(startIndex: Int, endIndexExclusive: Int): Int { val safeStart = startIndex.coerceIn(0, plainText.length) @@ -51,31 +53,32 @@ internal class MeasuredTextRangeWidthSource( if (safeEnd <= safeStart) return 0 val key = packRange(safeStart, safeEnd) return rangeWidthCache.getOrPut(key) { - val baseWidth = ctx.measureTextRange( - text = plainText, - startIndex = safeStart, - endIndexExclusive = safeEnd, - fontId = fontId, - fontSize = fontSizePx - ) + val baseWidth = + ctx.measureTextRange( + text = plainText, + startIndex = safeStart, + endIndexExclusive = safeEnd, + fontId = fontId, + fontSize = fontSizePx, + ) if (!hasBoldContribution) { baseWidth } else { - val boldExtra = TextStyleMetrics.boldExtraPxForRangeInText( - plainText = plainText, - spans = spans, - baseFlags = baseFlags, - rangeStart = safeStart, - rangeEnd = safeEnd - ) + val boldExtra = + TextStyleMetrics.boldExtraPxForRangeInText( + plainText = plainText, + spans = spans, + baseFlags = baseFlags, + rangeStart = safeStart, + rangeEnd = safeEnd, + ) baseWidth + boldExtra } } } - private fun packRange(start: Int, endExclusive: Int): Long { - return (start.toLong() shl 32) or (endExclusive.toLong() and 0xFFFF_FFFFL) - } + private fun packRange(start: Int, endExclusive: Int): Long = + (start.toLong() shl 32) or (endExclusive.toLong() and 0xFFFF_FFFFL) private fun TextStyleFlags.mask(): Int { var mask = 0 diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextChangeTracker.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextChangeTracker.kt index 0d8a0a4..be38f37 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextChangeTracker.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextChangeTracker.kt @@ -4,7 +4,7 @@ package org.dreamfinity.dsgl.core.dom.elements.support * Tracks "dirty since focus" state and commit baseline for change events. */ internal class TextChangeTracker( - initialValue: String + initialValue: String, ) { private var valueAtFocusStart: String = initialValue private var dirtySinceFocus: Boolean = false diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditOps.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditOps.kt index deb00d0..b5b39b9 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditOps.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditOps.kt @@ -3,13 +3,9 @@ package org.dreamfinity.dsgl.core.dom.elements.support import org.dreamfinity.dsgl.core.dom.elements.TextEditState internal object TextEditOps { - fun isPrintable(ch: Char): Boolean { - return ch >= ' ' && ch.code != 127 - } + fun isPrintable(ch: Char): Boolean = ch >= ' ' && ch.code != 127 - fun dragIdentity(key: Any?, node: Any): Any { - return key ?: node - } + fun dragIdentity(key: Any?, node: Any): Any = key ?: node fun selectedText(source: String, editState: TextEditState): String { if (!editState.hasSelection()) return "" @@ -36,7 +32,12 @@ internal object TextEditOps { return start to end } - fun moveCaretWithSelection(editState: TextEditState, next: Int, textLength: Int, extend: Boolean) { + fun moveCaretWithSelection( + editState: TextEditState, + next: Int, + textLength: Int, + extend: Boolean, + ) { if (extend) { if (editState.selectionAnchor == null) { editState.selectionAnchor = editState.caretIndex @@ -50,9 +51,14 @@ internal object TextEditOps { } } - fun replaceRange(source: String, start: Int, end: Int, insert: String): String { + fun replaceRange( + source: String, + start: Int, + end: Int, + insert: String, + ): String { val safeStart = start.coerceIn(0, source.length) val safeEnd = end.coerceIn(safeStart, source.length) return source.substring(0, safeStart) + insert + source.substring(safeEnd) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditShortcutDispatcher.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditShortcutDispatcher.kt index 0258c97..eb7815b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditShortcutDispatcher.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextEditShortcutDispatcher.kt @@ -11,7 +11,7 @@ internal enum class TextShortcutAction { CUT, PASTE, UNDO, - REDO + REDO, } internal data class TextShortcutCallbacks( @@ -27,7 +27,7 @@ internal data class TextShortcutCallbacks( val undo: () -> Unit, val redo: () -> Unit, val beforeHandled: () -> Unit, - val afterHandled: (TextShortcutAction) -> Unit + val afterHandled: (TextShortcutAction) -> Unit, ) internal object TextEditShortcutDispatcher { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextLayoutEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextLayoutEngine.kt index 5df5b71..5b65a6d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextLayoutEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/TextLayoutEngine.kt @@ -8,7 +8,7 @@ object TextLayoutEngine { val text: String, val startIndex: Int, val endIndexExclusive: Int, - val width: Int + val width: Int, ) { val length: Int get() = endIndexExclusive - startIndex @@ -18,7 +18,7 @@ object TextLayoutEngine { val lines: List, val maxLineWidth: Int, val totalHeight: Int, - val lineHeight: Int + val lineHeight: Int, ) { fun lineForCaret(caretIndex: Int): Int { if (lines.isEmpty()) return 0 @@ -38,7 +38,7 @@ object TextLayoutEngine { val fontHeight: Int, val fontFingerprint: Int, val usesRangeMeasurement: Boolean, - val rangeMeasureCacheKey: Any? + val rangeMeasureCacheKey: Any?, ) data class HotPathStats( @@ -54,15 +54,15 @@ object TextLayoutEngine { val temporarySegmentSubstringCalls: Long, val probeMeasureSubstringCalls: Long, val finalLineTextSubstringCalls: Long, - val substringSliceCalls: Long + val substringSliceCalls: Long, ) private const val MAX_CACHE_SIZE: Int = 512 - private val cache: MutableMap = object : LinkedHashMap(128, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MAX_CACHE_SIZE + private val cache: MutableMap = + object : LinkedHashMap(128, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > MAX_CACHE_SIZE } - } private val layoutCalls = AtomicLong(0L) private val cacheHits = AtomicLong(0L) private val cacheMisses = AtomicLong(0L) @@ -90,7 +90,7 @@ object TextLayoutEngine { fontHeight: Int, measureText: (String) -> Int, measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)? = null, - measureRangeCacheKey: Any? = null + measureRangeCacheKey: Any? = null, ): Layout { layoutCalls.incrementAndGet() val resolvedHeight = fontHeight.coerceAtLeast(1) @@ -104,23 +104,25 @@ object TextLayoutEngine { lines = lines, maxLineWidth = maxLineWidth, totalHeight = totalHeight, - lineHeight = resolvedHeight + lineHeight = resolvedHeight, ) } - val fingerprint = if (measureRange != null) { - measureRangeCacheKey.hashCode() - } else { - measureText("M") * 31 + measureText("i") - } - val key = CacheKey( - text = text, - maxWidth = constrainedWidth, - wrap = wrap, - fontHeight = resolvedHeight, - fontFingerprint = fingerprint, - usesRangeMeasurement = measureRange != null, - rangeMeasureCacheKey = measureRangeCacheKey - ) + val fingerprint = + if (measureRange != null) { + measureRangeCacheKey.hashCode() + } else { + measureText("M") * 31 + measureText("i") + } + val key = + CacheKey( + text = text, + maxWidth = constrainedWidth, + wrap = wrap, + fontHeight = resolvedHeight, + fontFingerprint = fingerprint, + usesRangeMeasurement = measureRange != null, + rangeMeasureCacheKey = measureRangeCacheKey, + ) synchronized(cache) { cache[key]?.let { cacheHits.incrementAndGet() @@ -141,8 +143,8 @@ object TextLayoutEngine { return result } - fun hotPathStats(): HotPathStats { - return HotPathStats( + fun hotPathStats(): HotPathStats = + HotPathStats( layoutCalls = layoutCalls.get(), cacheHits = cacheHits.get(), cacheMisses = cacheMisses.get(), @@ -155,9 +157,8 @@ object TextLayoutEngine { temporarySegmentSubstringCalls = temporarySegmentSubstringCalls.get(), probeMeasureSubstringCalls = probeMeasureSubstringCalls.get(), finalLineTextSubstringCalls = finalLineTextSubstringCalls.get(), - substringSliceCalls = substringSliceCalls.get() + substringSliceCalls = substringSliceCalls.get(), ) - } fun resetHotPathStats() { layoutCalls.set(0L) @@ -175,12 +176,13 @@ object TextLayoutEngine { substringSliceCalls.set(0L) } + @Suppress("LoopWithTooManyJumpStatements") private fun buildLines( text: String, maxWidth: Int?, wrap: TextWrap, measureText: (String) -> Int, - measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)? + measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)?, ): List { buildLinesCalls.incrementAndGet() if (text.isEmpty()) { @@ -198,13 +200,14 @@ object TextLayoutEngine { text = lineText, startIndex = logicalStart, endIndexExclusive = newlineIndex, - width = measureWidth( - measureRange = measureRange, - startIndex = logicalStart, - endIndexExclusive = newlineIndex, - fallbackMeasure = { measureText(lineText) } - ) - ) + width = + measureWidth( + measureRange = measureRange, + startIndex = logicalStart, + endIndexExclusive = newlineIndex, + fallbackMeasure = { measureText(lineText) }, + ), + ), ) } else { appendWrappedSegment( @@ -214,7 +217,7 @@ object TextLayoutEngine { segmentEndExclusive = newlineIndex, maxWidth = maxWidth, measureText = measureText, - measureRange = measureRange + measureRange = measureRange, ) } @@ -242,7 +245,7 @@ object TextLayoutEngine { segmentEndExclusive: Int, maxWidth: Int, measureText: (String) -> Int, - measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)? + measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)?, ) { appendWrappedSegmentCalls.incrementAndGet() if (segmentStart >= segmentEndExclusive) { @@ -252,14 +255,15 @@ object TextLayoutEngine { var lineStart = segmentStart while (lineStart < segmentEndExclusive) { - var lineEndExclusive = findMaxFittingEnd( - text = text, - start = lineStart, - segmentEndExclusive = segmentEndExclusive, - maxWidth = maxWidth, - measureText = measureText, - measureRange = measureRange - ) + var lineEndExclusive = + findMaxFittingEnd( + text = text, + start = lineStart, + segmentEndExclusive = segmentEndExclusive, + maxWidth = maxWidth, + measureText = measureText, + measureRange = measureRange, + ) if (lineEndExclusive <= lineStart) { lineEndExclusive = (lineStart + 1).coerceAtMost(segmentEndExclusive) } else if (lineEndExclusive < segmentEndExclusive) { @@ -275,13 +279,14 @@ object TextLayoutEngine { text = lineText, startIndex = lineStart, endIndexExclusive = lineEndExclusive, - width = measureWidth( - measureRange = measureRange, - startIndex = lineStart, - endIndexExclusive = lineEndExclusive, - fallbackMeasure = { measureText(lineText) } - ) - ) + width = + measureWidth( + measureRange = measureRange, + startIndex = lineStart, + endIndexExclusive = lineEndExclusive, + fallbackMeasure = { measureText(lineText) }, + ), + ), ) lineStart = lineEndExclusive } @@ -293,7 +298,7 @@ object TextLayoutEngine { segmentEndExclusive: Int, maxWidth: Int, measureText: (String) -> Int, - measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)? + measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)?, ): Int { findMaxFittingCalls.incrementAndGet() var low = (start + 1).coerceAtMost(segmentEndExclusive) @@ -301,16 +306,17 @@ object TextLayoutEngine { var best = start while (low <= high) { val mid = (low + high) ushr 1 - val width = measureWidth( - measureRange = measureRange, - startIndex = start, - endIndexExclusive = mid, - fallbackMeasure = { - probeMeasureSubstringCalls.incrementAndGet() - substringSliceCalls.incrementAndGet() - measureText(text.substring(start, mid)) - } - ) + val width = + measureWidth( + measureRange = measureRange, + startIndex = start, + endIndexExclusive = mid, + fallbackMeasure = { + probeMeasureSubstringCalls.incrementAndGet() + substringSliceCalls.incrementAndGet() + measureText(text.substring(start, mid)) + }, + ) if (width <= maxWidth) { best = mid low = mid + 1 @@ -325,16 +331,15 @@ object TextLayoutEngine { measureRange: ((startIndex: Int, endIndexExclusive: Int) -> Int)?, startIndex: Int, endIndexExclusive: Int, - fallbackMeasure: () -> Int - ): Int { - return if (measureRange != null) { + fallbackMeasure: () -> Int, + ): Int = + if (measureRange != null) { rangeMeasureCalls.incrementAndGet() measureRange.invoke(startIndex, endIndexExclusive) } else { plainMeasureCalls.incrementAndGet() fallbackMeasure() } - } private fun lastWhitespaceBreak(text: String, start: Int, endExclusive: Int): Int? { var index = endExclusive diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/UndoRedoHistory.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/UndoRedoHistory.kt index 98cf8e9..5ed2c56 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/UndoRedoHistory.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/elements/support/UndoRedoHistory.kt @@ -1,12 +1,12 @@ package org.dreamfinity.dsgl.core.dom.elements.support -import java.util.* +import java.util.ArrayDeque /** * Undo/redo stack pair with a fixed snapshot capacity. */ internal class UndoRedoHistory( - private val limit: Int + private val limit: Int, ) { private val undoStack: ArrayDeque = ArrayDeque() private val redoStack: ArrayDeque = ArrayDeque() diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/AffineTransform2D.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/AffineTransform2D.kt index defaf27..a671020 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/AffineTransform2D.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/AffineTransform2D.kt @@ -14,25 +14,23 @@ data class AffineTransform2D( val c: Float, val d: Float, val tx: Float, - val ty: Float + val ty: Float, ) { - fun transform(x: Float, y: Float): Pair { - return Pair( + fun transform(x: Float, y: Float): Pair = + Pair( a * x + c * y + tx, - b * x + d * y + ty + b * x + d * y + ty, ) - } - fun times(other: AffineTransform2D): AffineTransform2D { - return AffineTransform2D( + fun times(other: AffineTransform2D): AffineTransform2D = + AffineTransform2D( a = a * other.a + c * other.b, b = b * other.a + d * other.b, c = a * other.c + c * other.d, d = b * other.c + d * other.d, tx = a * other.tx + c * other.ty + tx, - ty = b * other.tx + d * other.ty + ty + ty = b * other.tx + d * other.ty + ty, ) - } fun inverseOrNull(): AffineTransform2D? { val det = a * d - b * c @@ -48,22 +46,19 @@ data class AffineTransform2D( } companion object { - val IDENTITY: AffineTransform2D = AffineTransform2D( - a = 1f, - b = 0f, - c = 0f, - d = 1f, - tx = 0f, - ty = 0f - ) + val IDENTITY: AffineTransform2D = + AffineTransform2D( + a = 1f, + b = 0f, + c = 0f, + d = 1f, + tx = 0f, + ty = 0f, + ) - fun translation(x: Float, y: Float): AffineTransform2D { - return AffineTransform2D(1f, 0f, 0f, 1f, x, y) - } + fun translation(x: Float, y: Float): AffineTransform2D = AffineTransform2D(1f, 0f, 0f, 1f, x, y) - fun scale(x: Float, y: Float): AffineTransform2D { - return AffineTransform2D(x, 0f, 0f, y, 0f, 0f) - } + fun scale(x: Float, y: Float): AffineTransform2D = AffineTransform2D(x, 0f, 0f, y, 0f, 0f) fun rotation(deg: Float): AffineTransform2D { val rad = Math.toRadians(deg.toDouble()) @@ -73,4 +68,3 @@ data class AffineTransform2D( } } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Border.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Border.kt index fe74fc1..3a3b602 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Border.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Border.kt @@ -8,7 +8,7 @@ data class Border( val right: Int, val bottom: Int, val left: Int, - val color: Int + val color: Int, ) { /** Combined left/right thickness. */ val horizontal: Int @@ -23,11 +23,10 @@ data class Border( val NONE: Border = Border(0, 0, 0, 0, 0) /** Same border on all sides. */ - fun all(width: Int, color: Int): Border = - Border(width, width, width, width, color) + fun all(width: Int, color: Int): Border = Border(width, width, width, width, color) /** Border with separate horizontal and vertical thicknesses. */ fun horizontalVertical(horizontal: Int, vertical: Int, color: Int): Border = Border(vertical, horizontal, vertical, horizontal, color) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Insets.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Insets.kt index f78f0e0..28f9c4c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Insets.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Insets.kt @@ -7,7 +7,7 @@ data class Insets( val top: Int, val right: Int, val bottom: Int, - val left: Int + val left: Int, ) { /** Combined left/right value. */ val horizontal: Int @@ -28,4 +28,4 @@ data class Insets( fun horizontalVertical(horizontal: Int, vertical: Int): Insets = Insets(vertical, horizontal, vertical, horizontal) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Size.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Size.kt index 686d431..7083d66 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Size.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/Size.kt @@ -1,20 +1,25 @@ package org.dreamfinity.dsgl.core.dom.layout /** Size in pixels. */ -data class Size(val width: Int, val height: Int) +data class Size( + val width: Int, + val height: Int, +) /** Rectangle bounds in pixels. */ -data class Rect(val x: Int, val y: Int, val width: Int, val height: Int) { - fun contains(px: Int, py: Int): Boolean { - return px >= x && py >= y && px < x + width && py < y + height - } +data class Rect( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) { + fun contains(px: Int, py: Int): Boolean = px >= x && py >= y && px < x + width && py < y + height - fun contains(px: Float, py: Float): Boolean { - return px >= x.toFloat() && - py >= y.toFloat() && - px < (x + width).toFloat() && - py < (y + height).toFloat() - } + fun contains(px: Float, py: Float): Boolean = + px >= x.toFloat() && + py >= y.toFloat() && + px < (x + width).toFloat() && + py < (y + height).toFloat() fun intersection(other: Rect): Rect? { val left = maxOf(x, other.x) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/UiMeasureContext.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/UiMeasureContext.kt index 118f874..e92bdc2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/UiMeasureContext.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/layout/UiMeasureContext.kt @@ -6,7 +6,7 @@ data class FontLineMetrics( val emSize: Float, val lineHeightEm: Float, val ascenderEm: Float, - val descenderEm: Float + val descenderEm: Float, ) /** @@ -39,7 +39,7 @@ interface UiMeasureContext { startIndex: Int, endIndexExclusive: Int, fontId: String?, - fontSize: Int? + fontSize: Int?, ): Int { val safeStart = startIndex.coerceIn(0, text.length) val safeEnd = endIndexExclusive.coerceIn(safeStart, text.length) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/DomReconciler.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/DomReconciler.kt index 95a14c9..10a3be3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/DomReconciler.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/DomReconciler.kt @@ -9,12 +9,12 @@ data class DomReconcileResult( val reusedNodes: Int, val insertedNodes: Int, val removedNodes: Int, - val replacedSubtrees: Int + val replacedSubtrees: Int, ) private data class ChildIdentity( val key: Any, - val type: Class + val type: Class, ) private class Counters { @@ -28,19 +28,20 @@ object DomReconciler { fun reconcile(currentRoot: DOMNode, nextRoot: DOMNode): DomReconcileResult { val detached = ArrayList(8) val counters = Counters() - val mergedRoot = reconcileNodeOrReplace( - current = currentRoot, - template = nextRoot, - detached = detached, - counters = counters - ) + val mergedRoot = + reconcileNodeOrReplace( + current = currentRoot, + template = nextRoot, + detached = detached, + counters = counters, + ) return DomReconcileResult( root = mergedRoot, detachedRoots = detached, reusedNodes = counters.reused, insertedNodes = counters.inserted, removedNodes = counters.removed, - replacedSubtrees = counters.replaced + replacedSubtrees = counters.replaced, ) } @@ -48,7 +49,7 @@ object DomReconciler { current: DOMNode, template: DOMNode, detached: MutableList, - counters: Counters + counters: Counters, ): DOMNode { if (!canReuse(current, template)) { counters.replaced += 1 @@ -68,7 +69,7 @@ object DomReconciler { current: DOMNode, template: DOMNode, detached: MutableList, - counters: Counters + counters: Counters, ) { if (current.children.isEmpty() && template.children.isEmpty()) return @@ -92,12 +93,13 @@ object DomReconciler { counters.inserted += countSubtree(templateChild) } else { consumed[matchIndex] = true - val merged = reconcileNodeOrReplace( - current = oldChildren[matchIndex], - template = templateChild, - detached = detached, - counters = counters - ) + val merged = + reconcileNodeOrReplace( + current = oldChildren[matchIndex], + template = templateChild, + detached = detached, + counters = counters, + ) merged.parent = current reconciledChildren.add(merged) } @@ -119,19 +121,13 @@ object DomReconciler { template: DOMNode, oldChildren: List, consumed: BooleanArray, - keyed: Map> + keyed: Map>, ): Int? { val templateKey = template.key if (templateKey != null) { val identity = ChildIdentity(templateKey, template.javaClass) val queue = keyed[identity] ?: return null - while (queue.isNotEmpty()) { - val candidate = queue.removeFirst() - if (!consumed[candidate]) { - return candidate - } - } - return null + return generateSequence { queue.removeFirstOrNull() }.firstOrNull { !consumed[it] } } if (index < oldChildren.size) { @@ -141,15 +137,11 @@ object DomReconciler { } } - for (candidateIndex in oldChildren.indices) { - if (consumed[candidateIndex]) continue - val candidate = oldChildren[candidateIndex] - if (candidate.key != null) continue - if (canReuse(candidate, template)) { - return candidateIndex - } + return oldChildren.indices.firstOrNull { candidateIndex -> + !consumed[candidateIndex] && + oldChildren[candidateIndex].key == null && + canReuse(oldChildren[candidateIndex], template) } - return null } private fun canReuse(current: DOMNode, template: DOMNode): Boolean { @@ -158,12 +150,11 @@ object DomReconciler { return !isReplaceOnlyType(current) } - private fun isReplaceOnlyType(node: DOMNode): Boolean { - return node is CheckboxGroupNode || - node is NumberInputNode || - node is DateInputNode || - node is RangeInputNode - } + private fun isReplaceOnlyType(node: DOMNode): Boolean = + node is CheckboxGroupNode || + node is NumberInputNode || + node is DateInputNode || + node is RangeInputNode private fun syncNode(current: DOMNode, template: DOMNode) { current.syncBaseFrom(template) @@ -230,6 +221,7 @@ object DomReconciler { current.syncFrom(template) } } + current.syncCustomFrom(template) } private fun countSubtree(node: DOMNode): Int { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/text/ResolvedTextMetrics.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/text/ResolvedTextMetrics.kt index 19f43e0..2a0bbc6 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/text/ResolvedTextMetrics.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dom/text/ResolvedTextMetrics.kt @@ -13,5 +13,5 @@ data class ResolvedTextMetrics( val ascenderPx: Float, val descenderPx: Float, val topLeadingPx: Float, - val bottomLeadingPx: Float + val bottomLeadingPx: Float, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ButtonDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ButtonDsl.kt index b9b2552..8cd1a97 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ButtonDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ButtonDsl.kt @@ -6,13 +6,17 @@ import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle import org.dreamfinity.dsgl.core.hooks.ref.RefTarget /** Button node props. */ -open class ButtonProps(var text: String) : TextProps() +open class ButtonProps( + var text: String, +) : TextProps() /** * Button-specific DSL scope. */ @DsglDsl -class ButtonScope internal constructor(private val node: ButtonNode) { +class ButtonScope internal constructor( + private val node: ButtonNode, +) { fun onClick(handler: (MouseClickEvent) -> Unit) { node.onClick(handler) } @@ -24,11 +28,11 @@ fun UiScope.button( text: String, props: ButtonProps.() -> Unit = {}, ref: RefTarget? = null, - block: ButtonScope.() -> Unit = {} + block: ButtonScope.() -> Unit = {}, ) = withProps(ButtonProps(text).apply(props)) { props -> ButtonNode( props.text, - key = props.key + key = props.key, ).apply { applyStyle(this, props.style) applyHandlers(this, props) @@ -36,4 +40,4 @@ fun UiScope.button( add(this) ButtonScope(this).block() } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorPickerDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorPickerDsl.kt index eea7f7b..6585e99 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorPickerDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorPickerDsl.kt @@ -36,67 +36,63 @@ open class ColorPickerProps : ComponentProps() { } internal fun hasControlledValue(): Boolean = valueSpecified + internal fun controlledValue(): RgbaColor? = valueInternal private var valueSpecified: Boolean = false private var valueInternal: RgbaColor? = null } - @DsglDsl -fun UiScope.colorPicker( - props: ColorPickerProps.() -> Unit = {}, - ref: RefTarget? = null -) = withProps(ColorPickerProps().apply(props)) { props -> - val controlled = props.hasControlledValue() - ColorPickerInlineNode( - controlled = controlled, - value = if (controlled) props.controlledValue() else null, - defaultValue = props.defaultValue, - previousValue = props.previousValue, - mode = props.mode, - alphaEnabled = props.alphaEnabled, - key = props.key - ).apply { - closeOnSelect = props.closeOnSelect - eyedropperEnabled = props.eyedropperEnabled - onPreviewColor = props.onPreviewColor - onChangeColor = props.onChangeColor - onCommitColor = props.onCommitColor - onRequestClose = props.onRequestClose - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.colorPicker(props: ColorPickerProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ColorPickerProps().apply(props)) { props -> + val controlled = props.hasControlledValue() + ColorPickerInlineNode( + controlled = controlled, + value = if (controlled) props.controlledValue() else null, + defaultValue = props.defaultValue, + previousValue = props.previousValue, + mode = props.mode, + alphaEnabled = props.alphaEnabled, + key = props.key, + ).apply { + closeOnSelect = props.closeOnSelect + eyedropperEnabled = props.eyedropperEnabled + onPreviewColor = props.onPreviewColor + onChangeColor = props.onChangeColor + onCommitColor = props.onCommitColor + onRequestClose = props.onRequestClose + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} @DsglDsl -fun UiScope.colorPickerPopup( - props: ColorPickerPopupProps.() -> Unit = {}, - ref: RefTarget? = null -) = withProps(ColorPickerPopupProps().apply(props)) { props -> - val controlled = props.hasControlledValue() - ColorPickerPopupPaneNode( - controlled = controlled, - value = if (controlled) props.controlledValue() else null, - defaultValue = props.defaultValue, - previousValue = props.previousValue, - mode = props.mode, - alphaEnabled = props.alphaEnabled, - key = props.key - ).apply { - closeOnSelect = props.closeOnSelect - popupTitle = props.popupTitle - popupWidth = props.popupWidth - popupDraggable = props.popupDraggable - popupCloseOnOutsideClick = props.popupCloseOnOutsideClick - onPreviewColor = props.onPreviewColor - onChangeColor = props.onChangeColor - onCommitColor = props.onCommitColor - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.colorPickerPopup(props: ColorPickerPopupProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ColorPickerPopupProps().apply(props)) { props -> + val controlled = props.hasControlledValue() + ColorPickerPopupPaneNode( + controlled = controlled, + value = if (controlled) props.controlledValue() else null, + defaultValue = props.defaultValue, + previousValue = props.previousValue, + mode = props.mode, + alphaEnabled = props.alphaEnabled, + key = props.key, + ).apply { + closeOnSelect = props.closeOnSelect + popupTitle = props.popupTitle + popupWidth = props.popupWidth + popupDraggable = props.popupDraggable + popupCloseOnOutsideClick = props.popupCloseOnOutsideClick + onPreviewColor = props.onPreviewColor + onChangeColor = props.onChangeColor + onCommitColor = props.onCommitColor + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorSurfaceDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorSurfaceDsl.kt new file mode 100644 index 0000000..59baf91 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ColorSurfaceDsl.kt @@ -0,0 +1,121 @@ +package org.dreamfinity.dsgl.core.dsl + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.colorpicker.internal.AlphaSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorFieldSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorSwatchSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.EyedropperMagnifierDrawNode +import org.dreamfinity.dsgl.core.colorpicker.internal.HueSurfaceNode +import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle +import org.dreamfinity.dsgl.core.hooks.ref.RefTarget + +internal open class ColorSwatchProps : ComponentProps() { + var allowEmpty: Boolean = false + var color: RgbaColor? = RgbaColor.WHITE + var highlighted: Boolean = false + var palette: ColorPickerStyle = ColorPickerStyle() +} + +internal open class HueSliderProps : ComponentProps() { + var hueDeg: Float = 0f + var palette: ColorPickerStyle = ColorPickerStyle() +} + +internal open class AlphaSliderProps : ComponentProps() { + var color: RgbaColor = RgbaColor.WHITE + var palette: ColorPickerStyle = ColorPickerStyle() +} + +internal open class ColorFieldProps : ComponentProps() { + var color: RgbaColor = RgbaColor.WHITE + var hueDeg: Float = 0f + var palette: ColorPickerStyle = ColorPickerStyle() +} + +internal open class EyedropperMagnifierProps : ComponentProps() { + var sourceColumns: Int = 1 + var sourceRows: Int = 1 + var magnification: Int = 1 + var showGrid: Boolean = true + var gridColor: Int = 0x66FFFFFF +} + +@DsglDsl +internal fun UiScope.colorSwatch(props: ColorSwatchProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ColorSwatchProps().apply(props)) { props -> + ColorSwatchSurfaceNode( + allowEmpty = props.allowEmpty, + key = props.key, + ).apply { + bind(style = props.palette, color = props.color, highlighted = props.highlighted) + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } + } + +@DsglDsl +internal fun UiScope.hueSlider(props: HueSliderProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(HueSliderProps().apply(props)) { props -> + HueSurfaceNode( + key = props.key, + ).apply { + bind(style = props.palette, hueDeg = props.hueDeg) + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } + } + +@DsglDsl +internal fun UiScope.alphaSlider(props: AlphaSliderProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(AlphaSliderProps().apply(props)) { props -> + AlphaSurfaceNode( + key = props.key, + ).apply { + bind(style = props.palette, color = props.color) + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } + } + +@DsglDsl +internal fun UiScope.colorField(props: ColorFieldProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ColorFieldProps().apply(props)) { props -> + ColorFieldSurfaceNode( + key = props.key, + ).apply { + bind(style = props.palette, color = props.color, hueDeg = props.hueDeg) + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } + } + +@DsglDsl +internal fun UiScope.eyedropperMagnifier( + props: EyedropperMagnifierProps.() -> Unit = {}, + ref: RefTarget? = null, +) = withProps(EyedropperMagnifierProps().apply(props)) { props -> + EyedropperMagnifierDrawNode( + key = props.key, + ).apply { + bind( + columns = props.sourceColumns, + rows = props.sourceRows, + magnification = props.magnification, + gridEnabled = props.showGrid, + gridColor = props.gridColor, + ) + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ComponentProps.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ComponentProps.kt index 9ce9e9f..2e91271 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ComponentProps.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ComponentProps.kt @@ -39,6 +39,7 @@ open class ComponentProps( var id: String? = null, var className: String = "", var classes: Set = emptySet(), + var overlapChildren: Boolean = false, var disabled: Boolean = false, var draggable: Boolean = false, var droppable: Boolean = false, @@ -70,7 +71,7 @@ open class ComponentProps( var onDragEnter: ((DragEnterEvent) -> Unit)? = null, var onDragOver: ((DragOverEvent) -> Unit)? = null, var onDragLeave: ((DragLeaveEvent) -> Unit)? = null, - var onDrop: ((DropEvent) -> Unit)? = null + var onDrop: ((DropEvent) -> Unit)? = null, ) { fun style(block: StyleScope.() -> Unit) { style = block @@ -89,4 +90,4 @@ open class ComponentProps( // } // return this // } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ContainerDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ContainerDsl.kt index 38aa2b6..3604157 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ContainerDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/ContainerDsl.kt @@ -8,11 +8,11 @@ import org.dreamfinity.dsgl.core.hooks.ref.RefTarget fun UiScope.div( props: ComponentProps.() -> Unit, ref: RefTarget? = null, - block: UiScope.() -> Unit = {} + block: UiScope.() -> Unit = {}, ) = withProps(ComponentProps().apply(props)) { props -> ContainerNode( - stackLayout = false, - key = props.key + stackLayout = props.overlapChildren, + key = props.key, ).apply { applyStyle(this, props.style) applyHandlers(this, props) @@ -21,21 +21,3 @@ fun UiScope.div( childScope(this).block() } } - -/** Overlay layout container (children overlap). */ -fun UiScope.overlay( - props: ComponentProps.() -> Unit, - ref: RefTarget? = null, - block: UiScope.() -> Unit = {} -) = withProps(ComponentProps().apply(props)) { props -> - ContainerNode( - stackLayout = true, - key = props.key - ).apply { - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) - childScope(this).block() - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/DsglDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/DsglDsl.kt index 417698a..71bc5c0 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/DsglDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/DsglDsl.kt @@ -4,4 +4,4 @@ package org.dreamfinity.dsgl.core.dsl * Marks the DSGL UI DSL to keep nested scopes safe. */ @DslMarker -annotation class DsglDsl \ No newline at end of file +annotation class DsglDsl diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/InputDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/InputDsl.kt index 00ceb0e..f6065f2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/InputDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/InputDsl.kt @@ -3,18 +3,22 @@ package org.dreamfinity.dsgl.core.dsl import org.dreamfinity.dsgl.core.dom.elements.* import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle import org.dreamfinity.dsgl.core.hooks.ref.RefTarget +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.select.SelectModelBuilder import org.dreamfinity.dsgl.core.select.selectModel import java.time.Instant import java.time.ZoneId /** Multiline text area props. */ -open class TextAreaProps(var placeholder: String = "") : TextProps() +open class TextAreaProps( + var placeholder: String = "", +) : TextProps() /** Select input props. */ open class SelectProps : ComponentProps() { var closeOnSelect: Boolean = true var defaultValue: String? = null + var ownerDomain: ScreenDomainId = ScreenDomainId.Application var value: String? get() = valueInternal @@ -24,6 +28,7 @@ open class SelectProps : ComponentProps() { } internal fun hasControlledValue(): Boolean = valueSpecified + internal fun controlledValue(): String? = valueInternal private var valueSpecified: Boolean = false @@ -31,7 +36,9 @@ open class SelectProps : ComponentProps() { } /** Input node props, driven by [org.dreamfinity.dsgl.core.dom.elements.InputType]. */ -open class InputProps(val type: InputType) : TextProps() +open class InputProps( + val type: InputType, +) : TextProps() /** Toggle/switch input props. */ open class ToggleProps : ComponentProps() { @@ -53,105 +60,107 @@ open class ToggleProps : ComponentProps() { } internal fun hasControlledChecked(): Boolean = checkedSpecified + internal fun controlledChecked(): Boolean = checkedInternal private var checkedSpecified: Boolean = false private var checkedInternal: Boolean = false } - /** Input node backed by an [org.dreamfinity.dsgl.core.dom.elements.InputType]. */ @DsglDsl -fun UiScope.input( - type: InputType, - props: InputProps.() -> Unit = {}, - ref: RefTarget? = null, -) = withProps(InputProps(type).apply(props)) { props -> - when (props.type) { - is InputType.Text -> TextInputNode( - text = props.type.value, - placeholder = props.type.placeholder, - allowedChars = props.type.allowedChars, - minLength = props.type.minLength, - maxLength = props.type.maxLength, - key = props.key - ) - - is InputType.Password -> PasswordInputNode( - text = props.type.value, - placeholder = props.type.placeholder, - minLength = props.type.minLength, - maxLength = props.type.maxLength, - key = props.key - ) - - is InputType.Number -> NumberInputNode( - value = props.type.value, - placeholder = props.type.placeholder, - min = props.type.min, - max = props.type.max, - key = props.key - ) - - is InputType.Range -> RangeInputNode( - value = props.type.value, - min = props.type.min, - max = props.type.max, - step = props.type.step, - key = props.key - ) - - is InputType.Checkbox -> CheckboxGroupNode( - variants = props.type.variants, - selected = props.type.selected, - minSelected = props.type.minSelected, - maxSelected = props.type.maxSelected, - key = props.key - ) - - is InputType.Radio -> RadioGroupNode( - variants = props.type.variants, - selectedId = props.type.selected, - key = props.key - ) - - is InputType.Date -> DateInputNode( - value = props.type.value ?: Instant.now(), - zoneId = props.type.zoneId ?: ZoneId.systemDefault(), - placeholder = props.type.placeholder, - key = props.key - ) - }.apply { - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.input(type: InputType, props: InputProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(InputProps(type).apply(props)) { props -> + when (props.type) { + is InputType.Text -> + TextInputNode( + text = props.type.value, + placeholder = props.type.placeholder, + allowedChars = props.type.allowedChars, + minLength = props.type.minLength, + maxLength = props.type.maxLength, + key = props.key, + ) + + is InputType.Password -> + PasswordInputNode( + text = props.type.value, + placeholder = props.type.placeholder, + minLength = props.type.minLength, + maxLength = props.type.maxLength, + key = props.key, + ) + + is InputType.Number -> + NumberInputNode( + value = props.type.value, + placeholder = props.type.placeholder, + min = props.type.min, + max = props.type.max, + key = props.key, + ) + + is InputType.Range -> + RangeInputNode( + value = props.type.value, + min = props.type.min, + max = props.type.max, + step = props.type.step, + key = props.key, + ) + + is InputType.Checkbox -> + CheckboxGroupNode( + variants = props.type.variants, + selected = props.type.selected, + minSelected = props.type.minSelected, + maxSelected = props.type.maxSelected, + key = props.key, + ) + + is InputType.Radio -> + RadioGroupNode( + variants = props.type.variants, + selectedId = props.type.selected, + key = props.key, + ) + + is InputType.Date -> + DateInputNode( + value = props.type.value ?: Instant.now(), + zoneId = props.type.zoneId ?: ZoneId.systemDefault(), + placeholder = props.type.placeholder, + key = props.key, + ) + }.apply { + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} /** Multiline text input area. */ @DsglDsl -fun UiScope.textarea( - props: TextAreaProps.() -> Unit = {}, - ref: RefTarget? = null -) = withProps(TextAreaProps().apply(props)) { props -> - TextAreaNode( - props.value, - props.placeholder, - props.key - ).apply { - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.textarea(props: TextAreaProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(TextAreaProps().apply(props)) { props -> + TextAreaNode( + props.value, + props.placeholder, + props.key, + ).apply { + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} @DsglDsl fun UiScope.select( props: SelectProps.() -> Unit = {}, ref: RefTarget? = null, - block: SelectModelBuilder.() -> Unit + block: SelectModelBuilder.() -> Unit, ) = withProps(SelectProps().apply(props)) { props -> val model = selectModel(block = block) val controlled = props.hasControlledValue() @@ -161,7 +170,8 @@ fun UiScope.select( value = if (controlled) props.controlledValue() else null, defaultValue = props.defaultValue, closeOnSelect = props.closeOnSelect, - key = props.key + ownerDomain = props.ownerDomain, + key = props.key, ).apply { applyStyle(this, props.style) applyHandlers(this, props) @@ -171,28 +181,26 @@ fun UiScope.select( } @DsglDsl -fun UiScope.toggle( - props: ToggleProps.() -> Unit = {}, - ref: RefTarget? = null -) = withProps(ToggleProps().apply(props)) { props -> - val controlled = props.hasControlledChecked() - ToggleNode( - controlled = controlled, - checked = if (controlled) props.controlledChecked() else false, - defaultChecked = props.defaultChecked, - key = props.key - ).apply { - trackOnColor = props.trackOnColor - trackOffColor = props.trackOffColor - trackDisabledColor = props.trackDisabledColor - thumbColor = props.thumbColor - thumbDisabledColor = props.thumbDisabledColor - focusOutlineColor = props.focusOutlineColor - switchWidthPx = props.switchWidthPx - switchHeightPx = props.switchHeightPx - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.toggle(props: ToggleProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ToggleProps().apply(props)) { props -> + val controlled = props.hasControlledChecked() + ToggleNode( + controlled = controlled, + checked = if (controlled) props.controlledChecked() else false, + defaultChecked = props.defaultChecked, + key = props.key, + ).apply { + trackOnColor = props.trackOnColor + trackOffColor = props.trackOffColor + trackDisabledColor = props.trackDisabledColor + thumbColor = props.thumbColor + thumbDisabledColor = props.thumbDisabledColor + focusOutlineColor = props.focusOutlineColor + switchWidthPx = props.switchWidthPx + switchHeightPx = props.switchHeightPx + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/MediaDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/MediaDsl.kt index efc7a5f..f203f32 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/MediaDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/MediaDsl.kt @@ -7,34 +7,32 @@ import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle import org.dreamfinity.dsgl.core.hooks.ref.RefTarget /** Image node props; accepts resource id, file://, or http(s) URLs in MC host. */ -open class ImageProps(var url: String) : ComponentProps() +open class ImageProps( + var url: String, +) : ComponentProps() /** Item stack node props for platform-specific stacks. */ open class ItemStackProps( var stack: ItemStackRef, var size: Int = 18, var rotYDeg: Double = 160.0, - var rotXDeg: Double = -11.0 + var rotXDeg: Double = -11.0, ) : ComponentProps() - /** Image node from resource, file, or URL (host-dependent). */ @DsglDsl -fun UiScope.img( - url: String, - props: ImageProps.() -> Unit = {}, - ref: RefTarget? = null, -) = withProps(ImageProps(url).apply(props)) { props -> - ImageNode( - props.url, - key = props.key - ).apply { - applyStyle(props.style) - applyHandlers(props) - applyRef(this, ref) - add(this) +fun UiScope.img(url: String, props: ImageProps.() -> Unit = {}, ref: RefTarget? = null) = + withProps(ImageProps(url).apply(props)) { props -> + ImageNode( + props.url, + key = props.key, + ).apply { + applyStyle(props.style) + applyHandlers(props) + applyRef(this, ref) + add(this) + } } -} /** Item stack node for platform-specific stack types. */ @DsglDsl @@ -48,11 +46,11 @@ fun UiScope.itemStack( props.size, props.rotYDeg, props.rotXDeg, - props.key + props.key, ).apply { applyStyle(this, props.style) applyHandlers(this, props) applyRef(this, ref) add(this) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/StyleScope.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/StyleScope.kt index fc4d7d8..9906ce1 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/StyleScope.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/StyleScope.kt @@ -7,19 +7,19 @@ import org.dreamfinity.dsgl.core.style.* data class StyleBorder( val width: CssLength, - val color: Int = DsglColors.BORDER + val color: Int = DsglColors.BORDER, ) data class StyleSpacing( val top: CssLength, val right: CssLength, val bottom: CssLength, - val left: CssLength + val left: CssLength, ) data class StyleOverflowAxes( val x: Overflow, - val y: Overflow + val y: Overflow, ) interface CssLengthUnitsDsl { @@ -62,12 +62,11 @@ class StyleTransformOriginBuilder : CssLengthUnitsDsl { y = value } - internal fun build(): TransformOrigin { - return TransformOrigin( + internal fun build(): TransformOrigin = + TransformOrigin( originX = x.coerceIn(0f, 1f), - originY = y.coerceIn(0f, 1f) + originY = y.coerceIn(0f, 1f), ) - } } @DsglDsl @@ -94,14 +93,13 @@ class StyleSpacingBuilder : CssLengthUnitsDsl { bottom = value } - internal fun build(): StyleSpacing { - return StyleSpacing( + internal fun build(): StyleSpacing = + StyleSpacing( top = top, right = right, bottom = bottom, - left = left + left = left, ) - } } @DsglDsl @@ -184,17 +182,21 @@ class StyleInsetBuilder : CssLengthUnitsDsl { } internal fun hasLeft(): Boolean = leftAssigned + internal fun hasTop(): Boolean = topAssigned + internal fun hasRight(): Boolean = rightAssigned + internal fun hasBottom(): Boolean = bottomAssigned } -@DsglDsl /** * Styling DSL attached to a [DOMNode]. */ -class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnitsDsl { - +@DsglDsl +class StyleScope internal constructor( + private val node: DOMNode, +) : CssLengthUnitsDsl { var display: Display get() = Display.Block set(value) { @@ -593,10 +595,11 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit } fun transformOrigin(originX: Float, originY: Float) { - transformOrigin = TransformOrigin( - originX = originX.coerceIn(0f, 1f), - originY = originY.coerceIn(0f, 1f) - ) + transformOrigin = + TransformOrigin( + originX = originX.coerceIn(0f, 1f), + originY = originY.coerceIn(0f, 1f), + ) } fun transformOrigin(block: StyleTransformOriginBuilder.() -> Unit) { @@ -619,7 +622,12 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit setSpacing(StyleProperty.MARGIN, vertical, horizontal, vertical, horizontal) } - fun margin(top: CssLength, right: CssLength, bottom: CssLength, left: CssLength) { + fun margin( + top: CssLength, + right: CssLength, + bottom: CssLength, + left: CssLength, + ) { setSpacing(StyleProperty.MARGIN, top, right, bottom, left) } @@ -639,7 +647,12 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit setSpacing(StyleProperty.PADDING, vertical, horizontal, vertical, horizontal) } - fun padding(top: CssLength, right: CssLength, bottom: CssLength, left: CssLength) { + fun padding( + top: CssLength, + right: CssLength, + bottom: CssLength, + left: CssLength, + ) { requireNonNegative(top, "padding") requireNonNegative(right, "padding") requireNonNegative(bottom, "padding") @@ -676,7 +689,13 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit border(maxLength(horizontal, vertical), color) } - fun border(top: CssLength, right: CssLength, bottom: CssLength, left: CssLength, color: Int = DsglColors.BORDER) { + fun border( + top: CssLength, + right: CssLength, + bottom: CssLength, + left: CssLength, + color: Int = DsglColors.BORDER, + ) { border(maxLength(top, right, bottom, left), color) } @@ -754,6 +773,7 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit setExpression(StyleProperty.ALIGN, variable) } + @Suppress("FunctionNaming") fun `var`(name: String): StyleExpression.VariableRef { val normalized = if (name.startsWith("--")) name else "--$name" return StyleExpression.VariableRef(normalized) @@ -764,11 +784,11 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit top: CssLength, right: CssLength, bottom: CssLength, - left: CssLength + left: CssLength, ) { setLiteral( property, - "${top.toCssLiteral()} ${right.toCssLiteral()} ${bottom.toCssLiteral()} ${left.toCssLiteral()}" + "${top.toCssLiteral()} ${right.toCssLiteral()} ${bottom.toCssLiteral()} ${left.toCssLiteral()}", ) } @@ -783,7 +803,12 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit return if (first.value >= second.value) first else second } - private fun maxLength(first: CssLength, second: CssLength, third: CssLength, fourth: CssLength): CssLength { + private fun maxLength( + first: CssLength, + second: CssLength, + third: CssLength, + fourth: CssLength, + ): CssLength { val firstMax = maxLength(first, second) val secondMax = maxLength(third, fourth) return maxLength(firstMax, secondMax) @@ -799,91 +824,108 @@ class StyleScope internal constructor(private val node: DOMNode) : CssLengthUnit private fun toColorLiteral(value: Int): String { val unsigned = value.toLong() and 0xFFFFFFFFL - return "#" + unsigned.toString(16).padStart(8, '0').uppercase() + return "#" + + unsigned + .toString(16) + .padStart(8, '0') + .uppercase() } - private fun Display.toCssLiteral(): String = when (this) { - Display.Block -> "block" - Display.Inline -> "inline" - Display.None -> "none" - Display.Flex -> "flex" - Display.Grid -> "grid" - } + private fun Display.toCssLiteral(): String = + when (this) { + Display.Block -> "block" + Display.Inline -> "inline" + Display.None -> "none" + Display.Flex -> "flex" + Display.Grid -> "grid" + } - private fun PositionMode.toCssLiteral(): String = when (this) { - PositionMode.Static -> "static" - PositionMode.Relative -> "relative" - PositionMode.Absolute -> "absolute" - PositionMode.Fixed -> "fixed" - PositionMode.Sticky -> "sticky" - } + private fun PositionMode.toCssLiteral(): String = + when (this) { + PositionMode.Static -> "static" + PositionMode.Relative -> "relative" + PositionMode.Absolute -> "absolute" + PositionMode.Fixed -> "fixed" + PositionMode.Sticky -> "sticky" + } - private fun Overflow.toCssLiteral(): String = when (this) { - Overflow.Visible -> "visible" - Overflow.Hidden -> "hidden" - Overflow.Scroll -> "scroll" - Overflow.Auto -> "auto" - } + private fun Overflow.toCssLiteral(): String = + when (this) { + Overflow.Visible -> "visible" + Overflow.Hidden -> "hidden" + Overflow.Scroll -> "scroll" + Overflow.Auto -> "auto" + } - private fun FlexDirection.toCssLiteral(): String = when (this) { - FlexDirection.Row -> "row" - FlexDirection.Column -> "column" - } + private fun FlexDirection.toCssLiteral(): String = + when (this) { + FlexDirection.Row -> "row" + FlexDirection.Column -> "column" + } - private fun JustifyContent.toCssLiteral(): String = when (this) { - JustifyContent.Start -> "start" - JustifyContent.Center -> "center" - JustifyContent.End -> "end" - JustifyContent.SpaceBetween -> "space-between" - JustifyContent.SpaceAround -> "space-around" - JustifyContent.SpaceEvenly -> "space-evenly" - } + private fun JustifyContent.toCssLiteral(): String = + when (this) { + JustifyContent.Start -> "start" + JustifyContent.Center -> "center" + JustifyContent.End -> "end" + JustifyContent.SpaceBetween -> "space-between" + JustifyContent.SpaceAround -> "space-around" + JustifyContent.SpaceEvenly -> "space-evenly" + } - private fun AlignItems.toCssLiteral(): String = when (this) { - AlignItems.Start -> "start" - AlignItems.Center -> "center" - AlignItems.End -> "end" - AlignItems.Stretch -> "stretch" - } + private fun AlignItems.toCssLiteral(): String = + when (this) { + AlignItems.Start -> "start" + AlignItems.Center -> "center" + AlignItems.End -> "end" + AlignItems.Stretch -> "stretch" + } - private fun JustifyItems.toCssLiteral(): String = when (this) { - JustifyItems.Start -> "start" - JustifyItems.Center -> "center" - JustifyItems.End -> "end" - JustifyItems.Stretch -> "stretch" - } + private fun JustifyItems.toCssLiteral(): String = + when (this) { + JustifyItems.Start -> "start" + JustifyItems.Center -> "center" + JustifyItems.End -> "end" + JustifyItems.Stretch -> "stretch" + } - private fun GridAutoFlow.toCssLiteral(): String = when (this) { - GridAutoFlow.Row -> "row" - GridAutoFlow.Column -> "column" - } + private fun GridAutoFlow.toCssLiteral(): String = + when (this) { + GridAutoFlow.Row -> "row" + GridAutoFlow.Column -> "column" + } - private fun TextWrap.toCssLiteral(): String = when (this) { - TextWrap.Wrap -> "wrap" - TextWrap.NoWrap -> "nowrap" - } + private fun TextWrap.toCssLiteral(): String = + when (this) { + TextWrap.Wrap -> "wrap" + TextWrap.NoWrap -> "nowrap" + } - private fun TextFormatting.toCssLiteral(): String = when (this) { - TextFormatting.None -> "none" - TextFormatting.Minecraft -> "minecraft" - } + private fun TextFormatting.toCssLiteral(): String = + when (this) { + TextFormatting.None -> "none" + TextFormatting.Minecraft -> "minecraft" + } - private fun FontWeight.toCssLiteral(): String = when (this) { - FontWeight.Normal -> "normal" - FontWeight.Bold -> "bold" - } + private fun FontWeight.toCssLiteral(): String = + when (this) { + FontWeight.Normal -> "normal" + FontWeight.Bold -> "bold" + } - private fun FontStyle.toCssLiteral(): String = when (this) { - FontStyle.Normal -> "normal" - FontStyle.Italic -> "italic" - } + private fun FontStyle.toCssLiteral(): String = + when (this) { + FontStyle.Normal -> "normal" + FontStyle.Italic -> "italic" + } - private fun TextDecoration.toCssLiteral(): String = when (this) { - TextDecoration.None -> "none" - TextDecoration.Underline -> "underline" - TextDecoration.Strikethrough -> "strikethrough" - TextDecoration.UnderlineStrikethrough -> "underline-strikethrough" - } + private fun TextDecoration.toCssLiteral(): String = + when (this) { + TextDecoration.None -> "none" + TextDecoration.Underline -> "underline" + TextDecoration.Strikethrough -> "strikethrough" + TextDecoration.UnderlineStrikethrough -> "underline-strikethrough" + } private fun UiTransform.toCssLiteral(): String { if (isIdentity()) return "none" diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/TextDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/TextDsl.kt index 14051f9..2e3279e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/TextDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/TextDsl.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package org.dreamfinity.dsgl.core.dsl import org.dreamfinity.dsgl.core.dom.elements.TextNode @@ -7,7 +9,9 @@ import org.dreamfinity.dsgl.core.hooks.ref.RefTarget import org.dreamfinity.dsgl.core.style.TextFormatting /** Static text props. */ -open class TextProps(value: String = "") : ComponentProps() { +open class TextProps( + value: String = "", +) : ComponentProps() { var source: TextSource = TextSource.Static(value) constructor(valueProvider: () -> String) : this() { @@ -21,34 +25,27 @@ open class TextProps(value: String = "") : ComponentProps() { } } - /** Text node. Supports static and rebuild-driven dynamic text. */ -fun UiScope.text( - props: TextProps.() -> Unit, - ref: RefTarget? = null -) = withProps(TextProps().apply(props)) { props -> - TextNode( - props.source, - key = props.key - ).apply { - applyStyle(this, props.style) - applyHandlers(this, props) - applyRef(this, ref) - add(this) +fun UiScope.text(props: TextProps.() -> Unit, ref: RefTarget? = null) = + withProps(TextProps().apply(props)) { props -> + TextNode( + props.source, + key = props.key, + ).apply { + applyStyle(this, props.style) + applyHandlers(this, props) + applyRef(this, ref) + add(this) + } } -} -fun UiScope.text( - value: String, - props: (TextProps.() -> Unit) = {}, - ref: RefTarget? = null, -) { +fun UiScope.text(value: String, props: (TextProps.() -> Unit) = {}, ref: RefTarget? = null) { text( props = { this.value = value props() }, - ref = ref + ref = ref, ) } @@ -71,27 +68,21 @@ fun UiScope.text( } } }, - ref = ref + ref = ref, ) } -fun UiScope.dynamicText( - value: () -> String -) { +fun UiScope.dynamicText(value: () -> String) { dynamicText(value = value, ref = null) } -fun UiScope.dynamicText( - value: () -> String, - props: TextProps.() -> Unit = {}, - ref: RefTarget? = null, -) { +fun UiScope.dynamicText(value: () -> String, props: TextProps.() -> Unit = {}, ref: RefTarget? = null) { text( props = { source = TextSource.Dynamic(value) props() }, - ref = ref + ref = ref, ) } @@ -114,6 +105,6 @@ fun UiScope.dynamicText( } } }, - ref = ref + ref = ref, ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/UiScope.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/UiScope.kt index c347204..146cb66 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/UiScope.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/dsl/UiScope.kt @@ -14,21 +14,16 @@ import org.dreamfinity.dsgl.core.hooks.ref.RefTarget * * Call this from [DsglWindow.render] to define the UI hierarchy. */ -fun ui(block: UiScope.() -> Unit): DomTree { - return buildUiTree(ownerWindow = null, root = ContainerNode(stackLayout = true), block = block) -} +fun ui(block: UiScope.() -> Unit): DomTree = + buildUiTree(ownerWindow = null, root = ContainerNode(stackLayout = true), block = block) -fun ui(root: DOMNode, block: UiScope.() -> Unit): DomTree { - return buildUiTree(ownerWindow = null, root = root, block = block) -} +fun ui(root: DOMNode, block: UiScope.() -> Unit): DomTree = buildUiTree(ownerWindow = null, root = root, block = block) -fun ui(window: DsglWindow, block: UiScope.() -> Unit): DomTree { - return buildUiTree(ownerWindow = window, root = ContainerNode(stackLayout = true), block = block) -} +fun ui(window: DsglWindow, block: UiScope.() -> Unit): DomTree = + buildUiTree(ownerWindow = window, root = ContainerNode(stackLayout = true), block = block) -fun ui(window: DsglWindow, root: DOMNode, block: UiScope.() -> Unit): DomTree { - return buildUiTree(ownerWindow = window, root = root, block = block) -} +fun ui(window: DsglWindow, root: DOMNode, block: UiScope.() -> Unit): DomTree = + buildUiTree(ownerWindow = window, root = root, block = block) private fun buildUiTree(ownerWindow: DsglWindow?, root: DOMNode, block: UiScope.() -> Unit): DomTree { val scope = UiScope(root, ownerWindow) @@ -43,33 +38,26 @@ private fun buildUiTree(ownerWindow: DsglWindow?, root: DOMNode, block: UiScope. class UiScope internal constructor( private val parent: DOMNode, private val ownerWindow: DsglWindow? = null, - private val providedContexts: Map, Any?> = emptyMap() + private val providedContexts: Map, Any?> = emptyMap(), ) { @PublishedApi - internal fun requireHookOwnerWindow(): DsglWindow { - return ownerWindow ?: throw IllegalStateException( - "Hook APIs require a UiScope owned by a DsglWindow render session." - ) - } + internal fun requireHookOwnerWindow(): DsglWindow = + ownerWindow ?: error("Hook APIs require a UiScope owned by a DsglWindow render session.") - internal fun childScope(childParent: DOMNode): UiScope { - return UiScope( + internal fun childScope(childParent: DOMNode): UiScope = + UiScope( parent = childParent, ownerWindow = ownerWindow, - providedContexts = providedContexts + providedContexts = providedContexts, ) - } - internal fun withProvidedContext( - context: DsglContext, - value: T, - block: UiScope.() -> R - ): R { - val nextScope = UiScope( - parent = parent, - ownerWindow = ownerWindow, - providedContexts = providedContexts + (context to value) - ) + internal fun withProvidedContext(context: DsglContext, value: T, block: UiScope.() -> R): R { + val nextScope = + UiScope( + parent = parent, + ownerWindow = ownerWindow, + providedContexts = providedContexts + (context to value), + ) return nextScope.block() } @@ -81,14 +69,9 @@ class UiScope internal constructor( return providedContexts[context] as T } + internal fun add(node: T): T = node.applyParent(parent) - internal fun add(node: T): T { - return node.applyParent(parent) - } - - internal fun mount(node: T): T { - return add(node) - } + internal fun mount(node: T): T = add(node) internal fun applyHandlers(node: DOMNode, props: ComponentProps) { node.applyHandlers(props) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ClickDispatch.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ClickDispatch.kt index 927d663..82a1f80 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ClickDispatch.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ClickDispatch.kt @@ -8,31 +8,31 @@ import org.dreamfinity.dsgl.core.dom.layout.Rect /** * Dispatches a click through the DOM tree; returns true if handled. */ -fun dispatchClick(root: DOMNode, event: MouseClickEvent): Boolean { - return dispatchClickInternal( +fun dispatchClick(root: DOMNode, event: MouseClickEvent): Boolean = + dispatchClickInternal( element = root, event = event, parentTransform = AffineTransform2D.IDENTITY, - parentInputClipRect = null + parentInputClipRect = null, ) -} internal fun dispatchClickInternal( element: DOMNode, event: MouseClickEvent, parentTransform: AffineTransform2D, - parentInputClipRect: Rect? + parentInputClipRect: Rect?, ): Boolean { if (!element.isHitTestVisible()) { return false } - val projection = UsedInteractionGeometryResolver.projectNodeAtPoint( - node = element, - mouseX = event.mouseX, - mouseY = event.mouseY, - parentTransform = parentTransform, - parentInputClipRect = parentInputClipRect - ) ?: return false + val projection = + UsedInteractionGeometryResolver.projectNodeAtPoint( + node = element, + mouseX = event.mouseX, + mouseY = event.mouseY, + parentTransform = parentTransform, + parentInputClipRect = parentInputClipRect, + ) ?: return false val childInputClipRect = projection.childInputClipRect if (projection.canTraverseChildren) { @@ -42,7 +42,7 @@ internal fun dispatchClickInternal( element = child, event = event, parentTransform = projection.worldTransform, - parentInputClipRect = childInputClipRect + parentInputClipRect = childInputClipRect, ) ) { return true @@ -56,4 +56,3 @@ internal fun dispatchClickInternal( return element.handleClick(event) } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/EventBus.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/EventBus.kt index c4d9442..245765c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/EventBus.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/EventBus.kt @@ -1,24 +1,29 @@ package org.dreamfinity.dsgl.core.event import org.dreamfinity.dsgl.core.dom.DOMNode -import java.util.* +import java.util.Collections +import java.util.EnumMap +import java.util.IdentityHashMap +import java.util.WeakHashMap typealias EventCallback = (Event) -> Unit object EventBus { private val listeners: MutableMap>> = EnumMap(Events::class.java) - private val nonBubblingEvents: Set = setOf( - Events.MOUSEENTER, - Events.MOUSELEAVE, - Events.MOUSEOVER, - Events.FOCUS, - Events.BLUR - ) + private val nonBubblingEvents: Set = + setOf( + Events.MOUSEENTER, + Events.MOUSELEAVE, + Events.MOUSEOVER, + Events.FOCUS, + Events.BLUR, + ) - private fun getEventMap(eventType: Events): MutableMap> { - return listeners.getOrPut(eventType) { WeakHashMap() } - } + private fun getEventMap(eventType: Events): MutableMap> = + listeners.getOrPut(eventType) { + WeakHashMap() + } fun DOMNode.addEventListener(eventType: Events, callback: (E) -> Unit) { getEventMap(eventType).getOrPut(this) { arrayListOf() }.add(callback as EventCallback) @@ -60,7 +65,7 @@ object EventBus { data class DebugListenerSnapshot( val registeredNodes: Int, - val registeredCallbacks: Int + val registeredCallbacks: Int, ) fun debugListenerSnapshot(): DebugListenerSnapshot { @@ -74,12 +79,12 @@ object EventBus { } return DebugListenerSnapshot( registeredNodes = nodes.size, - registeredCallbacks = callbacks + registeredCallbacks = callbacks, ) } - internal fun hasInputListeners(node: DOMNode): Boolean { - return listOf( + internal fun hasInputListeners(node: DOMNode): Boolean = + listOf( Events.MOUSEDOWN, Events.MOUSEUP, Events.CLICK, @@ -87,12 +92,11 @@ object EventBus { Events.WHEEL, Events.MOUSEMOVE, Events.KEYDOWN, - Events.KEYUP + Events.KEYUP, ).any { eventType -> val callbacks = listeners[eventType]?.get(node) callbacks != null && callbacks.isNotEmpty() } - } fun post(event: Event) { val allListeners = listeners[event.type] ?: return @@ -129,27 +133,29 @@ object EventBus { return } - val validListeners = when (event.type) { - Events.KEYUP, Events.KEYDOWN -> allListeners - Events.MOUSEDOWN, Events.CLICK, Events.MOUSEUP, Events.WHEEL, Events.MOUSEOVER -> - allListeners.filter { it.key.hovered(event as MouseEvent) } - - Events.MOUSEOUT, Events.MOUSELEAVE -> - allListeners.filter { !it.key.hovered(event as MouseEvent) } - - Events.MOUSEMOVE -> allListeners - Events.DRAG -> allListeners - Events.MOUSEENTER -> allListeners - Events.DRAGSTART, - Events.DRAGGING, - Events.DRAGEND, - Events.DRAGENTER, - Events.DRAGOVER, - Events.DRAGLEAVE, - Events.DROP -> allListeners - - Events.FOCUS, Events.BLUR, Events.INPUT, Events.CHANGE -> allListeners - } + val validListeners = + when (event.type) { + Events.KEYUP, Events.KEYDOWN -> allListeners + Events.MOUSEDOWN, Events.CLICK, Events.MOUSEUP, Events.WHEEL, Events.MOUSEOVER -> + allListeners.filter { it.key.hovered(event as MouseEvent) } + + Events.MOUSEOUT, Events.MOUSELEAVE -> + allListeners.filter { !it.key.hovered(event as MouseEvent) } + + Events.MOUSEMOVE -> allListeners + Events.DRAG -> allListeners + Events.MOUSEENTER -> allListeners + Events.DRAGSTART, + Events.DRAGGING, + Events.DRAGEND, + Events.DRAGENTER, + Events.DRAGOVER, + Events.DRAGLEAVE, + Events.DROP, + -> allListeners + + Events.FOCUS, Events.BLUR, Events.INPUT, Events.CHANGE -> allListeners + } validListeners.forEach { (_, callbacks) -> callbacks.forEach { callback -> diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/Events.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/Events.kt index 7b97a63..85b78b3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/Events.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/Events.kt @@ -29,5 +29,5 @@ enum class Events { FOCUS, BLUR, INPUT, - CHANGE + CHANGE, } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusGainEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusGainEvent.kt index acd4026..7636ab4 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusGainEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusGainEvent.kt @@ -4,7 +4,7 @@ package org.dreamfinity.dsgl.core.event * Focus event fired when an element receives focus. */ data class FocusGainEvent( - val previousTargetKey: Any? = null + val previousTargetKey: Any? = null, ) : Event() { override val type: Events get() = Events.FOCUS diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusLoseEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusLoseEvent.kt index bf03505..ea1a861 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusLoseEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusLoseEvent.kt @@ -4,7 +4,7 @@ package org.dreamfinity.dsgl.core.event * Blur event fired when an element loses focus. */ data class FocusLoseEvent( - val nextTargetKey: Any? = null + val nextTargetKey: Any? = null, ) : Event() { override val type: Events get() = Events.BLUR diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusManager.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusManager.kt index a9dd297..7fdf088 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusManager.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/FocusManager.kt @@ -11,10 +11,17 @@ object FocusManager { private var focusedKey: Any? = null private var focusedPath: IntArray? = null private var lastRoot: DOMNode? = null + private var preservePointerFocusDepth: Int = 0 /** Current focused node, if any. */ fun focusedNode(): DOMNode? = focused + /** Current focused node when it belongs to the given physical DOM root. */ + fun focusedNodeWithin(root: DOMNode): DOMNode? { + val currentFocus = focused ?: return null + return if (isSameOrAncestor(root, currentFocus)) currentFocus else null + } + /** Returns true if the given node is focused. */ fun isFocused(node: DOMNode): Boolean = focused === node @@ -43,6 +50,13 @@ object FocusManager { } } + /** Resolves and requests focus from a node reference. */ + fun requestFocusFrom(start: DOMNode?): Boolean { + val focusable = resolveFocusable(start) ?: return false + requestFocus(focusable) + return true + } + /** Clears current focus. */ fun clearFocus() { requestFocus(null) @@ -60,6 +74,7 @@ object FocusManager { /** Updates focus from an event target. */ fun updateFocusFromTarget(target: DOMNode?) { + if (preservePointerFocusDepth > 0) return val focusable = resolveFocusable(target) if (focusable != null) { requestFocus(focusable) @@ -68,6 +83,15 @@ object FocusManager { } } + internal inline fun preservePointerFocus(block: () -> T): T { + preservePointerFocusDepth += 1 + return try { + block() + } finally { + preservePointerFocusDepth -= 1 + } + } + /** * Retains focus across rebuilds using node key or path. */ @@ -107,6 +131,11 @@ object FocusManager { fun requestFocusByKey(key: Any?): Boolean { if (key == null) return false val root = lastRoot ?: return false + return requestFocusByKey(root, key) + } + + fun requestFocusByKey(root: DOMNode, key: Any?): Boolean { + if (key == null) return false val target = findByKey(root, key) ?: return false val focusable = findFirstFocusable(target) ?: return false requestFocus(focusable) @@ -116,6 +145,11 @@ object FocusManager { fun requestFocusFirstInSubtree(rootKey: Any?): Boolean { if (rootKey == null) return false val root = lastRoot ?: return false + return requestFocusFirstInSubtree(root, rootKey) + } + + fun requestFocusFirstInSubtree(root: DOMNode, rootKey: Any?): Boolean { + if (rootKey == null) return false val subtree = findByKey(root, rootKey) ?: return false val focusable = findFirstFocusable(subtree) ?: return false requestFocus(focusable) @@ -173,4 +207,13 @@ object FocusManager { } return null } + + private fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { + var current = node + while (current != null) { + if (current === candidate) return true + current = current.parent + } + return false + } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/HoverDispatcher.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/HoverDispatcher.kt index 7bacf07..2c62f29 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/HoverDispatcher.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/HoverDispatcher.kt @@ -18,7 +18,7 @@ fun collectHoverChain(root: DOMNode, mouseX: Int, mouseY: Int): List { mouseY = mouseY, parentTransform = AffineTransform2D.IDENTITY, parentInputClipRect = null, - out = out + out = out, ) return out } @@ -29,18 +29,19 @@ internal fun collectHoverChain( mouseY: Int, parentTransform: AffineTransform2D, parentInputClipRect: Rect?, - out: MutableList + out: MutableList, ): Boolean { if (root.styleDisabled) return false if (!root.isHitTestVisible()) return false - val projection = UsedInteractionGeometryResolver.projectNodeAtPoint( - node = root, - mouseX = mouseX, - mouseY = mouseY, - parentTransform = parentTransform, - parentInputClipRect = parentInputClipRect - ) ?: return false + val projection = + UsedInteractionGeometryResolver.projectNodeAtPoint( + node = root, + mouseX = mouseX, + mouseY = mouseY, + parentTransform = parentTransform, + parentInputClipRect = parentInputClipRect, + ) ?: return false out.add(root) @@ -54,7 +55,7 @@ internal fun collectHoverChain( mouseY = mouseY, parentTransform = projection.worldTransform, parentInputClipRect = childInputClipRect, - out = out + out = out, ) ) { return true @@ -78,7 +79,7 @@ fun updateHover( mouseX: Int, mouseY: Int, mouseDX: Int, - mouseDY: Int + mouseDY: Int, ) { val currHoverChain = ArrayList(prevHoverChain.size + 4) collectHoverChain( @@ -87,7 +88,7 @@ fun updateHover( mouseY = mouseY, parentTransform = AffineTransform2D.IDENTITY, parentInputClipRect = null, - out = currHoverChain + out = currHoverChain, ) val minSize = minOf(prevHoverChain.size, currHoverChain.size) @@ -134,9 +135,9 @@ private fun isSameHoverNode(prev: DOMNode, curr: DOMNode): Boolean { val currKey = curr.key if (prevKey != null || currKey != null) { return prevKey != null && - currKey != null && - prevKey == currKey && - prev.javaClass == curr.javaClass + currKey != null && + prevKey == currKey && + prev.javaClass == curr.javaClass } // Root is recreated on every rebuild but is conceptually the same hover scope. @@ -181,4 +182,3 @@ private fun label(element: DOMNode): String { val keyPart = element.key?.let { " key=$it" } ?: "" return element.javaClass.simpleName + keyPart } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/InputEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/InputEvent.kt index dd48392..8c895ab 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/InputEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/InputEvent.kt @@ -5,7 +5,7 @@ package org.dreamfinity.dsgl.core.event */ data class InputEvent( val value: String, - val parsedValue: Any? = null + val parsedValue: Any? = null, ) : Event() { override val type: Events get() = Events.INPUT diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyInput.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyInput.kt index 74a824c..3421aaf 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyInput.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyInput.kt @@ -34,4 +34,4 @@ object KeyInput { else -> ch } } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyModifiers.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyModifiers.kt index e68a76d..07921e0 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyModifiers.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyModifiers.kt @@ -7,17 +7,20 @@ object KeyModifiers { @Volatile var shiftDown: Boolean = false private set + @Volatile var controlDown: Boolean = false private set + @Volatile var metaDown: Boolean = false private set - private val macOs: Boolean = run { - val osName = System.getProperty("os.name") ?: return@run false - osName.lowercase().contains("mac") - } + private val macOs: Boolean = + run { + val osName = System.getProperty("os.name") ?: return@run false + osName.lowercase().contains("mac") + } val shortcutDown: Boolean get() = if (macOs) metaDown else controlDown @@ -37,4 +40,4 @@ object KeyModifiers { controlDown = control metaDown = meta } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyDownEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyDownEvent.kt index 2eb8617..dceeb5d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyDownEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyDownEvent.kt @@ -3,7 +3,10 @@ package org.dreamfinity.dsgl.core.event /** * Key down event. */ -data class KeyboardKeyDownEvent(var keyChar: Char, var keyCode: Int) : Event() { +data class KeyboardKeyDownEvent( + var keyChar: Char, + var keyCode: Int, +) : Event() { override val type: Events get() = Events.KEYDOWN -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyUpEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyUpEvent.kt index 67180f2..12246fb 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyUpEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/KeyboardKeyUpEvent.kt @@ -3,7 +3,10 @@ package org.dreamfinity.dsgl.core.event /** * Key up event. */ -data class KeyboardKeyUpEvent(var keyChar: Char, var keyCode: Int) : Event() { +data class KeyboardKeyUpEvent( + var keyChar: Char, + var keyCode: Int, +) : Event() { override val type: Events get() = Events.KEYUP -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseButton.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseButton.kt index 1625eaa..4eb83a1 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseButton.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseButton.kt @@ -6,5 +6,5 @@ package org.dreamfinity.dsgl.core.event enum class MouseButton { LEFT, RIGHT, - MIDDLE -} \ No newline at end of file + MIDDLE, +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseClickEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseClickEvent.kt index a5459cf..fee7a9f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseClickEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseClickEvent.kt @@ -6,8 +6,8 @@ package org.dreamfinity.dsgl.core.event data class MouseClickEvent( override var mouseX: Int, override var mouseY: Int, - var mouseButton: MouseButton + var mouseButton: MouseButton, ) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.CLICK -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDownEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDownEvent.kt index 1a82fde..d1827cb 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDownEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDownEvent.kt @@ -6,8 +6,8 @@ package org.dreamfinity.dsgl.core.event data class MouseDownEvent( override var mouseX: Int, override var mouseY: Int, - var mouseButton: MouseButton + var mouseButton: MouseButton, ) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSEDOWN -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDragEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDragEvent.kt index a298067..cc54e8b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDragEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseDragEvent.kt @@ -8,8 +8,8 @@ data class MouseDragEvent( var lastMouseY: Int, var dx: Int, var dy: Int, - var mouseButton: MouseButton + var mouseButton: MouseButton, ) : MouseEvent(lastMouseX, lastMouseY) { override val type: Events get() = Events.DRAG -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEnterEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEnterEvent.kt index 0bb62b9..1b9a443 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEnterEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEnterEvent.kt @@ -3,7 +3,10 @@ package org.dreamfinity.dsgl.core.event /** * Mouse enter event for a node's bounds. */ -class MouseEnterEvent(mouseX: Int, mouseY: Int) : MouseEvent(mouseX, mouseY) { +class MouseEnterEvent( + mouseX: Int, + mouseY: Int, +) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSEENTER -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEvent.kt index da113ff..b555ce4 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseEvent.kt @@ -3,4 +3,7 @@ package org.dreamfinity.dsgl.core.event /** * Base mouse event with cursor position in UI coordinates. */ -abstract class MouseEvent(open var mouseX: Int, open var mouseY: Int) : Event() \ No newline at end of file +abstract class MouseEvent( + open var mouseX: Int, + open var mouseY: Int, +) : Event() diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseLeaveEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseLeaveEvent.kt index baaa576..b1f50df 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseLeaveEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseLeaveEvent.kt @@ -3,7 +3,10 @@ package org.dreamfinity.dsgl.core.event /** * Mouse leave event for a node's bounds. */ -class MouseLeaveEvent(mouseX: Int, mouseY: Int) : MouseEvent(mouseX, mouseY) { +class MouseLeaveEvent( + mouseX: Int, + mouseY: Int, +) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSELEAVE -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseMoveEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseMoveEvent.kt index 859e87e..89a5994 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseMoveEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseMoveEvent.kt @@ -7,8 +7,8 @@ class MouseMoveEvent( mouseX: Int, mouseY: Int, val prevX: Int = mouseX, - val prevY: Int = mouseY + val prevY: Int = mouseY, ) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSEMOVE -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseOverEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseOverEvent.kt index b9fcae6..a14228f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseOverEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseOverEvent.kt @@ -3,7 +3,10 @@ package org.dreamfinity.dsgl.core.event /** * Mouse over (move within bounds) event. */ -class MouseOverEvent(mouseX: Int, mouseY: Int) : MouseEvent(mouseX, mouseY) { +class MouseOverEvent( + mouseX: Int, + mouseY: Int, +) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSEOVER -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseUpEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseUpEvent.kt index e9ee672..55dc6d4 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseUpEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseUpEvent.kt @@ -6,8 +6,8 @@ package org.dreamfinity.dsgl.core.event data class MouseUpEvent( override var mouseX: Int, override var mouseY: Int, - var mouseButton: MouseButton + var mouseButton: MouseButton, ) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.MOUSEUP -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseWheelEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseWheelEvent.kt index f423540..2ed7878 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseWheelEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/MouseWheelEvent.kt @@ -6,8 +6,8 @@ package org.dreamfinity.dsgl.core.event data class MouseWheelEvent( override var mouseX: Int, override var mouseY: Int, - var dWheel: Int + var dWheel: Int, ) : MouseEvent(mouseX, mouseY) { override val type: Events get() = Events.WHEEL -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ValueChangedEvent.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ValueChangedEvent.kt index 75b1618..aa3b4a1 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ValueChangedEvent.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/event/ValueChangedEvent.kt @@ -5,7 +5,7 @@ package org.dreamfinity.dsgl.core.event */ data class ValueChangedEvent( val value: String, - val parsedValue: Any? = null + val parsedValue: Any? = null, ) : Event() { override val type: Events get() = Events.CHANGE diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontDiscovery.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontDiscovery.kt index 9ad3ab4..45c8792 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontDiscovery.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontDiscovery.kt @@ -8,7 +8,7 @@ import kotlin.io.path.isRegularFile enum class FontAssetSource { Jar, - External + External, } internal data class FontPackageCandidate( @@ -16,7 +16,7 @@ internal data class FontPackageCandidate( val ttfPath: Path, val metaPath: Path, val atlasPath: Path, - val missing: List + val missing: List, ) { val isValid: Boolean get() = missing.isEmpty() @@ -31,16 +31,16 @@ internal object FontDiscovery { return normalized.removeSuffix(".ttf") } - fun parseGeneratedFontIndex(content: String): List { - return content.lineSequence() + fun parseGeneratedFontIndex(content: String): List = + content + .lineSequence() .map { it.trim() } .filter { it.isNotBlank() && it.endsWith(".ttf", ignoreCase = true) } .toList() - } fun resolveSourcePriority( jarFontIds: Collection, - externalFontIds: Collection + externalFontIds: Collection, ): Map { val result = linkedMapOf() jarFontIds.forEach { result[it] = FontAssetSource.Jar } @@ -53,8 +53,12 @@ internal object FontDiscovery { val candidates = ArrayList(16) Files.walk(root).use { stream -> stream - .filter { it.isRegularFile() && it.fileName.toString().endsWith(".ttf", ignoreCase = true) } - .forEach { ttfPath -> + .filter { + it.isRegularFile() && + it.fileName + .toString() + .endsWith(".ttf", ignoreCase = true) + }.forEach { ttfPath -> val relative = normalizeRelativePath(root.relativize(ttfPath).toString()) val fontId = fontIdFromRelativeTtfPath(relative) val baseRelative = relative.removeSuffix(".ttf") @@ -63,21 +67,22 @@ internal object FontDiscovery { val missing = mutableListOf() if (!meta.isRegularFile()) missing += "$fontId-meta.json" if (!atlas.isRegularFile()) missing += "$fontId-mtsdf.rgba.deflate" - candidates += FontPackageCandidate( - fontId = fontId, - ttfPath = ttfPath.toAbsolutePath().normalize(), - metaPath = meta.toAbsolutePath().normalize(), - atlasPath = atlas.toAbsolutePath().normalize(), - missing = missing - ) + candidates += + FontPackageCandidate( + fontId = fontId, + ttfPath = ttfPath.toAbsolutePath().normalize(), + metaPath = meta.toAbsolutePath().normalize(), + atlasPath = atlas.toAbsolutePath().normalize(), + missing = missing, + ) } } return candidates.sortedBy { it.fontId.lowercase() } } - internal fun normalizeRelativePath(path: String): String { - return path.replace('\\', '/') + internal fun normalizeRelativePath(path: String): String = + path + .replace('\\', '/') .trimStart('/', '\\') .replace(File.separatorChar, '/') - } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontRegistry.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontRegistry.kt index 44a8b15..93c378f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontRegistry.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/FontRegistry.kt @@ -10,7 +10,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths -import java.util.* +import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import java.util.zip.Inflater @@ -19,7 +19,7 @@ import javax.imageio.ImageIO enum class FontPathMode { Resource, - FileSystem + FileSystem, } data class MsdfFontResource( @@ -28,13 +28,13 @@ data class MsdfFontResource( val pathMode: FontPathMode, val metaPath: String, val atlasPath: String, - val ttfPath: String? + val ttfPath: String?, ) data class FontTextureHandle( val textureId: Int, val width: Int, - val height: Int + val height: Int, ) data class LoadedMsdfFont( @@ -44,17 +44,17 @@ data class LoadedMsdfFont( val preferredMissingGlyphIndex: Int?, val preferredQuestionGlyphIndex: Int?, val atlasPayload: AtlasPayload, - var handle: FontTextureHandle? = null + var handle: FontTextureHandle? = null, ) data class AtlasBitmap( val width: Int, val height: Int, - var rgbaBytes: ByteArray + var rgbaBytes: ByteArray, ) class AtlasPayload internal constructor( - private var encodedPngBytes: ByteArray? + private var encodedPngBytes: ByteArray?, ) { @Volatile private var decodedBitmap: AtlasBitmap? = null @@ -70,7 +70,7 @@ class AtlasPayload internal constructor( decodedBitmap?.let { return it } synchronized(this) { decodedBitmap?.let { return it } - val bytes = encodedPngBytes ?: throw IllegalStateException("Missing atlas payload bytes") + val bytes = encodedPngBytes ?: error("Missing atlas payload bytes") val decoded = decodeAtlasBitmap(bytes) decodedBitmap = decoded encodedPngBytes = null @@ -109,9 +109,10 @@ class AtlasPayload internal constructor( } private fun decodePng(bytes: ByteArray): AtlasBitmap { - val image = ByteArrayInputStream(bytes).use { input -> - ImageIO.read(input) - } ?: throw IllegalStateException("Atlas payload is neither deflated rgba nor PNG") + val image = + ByteArrayInputStream(bytes).use { input -> + ImageIO.read(input) + } ?: error("Atlas payload is neither deflated rgba nor PNG") val width = image.width.coerceAtLeast(1) val height = image.height.coerceAtLeast(1) @@ -139,7 +140,7 @@ data class RegisteredFontInfo( val source: FontAssetSource, val metaPath: String, val atlasPath: String, - val ttfPath: String? + val ttfPath: String?, ) data class FontPreloadSummary( @@ -151,7 +152,7 @@ data class FontPreloadSummary( val failedFonts: Int, val totalFonts: Int, val durationMs: Long, - val fallbackReady: Boolean + val fallbackReady: Boolean, ) data class ShapedGlyph( @@ -162,7 +163,7 @@ data class ShapedGlyph( val advance: Float, val charStart: Int, val charEnd: Int, - val sourceCodepoint: Int + val sourceCodepoint: Int, ) data class ShapedTextRun( @@ -170,13 +171,13 @@ data class ShapedTextRun( val charStart: Int, val charEnd: Int, val glyphs: List, - val advance: Float + val advance: Float, ) data class ShapedText( val glyphs: List, val runs: List, - val width: Float + val width: Float, ) object FontRegistry { @@ -198,12 +199,12 @@ object FontRegistry { val rangeStart: Int, val rangeEnd: Int, val directionFlags: Int, - val formattingMode: String + val formattingMode: String, ) private data class FontCodepointKey( val fontId: FontId, - val codepoint: Int + val codepoint: Int, ) private data class MutableShapingSegment( @@ -212,7 +213,7 @@ object FontRegistry { val sourceStartByChar: MutableList = ArrayList(), val sourceEndByChar: MutableList = ArrayList(), var charStart: Int = Int.MAX_VALUE, - var charEnd: Int = 0 + var charEnd: Int = 0, ) private val descriptors: MutableMap = linkedMapOf() @@ -221,9 +222,8 @@ object FontRegistry { private val parseErrorLogTimes: MutableMap = ConcurrentHashMap() private val shapeCache: MutableMap = object : LinkedHashMap(128, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > 1024 - } + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > 1024 } private val derivedFontCache: MutableMap = ConcurrentHashMap() private val fontRenderContext: FontRenderContext = FontRenderContext(AffineTransform(), true, true) @@ -256,7 +256,7 @@ object FontRegistry { val hits: Long, val misses: Long, val entries: Int, - val maxEntries: Int + val maxEntries: Int, ) data class TextHotPathStats( @@ -273,23 +273,24 @@ object FontRegistry { val glyphIndexForCodepointCalls: Long, val glyphIndexCacheHits: Long, val glyphIndexCacheMisses: Long, - val glyphIndexVectorBuildCalls: Long + val glyphIndexVectorBuildCalls: Long, ) @Synchronized fun discoverAndPreloadFonts( externalFontsDir: File?, - classLoader: ClassLoader = javaClass.classLoader + classLoader: ClassLoader = javaClass.classLoader, ): FontPreloadSummary { val startedAt = System.nanoTime() resourceClassLoader = classLoader ensureDefaults() val jarDiscovered = descriptors.values.count { it.source == FontAssetSource.Jar } - val externalPackages = externalFontsDir - ?.takeIf { it.exists() && it.isDirectory } - ?.let { FontDiscovery.discoverExternalPackages(it.toPath()) } - .orEmpty() + val externalPackages = + externalFontsDir + ?.takeIf { it.exists() && it.isDirectory } + ?.let { FontDiscovery.discoverExternalPackages(it.toPath()) } + .orEmpty() var externalDiscovered = 0 var externalOverrodeJar = 0 @@ -302,9 +303,9 @@ object FontRegistry { key = "external-missing:${candidate.fontId}", message = "[DSGL-MSDF] Skipping external font '${candidate.fontId}', missing: ${ candidate.missing.joinToString( - ", " + ", ", ) - }" + }", ) return@forEach } @@ -318,7 +319,7 @@ object FontRegistry { fontId = candidate.fontId, metaFile = candidate.metaPath, atlasFile = candidate.atlasPath, - ttfFile = candidate.ttfPath + ttfFile = candidate.ttfPath, ) } @@ -329,14 +330,15 @@ object FontRegistry { if (!fallbackReady) { logParseErrorRateLimited( key = "fallback-missing", - message = "[DSGL-MSDF] Fallback font '$FALLBACK_FONT_ID' is not available after preload." + message = "[DSGL-MSDF] Fallback font '$FALLBACK_FONT_ID' is not available after preload.", ) } val durationMs = ((System.nanoTime() - startedAt) / 1_000_000L).coerceAtLeast(0L) println( "[DSGL-MSDF] preload summary: jar=$jarDiscovered external=$externalDiscovered " + - "override=$externalOverrodeJar invalidExternal=$invalidExternalPackages loaded=$loadedFonts/$totalFonts in ${durationMs}ms" + "override=$externalOverrodeJar invalidExternal=$invalidExternalPackages " + + "loaded=$loadedFonts/$totalFonts in ${durationMs}ms", ) return FontPreloadSummary( @@ -348,7 +350,7 @@ object FontRegistry { failedFonts = failedFonts, totalFonts = totalFonts, durationMs = durationMs, - fallbackReady = fallbackReady + fallbackReady = fallbackReady, ) } @@ -368,29 +370,28 @@ object FontRegistry { metaResourcePath: String, atlasResourcePath: String, ttfResourcePath: String? = null, - source: FontAssetSource = FontAssetSource.Jar + source: FontAssetSource = FontAssetSource.Jar, ) { val normalizedMeta = normalizeRelativePath(metaResourcePath) val normalizedAtlas = normalizeRelativePath(atlasResourcePath) - val normalizedTtf = ttfResourcePath?.let(::normalizeRelativePath) - ?: (normalizedMeta.removeSuffix("-meta.json") + ".ttf") - - descriptors[fontId] = MsdfFontResource( - fontId = fontId, - source = source, - pathMode = FontPathMode.Resource, - metaPath = normalizedMeta, - atlasPath = normalizedAtlas, - ttfPath = normalizedTtf - ) + val normalizedTtf = + ttfResourcePath?.let(::normalizeRelativePath) + ?: (normalizedMeta.removeSuffix("-meta.json") + ".ttf") + + descriptors[fontId] = + MsdfFontResource( + fontId = fontId, + source = source, + pathMode = FontPathMode.Resource, + metaPath = normalizedMeta, + atlasPath = normalizedAtlas, + ttfPath = normalizedTtf, + ) onDescriptorChanged(fontId) } @Synchronized - fun registerGeneratedFromTtfPath( - relativeTtfPath: String, - fontId: FontId = fontIdFromTtfPath(relativeTtfPath) - ) { + fun registerGeneratedFromTtfPath(relativeTtfPath: String, fontId: FontId = fontIdFromTtfPath(relativeTtfPath)) { val normalized = normalizeRelativePath(relativeTtfPath) require(normalized.endsWith(".ttf", ignoreCase = true)) { "Expected .ttf path, got '$relativeTtfPath'" @@ -401,7 +402,7 @@ object FontRegistry { metaResourcePath = "fonts/$base-meta.json", atlasResourcePath = "fonts/$base-mtsdf.rgba.deflate", ttfResourcePath = "fonts/$normalized", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) } @@ -435,7 +436,7 @@ object FontRegistry { source = descriptor.source, metaPath = descriptor.metaPath, atlasPath = descriptor.atlasPath, - ttfPath = descriptor.ttfPath + ttfPath = descriptor.ttfPath, ) } } @@ -460,24 +461,26 @@ object FontRegistry { if (text.contains('\n')) { return text.lineSequence().maxOfOrNull { line -> measureText(line, fontId, fontSize) } ?: 0 } - return shapeText(text, fontId, fontSize).width.toInt().coerceAtLeast(0) + return shapeText(text, fontId, fontSize) + .width + .toInt() + .coerceAtLeast(0) } fun shapeText( text: String, fontId: FontId?, fontSize: Int?, - formattingMode: String = "plain" - ): ShapedText { - return shapeTextRange( + formattingMode: String = "plain", + ): ShapedText = + shapeTextRange( text = text, startIndex = 0, endIndexExclusive = text.length, fontId = fontId, fontSize = fontSize, - formattingMode = formattingMode + formattingMode = formattingMode, ) - } fun shapeTextRange( text: String, @@ -485,7 +488,7 @@ object FontRegistry { endIndexExclusive: Int, fontId: FontId?, fontSize: Int?, - formattingMode: String = "plain" + formattingMode: String = "plain", ): ShapedText { shapeTextRangeCalls.incrementAndGet() val (safeStart, safeEnd) = normalizeRange(text, startIndex, endIndexExclusive) @@ -499,21 +502,23 @@ object FontRegistry { return ShapedText( glyphs = emptyList(), runs = emptyList(), - width = fallbackMeasureText(text, fontSize).toFloat() + width = fallbackMeasureText(text, fontSize).toFloat(), ) } - val fallback = getExact(FALLBACK_FONT_ID) - ?.takeIf { it.descriptor.fontId != primary.descriptor.fontId && it.awtBaseFont != null } - val cacheKey = ShapeCacheKey( - primaryFontId = primary.descriptor.fontId, - fallbackFontId = fallback?.descriptor?.fontId, - fontPx = fontPx, - text = text, - rangeStart = safeStart, - rangeEnd = safeEnd, - directionFlags = Font.LAYOUT_LEFT_TO_RIGHT, - formattingMode = formattingMode - ) + val fallback = + getExact(FALLBACK_FONT_ID) + ?.takeIf { it.descriptor.fontId != primary.descriptor.fontId && it.awtBaseFont != null } + val cacheKey = + ShapeCacheKey( + primaryFontId = primary.descriptor.fontId, + fallbackFontId = fallback?.descriptor?.fontId, + fontPx = fontPx, + text = text, + rangeStart = safeStart, + rangeEnd = safeEnd, + directionFlags = Font.LAYOUT_LEFT_TO_RIGHT, + formattingMode = formattingMode, + ) synchronized(shapeCache) { shapeCacheRequests.incrementAndGet() shapeCache[cacheKey]?.let { @@ -522,14 +527,15 @@ object FontRegistry { } } shapeCacheMisses.incrementAndGet() - val shaped = shapeSingleLine( - text = text, - startIndex = safeStart, - endIndexExclusive = safeEnd, - primary = primary, - fallback = fallback, - fontPx = fontPx - ) + val shaped = + shapeSingleLine( + text = text, + startIndex = safeStart, + endIndexExclusive = safeEnd, + primary = primary, + fallback = fallback, + fontPx = fontPx, + ) synchronized(shapeCache) { shapeCache[cacheKey] = shaped } @@ -541,13 +547,9 @@ object FontRegistry { return font.meta.lineHeightPx(resolveFontSize(fontSize)) } - fun resolveFontSize(fontSize: Int?): Int { - return (fontSize ?: DEFAULT_FONT_SIZE).coerceAtLeast(1) - } + fun resolveFontSize(fontSize: Int?): Int = (fontSize ?: DEFAULT_FONT_SIZE).coerceAtLeast(1) - fun fontIdFromTtfPath(relativeTtfPath: String): FontId { - return FontDiscovery.fontIdFromRelativeTtfPath(relativeTtfPath) - } + fun fontIdFromTtfPath(relativeTtfPath: String): FontId = FontDiscovery.fontIdFromRelativeTtfPath(relativeTtfPath) fun predecodeAtlases(fontIds: Collection): Int { ensureDefaults() @@ -559,7 +561,9 @@ object FontRegistry { .onFailure { error -> logParseErrorRateLimited( key = "atlas-predecode:$fontId", - message = "[DSGL-MSDF] Failed atlas predecode for '$fontId': ${error.message ?: error.javaClass.simpleName}" + message = + "[DSGL-MSDF] Failed atlas predecode for '$fontId': " + + "${error.message ?: error.javaClass.simpleName}", ) } } @@ -573,7 +577,7 @@ object FontRegistry { hits = shapeCacheHits.get(), misses = shapeCacheMisses.get(), entries = entries, - maxEntries = 1024 + maxEntries = 1024, ) } @@ -583,8 +587,8 @@ object FontRegistry { shapeCacheMisses.set(0L) } - fun textHotPathStats(): TextHotPathStats { - return TextHotPathStats( + fun textHotPathStats(): TextHotPathStats = + TextHotPathStats( shapeTextRangeCalls = shapeTextRangeCalls.get(), shapeSegmentCalls = shapeSegmentCalls.get(), requiresReplacementGlyphCalls = requiresReplacementGlyphCalls.get(), @@ -598,9 +602,8 @@ object FontRegistry { glyphIndexForCodepointCalls = glyphIndexForCodepointCalls.get(), glyphIndexCacheHits = glyphIndexCacheHits.get(), glyphIndexCacheMisses = glyphIndexCacheMisses.get(), - glyphIndexVectorBuildCalls = glyphIndexVectorBuildCalls.get() + glyphIndexVectorBuildCalls = glyphIndexVectorBuildCalls.get(), ) - } fun resetTextHotPathStats() { shapeTextRangeCalls.set(0L) @@ -632,35 +635,35 @@ object FontRegistry { metaResourcePath = "fonts/minecraft/MinecraftDefault-Regular-meta.json", atlasResourcePath = "fonts/minecraft/MinecraftDefault-Regular-mtsdf.rgba.deflate", ttfResourcePath = "fonts/minecraft/MinecraftDefault-Regular.ttf", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) registerMsdf( fontId = FONT_UBUNTU, metaResourcePath = "fonts/ubuntu/Ubuntu-Regular-meta.json", atlasResourcePath = "fonts/ubuntu/Ubuntu-Regular-mtsdf.rgba.deflate", ttfResourcePath = "fonts/ubuntu/Ubuntu-Regular.ttf", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) registerMsdf( fontId = FONT_NOTO_SANS, metaResourcePath = "fonts/noto/Noto_Sans/NotoSans-Regular-meta.json", atlasResourcePath = "fonts/noto/Noto_Sans/NotoSans-Regular-mtsdf.rgba.deflate", ttfResourcePath = "fonts/noto/Noto_Sans/NotoSans-Regular.ttf", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) registerMsdf( fontId = FONT_JB_MONO, metaResourcePath = "fonts/jetbrains_mono/JetBrainsMono-Regular-meta.json", atlasResourcePath = "fonts/jetbrains_mono/JetBrainsMono-Regular-mtsdf.rgba.deflate", ttfResourcePath = "fonts/jetbrains_mono/JetBrainsMono-Regular.ttf", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) registerMsdf( fontId = TELEGRAFICO, metaResourcePath = "fonts/telegrafico/telegrafico-meta.json", atlasResourcePath = "fonts/telegrafico/telegrafico-mtsdf.rgba.deflate", ttfResourcePath = "fonts/telegrafico/telegrafico.ttf", - source = FontAssetSource.Jar + source = FontAssetSource.Jar, ) loadGeneratedFontManifest() defaultsRegistered = true @@ -671,16 +674,29 @@ object FontRegistry { fontId: FontId, metaFile: Path, atlasFile: Path, - ttfFile: Path + ttfFile: Path, ) { - descriptors[fontId] = MsdfFontResource( - fontId = fontId, - source = FontAssetSource.External, - pathMode = FontPathMode.FileSystem, - metaPath = metaFile.toAbsolutePath().normalize().toString(), - atlasPath = atlasFile.toAbsolutePath().normalize().toString(), - ttfPath = ttfFile.toAbsolutePath().normalize().toString() - ) + descriptors[fontId] = + MsdfFontResource( + fontId = fontId, + source = FontAssetSource.External, + pathMode = FontPathMode.FileSystem, + metaPath = + metaFile + .toAbsolutePath() + .normalize() + .toString(), + atlasPath = + atlasFile + .toAbsolutePath() + .normalize() + .toString(), + ttfPath = + ttfFile + .toAbsolutePath() + .normalize() + .toString(), + ) onDescriptorChanged(fontId) } @@ -714,39 +730,41 @@ object FontRegistry { } val metaRaw = String(metaBytes, StandardCharsets.UTF_8) - val meta = runCatching { MsdfFontMetaParser.parse(metaRaw) } - .onFailure { error -> - failFontLoad( - descriptor, - "Failed to parse metadata '${descriptor.metaPath}': ${error.message ?: error.javaClass.simpleName}" - ) - } - .getOrNull() ?: return null - - val awtBase = descriptor.ttfPath?.let { ttfPath -> - val ttfBytes = readBytes(ttfPath, descriptor.pathMode) - if (ttfBytes == null) { - failFontLoad(descriptor, "Missing TTF '$ttfPath'") - return null - } - runCatching { loadAwtFont(ttfBytes) } + val meta = + runCatching { MsdfFontMetaParser.parse(metaRaw) } .onFailure { error -> + val reason = error.message ?: error.javaClass.simpleName failFontLoad( descriptor, - "Failed to load TTF '$ttfPath': ${error.message ?: error.javaClass.simpleName}" + "Failed to parse metadata '${descriptor.metaPath}': $reason", ) + }.getOrNull() ?: return null + + val awtBase = + descriptor.ttfPath?.let { ttfPath -> + val ttfBytes = readBytes(ttfPath, descriptor.pathMode) + if (ttfBytes == null) { + failFontLoad(descriptor, "Missing TTF '$ttfPath'") + return null } - .getOrNull() ?: return null - } + runCatching { loadAwtFont(ttfBytes) } + .onFailure { error -> + failFontLoad( + descriptor, + "Failed to load TTF '$ttfPath': ${error.message ?: error.javaClass.simpleName}", + ) + }.getOrNull() ?: return null + } - val loadedFont = LoadedMsdfFont( - descriptor = descriptor, - meta = meta, - awtBaseFont = awtBase, - preferredMissingGlyphIndex = computeGlyphIndexForCodepoint(awtBase, 0xFFFD), - preferredQuestionGlyphIndex = computeGlyphIndexForCodepoint(awtBase, '?'.code), - atlasPayload = AtlasPayload(atlasBytes) - ) + val loadedFont = + LoadedMsdfFont( + descriptor = descriptor, + meta = meta, + awtBaseFont = awtBase, + preferredMissingGlyphIndex = computeGlyphIndexForCodepoint(awtBase, 0xFFFD), + preferredQuestionGlyphIndex = computeGlyphIndexForCodepoint(awtBase, '?'.code), + atlasPayload = AtlasPayload(atlasBytes), + ) loaded[descriptor.fontId] = loadedFont failedLoads.remove(descriptor.fontId) return loadedFont @@ -756,7 +774,9 @@ object FontRegistry { failedLoads.add(descriptor.fontId) logParseErrorRateLimited( key = "font-load:${descriptor.fontId}", - message = "[DSGL-MSDF] Failed to load font '${descriptor.fontId}' (${descriptor.source.name.lowercase()}): $reason" + message = + "[DSGL-MSDF] Failed to load font '${descriptor.fontId}' " + + "(${descriptor.source.name.lowercase()}): $reason", ) } @@ -773,9 +793,7 @@ object FontRegistry { } } - private fun normalizeRelativePath(path: String): String { - return FontDiscovery.normalizeRelativePath(path) - } + private fun normalizeRelativePath(path: String): String = FontDiscovery.normalizeRelativePath(path) private fun loadGeneratedFontManifest() { val manifestPath = "fonts/generated-fonts.txt" @@ -797,15 +815,16 @@ object FontRegistry { endIndexExclusive: Int, primary: LoadedMsdfFont, fallback: LoadedMsdfFont?, - fontPx: Int + fontPx: Int, ): ShapedText { - val segments = buildShapingSegments( - text = text, - startIndex = startIndex, - endIndexExclusive = endIndexExclusive, - primary = primary, - fallback = fallback - ) + val segments = + buildShapingSegments( + text = text, + startIndex = startIndex, + endIndexExclusive = endIndexExclusive, + primary = primary, + fallback = fallback, + ) if (segments.isEmpty()) { return ShapedText(glyphs = emptyList(), runs = emptyList(), width = 0f) } @@ -814,13 +833,14 @@ object FontRegistry { var penX = 0f segments.forEach { segment -> - val shapedRun = shapeSegment( - sourceText = text, - sourceRangeStart = startIndex, - segment = segment, - fontPx = fontPx, - penX = penX - ) + val shapedRun = + shapeSegment( + sourceText = text, + sourceRangeStart = startIndex, + segment = segment, + fontPx = fontPx, + penX = penX, + ) allGlyphs += shapedRun.glyphs runs += shapedRun penX += shapedRun.advance @@ -828,7 +848,7 @@ object FontRegistry { return ShapedText( glyphs = allGlyphs, runs = runs, - width = penX.coerceAtLeast(0f) + width = penX.coerceAtLeast(0f), ) } @@ -837,7 +857,7 @@ object FontRegistry { startIndex: Int, endIndexExclusive: Int, primary: LoadedMsdfFont, - fallback: LoadedMsdfFont? + fallback: LoadedMsdfFont?, ): List { val out = ArrayList(4) var segment: MutableShapingSegment? = null @@ -852,16 +872,18 @@ object FontRegistry { val primaryNeedsReplacement = requiresReplacementGlyph(primary, codepoint) val fallbackNeedsReplacement = fallback?.let { requiresReplacementGlyph(it, codepoint) } ?: true - val selectedFont = when { - !primaryNeedsReplacement -> primary - fallback != null && !fallbackNeedsReplacement -> fallback - fallback != null -> fallback - else -> primary - } - val replacementNeeded = when (selectedFont.descriptor.fontId) { - primary.descriptor.fontId -> primaryNeedsReplacement - else -> fallbackNeedsReplacement - } + val selectedFont = + when { + !primaryNeedsReplacement -> primary + fallback != null && !fallbackNeedsReplacement -> fallback + fallback != null -> fallback + else -> primary + } + val replacementNeeded = + when (selectedFont.descriptor.fontId) { + primary.descriptor.fontId -> primaryNeedsReplacement + else -> fallbackNeedsReplacement + } if (segment == null || segment.font.descriptor.fontId != selectedFont.descriptor.fontId) { segment = MutableShapingSegment(font = selectedFont) @@ -890,7 +912,7 @@ object FontRegistry { sourceRangeStart: Int, segment: MutableShapingSegment, fontPx: Int, - penX: Float + penX: Float, ): ShapedTextRun { shapeSegmentCalls.incrementAndGet() val font = deriveAwtFont(segment.font, fontPx) @@ -900,50 +922,58 @@ object FontRegistry { charStart = if (segment.charStart == Int.MAX_VALUE) 0 else segment.charStart, charEnd = segment.charEnd, glyphs = emptyList(), - advance = 0f + advance = 0f, ) } - val chars = segment.text.toString().toCharArray() - val glyphVector = runCatching { - font.layoutGlyphVector( - fontRenderContext, - chars, - 0, - chars.size, - Font.LAYOUT_LEFT_TO_RIGHT - ) - }.getOrElse { - font.createGlyphVector(fontRenderContext, segment.text.toString()) - } + val chars = + segment.text + .toString() + .toCharArray() + val glyphVector = + runCatching { + font.layoutGlyphVector( + fontRenderContext, + chars, + 0, + chars.size, + Font.LAYOUT_LEFT_TO_RIGHT, + ) + }.getOrElse { + font.createGlyphVector(fontRenderContext, segment.text.toString()) + } val glyphCount = glyphVector.numGlyphs val positions = glyphVector.getGlyphPositions(0, glyphCount + 1, null) val runGlyphs = ArrayList(glyphCount) for (i in 0 until glyphCount) { - val charIndex = glyphVector.getGlyphCharIndex(i) - .coerceIn(0, (segment.sourceStartByChar.size - 1).coerceAtLeast(0)) + val charIndex = + glyphVector + .getGlyphCharIndex(i) + .coerceIn(0, (segment.sourceStartByChar.size - 1).coerceAtLeast(0)) val sourceStart = segment.sourceStartByChar.getOrElse(charIndex) { 0 } val sourceEnd = segment.sourceEndByChar.getOrElse(charIndex) { sourceStart + 1 } val sourceGlobalStart = sourceRangeStart + sourceStart - val sourceCodepoint = if (charIndex in chars.indices) { - Character.codePointAt(chars, charIndex, chars.size) - } else if (sourceGlobalStart in sourceText.indices) { - Character.codePointAt(sourceText, sourceGlobalStart) - } else { - '?'.code - } + val sourceCodepoint = + if (charIndex in chars.indices) { + Character.codePointAt(chars, charIndex, chars.size) + } else if (sourceGlobalStart in sourceText.indices) { + Character.codePointAt(sourceText, sourceGlobalStart) + } else { + '?'.code + } val x = (penX + positions[i * 2]) val y = positions[i * 2 + 1] val advance = (positions[(i + 1) * 2] - positions[i * 2]) - runGlyphs += ShapedGlyph( - fontId = segment.font.descriptor.fontId, - glyphIndex = glyphVector.getGlyphCode(i), - x = x, - y = y, - advance = advance, - charStart = sourceStart, - charEnd = sourceEnd, - sourceCodepoint = sourceCodepoint - ) + runGlyphs += + ShapedGlyph( + fontId = segment.font.descriptor.fontId, + glyphIndex = glyphVector.getGlyphCode(i), + x = x, + y = y, + advance = advance, + charStart = sourceStart, + charEnd = sourceEnd, + sourceCodepoint = sourceCodepoint, + ) } val runAdvance = positions[glyphCount * 2].coerceAtLeast(0f) return ShapedTextRun( @@ -951,7 +981,7 @@ object FontRegistry { charStart = if (segment.charStart == Int.MAX_VALUE) 0 else segment.charStart, charEnd = segment.charEnd, glyphs = runGlyphs, - advance = runAdvance + advance = runAdvance, ) } @@ -987,23 +1017,24 @@ object FontRegistry { } requiresReplacementGlyphCacheMisses.incrementAndGet() requiresReplacementGlyphEvaluations.incrementAndGet() - val resolved = if (!Character.isValidCodePoint(codepoint)) { - true - } else if (!canDisplay(font, codepoint)) { - true - } else { - val glyphIndex = glyphIndexForCodepoint(font, codepoint) - if (glyphIndex == null || glyphIndex < 0) { + val resolved = + if (!Character.isValidCodePoint(codepoint)) { + true + } else if (!canDisplay(font, codepoint)) { true } else { - val missingIndex = font.preferredMissingGlyphIndex - if (missingIndex != null && glyphIndex == missingIndex) { + val glyphIndex = glyphIndexForCodepoint(font, codepoint) + if (glyphIndex == null || glyphIndex < 0) { true } else { - glyphIndex == 0 && (missingIndex == null || missingIndex == 0) + val missingIndex = font.preferredMissingGlyphIndex + if (missingIndex != null && glyphIndex == missingIndex) { + true + } else { + glyphIndex == 0 && (missingIndex == null || missingIndex == 0) + } } } - } requiresReplacementGlyphByFontCodepoint[key] = resolved return resolved } @@ -1036,11 +1067,10 @@ object FontRegistry { }.getOrNull() } - private fun loadAwtFont(ttfBytes: ByteArray): Font { - return ByteArrayInputStream(ttfBytes).use { input -> + private fun loadAwtFont(ttfBytes: ByteArray): Font = + ByteArrayInputStream(ttfBytes).use { input -> Font.createFont(Font.TRUETYPE_FONT, input) } - } private fun normalizeRange(text: String, startIndex: Int, endIndexExclusive: Int): Pair { var start = startIndex.coerceIn(0, text.length) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMeta.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMeta.kt index 21e720c..34cd873 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMeta.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMeta.kt @@ -11,7 +11,7 @@ data class MsdfAtlasInfo( val size: Float, val width: Int, val height: Int, - val yOrigin: String + val yOrigin: String, ) data class MsdfMetrics( @@ -20,21 +20,21 @@ data class MsdfMetrics( val ascender: Float, val descender: Float, val underlineY: Float? = null, - val underlineThickness: Float? = null + val underlineThickness: Float? = null, ) data class MsdfPlaneBounds( val left: Float, val bottom: Float, val right: Float, - val top: Float + val top: Float, ) data class MsdfAtlasBounds( val left: Float, val bottom: Float, val right: Float, - val top: Float + val top: Float, ) data class MsdfGlyph( @@ -42,7 +42,7 @@ data class MsdfGlyph( val codepoint: Int?, val advance: Float, val planeBounds: MsdfPlaneBounds?, - val atlasBounds: MsdfAtlasBounds? + val atlasBounds: MsdfAtlasBounds?, ) { val drawable: Boolean get() = planeBounds != null && atlasBounds != null @@ -51,7 +51,7 @@ data class MsdfGlyph( data class MsdfKerningPair( val leftGlyphIndex: Int, val rightGlyphIndex: Int, - val advance: Float + val advance: Float, ) data class MsdfGlyphRunItem( @@ -60,7 +60,7 @@ data class MsdfGlyphRunItem( val glyph: MsdfGlyph?, val advancePx: Float, val draw: Boolean, - val usedFallback: Boolean + val usedFallback: Boolean, ) data class MsdfFontMeta( @@ -71,32 +71,35 @@ data class MsdfFontMeta( val kerningPairsByIndex: Map, val kerningPairsByCodepoint: Map, val replacementGlyphIndex: Int?, - val replacementCodepoint: Int? + val replacementCodepoint: Int?, ) { private val tabWidthInSpaces: Int = 4 - private val denseGlyphsByIndex: Array = run { - val maxIndex = glyphsByIndex.keys.maxOrNull() ?: -1 - if (maxIndex < 0) { - emptyArray() - } else { - arrayOfNulls(maxIndex + 1).also { dense -> - glyphsByIndex.forEach { (index, glyph) -> - if (index >= 0 && index < dense.size) { - dense[index] = glyph + private val denseGlyphsByIndex: Array = + run { + val maxIndex = glyphsByIndex.keys.maxOrNull() ?: -1 + if (maxIndex < 0) { + emptyArray() + } else { + arrayOfNulls(maxIndex + 1).also { dense -> + glyphsByIndex.forEach { (index, glyph) -> + if (index >= 0 && index < dense.size) { + dense[index] = glyph + } } } } } - } - private val cachedFallbackGlyph: MsdfGlyph? = run { - val replacementByIndex = replacementGlyphIndex - ?.takeIf { it >= 0 && it < denseGlyphsByIndex.size } - ?.let { denseGlyphsByIndex[it] } - if (replacementByIndex != null) return@run replacementByIndex - val replacementByCodepoint = replacementCodepoint?.let(glyphsByCodepoint::get) - if (replacementByCodepoint != null) return@run replacementByCodepoint - glyphsByIndex.values.firstOrNull() ?: glyphsByCodepoint.values.firstOrNull() - } + private val cachedFallbackGlyph: MsdfGlyph? = + run { + val replacementByIndex = + replacementGlyphIndex + ?.takeIf { it >= 0 && it < denseGlyphsByIndex.size } + ?.let { denseGlyphsByIndex[it] } + if (replacementByIndex != null) return@run replacementByIndex + val replacementByCodepoint = replacementCodepoint?.let(glyphsByCodepoint::get) + if (replacementByCodepoint != null) return@run replacementByCodepoint + glyphsByIndex.values.firstOrNull() ?: glyphsByCodepoint.values.firstOrNull() + } fun glyphByIndex(glyphIndex: Int): MsdfGlyph? { if (glyphIndex >= 0 && glyphIndex < denseGlyphsByIndex.size) { @@ -107,13 +110,9 @@ data class MsdfFontMeta( fun glyph(codepoint: Int): MsdfGlyph? = glyphsByCodepoint[codepoint] - fun fallbackGlyph(): MsdfGlyph? { - return cachedFallbackGlyph - } + fun fallbackGlyph(): MsdfGlyph? = cachedFallbackGlyph - fun glyphOrFallback(codepoint: Int): MsdfGlyph? { - return glyph(codepoint) ?: fallbackGlyph() - } + fun glyphOrFallback(codepoint: Int): MsdfGlyph? = glyph(codepoint) ?: fallbackGlyph() fun resolveGlyphRun(codepoint: Int, fontSizePx: Int): MsdfGlyphRunItem? { val size = fontSizePx.coerceAtLeast(1) @@ -126,7 +125,7 @@ data class MsdfFontMeta( glyph = explicitSpaceGlyph, advancePx = spaceAdvancePx(size), draw = false, - usedFallback = false + usedFallback = false, ) } @@ -137,7 +136,7 @@ data class MsdfFontMeta( glyph = null, advancePx = spaceAdvancePx(size) * tabWidthInSpaces, draw = false, - usedFallback = false + usedFallback = false, ) } @@ -150,7 +149,7 @@ data class MsdfFontMeta( glyph = direct, advancePx = advancePx(direct, size), draw = direct.drawable, - usedFallback = false + usedFallback = false, ) } val fallback = fallbackGlyph() ?: return null @@ -160,7 +159,7 @@ data class MsdfFontMeta( glyph = fallback, advancePx = advancePx(fallback, size), draw = fallback.drawable, - usedFallback = true + usedFallback = true, ) } } @@ -189,17 +188,13 @@ data class MsdfFontMeta( return ceil(metrics.lineHeight * scale).toInt().coerceAtLeast(1) } - fun advancePx(glyph: MsdfGlyph, fontSizePx: Int): Float { - return glyph.advance * scalePx(fontSizePx) - } + fun advancePx(glyph: MsdfGlyph, fontSizePx: Int): Float = glyph.advance * scalePx(fontSizePx) - fun kerningPx(leftCodepoint: Int, rightCodepoint: Int, fontSizePx: Int): Float { - return kerning(leftCodepoint, rightCodepoint) * scalePx(fontSizePx) - } + fun kerningPx(leftCodepoint: Int, rightCodepoint: Int, fontSizePx: Int): Float = + kerning(leftCodepoint, rightCodepoint) * scalePx(fontSizePx) - fun kerningPxByIndex(leftGlyphIndex: Int, rightGlyphIndex: Int, fontSizePx: Int): Float { - return kerningByIndex(leftGlyphIndex, rightGlyphIndex) * scalePx(fontSizePx) - } + fun kerningPxByIndex(leftGlyphIndex: Int, rightGlyphIndex: Int, fontSizePx: Int): Float = + kerningByIndex(leftGlyphIndex, rightGlyphIndex) * scalePx(fontSizePx) fun measureTextWidth(text: String, fontSizePx: Int): Int { if (text.isEmpty()) return 0 @@ -258,9 +253,6 @@ data class MsdfFontMeta( } companion object { - fun kerningKey(left: Int, right: Int): Long { - return (left.toLong() shl 32) or (right.toLong() and 0xFFFF_FFFFL) - } + fun kerningKey(left: Int, right: Int): Long = (left.toLong() shl 32) or (right.toLong() and 0xFFFF_FFFFL) } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMetaParser.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMetaParser.kt index e9fe4d1..92d0432 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMetaParser.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontMetaParser.kt @@ -3,60 +3,63 @@ import kotlinx.serialization.json.Json object MsdfFontMetaParser { - val json: Json = Json { - ignoreUnknownKeys = true - isLenient = true - explicitNulls = false - } + val json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } fun parse(rawJson: String): MsdfFontMeta { val parsed = json.decodeFromString(rawJson) validate(parsed) - val atlas = MsdfAtlasInfo( - type = parsed.atlas.type, - distanceRange = parsed.atlas.distanceRange, - size = parsed.atlas.size, - width = parsed.atlas.width, - height = parsed.atlas.height, - yOrigin = parsed.atlas.yOrigin - ) + val atlas = + MsdfAtlasInfo( + type = parsed.atlas.type, + distanceRange = parsed.atlas.distanceRange, + size = parsed.atlas.size, + width = parsed.atlas.width, + height = parsed.atlas.height, + yOrigin = parsed.atlas.yOrigin, + ) - val metrics = MsdfMetrics( - emSize = parsed.metrics.emSize, - lineHeight = parsed.metrics.lineHeight, - ascender = parsed.metrics.ascender, - descender = parsed.metrics.descender, - underlineY = parsed.metrics.underlineY, - underlineThickness = parsed.metrics.underlineThickness - ) + val metrics = + MsdfMetrics( + emSize = parsed.metrics.emSize, + lineHeight = parsed.metrics.lineHeight, + ascender = parsed.metrics.ascender, + descender = parsed.metrics.descender, + underlineY = parsed.metrics.underlineY, + underlineThickness = parsed.metrics.underlineThickness, + ) val anyExplicitGlyphIndex = parsed.glyphs.any { it.glyphIndex >= 0 } val glyphsByIndex = linkedMapOf() val glyphsByCodepoint = linkedMapOf() parsed.glyphs.forEachIndexed { listIndex, glyph -> - val resolvedGlyphIndex = when { - glyph.glyphIndex >= 0 -> glyph.glyphIndex - !anyExplicitGlyphIndex -> listIndex - else -> throw IllegalArgumentException( - "Glyph metadata has mixed explicit/implicit glyph indices. " + - "Provide a stable glyph index field for every glyph entry." + val resolvedGlyphIndex = + when { + glyph.glyphIndex >= 0 -> glyph.glyphIndex + !anyExplicitGlyphIndex -> listIndex + else -> throw IllegalArgumentException( + "Glyph metadata has mixed explicit/implicit glyph indices. " + + "Provide a stable glyph index field for every glyph entry.", + ) + } + + val runtimeGlyph = + MsdfGlyph( + glyphIndex = resolvedGlyphIndex, + codepoint = glyph.unicodeCodepoint, + advance = glyph.advance, + planeBounds = glyph.planeBounds?.toRuntime(), + atlasBounds = glyph.atlasBounds?.toRuntime(), ) - } - - val runtimeGlyph = MsdfGlyph( - glyphIndex = resolvedGlyphIndex, - codepoint = glyph.unicodeCodepoint, - advance = glyph.advance, - planeBounds = glyph.planeBounds?.toRuntime(), - atlasBounds = glyph.atlasBounds?.toRuntime() - ) val previous = glyphsByIndex.putIfAbsent(resolvedGlyphIndex, runtimeGlyph) - if (previous != null) { - throw IllegalArgumentException("Duplicate glyph index $resolvedGlyphIndex in metadata") - } + require(previous == null) { "Duplicate glyph index $resolvedGlyphIndex in metadata" } val codepoint = runtimeGlyph.codepoint if (codepoint != null && !glyphsByCodepoint.containsKey(codepoint)) { @@ -78,16 +81,18 @@ object MsdfFontMetaParser { } } - val replacementCodepoint = when { - glyphsByCodepoint.containsKey(0xFFFD) -> 0xFFFD - glyphsByCodepoint.containsKey('?'.code) -> '?'.code - else -> glyphsByCodepoint.keys.firstOrNull() - } - val replacementGlyphIndex = when { - replacementCodepoint != null -> glyphsByCodepoint[replacementCodepoint]?.glyphIndex - glyphsByIndex.containsKey(0) -> 0 - else -> glyphsByIndex.keys.firstOrNull() - } + val replacementCodepoint = + when { + glyphsByCodepoint.containsKey(0xFFFD) -> 0xFFFD + glyphsByCodepoint.containsKey('?'.code) -> '?'.code + else -> glyphsByCodepoint.keys.firstOrNull() + } + val replacementGlyphIndex = + when { + replacementCodepoint != null -> glyphsByCodepoint[replacementCodepoint]?.glyphIndex + glyphsByIndex.containsKey(0) -> 0 + else -> glyphsByIndex.keys.firstOrNull() + } return MsdfFontMeta( atlas = atlas, @@ -97,38 +102,29 @@ object MsdfFontMetaParser { kerningPairsByIndex = kerningByIndex, kerningPairsByCodepoint = kerningByCodepoint, replacementGlyphIndex = replacementGlyphIndex, - replacementCodepoint = replacementCodepoint + replacementCodepoint = replacementCodepoint, ) } private fun validate(meta: MsdfMetaJson) { - if (meta.atlas.width <= 0 || meta.atlas.height <= 0) { - throw IllegalArgumentException("atlas.width and atlas.height must be > 0") - } - if (meta.metrics.lineHeight <= 0f) { - throw IllegalArgumentException("metrics.lineHeight must be > 0") - } - if (meta.glyphs.isEmpty()) { - throw IllegalArgumentException("glyphs list must not be empty") - } + require(meta.atlas.width > 0 && meta.atlas.height > 0) { "atlas.width and atlas.height must be > 0" } + require(meta.metrics.lineHeight > 0f) { "metrics.lineHeight must be > 0" } + require(meta.glyphs.isNotEmpty()) { "glyphs list must not be empty" } } - private fun MsdfPlaneBoundsJson.toRuntime(): MsdfPlaneBounds { - return MsdfPlaneBounds( + private fun MsdfPlaneBoundsJson.toRuntime(): MsdfPlaneBounds = + MsdfPlaneBounds( left = left, bottom = bottom, right = right, - top = top + top = top, ) - } - private fun MsdfAtlasBoundsJson.toRuntime(): MsdfAtlasBounds { - return MsdfAtlasBounds( + private fun MsdfAtlasBoundsJson.toRuntime(): MsdfAtlasBounds = + MsdfAtlasBounds( left = left, bottom = bottom, right = right, - top = top + top = top, ) - } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfMetaJson.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfMetaJson.kt index f9ca831..5a8e467 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfMetaJson.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/MsdfMetaJson.kt @@ -1,8 +1,8 @@ package org.dreamfinity.dsgl.core.font +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -11,8 +11,8 @@ import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull @@ -25,7 +25,7 @@ internal data class MsdfMetaJson( val atlas: MsdfAtlasJson = MsdfAtlasJson(), val metrics: MsdfMetricsJson = MsdfMetricsJson(), val glyphs: List = emptyList(), - val kerning: List = emptyList() + val kerning: List = emptyList(), ) @Serializable @@ -39,7 +39,7 @@ internal data class MsdfAtlasJson( val width: Int = 0, @Serializable(with = NumberAsIntSerializer::class) val height: Int = 0, - val yOrigin: String = "bottom" + val yOrigin: String = "bottom", ) @Serializable @@ -53,7 +53,7 @@ internal data class MsdfMetricsJson( @Serializable(with = NumberAsFloatSerializer::class) val descender: Float = 0f, val underlineY: Float? = null, - val underlineThickness: Float? = null + val underlineThickness: Float? = null, ) @Serializable(with = MsdfGlyphJsonSerializer::class) @@ -63,7 +63,7 @@ internal data class MsdfGlyphJson( @Serializable(with = NumberAsFloatSerializer::class) val advance: Float = 0f, val planeBounds: MsdfPlaneBoundsJson? = null, - val atlasBounds: MsdfAtlasBoundsJson? = null + val atlasBounds: MsdfAtlasBoundsJson? = null, ) @Serializable @@ -75,7 +75,7 @@ internal data class MsdfPlaneBoundsJson( @Serializable(with = NumberAsFloatSerializer::class) val right: Float = 0f, @Serializable(with = NumberAsFloatSerializer::class) - val top: Float = 0f + val top: Float = 0f, ) @Serializable @@ -87,7 +87,7 @@ internal data class MsdfAtlasBoundsJson( @Serializable(with = NumberAsFloatSerializer::class) val right: Float = 0f, @Serializable(with = NumberAsFloatSerializer::class) - val top: Float = 0f + val top: Float = 0f, ) @Serializable @@ -99,7 +99,7 @@ internal data class MsdfKerningJson( @Serializable(with = NumberAsIntSerializer::class) val rightGlyphIndex: Int = 0, @Serializable(with = NumberAsFloatSerializer::class) - val advance: Float = 0f + val advance: Float = 0f, ) internal object NumberAsFloatSerializer : KSerializer { @@ -107,8 +107,9 @@ internal object NumberAsFloatSerializer : KSerializer { PrimitiveSerialDescriptor("NumberAsFloat", PrimitiveKind.FLOAT) override fun deserialize(decoder: Decoder): Float { - val primitive = (decoder as? JsonDecoder)?.decodeJsonElement()?.jsonPrimitive - ?: return decoder.decodeFloat() + val primitive = + (decoder as? JsonDecoder)?.decodeJsonElement()?.jsonPrimitive + ?: return decoder.decodeFloat() return primitive.toFloatStrict() } @@ -122,8 +123,9 @@ internal object NumberAsIntSerializer : KSerializer { PrimitiveSerialDescriptor("NumberAsInt", PrimitiveKind.INT) override fun deserialize(decoder: Decoder): Int { - val primitive = (decoder as? JsonDecoder)?.decodeJsonElement()?.jsonPrimitive - ?: return decoder.decodeInt() + val primitive = + (decoder as? JsonDecoder)?.decodeJsonElement()?.jsonPrimitive + ?: return decoder.decodeInt() return primitive.toIntStrict() } @@ -141,39 +143,44 @@ private fun JsonPrimitive.toFloatStrict(): Float { } internal object MsdfGlyphJsonSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MsdfGlyphJson") { - element("glyphIndex", isOptional = true) - element("unicodeCodepoint", isOptional = true) - element("advance", isOptional = true) - element("planeBounds", isOptional = true) - element("atlasBounds", isOptional = true) - } + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("MsdfGlyphJson") { + element("glyphIndex", isOptional = true) + element("unicodeCodepoint", isOptional = true) + element("advance", isOptional = true) + element("planeBounds", isOptional = true) + element("atlasBounds", isOptional = true) + } override fun deserialize(decoder: Decoder): MsdfGlyphJson { - val jsonDecoder = decoder as? JsonDecoder - ?: throw IllegalArgumentException("MsdfGlyphJsonSerializer requires JsonDecoder") + val jsonDecoder = + decoder as? JsonDecoder + ?: throw IllegalArgumentException("MsdfGlyphJsonSerializer requires JsonDecoder") val obj = jsonDecoder.decodeJsonElement().jsonObject val glyphIndex = resolveGlyphIndex(obj) val unicodeCodepoint = resolveUnicodeCodepoint(obj) val advance = obj["advance"]?.jsonPrimitive?.toFloatStrict() ?: 0f - val plane = obj["planeBounds"]?.let { - jsonDecoder.json.decodeFromJsonElement(MsdfPlaneBoundsJson.serializer(), it) - } - val atlas = obj["atlasBounds"]?.let { - jsonDecoder.json.decodeFromJsonElement(MsdfAtlasBoundsJson.serializer(), it) - } + val plane = + obj["planeBounds"]?.let { + jsonDecoder.json.decodeFromJsonElement(MsdfPlaneBoundsJson.serializer(), it) + } + val atlas = + obj["atlasBounds"]?.let { + jsonDecoder.json.decodeFromJsonElement(MsdfAtlasBoundsJson.serializer(), it) + } return MsdfGlyphJson( glyphIndex = glyphIndex, unicodeCodepoint = unicodeCodepoint, advance = advance, planeBounds = plane, - atlasBounds = atlas + atlasBounds = atlas, ) } override fun serialize(encoder: Encoder, value: MsdfGlyphJson) { - val jsonEncoder = encoder as? JsonEncoder - ?: throw IllegalArgumentException("MsdfGlyphJsonSerializer requires JsonEncoder") + val jsonEncoder = + encoder as? JsonEncoder + ?: throw IllegalArgumentException("MsdfGlyphJsonSerializer requires JsonEncoder") val map = linkedMapOf() if (value.glyphIndex >= 0) { map["index"] = JsonPrimitive(value.glyphIndex) @@ -231,4 +238,3 @@ private fun JsonPrimitive.floatOrNull(): Float? { if (direct != null) return direct return doubleOrNull?.toFloat() } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/UnicodeCodepoints.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/UnicodeCodepoints.kt index 0a1c4b8..7c7cd22 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/UnicodeCodepoints.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/font/UnicodeCodepoints.kt @@ -9,7 +9,10 @@ inline fun forEachCodepoint(text: CharSequence, action: (Int) -> Unit) { } } -inline fun forEachCodepointIndexed(text: CharSequence, action: (codepoint: Int, startIndex: Int, endIndex: Int) -> Unit) { +inline fun forEachCodepointIndexed( + text: CharSequence, + action: (codepoint: Int, startIndex: Int, endIndex: Int) -> Unit, +) { var index = 0 while (index < text.length) { val start = index diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntime.kt index e348abb..b21ad0e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntime.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntime.kt @@ -1,9 +1,9 @@ package org.dreamfinity.dsgl.core.hooks import org.dreamfinity.dsgl.core.DsglWindow -import kotlin.reflect.KType -import kotlin.reflect.KClass import java.util.ArrayDeque +import kotlin.reflect.KClass +import kotlin.reflect.KType internal enum class HookEntryKind { Ref, @@ -12,19 +12,21 @@ internal enum class HookEntryKind { Memo, Effect, CustomScope, - Custom + Custom, } enum class HookRenderSessionMode { Normal, - HotReload + HotReload, } class HookHotReloadRemountException( - message: String + message: String, ) : RuntimeException(message) -internal class HookUsageException(message: String) : IllegalStateException(message) +internal class HookUsageException( + message: String, +) : IllegalStateException(message) @PublishedApi internal sealed interface HookSignature { @@ -32,32 +34,32 @@ internal sealed interface HookSignature { } internal data class KindOnlyHookSignature( - val kind: HookEntryKind + val kind: HookEntryKind, ) : HookSignature { override fun diagnosticLabel(): String = "KindOnly($kind)" } internal data class StateHookSignature( - val valueType: KType + val valueType: KType, ) : HookSignature { override fun diagnosticLabel(): String = "State<$valueType>" } internal data class RefHookSignature( - val valueType: KType + val valueType: KType, ) : HookSignature { override fun diagnosticLabel(): String = "Ref<$valueType>" } internal data class MemoHookSignature( - val valueType: KType + val valueType: KType, ) : HookSignature { override fun diagnosticLabel(): String = "Memo<$valueType>" } internal data class ReducerHookSignature( val stateClass: KClass<*>?, - val initialWasNull: Boolean + val initialWasNull: Boolean, ) : HookSignature { override fun diagnosticLabel(): String { val classLabel = stateClass?.qualifiedName ?: "unknown" @@ -68,11 +70,11 @@ internal data class ReducerHookSignature( internal enum class EffectHookRunMode { OnDependencyChange, - EveryCommit + EveryCommit, } internal data class EffectHookSignature( - val runMode: EffectHookRunMode + val runMode: EffectHookRunMode, ) : HookSignature { override fun diagnosticLabel(): String = "Effect<$runMode>" } @@ -92,19 +94,18 @@ internal object HookSignatures { internal fun memo(valueType: KType): HookSignature = MemoHookSignature(valueType) @PublishedApi - internal fun reducer(stateClass: KClass<*>?, initialWasNull: Boolean): HookSignature { - return ReducerHookSignature( + internal fun reducer(stateClass: KClass<*>?, initialWasNull: Boolean): HookSignature = + ReducerHookSignature( stateClass = stateClass, - initialWasNull = initialWasNull + initialWasNull = initialWasNull, ) - } @PublishedApi internal fun effect(runMode: EffectHookRunMode): HookSignature = EffectHookSignature(runMode) } internal data class HookPath( - val segments: List + val segments: List, ) { init { require(segments.isNotEmpty()) { "Hook path must contain at least one segment." } @@ -114,11 +115,9 @@ internal data class HookPath( } internal class OwnerIdentity( - private val owner: Any + private val owner: Any, ) { - override fun equals(other: Any?): Boolean { - return other is OwnerIdentity && owner === other.owner - } + override fun equals(other: Any?): Boolean = other is OwnerIdentity && owner === other.owner override fun hashCode(): Int = System.identityHashCode(owner) } @@ -128,46 +127,46 @@ internal sealed interface ComponentDiscriminator { } private data class ExplicitKeyDiscriminator( - val key: Any + val key: Any, ) : ComponentDiscriminator { override fun debugLabel(): String = "key=$key" } private data class PositionalIndexDiscriminator( - val index: Int + val index: Int, ) : ComponentDiscriminator { override fun debugLabel(): String = "#$index" } internal data class ComponentIdentitySegment( val name: String, - private val discriminator: ComponentDiscriminator + private val discriminator: ComponentDiscriminator, ) { fun debugLabel(): String = "$name[${discriminator.debugLabel()}]" } internal data class ComponentInstanceId( private val ownerIdentity: OwnerIdentity, - val segments: List + val segments: List, ) { companion object { - fun root(owner: Any): ComponentInstanceId { - return ComponentInstanceId( + fun root(owner: Any): ComponentInstanceId = + ComponentInstanceId( ownerIdentity = OwnerIdentity(owner), - segments = emptyList() + segments = emptyList(), ) - } } fun child(name: String, explicitKey: Any?, positionalIndex: Int): ComponentInstanceId { - val discriminator = if (explicitKey != null) { - ExplicitKeyDiscriminator(explicitKey) - } else { - PositionalIndexDiscriminator(positionalIndex) - } + val discriminator = + if (explicitKey != null) { + ExplicitKeyDiscriminator(explicitKey) + } else { + PositionalIndexDiscriminator(positionalIndex) + } return ComponentInstanceId( ownerIdentity = ownerIdentity, - segments = segments + ComponentIdentitySegment(name, discriminator) + segments = segments + ComponentIdentitySegment(name, discriminator), ) } @@ -192,7 +191,7 @@ internal data class HookEntry( val signature: HookSignature, var value: Any?, val synthetic: Boolean, - val createdRenderEpoch: Long + val createdRenderEpoch: Long, ) { var lastVisitedRenderEpoch: Long = createdRenderEpoch } @@ -201,24 +200,24 @@ internal data class ResolvedHookEntry( val path: HookPath, val entry: HookEntry, val created: Boolean, - val synthetic: Boolean + val synthetic: Boolean, ) internal data class ResolvedTypedHookEntry( val path: HookPath, val value: T, val created: Boolean, - val synthetic: Boolean + val synthetic: Boolean, ) private data class SyntheticCounterKey( val scopeSegments: List, - val hookName: String + val hookName: String, ) private enum class HookCompatibilityMismatchReason { Kind, - Signature + Signature, } private data class HookCompatibilityMismatch( @@ -228,7 +227,7 @@ private data class HookCompatibilityMismatch( val requestedKind: HookEntryKind, val existingSignature: HookSignature, val requestedSignature: HookSignature, - val reason: HookCompatibilityMismatchReason + val reason: HookCompatibilityMismatchReason, ) { fun runtimeErrorMessage(): String { val guidance = "Use distinct delegated property names/scopes for semantically different hooks." @@ -247,22 +246,21 @@ private data class HookCompatibilityMismatch( } } - fun hotReloadWarningMessage(): String { - return "[DSGL][Hooks] Hot-reload remount/reset for component '${componentId.debugPath()}' due to " + + fun hotReloadWarningMessage(): String = + "[DSGL][Hooks] Hot-reload remount/reset for component '${componentId.debugPath()}' due to " + "incompatible hook at path '$path': " + "previous=${existingSignature.diagnosticLabel()} ($existingKind), " + "next=${requestedSignature.diagnosticLabel()} ($requestedKind). " + "Local hook state for this component subtree was reset. " + "Use distinct delegated property names/scopes for semantically different hooks." - } } private class HookCompatibilityMismatchException( - val mismatch: HookCompatibilityMismatch + val mismatch: HookCompatibilityMismatch, ) : RuntimeException() internal class ComponentHookContext( - val componentId: ComponentInstanceId + val componentId: ComponentInstanceId, ) { private val entriesByPath: MutableMap = linkedMapOf() private val claimedPathsThisRender: MutableSet = linkedSetOf() @@ -283,7 +281,7 @@ internal class ComponentHookContext( kind: HookEntryKind, signature: HookSignature, renderEpoch: Long, - initializer: () -> Any? + initializer: () -> Any?, ): ResolvedHookEntry { val path = HookPath(scopeSegments + delegateName) return resolvePath( @@ -292,7 +290,7 @@ internal class ComponentHookContext( signature = signature, synthetic = false, renderEpoch = renderEpoch, - initializer = initializer + initializer = initializer, ) } @@ -302,7 +300,7 @@ internal class ComponentHookContext( kind: HookEntryKind, signature: HookSignature, renderEpoch: Long, - initializer: () -> Any? + initializer: () -> Any?, ): ResolvedHookEntry { val counterKey = SyntheticCounterKey(scopeSegments = scopeSegments, hookName = hookName) val index = syntheticCounterByScope[counterKey] ?: 0 @@ -315,7 +313,7 @@ internal class ComponentHookContext( signature = signature, synthetic = true, renderEpoch = renderEpoch, - initializer = initializer + initializer = initializer, ) } @@ -331,22 +329,23 @@ internal class ComponentHookContext( fun entryCount(): Int = entriesByPath.size + @Suppress("ThrowsCount") private fun resolvePath( path: HookPath, kind: HookEntryKind, signature: HookSignature, synthetic: Boolean, renderEpoch: Long, - initializer: () -> Any? + initializer: () -> Any?, ): ResolvedHookEntry { if (!claimedPathsThisRender.add(path)) { if (synthetic) { throw HookUsageException( - "Synthetic hook key collision at path '$path' in component '${componentId.debugPath()}'." + "Synthetic hook key collision at path '$path' in component '${componentId.debugPath()}'.", ) } throw HookUsageException( - "Duplicate hook path '$path' in component '${componentId.debugPath()}'." + "Duplicate hook path '$path' in component '${componentId.debugPath()}'.", ) } @@ -354,7 +353,7 @@ internal class ComponentHookContext( if (existing != null) { if (existing.synthetic != synthetic) { throw HookUsageException( - "Synthetic hook key collision at path '$path' in component '${componentId.debugPath()}'." + "Synthetic hook key collision at path '$path' in component '${componentId.debugPath()}'.", ) } if (existing.kind != kind) { @@ -366,8 +365,8 @@ internal class ComponentHookContext( requestedKind = kind, existingSignature = existing.signature, requestedSignature = signature, - reason = HookCompatibilityMismatchReason.Kind - ) + reason = HookCompatibilityMismatchReason.Kind, + ), ) } if (existing.signature != signature) { @@ -379,21 +378,22 @@ internal class ComponentHookContext( requestedKind = kind, existingSignature = existing.signature, requestedSignature = signature, - reason = HookCompatibilityMismatchReason.Signature - ) + reason = HookCompatibilityMismatchReason.Signature, + ), ) } existing.lastVisitedRenderEpoch = renderEpoch return ResolvedHookEntry(path = path, entry = existing, created = false, synthetic = synthetic) } - val created = HookEntry( - kind = kind, - signature = signature, - value = initializer(), - synthetic = synthetic, - createdRenderEpoch = renderEpoch - ) + val created = + HookEntry( + kind = kind, + signature = signature, + value = initializer(), + synthetic = synthetic, + createdRenderEpoch = renderEpoch, + ) created.lastVisitedRenderEpoch = renderEpoch entriesByPath[path] = created return ResolvedHookEntry(path = path, entry = created, created = true, synthetic = synthetic) @@ -404,30 +404,30 @@ internal class ComponentHookRuntime { internal data class StorageHookBindingToken( val hookName: String, val renderEpoch: Long, - var bound: Boolean = false + var bound: Boolean = false, ) private enum class ComponentFrameOrigin { Root, Explicit, - Inferred + Inferred, } private data class InferredComponentDescriptor( val ownerClassName: String, val methodName: String, - val componentName: String + val componentName: String, ) private data class HookCallSite( val className: String, val methodName: String, - val lineNumber: Int + val lineNumber: Int, ) private data class InferenceSnapshot( val descriptors: List, - val leafCallSite: HookCallSite? + val leafCallSite: HookCallSite?, ) private data class ComponentFrame( @@ -437,12 +437,12 @@ internal class ComponentHookRuntime { val positionalChildCounters: MutableMap = linkedMapOf(), val origin: ComponentFrameOrigin, val inferredDescriptor: InferredComponentDescriptor? = null, - val seenHookCallSitesThisRender: MutableSet = linkedSetOf() + val seenHookCallSitesThisRender: MutableSet = linkedSetOf(), ) private data class EffectRegistrationKey( val componentId: ComponentInstanceId, - val path: HookPath + val path: HookPath, ) private data class EffectRenderRegistration( @@ -450,18 +450,18 @@ internal class ComponentHookRuntime { val path: HookPath, val runMode: EffectHookRunMode, val deps: List, - val effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit + val effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit, ) private data class CommittedEffectState( val runMode: EffectHookRunMode, val deps: List, - val cleanup: (() -> Unit)? + val cleanup: (() -> Unit)?, ) private data class PendingEffectCommitBatch( val visitedComponents: Set, - val registrationsInOrder: List + val registrationsInOrder: List, ) private val contextsByComponent: MutableMap = linkedMapOf() @@ -469,6 +469,8 @@ internal class ComponentHookRuntime { private val componentStack: ArrayDeque = ArrayDeque() private val pendingStorageHookBindings: MutableList = arrayListOf() private val renderEffectRegistrations: MutableMap = linkedMapOf() + + @Suppress("ktlint:standard:max-line-length", "ktlint:standard:property-wrapping", "MaxLineLength") private val committedEffectsByComponent: MutableMap> = linkedMapOf() private var pendingEffectCommitBatch: PendingEffectCommitBatch? = null @@ -500,8 +502,8 @@ internal class ComponentHookRuntime { ComponentFrame( componentId = rootId, context = rootContext, - origin = ComponentFrameOrigin.Root - ) + origin = ComponentFrameOrigin.Root, + ), ) } @@ -514,22 +516,23 @@ internal class ComponentHookRuntime { unwindInferredFramesForRenderEnd() - val unboundStorageHook = pendingStorageHookBindings.firstOrNull { token -> - token.renderEpoch == renderEpoch && !token.bound - } + val unboundStorageHook = + pendingStorageHookBindings.firstOrNull { token -> + token.renderEpoch == renderEpoch && !token.bound + } if (unboundStorageHook != null) { failStorageBackedHookWithoutDelegate(unboundStorageHook.hookName) } val current = componentStack.lastOrNull() if (componentStack.size != 1 || current == null) { throw HookUsageException( - "Invalid nested component scope behavior: render ended with unbalanced component scopes." + "Invalid nested component scope behavior: render ended with unbalanced component scopes.", ) } if (current.scopeSegments.isNotEmpty()) { throw HookUsageException( "Invalid nested hook scope behavior: render ended with unclosed custom hook scope " + - "'${current.scopeSegments.last()}'." + "'${current.scopeSegments.last()}'.", ) } @@ -547,32 +550,28 @@ internal class ComponentHookRuntime { } } - pendingEffectCommitBatch = PendingEffectCommitBatch( - visitedComponents = enteredComponentIdsThisRender.toSet(), - registrationsInOrder = renderEffectRegistrations.values.toList() - ) + pendingEffectCommitBatch = + PendingEffectCommitBatch( + visitedComponents = enteredComponentIdsThisRender.toSet(), + registrationsInOrder = renderEffectRegistrations.values.toList(), + ) clearRenderSessionStateOnly() } - fun registerEffectOnDependencyChange( - deps: List, - effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit - ) { + fun registerEffectOnDependencyChange(deps: List, effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit) { registerEffectInvocation( runMode = EffectHookRunMode.OnDependencyChange, deps = deps, - effect = effect + effect = effect, ) } - fun registerEffectEveryCommit( - effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit - ) { + fun registerEffectEveryCommit(effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit) { registerEffectInvocation( runMode = EffectHookRunMode.EveryCommit, deps = emptyList(), - effect = effect + effect = effect, ) } @@ -595,7 +594,7 @@ internal class ComponentHookRuntime { pendingEffectCommitBatch = null runCommittedEffectCleanupForSubtree( root = null, - reason = "component runtime disposal" + reason = "component runtime disposal", ) committedEffectsByComponent.clear() contextsByComponent.clear() @@ -613,10 +612,11 @@ internal class ComponentHookRuntime { componentName = normalizedName, key = key, origin = ComponentFrameOrigin.Explicit, - inferredDescriptor = null + inferredDescriptor = null, ) } + @Suppress("ThrowsCount") fun leaveComponentInstance() { ensureActiveRender() if (componentStack.size <= 1) { @@ -625,13 +625,13 @@ internal class ComponentHookRuntime { val frame = componentStack.last() if (frame.origin != ComponentFrameOrigin.Explicit) { throw HookUsageException( - "Cannot leave inferred/root component hook scope with explicit leave call." + "Cannot leave inferred/root component hook scope with explicit leave call.", ) } if (frame.scopeSegments.isNotEmpty()) { throw HookUsageException( "Invalid nested hook scope behavior: component '${frame.componentId.debugPath()}' " + - "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'." + "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'.", ) } componentStack.removeLast() @@ -654,7 +654,7 @@ internal class ComponentHookRuntime { delegateName = normalizedName, kind = HookEntryKind.CustomScope, signature = HookSignatures.kindOnly(HookEntryKind.CustomScope), - renderEpoch = renderEpoch + renderEpoch = renderEpoch, ) { Unit } recordInferredHookCallSite(frame) frame.scopeSegments.addLast(normalizedName) @@ -664,7 +664,7 @@ internal class ComponentHookRuntime { val frame = currentFrame() if (frame.scopeSegments.isEmpty()) { throw HookUsageException( - "Invalid nested hook scope behavior: no active custom hook scope to leave." + "Invalid nested hook scope behavior: no active custom hook scope to leave.", ) } frame.scopeSegments.removeLast() @@ -679,22 +679,14 @@ internal class ComponentHookRuntime { } } - fun resolveNamedEntry( - kind: HookEntryKind, - delegateName: String, - initializer: () -> Any? - ): ResolvedHookEntry { + fun resolveNamedEntry(kind: HookEntryKind, delegateName: String, initializer: () -> Any?): ResolvedHookEntry { val frame = currentFrameForHookResolution() val normalizedName = validateSegment(delegateName, "delegated property") val signature = HookSignatures.kindOnly(kind) return resolveNamedEntryWithSignature(frame, kind, signature, normalizedName, initializer) } - fun resolveUnnamedEntry( - kind: HookEntryKind, - hookName: String, - initializer: () -> Any? - ): ResolvedHookEntry { + fun resolveUnnamedEntry(kind: HookEntryKind, hookName: String, initializer: () -> Any?): ResolvedHookEntry { val frame = currentFrameForHookResolution() val normalizedHookName = validateSegment(hookName, "hook name") val signature = HookSignatures.kindOnly(kind) @@ -706,22 +698,23 @@ internal class ComponentHookRuntime { delegateName: String, signature: HookSignature, expectedRawType: Class<*>, - initializer: () -> T + initializer: () -> T, ): ResolvedTypedHookEntry { val frame = currentFrameForHookResolution() val normalizedName = validateSegment(delegateName, "delegated property") val resolved = resolveNamedEntryWithSignature(frame, kind, signature, normalizedName, initializer) - val typedValue: T = castResolvedEntryValue( - value = resolved.entry.value, - path = resolved.path, - componentLabel = frame.componentId.debugPath(), - expectedRawType = expectedRawType - ) + val typedValue: T = + castResolvedEntryValue( + value = resolved.entry.value, + path = resolved.path, + componentLabel = frame.componentId.debugPath(), + expectedRawType = expectedRawType, + ) return ResolvedTypedHookEntry( path = resolved.path, value = typedValue, created = resolved.created, - synthetic = resolved.synthetic + synthetic = resolved.synthetic, ) } @@ -729,38 +722,38 @@ internal class ComponentHookRuntime { kind: HookEntryKind, delegateName: String, signature: HookSignature, - noinline initializer: () -> T - ): ResolvedTypedHookEntry { - return resolveNamedTypedEntry( + noinline initializer: () -> T, + ): ResolvedTypedHookEntry = + resolveNamedTypedEntry( kind = kind, delegateName = delegateName, signature = signature, expectedRawType = T::class.java, - initializer = initializer + initializer = initializer, ) - } fun resolveUnnamedTypedEntry( kind: HookEntryKind, hookName: String, signature: HookSignature, expectedRawType: Class<*>, - initializer: () -> T + initializer: () -> T, ): ResolvedTypedHookEntry { val frame = currentFrameForHookResolution() val normalizedHookName = validateSegment(hookName, "hook name") val resolved = resolveUnnamedEntryWithSignature(frame, kind, signature, normalizedHookName, initializer) - val typedValue: T = castResolvedEntryValue( - value = resolved.entry.value, - path = resolved.path, - componentLabel = frame.componentId.debugPath(), - expectedRawType = expectedRawType - ) + val typedValue: T = + castResolvedEntryValue( + value = resolved.entry.value, + path = resolved.path, + componentLabel = frame.componentId.debugPath(), + expectedRawType = expectedRawType, + ) return ResolvedTypedHookEntry( path = resolved.path, value = typedValue, created = resolved.created, - synthetic = resolved.synthetic + synthetic = resolved.synthetic, ) } @@ -768,30 +761,29 @@ internal class ComponentHookRuntime { kind: HookEntryKind, hookName: String, signature: HookSignature, - noinline initializer: () -> T - ): ResolvedTypedHookEntry { - return resolveUnnamedTypedEntry( + noinline initializer: () -> T, + ): ResolvedTypedHookEntry = + resolveUnnamedTypedEntry( kind = kind, hookName = hookName, signature = signature, expectedRawType = T::class.java, - initializer = initializer + initializer = initializer, ) - } - fun failStorageBackedHookWithoutDelegate(hookName: String): Nothing { + fun failStorageBackedHookWithoutDelegate(hookName: String): Nothing = throw HookUsageException( "Storage-backed hook '$hookName' must be bound via delegated property syntax (`by $hookName(...)`). " + - "Direct assignment is not supported." + "Direct assignment is not supported.", ) - } fun registerStorageBackedHookCandidate(hookName: String): StorageHookBindingToken { ensureActiveRender() - val token = StorageHookBindingToken( - hookName = hookName, - renderEpoch = renderEpoch - ) + val token = + StorageHookBindingToken( + hookName = hookName, + renderEpoch = renderEpoch, + ) pendingStorageHookBindings.add(token) return token } @@ -800,7 +792,7 @@ internal class ComponentHookRuntime { ensureActiveRender() if (token.renderEpoch != renderEpoch) { throw HookUsageException( - "Storage-backed hook '${token.hookName}' binding token does not belong to current render session." + "Storage-backed hook '${token.hookName}' binding token does not belong to current render session.", ) } token.bound = true @@ -815,46 +807,46 @@ internal class ComponentHookRuntime { kind: HookEntryKind, signature: HookSignature, delegateName: String, - initializer: () -> Any? - ): ResolvedHookEntry { - return try { - val resolved = frame.context.resolveNamedEntry( - scopeSegments = frame.scopeSegments.toList(), - delegateName = delegateName, - kind = kind, - signature = signature, - renderEpoch = renderEpoch, - initializer = initializer - ) + initializer: () -> Any?, + ): ResolvedHookEntry = + try { + val resolved = + frame.context.resolveNamedEntry( + scopeSegments = frame.scopeSegments.toList(), + delegateName = delegateName, + kind = kind, + signature = signature, + renderEpoch = renderEpoch, + initializer = initializer, + ) recordInferredHookCallSite(frame) resolved } catch (mismatch: HookCompatibilityMismatchException) { handleCompatibilityMismatch(mismatch.mismatch) } - } private fun resolveUnnamedEntryWithSignature( frame: ComponentFrame, kind: HookEntryKind, signature: HookSignature, hookName: String, - initializer: () -> Any? - ): ResolvedHookEntry { - return try { - val resolved = frame.context.resolveUnnamedEntry( - scopeSegments = frame.scopeSegments.toList(), - hookName = hookName, - kind = kind, - signature = signature, - renderEpoch = renderEpoch, - initializer = initializer - ) + initializer: () -> Any?, + ): ResolvedHookEntry = + try { + val resolved = + frame.context.resolveUnnamedEntry( + scopeSegments = frame.scopeSegments.toList(), + hookName = hookName, + kind = kind, + signature = signature, + renderEpoch = renderEpoch, + initializer = initializer, + ) recordInferredHookCallSite(frame) resolved } catch (mismatch: HookCompatibilityMismatchException) { handleCompatibilityMismatch(mismatch.mismatch) } - } private fun handleCompatibilityMismatch(mismatch: HookCompatibilityMismatch): Nothing { if (renderSessionMode != HookRenderSessionMode.HotReload) { @@ -866,9 +858,10 @@ internal class ComponentHookRuntime { } private fun resetComponentSubtree(root: ComponentInstanceId) { - val idsToRemove = contextsByComponent.keys - .filter { componentId -> componentId.isSameOrDescendantOf(root) } - .toSet() + val idsToRemove = + contextsByComponent.keys + .filter { componentId -> componentId.isSameOrDescendantOf(root) } + .toSet() if (idsToRemove.isEmpty()) return val iterator = contextsByComponent.entries.iterator() while (iterator.hasNext()) { @@ -879,7 +872,7 @@ internal class ComponentHookRuntime { } runCommittedEffectCleanupForSubtree( root = root, - reason = "hot-reload remount/reset" + reason = "hot-reload remount/reset", ) committedEffectsByComponent.keys .filter { componentId -> componentId.isSameOrDescendantOf(root) } @@ -902,26 +895,29 @@ internal class ComponentHookRuntime { private fun registerEffectInvocation( runMode: EffectHookRunMode, deps: List, - effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit + effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit, ) { val frame = currentFrameForHookResolution() - val resolved = resolveUnnamedEntryWithSignature( - frame = frame, - kind = HookEntryKind.Effect, - signature = HookSignatures.effect(runMode), - hookName = "useEffect" - ) { } - val key = EffectRegistrationKey( - componentId = frame.componentId, - path = resolved.path - ) - renderEffectRegistrations[key] = EffectRenderRegistration( - componentId = frame.componentId, - path = resolved.path, - runMode = runMode, - deps = deps, - effect = effect - ) + val resolved = + resolveUnnamedEntryWithSignature( + frame = frame, + kind = HookEntryKind.Effect, + signature = HookSignatures.effect(runMode), + hookName = "useEffect", + ) { } + val key = + EffectRegistrationKey( + componentId = frame.componentId, + path = resolved.path, + ) + renderEffectRegistrations[key] = + EffectRenderRegistration( + componentId = frame.componentId, + path = resolved.path, + runMode = runMode, + deps = deps, + effect = effect, + ) } private fun applyEffectCommitBatch(batch: PendingEffectCommitBatch) { @@ -930,9 +926,11 @@ internal class ComponentHookRuntime { val runQueue: MutableList = arrayListOf() val visitedComponents = batch.visitedComponents - val registrationsByComponent: MutableMap> = linkedMapOf() + val registrationsByComponent: MutableMap> = + linkedMapOf() batch.registrationsInOrder.forEach { registration -> - registrationsByComponent.getOrPut(registration.componentId) { arrayListOf() } + registrationsByComponent + .getOrPut(registration.componentId) { arrayListOf() } .add(registration) } @@ -955,15 +953,16 @@ internal class ComponentHookRuntime { runQueue.add(registration) return@forEach } - val shouldRerun = when (registration.runMode) { - EffectHookRunMode.EveryCommit -> true - EffectHookRunMode.OnDependencyChange -> existing.deps != registration.deps - } + val shouldRerun = + when (registration.runMode) { + EffectHookRunMode.EveryCommit -> true + EffectHookRunMode.OnDependencyChange -> existing.deps != registration.deps + } if (shouldRerun) { val cleanup = existing.cleanup if (cleanup != null) { cleanupQueue.add( - EffectRegistrationKey(registration.componentId, registration.path) to cleanup + EffectRegistrationKey(registration.componentId, registration.path) to cleanup, ) } runQueue.add(registration) @@ -992,7 +991,7 @@ internal class ComponentHookRuntime { componentId = key.componentId, path = key.path, cleanup = cleanup, - reason = "effect cleanup" + reason = "effect cleanup", ) } @@ -1005,32 +1004,35 @@ internal class ComponentHookRuntime { } runQueue.forEach { registration -> - val committedByPath = committedEffectsByComponent - .getOrPut(registration.componentId) { linkedMapOf() } - val cleanup = runCommittedEffect( - componentId = registration.componentId, - path = registration.path, - effect = registration.effect - ) - committedByPath[registration.path] = CommittedEffectState( - runMode = registration.runMode, - deps = registration.deps, - cleanup = cleanup - ) + val committedByPath = + committedEffectsByComponent + .getOrPut(registration.componentId) { linkedMapOf() } + val cleanup = + runCommittedEffect( + componentId = registration.componentId, + path = registration.path, + effect = registration.effect, + ) + committedByPath[registration.path] = + CommittedEffectState( + runMode = registration.runMode, + deps = registration.deps, + cleanup = cleanup, + ) } } private fun runCommittedEffect( componentId: ComponentInstanceId, path: HookPath, - effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit + effect: (registerCleanup: (() -> Unit) -> Unit) -> Unit, ): (() -> Unit)? { var cleanupRegistration: (() -> Unit)? = null effect { cleanup -> if (cleanupRegistration != null) { throw HookUsageException( "Effect at path '$path' in component '${componentId.debugPath()}' " + - "registered multiple cleanup handlers via onDispose." + "registered multiple cleanup handlers via onDispose.", ) } cleanupRegistration = cleanup @@ -1042,22 +1044,21 @@ internal class ComponentHookRuntime { componentId: ComponentInstanceId, path: HookPath, cleanup: () -> Unit, - reason: String + reason: String, ) { try { cleanup() - } catch (error: Throwable) { + } catch ( + @Suppress("TooGenericExceptionCaught") error: Throwable, + ) { println( "[DSGL][Hooks] Effect cleanup failed at path '$path' in component '${componentId.debugPath()}' " + - "during $reason: ${error.message}" + "during $reason: ${error.message}", ) } } - private fun runCommittedEffectCleanupForSubtree( - root: ComponentInstanceId?, - reason: String - ) { + private fun runCommittedEffectCleanupForSubtree(root: ComponentInstanceId?, reason: String) { val entries = committedEffectsByComponent.entries.toList() entries.forEach { (componentId, byPath) -> if (root != null && !componentId.isSameOrDescendantOf(root)) { @@ -1069,7 +1070,7 @@ internal class ComponentHookRuntime { componentId = componentId, path = path, cleanup = cleanup, - reason = reason + reason = reason, ) } } @@ -1094,8 +1095,9 @@ internal class ComponentHookRuntime { private fun prepareInferredComponentFramesForHookResolution() { ensureActiveRender() - val top = componentStack.lastOrNull() - ?: throw HookUsageException("Hook runtime has no active component frame.") + val top = + componentStack.lastOrNull() + ?: throw HookUsageException("Hook runtime has no active component frame.") if (top.origin == ComponentFrameOrigin.Explicit) { currentInferenceLeafCallSite = null return @@ -1112,7 +1114,9 @@ internal class ComponentHookRuntime { while (commonPrefixLength < existingCount && commonPrefixLength < desiredPath.size) { val existingFrame = componentStack.elementAt(baseIndex + 1 + commonPrefixLength) val existingDescriptor = existingFrame.inferredDescriptor - if (existingFrame.origin != ComponentFrameOrigin.Inferred || existingDescriptor != desiredPath[commonPrefixLength]) { + if (existingFrame.origin != ComponentFrameOrigin.Inferred || + existingDescriptor != desiredPath[commonPrefixLength] + ) { break } commonPrefixLength += 1 @@ -1122,18 +1126,20 @@ internal class ComponentHookRuntime { popInferredFrameForTransition() } - var parent = componentStack.lastOrNull() - ?: throw HookUsageException("Hook runtime has no active component frame.") + var parent = + componentStack.lastOrNull() + ?: throw HookUsageException("Hook runtime has no active component frame.") var index = commonPrefixLength while (index < desiredPath.size) { val descriptor = desiredPath[index] - parent = pushComponentFrame( - parent = parent, - componentName = descriptor.componentName, - key = null, - origin = ComponentFrameOrigin.Inferred, - inferredDescriptor = descriptor - ) + parent = + pushComponentFrame( + parent = parent, + componentName = descriptor.componentName, + key = null, + origin = ComponentFrameOrigin.Inferred, + inferredDescriptor = descriptor, + ) index += 1 } } @@ -1149,16 +1155,20 @@ internal class ComponentHookRuntime { throw HookUsageException("Hook runtime internal error: no non-inferred frame in component stack.") } + @Suppress("ThrowsCount") private fun popInferredFrameForTransition() { - val frame = componentStack.lastOrNull() - ?: throw HookUsageException("Hook runtime has no active component frame.") + val frame = + componentStack.lastOrNull() + ?: throw HookUsageException("Hook runtime has no active component frame.") if (frame.origin != ComponentFrameOrigin.Inferred) { - throw HookUsageException("Hook runtime internal error: attempted to pop non-inferred frame during inference transition.") + throw HookUsageException( + "Hook runtime internal error: attempted to pop non-inferred frame during inference transition.", + ) } if (frame.scopeSegments.isNotEmpty()) { throw HookUsageException( "Invalid nested hook scope behavior: component '${frame.componentId.debugPath()}' " + - "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'." + "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'.", ) } componentStack.removeLast() @@ -1171,13 +1181,14 @@ internal class ComponentHookRuntime { ComponentFrameOrigin.Inferred -> popInferredFrameForTransition() ComponentFrameOrigin.Explicit -> { throw HookUsageException( - "Invalid nested component scope behavior: render ended with unbalanced explicit component scopes." + "Invalid nested component scope behavior: " + + "render ended with unbalanced explicit component scopes.", ) } ComponentFrameOrigin.Root -> { throw HookUsageException( - "Hook runtime internal error: root frame encountered before stack unwind completed." + "Hook runtime internal error: root frame encountered before stack unwind completed.", ) } } @@ -1189,43 +1200,50 @@ internal class ComponentHookRuntime { componentName: String, key: Any?, origin: ComponentFrameOrigin, - inferredDescriptor: InferredComponentDescriptor? + inferredDescriptor: InferredComponentDescriptor?, ): ComponentFrame { - val positionalIndex = if (key == null) { - val current = parent.positionalChildCounters[componentName] ?: 0 - parent.positionalChildCounters[componentName] = current + 1 - current - } else { - -1 - } + val positionalIndex = + if (key == null) { + val current = parent.positionalChildCounters[componentName] ?: 0 + parent.positionalChildCounters[componentName] = current + 1 + current + } else { + -1 + } - val childId = parent.componentId.child( - name = componentName, - explicitKey = key, - positionalIndex = positionalIndex - ) + val childId = + parent.componentId.child( + name = componentName, + explicitKey = key, + positionalIndex = positionalIndex, + ) if (!enteredComponentIdsThisRender.add(childId)) { throw HookUsageException( - "Duplicate component identity '${childId.debugPath()}' in a single render pass." + "Duplicate component identity '${childId.debugPath()}' in a single render pass.", ) } val childContext = contextsByComponent.getOrPut(childId) { ComponentHookContext(childId) } childContext.beginRender(renderEpoch) - val childFrame = ComponentFrame( - componentId = childId, - context = childContext, - origin = origin, - inferredDescriptor = inferredDescriptor - ) + val childFrame = + ComponentFrame( + componentId = childId, + context = childContext, + origin = origin, + inferredDescriptor = inferredDescriptor, + ) componentStack.addLast(childFrame) return childFrame } + // Stack-frame walker mutates three independent pieces of state (innerToOuter list, + // leafCallSite, foundRenderBoundary flag) while classifying each frame; skip-frame and + // stop-at-boundary are distinct control flows that don't collapse into a filter chain. + @Suppress("LoopWithTooManyJumpStatements") private fun inferComponentSnapshotFromCallContext(): InferenceSnapshot { val innerToOuter: MutableList = arrayListOf() var leafCallSite: HookCallSite? = null var foundRenderBoundary = false - val trace = Throwable().stackTrace + val trace = Throwable("hook-call-context-inference").stackTrace for (frame in trace) { if (isInferenceFrameworkFrame(frame)) { continue @@ -1235,17 +1253,19 @@ internal class ComponentHookRuntime { foundRenderBoundary = true break } - val descriptor = InferredComponentDescriptor( - ownerClassName = frame.className, - methodName = methodName, - componentName = inferredComponentName(frame.className, methodName) - ) - if (leafCallSite == null) { - leafCallSite = HookCallSite( - className = frame.className, + val descriptor = + InferredComponentDescriptor( + ownerClassName = frame.className, methodName = methodName, - lineNumber = frame.lineNumber + componentName = inferredComponentName(frame.className, methodName), ) + if (leafCallSite == null) { + leafCallSite = + HookCallSite( + className = frame.className, + methodName = methodName, + lineNumber = frame.lineNumber, + ) } if (innerToOuter.lastOrNull() == descriptor) { continue @@ -1255,14 +1275,14 @@ internal class ComponentHookRuntime { if (!foundRenderBoundary || innerToOuter.isEmpty()) { return InferenceSnapshot( descriptors = emptyList(), - leafCallSite = null + leafCallSite = null, ) } val result = innerToOuter.toMutableList() result.reverse() return InferenceSnapshot( descriptors = result, - leafCallSite = leafCallSite + leafCallSite = leafCallSite, ) } @@ -1281,10 +1301,11 @@ internal class ComponentHookRuntime { } private fun normalizeInferenceMethodName(rawMethodName: String): String? { - val method = when { - rawMethodName.endsWith("\$default") -> rawMethodName.removeSuffix("\$default") - else -> rawMethodName - } + val method = + when { + rawMethodName.endsWith("\$default") -> rawMethodName.removeSuffix("\$default") + else -> rawMethodName + } if (method.isBlank()) return null if (method == "getStackTrace") return null if (method.startsWith("invoke")) return null @@ -1312,10 +1333,11 @@ internal class ComponentHookRuntime { val raw = "inferred_${ownerSimple}_$methodName" return validateSegment( value = raw.replace('.', '_'), - label = "inferred component name" + label = "inferred component name", ) } + @Suppress("ThrowsCount") private fun maybeAdvanceInferredSiblingFrame(frame: ComponentFrame): ComponentFrame { if (frame.origin != ComponentFrameOrigin.Inferred) { return frame @@ -1324,23 +1346,25 @@ internal class ComponentHookRuntime { if (!frame.seenHookCallSitesThisRender.contains(callSite)) { return frame } - val descriptor = frame.inferredDescriptor - ?: throw HookUsageException("Hook runtime internal error: inferred frame without descriptor.") + val descriptor = + frame.inferredDescriptor + ?: throw HookUsageException("Hook runtime internal error: inferred frame without descriptor.") if (frame.scopeSegments.isNotEmpty()) { throw HookUsageException( "Invalid nested hook scope behavior: component '${frame.componentId.debugPath()}' " + - "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'." + "ended with unclosed custom hook scope '${frame.scopeSegments.last()}'.", ) } componentStack.removeLast() - val parent = componentStack.lastOrNull() - ?: throw HookUsageException("Hook runtime has no parent frame for inferred sibling transition.") + val parent = + componentStack.lastOrNull() + ?: throw HookUsageException("Hook runtime has no parent frame for inferred sibling transition.") return pushComponentFrame( parent = parent, componentName = descriptor.componentName, key = null, origin = ComponentFrameOrigin.Inferred, - inferredDescriptor = descriptor + inferredDescriptor = descriptor, ) } @@ -1379,13 +1403,13 @@ internal class ComponentHookRuntime { value: Any?, path: HookPath, componentLabel: String, - expectedRawType: Class<*> + expectedRawType: Class<*>, ): T { if (value == null || !expectedRawType.isInstance(value)) { val actualType = if (value == null) "null" else value.javaClass.name throw HookUsageException( "Hook value type mismatch at path '$path' in component '$componentLabel': " + - "expected=${expectedRawType.name}, actual=$actualType." + "expected=${expectedRawType.name}, actual=$actualType.", ) } @Suppress("UNCHECKED_CAST") diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseContext.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseContext.kt index a8168e0..8e40ff3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseContext.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseContext.kt @@ -1,27 +1,21 @@ +@file:Suppress("MatchingDeclarationName") + package org.dreamfinity.dsgl.core.hooks import org.dreamfinity.dsgl.core.dsl.UiScope class DsglContext( val name: String, - val defaultValue: T + val defaultValue: T, ) -fun createContext(defaultValue: T, name: String = "Context"): DsglContext { - return DsglContext( +fun createContext(defaultValue: T, name: String = "Context"): DsglContext = + DsglContext( name = name, - defaultValue = defaultValue + defaultValue = defaultValue, ) -} -fun UiScope.useContext(context: DsglContext): T { - return readContextValue(context) -} +fun UiScope.useContext(context: DsglContext): T = readContextValue(context) -fun UiScope.provideContext( - context: DsglContext, - value: T, - block: UiScope.() -> R -): R { - return withProvidedContext(context = context, value = value, block = block) -} +fun UiScope.provideContext(context: DsglContext, value: T, block: UiScope.() -> R): R = + withProvidedContext(context = context, value = value, block = block) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseEffect.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseEffect.kt index 5fc49d6..320eec1 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseEffect.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseEffect.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package org.dreamfinity.dsgl.core.hooks import org.dreamfinity.dsgl.core.DsglWindow @@ -18,11 +20,12 @@ fun UiScope.useEffectEveryCommit(effect: EffectScope.() -> Unit) { internal fun DsglWindow.useEffect(vararg deps: Any?, effect: EffectScope.() -> Unit) { val runtime = hookRuntime() runtime.registerEffectOnDependencyChange(deps.toList()) { registerCleanup -> - val scope = object : EffectScope { - override fun onDispose(cleanup: () -> Unit) { - registerCleanup(cleanup) + val scope = + object : EffectScope { + override fun onDispose(cleanup: () -> Unit) { + registerCleanup(cleanup) + } } - } effect(scope) } } @@ -30,11 +33,12 @@ internal fun DsglWindow.useEffect(vararg deps: Any?, effect: EffectScope.() -> U internal fun DsglWindow.useEffectEveryCommit(effect: EffectScope.() -> Unit) { val runtime = hookRuntime() runtime.registerEffectEveryCommit { registerCleanup -> - val scope = object : EffectScope { - override fun onDispose(cleanup: () -> Unit) { - registerCleanup(cleanup) + val scope = + object : EffectScope { + override fun onDispose(cleanup: () -> Unit) { + registerCleanup(cleanup) + } } - } effect(scope) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseMemo.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseMemo.kt index b2f3233..69a621c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseMemo.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseMemo.kt @@ -8,7 +8,7 @@ import kotlin.reflect.typeOf internal class MemoHookCell( deps: List, - value: T + value: T, ) { private var depsSnapshot: List = deps private var cachedValue: T = value @@ -24,112 +24,109 @@ internal class MemoHookCell( fun read(): T = cachedValue } -class MemoHookDelegate @PublishedApi internal constructor( - private val window: DsglWindow, - private val hookName: String, - private val deps: List, - private val compute: () -> T, - private val signature: HookSignature -) { - private val runtime = window.hookRuntime() - private val bindingToken = runtime.registerStorageBackedHookCandidate(hookName) - private var boundCell: MemoHookCell? = null +class MemoHookDelegate + @PublishedApi + internal constructor( + private val window: DsglWindow, + private val hookName: String, + private val deps: List, + private val compute: () -> T, + private val signature: HookSignature, + ) { + private val runtime = window.hookRuntime() + private val bindingToken = runtime.registerStorageBackedHookCandidate(hookName) + private var boundCell: MemoHookCell? = null - operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): MemoHookDelegate { - runtime.markStorageBackedHookBound(bindingToken) - val resolved = runtime.resolveNamedTypedEntry( - kind = HookEntryKind.Memo, - delegateName = property.name, - signature = signature, - expectedRawType = MemoHookCell::class.java - ) { - MemoHookCell(deps = deps, value = compute()) + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): MemoHookDelegate { + runtime.markStorageBackedHookBound(bindingToken) + val resolved = + runtime.resolveNamedTypedEntry( + kind = HookEntryKind.Memo, + delegateName = property.name, + signature = signature, + expectedRawType = MemoHookCell::class.java, + ) { + MemoHookCell(deps = deps, value = compute()) + } + resolved.value.sync(nextDeps = deps, compute = compute) + boundCell = resolved.value + return this } - resolved.value.sync(nextDeps = deps, compute = compute) - boundCell = resolved.value - return this - } - operator fun getValue(thisRef: Any?, property: KProperty<*>): T { - val resolved = boundCell - if (resolved != null) { - return resolved.read() + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + val resolved = boundCell + if (resolved != null) { + return resolved.read() + } + runtime.failStorageBackedHookWithoutDelegate(hookName) } - runtime.failStorageBackedHookWithoutDelegate(hookName) } -} @OptIn(ExperimentalStdlibApi::class) -inline fun UiScope.useMemo(vararg deps: Any?, noinline compute: () -> T): MemoHookDelegate { - return createMemoHookDelegate( +inline fun UiScope.useMemo(vararg deps: Any?, noinline compute: () -> T): MemoHookDelegate = + createMemoHookDelegate( window = requireHookOwnerWindow(), hookName = "useMemo", deps = deps.toList(), compute = compute, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @OptIn(ExperimentalStdlibApi::class) -inline fun UiScope.useMemo(noinline compute: () -> T): MemoHookDelegate { - return createMemoHookDelegate( +inline fun UiScope.useMemo(noinline compute: () -> T): MemoHookDelegate = + createMemoHookDelegate( window = requireHookOwnerWindow(), hookName = "useMemo", deps = emptyList(), compute = compute, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @OptIn(ExperimentalStdlibApi::class) -inline fun UiScope.useCallback(vararg deps: Any?, noinline factory: () -> F): MemoHookDelegate { - return createMemoHookDelegate( +inline fun UiScope.useCallback(vararg deps: Any?, noinline factory: () -> F): MemoHookDelegate = + createMemoHookDelegate( window = requireHookOwnerWindow(), hookName = "useCallback", deps = deps.toList(), compute = factory, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @PublishedApi @OptIn(ExperimentalStdlibApi::class) -internal inline fun DsglWindow.useMemo(vararg deps: Any?, noinline compute: () -> T): MemoHookDelegate { - return createMemoHookDelegate( +internal inline fun DsglWindow.useMemo(vararg deps: Any?, noinline compute: () -> T): MemoHookDelegate = + createMemoHookDelegate( window = this, hookName = "useMemo", deps = deps.toList(), compute = compute, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @PublishedApi @OptIn(ExperimentalStdlibApi::class) -internal inline fun DsglWindow.useMemo(noinline compute: () -> T): MemoHookDelegate { - return createMemoHookDelegate( +internal inline fun DsglWindow.useMemo(noinline compute: () -> T): MemoHookDelegate = + createMemoHookDelegate( window = this, hookName = "useMemo", deps = emptyList(), compute = compute, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @PublishedApi @OptIn(ExperimentalStdlibApi::class) internal inline fun DsglWindow.useCallback( vararg deps: Any?, - noinline factory: () -> F -): MemoHookDelegate { - return createMemoHookDelegate( + noinline factory: () -> F, +): MemoHookDelegate = + createMemoHookDelegate( window = this, hookName = "useCallback", deps = deps.toList(), compute = factory, - signature = HookSignatures.memo(typeOf()) + signature = HookSignatures.memo(typeOf()), ) -} @PublishedApi internal fun createMemoHookDelegate( @@ -137,13 +134,12 @@ internal fun createMemoHookDelegate( hookName: String, deps: List, compute: () -> T, - signature: HookSignature -): MemoHookDelegate { - return MemoHookDelegate( + signature: HookSignature, +): MemoHookDelegate = + MemoHookDelegate( window = window, hookName = hookName, deps = deps, compute = compute, - signature = signature + signature = signature, ) -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseReducer.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseReducer.kt index 74a6d44..e55e2da 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseReducer.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseReducer.kt @@ -4,44 +4,38 @@ import org.dreamfinity.dsgl.core.DsglWindow import org.dreamfinity.dsgl.core.dsl.UiScope import kotlin.reflect.KClass -fun UiScope.useReducer( - initialState: S, - reducer: (S, A) -> S -): Pair Unit> { - return requireHookOwnerWindow().useReducer(initialState = initialState, reducer = reducer) -} +fun UiScope.useReducer(initialState: S, reducer: (S, A) -> S): Pair Unit> = + requireHookOwnerWindow().useReducer(initialState = initialState, reducer = reducer) -internal fun DsglWindow.useReducer( - initialState: S, - reducer: (S, A) -> S -): Pair Unit> { - return createReducerHookPair( +internal fun DsglWindow.useReducer(initialState: S, reducer: (S, A) -> S): Pair Unit> = + createReducerHookPair( window = this, initialState = initialState, - reducer = reducer + reducer = reducer, ) -} internal fun createReducerHookPair( window: DsglWindow, initialState: S, - reducer: (S, A) -> S + reducer: (S, A) -> S, ): Pair Unit> { val runtime = window.hookRuntime() - val signature = HookSignatures.reducer( - stateClass = reducerStateClassOf(initialState), - initialWasNull = initialState == null - ) - val resolved = runtime.resolveUnnamedTypedEntry( - kind = HookEntryKind.Reducer, - hookName = "useReducer", - signature = signature, - expectedRawType = HookStateCell::class.java - ) { - HookStateCell(initialState) { - window.onHookStateChanged() + val signature = + HookSignatures.reducer( + stateClass = reducerStateClassOf(initialState), + initialWasNull = initialState == null, + ) + val resolved = + runtime.resolveUnnamedTypedEntry( + kind = HookEntryKind.Reducer, + hookName = "useReducer", + signature = signature, + expectedRawType = HookStateCell::class.java, + ) { + HookStateCell(initialState) { + window.onHookStateChanged() + } } - } val stateCell = resolved.value val dispatch: (A) -> Unit = { action -> stateCell.write(reducer(stateCell.read(), action)) @@ -49,6 +43,4 @@ internal fun createReducerHookPair( return stateCell.read() to dispatch } -private fun reducerStateClassOf(value: Any?): KClass<*>? { - return value?.let { current -> current::class } -} +private fun reducerStateClassOf(value: Any?): KClass<*>? = value?.let { current -> current::class } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseState.kt index 4826130..f81474d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseState.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/UseState.kt @@ -8,7 +8,7 @@ import kotlin.reflect.typeOf internal class HookStateCell( initial: T, - private val onChange: () -> Unit + private val onChange: () -> Unit, ) { private var stored: T = initial @@ -22,68 +22,63 @@ internal class HookStateCell( } } -class StateHookDelegate @PublishedApi internal constructor( - private val window: DsglWindow, - private val initial: T, - private val signature: HookSignature -) { - private val runtime = window.hookRuntime() - private val bindingToken = runtime.registerStorageBackedHookCandidate("useState") - private var boundCell: HookStateCell? = null +class StateHookDelegate + @PublishedApi + internal constructor( + private val window: DsglWindow, + private val initial: T, + private val signature: HookSignature, + ) { + private val runtime = window.hookRuntime() + private val bindingToken = runtime.registerStorageBackedHookCandidate("useState") + private var boundCell: HookStateCell? = null - operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): StateHookDelegate { - runtime.markStorageBackedHookBound(bindingToken) - val resolved = runtime.resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = property.name, - signature = signature, - expectedRawType = HookStateCell::class.java - ) { - HookStateCell(initial) { - window.onHookStateChanged() - } + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): StateHookDelegate { + runtime.markStorageBackedHookBound(bindingToken) + val resolved = + runtime.resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = property.name, + signature = signature, + expectedRawType = HookStateCell::class.java, + ) { + HookStateCell(initial) { + window.onHookStateChanged() + } + } + boundCell = resolved.value + return this } - boundCell = resolved.value - return this - } - operator fun getValue(thisRef: Any?, property: KProperty<*>): T { - return requireBoundCell().read() - } + operator fun getValue(thisRef: Any?, property: KProperty<*>): T = requireBoundCell().read() - operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - requireBoundCell().write(value) - } + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + requireBoundCell().write(value) + } - private fun requireBoundCell(): HookStateCell { - val resolved = boundCell - if (resolved != null) { - return resolved + private fun requireBoundCell(): HookStateCell { + val resolved = boundCell + if (resolved != null) { + return resolved + } + runtime.failStorageBackedHookWithoutDelegate("useState") } - runtime.failStorageBackedHookWithoutDelegate("useState") } -} @OptIn(ExperimentalStdlibApi::class) -inline fun UiScope.useState(initial: T): StateHookDelegate { - return createStateHookDelegate(window = requireHookOwnerWindow(), initial = initial) -} +inline fun UiScope.useState(initial: T): StateHookDelegate = + createStateHookDelegate(window = requireHookOwnerWindow(), initial = initial) @PublishedApi @OptIn(ExperimentalStdlibApi::class) -internal inline fun DsglWindow.useState(initial: T): StateHookDelegate { - return createStateHookDelegate(window = this, initial = initial) -} +internal inline fun DsglWindow.useState(initial: T): StateHookDelegate = + createStateHookDelegate(window = this, initial = initial) @PublishedApi @OptIn(ExperimentalStdlibApi::class) -internal inline fun createStateHookDelegate( - window: DsglWindow, - initial: T -): StateHookDelegate { - return StateHookDelegate( +internal inline fun createStateHookDelegate(window: DsglWindow, initial: T): StateHookDelegate = + StateHookDelegate( window = window, initial = initial, - signature = HookSignatures.state(typeOf()) + signature = HookSignatures.state(typeOf()), ) -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/ElementHandle.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/ElementHandle.kt index bc90d33..bbb9f28 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/ElementHandle.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/ElementHandle.kt @@ -14,7 +14,7 @@ interface ElementHandle { } internal class NodeElementHandle( - node: DOMNode + node: DOMNode, ) : ElementHandle { private var nodeRef: DOMNode? = node @@ -25,13 +25,12 @@ internal class NodeElementHandle( get() = nodeRef?.bounds ?: Rect(0, 0, 0, 0) override fun requestFocus() { - val node = nodeRef ?: return - val focusable = FocusManager.resolveFocusable(node) ?: return - FocusManager.requestFocus(focusable) + FocusManager.requestFocusFrom(nodeRef) } + @Suppress("ForbiddenComment") override fun scrollIntoView() { - // TODO: add scroll container integration when generic scrolling API is available. + // TODO(Veritaris): add scroll container integration when generic scrolling API is available. } fun detach() { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/RefManager.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/RefManager.kt index 8bcd3c1..6526470 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/RefManager.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/RefManager.kt @@ -1,12 +1,12 @@ package org.dreamfinity.dsgl.core.hooks.ref import org.dreamfinity.dsgl.core.dom.DOMNode -import java.util.* +import java.util.IdentityHashMap internal class RefManager { private data class AttachedRef( val target: RefTarget, - val handle: NodeElementHandle + val handle: NodeElementHandle, ) private val attachedByNode: MutableMap = IdentityHashMap() @@ -42,9 +42,10 @@ internal class RefManager { } } - val staleNodes = attachedByNode.keys.filter { node -> - !visited.containsKey(node) - } + val staleNodes = + attachedByNode.keys.filter { node -> + !visited.containsKey(node) + } staleNodes.forEach { node -> val attached = attachedByNode.remove(node) ?: return@forEach attached.target.set(null) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/Refs.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/Refs.kt index 9448306..06c98fc 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/Refs.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ref/Refs.kt @@ -24,63 +24,63 @@ interface Ref : RefTarget { } class RefObject( - override var current: T? = null + override var current: T? = null, ) : Ref fun createRef(initial: T? = null): Ref = RefObject(initial) -class RefHookDelegate @PublishedApi internal constructor( - private val window: DsglWindow, - private val initial: T?, - private val signature: HookSignature -) { - private val runtime = window.hookRuntime() - private val bindingToken = runtime.registerStorageBackedHookCandidate("useRef") - private var boundRef: Ref? = null +class RefHookDelegate + @PublishedApi + internal constructor( + private val window: DsglWindow, + private val initial: T?, + private val signature: HookSignature, + ) { + private val runtime = window.hookRuntime() + private val bindingToken = runtime.registerStorageBackedHookCandidate("useRef") + private var boundRef: Ref? = null - operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): RefHookDelegate { - runtime.markStorageBackedHookBound(bindingToken) - val resolved = runtime.resolveNamedTypedEntry( - kind = HookEntryKind.Ref, - delegateName = property.name, - signature = signature, - expectedRawType = Ref::class.java - ) { - RefObject(initial) + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): RefHookDelegate { + runtime.markStorageBackedHookBound(bindingToken) + val resolved = + runtime.resolveNamedTypedEntry( + kind = HookEntryKind.Ref, + delegateName = property.name, + signature = signature, + expectedRawType = Ref::class.java, + ) { + RefObject(initial) + } + boundRef = resolved.value + return this } - boundRef = resolved.value - return this - } - operator fun getValue(thisRef: Any?, property: KProperty<*>): Ref { - val resolved = boundRef - if (resolved != null) { - return resolved + operator fun getValue(thisRef: Any?, property: KProperty<*>): Ref { + val resolved = boundRef + if (resolved != null) { + return resolved + } + runtime.failStorageBackedHookWithoutDelegate("useRef") } - runtime.failStorageBackedHookWithoutDelegate("useRef") } -} @OptIn(ExperimentalStdlibApi::class) -inline fun UiScope.useRef(initial: T? = null): RefHookDelegate { - return createRefHookDelegate(window = requireHookOwnerWindow(), initial = initial) -} +inline fun UiScope.useRef(initial: T? = null): RefHookDelegate = + createRefHookDelegate(window = requireHookOwnerWindow(), initial = initial) @PublishedApi @OptIn(ExperimentalStdlibApi::class) -internal inline fun DsglWindow.useRef(initial: T? = null): RefHookDelegate { - return createRefHookDelegate(window = this, initial = initial) -} +internal inline fun DsglWindow.useRef(initial: T? = null): RefHookDelegate = + createRefHookDelegate(window = this, initial = initial) @PublishedApi @OptIn(ExperimentalStdlibApi::class) internal inline fun createRefHookDelegate( window: DsglWindow, - initial: T? = null -): RefHookDelegate { - return RefHookDelegate( + initial: T? = null, +): RefHookDelegate = + RefHookDelegate( window = window, initial = initial, - signature = HookSignatures.ref(typeOf()) + signature = HookSignatures.ref(typeOf()), ) -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/host/DsglWindowHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/host/DsglWindowHost.kt index f39a2e3..0305d71 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/host/DsglWindowHost.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/host/DsglWindowHost.kt @@ -10,27 +10,26 @@ data class Viewport( val height: Int, val scale: Float = 1f, val x: Int = 0, - val y: Int = 0 + val y: Int = 0, ) data class ViewportPoint( val x: Int, - val y: Int + val y: Int, ) data class GlScissorRect( val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, ) -fun Viewport.rawMouseToDsgl(rawX: Int, rawY: Int): ViewportPoint { - return ViewportPoint( +fun Viewport.rawMouseToDsgl(rawX: Int, rawY: Int): ViewportPoint = + ViewportPoint( x = rawX - x, - y = (y + height) - rawY - 1 + y = (y + height) - rawY - 1, ) -} fun Viewport.rawMouseToDsglX(rawX: Int): Int = rawX - x @@ -40,7 +39,7 @@ fun Viewport.dsglRectToGlScissor( dsglX: Int, dsglY: Int, dsglWidth: Int, - dsglHeight: Int + dsglHeight: Int, ): GlScissorRect { val widthPx = dsglWidth.coerceAtLeast(0) val heightPx = dsglHeight.coerceAtLeast(0) @@ -48,7 +47,7 @@ fun Viewport.dsglRectToGlScissor( x = x + dsglX, y = y + height - (dsglY + heightPx), width = widthPx, - height = heightPx + height = heightPx, ) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/input/ClipboardBridge.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/input/ClipboardBridge.kt index ebaa537..8fd9964 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/input/ClipboardBridge.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/input/ClipboardBridge.kt @@ -5,6 +5,7 @@ package org.dreamfinity.dsgl.core.input */ interface ClipboardAccess { fun readText(): String + fun writeText(value: String) } @@ -16,11 +17,9 @@ object ClipboardBridge { provider = access } - fun readText(): String { - return provider?.readText() ?: "" - } + fun readText(): String = provider?.readText() ?: "" fun writeText(value: String) { provider?.writeText(value) } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorController.kt index a034f92..0caf97a 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorController.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorController.kt @@ -4,14 +4,12 @@ import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState import org.dreamfinity.dsgl.core.colorpicker.ColorTextCodec import org.dreamfinity.dsgl.core.colorpicker.RgbaColor -import org.dreamfinity.dsgl.core.colorpicker.internal.InspectorColorPickerHost import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerPanelManager +import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerPortalService import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.UsedInteractionGeometryResolver import org.dreamfinity.dsgl.core.dom.elements.TextEditState import org.dreamfinity.dsgl.core.dom.elements.support.TextEditOps import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Insets import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.KeyInput import org.dreamfinity.dsgl.core.event.KeyModifiers @@ -19,27 +17,24 @@ import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.collectHoverChain import org.dreamfinity.dsgl.core.input.ClipboardBridge import org.dreamfinity.dsgl.core.popup.FloatingPaneDragModel -import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.* enum class InspectorMode { Pick, - Locked + Locked, } enum class InspectorPanelState { Expanded, - Minimized + Minimized, } class InspectorController( - colorPickerManager: InspectorColorPickerHost = SystemColorPickerPanelManager() + colorPickerManager: SystemColorPickerPortalService = SystemColorPickerPanelManager(), ) { - private var colorPickerManager: InspectorColorPickerHost = colorPickerManager + private var colorPickerManager: SystemColorPickerPortalService = colorPickerManager private enum class EditOperation { - CyclePrev, - CycleNext, Decrement, Increment, ResetProperty, @@ -48,7 +43,7 @@ class InspectorController( ToggleValueSelect, SelectValueOption, ToggleUnitSelect, - SelectUnitOption + SelectUnitOption, } private enum class ActionKind { @@ -58,7 +53,7 @@ class InspectorController( Child, EditProperty, ResetSelectedOverrides, - ClearAllOverrides + ClearAllOverrides, } private data class PanelAction( @@ -68,16 +63,7 @@ class InspectorController( val property: StyleProperty? = null, val editOperation: EditOperation? = null, val step: Float = 1f, - val payload: String? = null - ) - - private data class DropdownOverlay( - val x: Int, - val y: Int, - val width: Int, - val options: List, - val property: StyleProperty, - val operation: EditOperation + val payload: String? = null, ) private data class DropdownLayout( @@ -85,22 +71,14 @@ class InspectorController( val property: StyleProperty, val isUnit: Boolean, val totalOptions: Int, - val visibleRows: Int + val visibleRows: Int, ) private data class SelectionStyleCache( val key: Any?, val nodeClass: Class, val layoutVersion: Long, - val inspection: StyleInspection - ) - - private data class NodeBoxes( - val margin: Rect, - val border: Rect, - val padding: Rect, - val content: Rect, - val parentContent: Rect? + val inspection: StyleInspection, ) private enum class DragMode { @@ -115,7 +93,7 @@ class InspectorController( ResizeBottomLeft, ResizeBottomRight, MinimizedMove, - ScrollbarThumb + ScrollbarThumb, } var active: Boolean = false @@ -131,7 +109,7 @@ class InspectorController( get() = minimizedPosX to minimizedPosY val isPointerCaptured: Boolean - get() = dragMode != DragMode.None + get() = dragMode != DragMode.None && dragMode != DragMode.ScrollbarThumb val hoveredKey: String? get() = hoveredNode?.key?.toString() @@ -154,7 +132,6 @@ class InspectorController( private var headerBounds: Rect = Rect(0, 0, 0, 0) private var contentBounds: Rect = Rect(0, 0, 0, 0) private val panelActions: MutableList = ArrayList() - private val dropdownOverlays: MutableList = ArrayList() private val dropdownLayouts: MutableList = ArrayList() private var cachedStyle: SelectionStyleCache? = null private var styleEditorError: String? = null @@ -168,6 +145,8 @@ class InspectorController( private var dragStartOffsetX: Int = 0 private var dragStartOffsetY: Int = 0 private var dragMoved: Boolean = false + private var floatingPanelPointerCapture: Boolean = false + private var floatingPanelAuthorityEnabled: Boolean = false private val paneMoveDrag: FloatingPaneDragModel = FloatingPaneDragModel() private var viewportW: Int = 0 private var viewportH: Int = 0 @@ -234,8 +213,6 @@ class InspectorController( set(value) { editSession.openUnitScrollIndex = value } - private var variableTooltipText: String? = null - private var variableTooltipRect: Rect = Rect(0, 0, 0, 0) private var nativeSelectedHighlight: InspectorHighlightSnapshot? = null private var nativeHoveredHighlight: InspectorHighlightSnapshot? = null @@ -245,6 +222,11 @@ class InspectorController( private val nativeDropdowns: MutableList = ArrayList() private var nativeStyleEditorResetRect: Rect = Rect(0, 0, 0, 0) private var nativeStyleEditorClearRect: Rect = Rect(0, 0, 0, 0) + private val styleEditorSnapshotBuilder: InspectorStyleEditorSnapshotBuilder = + InspectorStyleEditorSnapshotBuilder( + resolveLiteralFromComputed = ::literalFromComputed, + renderExpressionLabel = ::expressionLabel, + ) private val minPanelWidth: Int = 240 private val minPanelHeight: Int = 160 @@ -257,7 +239,6 @@ class InspectorController( private val secondaryFontSizePx: Int = parseLengthPxInt("24px", allowNegative = false) private val lineHeightPx: Int = (textFontSizePx + 8).coerceAtLeast(28) private val rowHeightPx: Int = (textFontSizePx + 10).coerceAtLeast(32) - private val caretBlinkPeriodMs: Long = 500L fun toggle() { active = !active @@ -270,10 +251,10 @@ class InspectorController( deactivateInternal() } - fun installColorPickerHost(host: InspectorColorPickerHost) { - if (colorPickerManager === host) return + fun installColorPickerPortalService(portalService: SystemColorPickerPortalService) { + if (colorPickerManager === portalService) return colorPickerManager.close() - colorPickerManager = host + colorPickerManager = portalService } fun toggleMode() { @@ -303,6 +284,7 @@ class InspectorController( minimizedPosY = current.y panelState = InspectorPanelState.Minimized dragMode = DragMode.None + floatingPanelPointerCapture = false dragMoved = false } @@ -312,28 +294,36 @@ class InspectorController( expandedRect = clampExpandedRect(expandedRect, viewportW, viewportH) panelState = InspectorPanelState.Expanded dragMode = DragMode.None + floatingPanelPointerCapture = false dragMoved = false } - fun blocksUnderlyingInput(): Boolean = active && (mode == InspectorMode.Pick || dragMode != DragMode.None) + fun blocksUnderlyingInput(): Boolean = + active && + ( + mode == InspectorMode.Pick || + dragMode != DragMode.None || + floatingPanelPointerCapture + ) fun shouldConsumePointer(mouseX: Int, mouseY: Int): Boolean { if (!active) return false - if (dragMode != DragMode.None) return true + if (dragMode != DragMode.None || floatingPanelPointerCapture) return true if (editSession.textSelectionDragActive) return true if (mode == InspectorMode.Pick) return true return hitTestUi(mouseX, mouseY) } + fun shouldConsumeWheel(mouseX: Int, mouseY: Int): Boolean { if (!active) return false - if (dragMode != DragMode.None) return true + if (dragMode != DragMode.None || floatingPanelPointerCapture) return true if (mode == InspectorMode.Pick) return true return hitTestUi(mouseX, mouseY) } fun shouldConsumeKeyboard(mouseX: Int, mouseY: Int): Boolean { if (!active) return false - if (dragMode != DragMode.None) return true + if (dragMode != DragMode.None || floatingPanelPointerCapture) return true if (mode == InspectorMode.Pick) return true return hitTestUi(mouseX, mouseY) } @@ -342,9 +332,7 @@ class InspectorController( lastHandledPointerEvent = reason } - fun hitTestUi(mouseX: Int, mouseY: Int): Boolean { - return isInsideInspectorUi(mouseX, mouseY) - } + fun hitTestUi(mouseX: Int, mouseY: Int): Boolean = isInsideInspectorUi(mouseX, mouseY) fun onLayoutCommitted(root: DOMNode, layoutVersion: Long) { val layoutChanged = this.layoutVersion != layoutVersion @@ -380,6 +368,7 @@ class InspectorController( } refreshHoverIfNeeded() } + fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { if (!active || delta == 0) return false this.mouseX = mouseX @@ -399,11 +388,12 @@ class InspectorController( val maxScroll = maxOf(0, panelContentHeight - contentBounds.height) val steps = (kotlin.math.abs(delta) / 120).coerceAtLeast(1) val amount = steps * 18 - val next = if (delta < 0) { - (panelScrollY + amount).coerceAtMost(maxScroll) - } else { - (panelScrollY - amount).coerceAtLeast(0) - } + val next = + if (delta < 0) { + (panelScrollY + amount).coerceAtMost(maxScroll) + } else { + (panelScrollY - amount).coerceAtLeast(0) + } panelScrollY = next return true } @@ -415,17 +405,19 @@ class InspectorController( val maxFirst = (target.totalOptions - target.visibleRows).coerceAtLeast(0) if (maxFirst <= 0) return true if (target.isUnit) { - openUnitSelectScrollIndex = if (delta < 0) { - (openUnitSelectScrollIndex + steps).coerceAtMost(maxFirst) - } else { - (openUnitSelectScrollIndex - steps).coerceAtLeast(0) - } + openUnitSelectScrollIndex = + if (delta < 0) { + (openUnitSelectScrollIndex + steps).coerceAtMost(maxFirst) + } else { + (openUnitSelectScrollIndex - steps).coerceAtLeast(0) + } } else { - openValueSelectScrollIndex = if (delta < 0) { - (openValueSelectScrollIndex + steps).coerceAtMost(maxFirst) - } else { - (openValueSelectScrollIndex - steps).coerceAtLeast(0) - } + openValueSelectScrollIndex = + if (delta < 0) { + (openValueSelectScrollIndex + steps).coerceAtMost(maxFirst) + } else { + (openValueSelectScrollIndex - steps).coerceAtLeast(0) + } } return true } @@ -435,7 +427,6 @@ class InspectorController( if (activeEditProperty == null) { if (keyCode == KeyCodes.ESCAPE) { editSession.closeAllDropdowns() - variableTooltipText = null return true } return false @@ -510,7 +501,11 @@ class InspectorController( return true } KeyCodes.V -> { - val paste = ClipboardBridge.readText().replace("\r", "").replace("\n", "") + val paste = + ClipboardBridge + .readText() + .replace("\r", "") + .replace("\n", "") if (paste.isNotEmpty()) { replaceActiveSelection(paste) } else { @@ -598,6 +593,7 @@ class InspectorController( TextEditOps.moveCaretWithSelection(activeEditState, next, activeEditBuffer.length, extend) activeEditState.resetBlinkClock() } + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { if (!active) return false if (button != MouseButton.LEFT) { @@ -617,9 +613,11 @@ class InspectorController( } else { clearHoveredState() } - variableTooltipText = null if (panelState == InspectorPanelState.Minimized) { if (minimizedBounds.contains(mouseX, mouseY)) { + if (floatingPanelAuthorityEnabled) { + return true + } startMinimizedMoveDrag(mouseX, mouseY) return true } @@ -637,7 +635,7 @@ class InspectorController( if (shouldCommitActiveEdit(action)) { commitActiveTextEdit() } - if (startScrollbarDrag(mouseX, mouseY)) { + if (!floatingPanelAuthorityEnabled && startScrollbarDrag(mouseX, mouseY)) { return true } if (action != null) { @@ -649,14 +647,16 @@ class InspectorController( return true } editSession.closeAllDropdowns() - val resizeMode = resolveResizeDragMode(mouseX, mouseY) - if (resizeMode != DragMode.None) { - startExpandedDrag(resizeMode, mouseX, mouseY) - return true - } - if (headerBounds.contains(mouseX, mouseY)) { - startExpandedDrag(DragMode.Move, mouseX, mouseY) - return true + if (!floatingPanelAuthorityEnabled) { + val resizeMode = resolveResizeDragMode(mouseX, mouseY) + if (resizeMode != DragMode.None) { + startExpandedDrag(resizeMode, mouseX, mouseY) + return true + } + if (headerBounds.contains(mouseX, mouseY)) { + startExpandedDrag(DragMode.Move, mouseX, mouseY) + return true + } } val hitPanel = if (panelBounds.width > 0 && panelBounds.height > 0) panelBounds else expandedRect if (hitPanel.contains(mouseX, mouseY)) { @@ -697,7 +697,13 @@ class InspectorController( } return true } - fun onCapturedPointerMove(mouseX: Int, mouseY: Int, viewportWidth: Int, viewportHeight: Int) { + + fun onCapturedPointerMove( + mouseX: Int, + mouseY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { if (!active || dragMode == DragMode.None) return this.mouseX = mouseX this.mouseY = mouseY @@ -715,7 +721,8 @@ class InspectorController( DragMode.ResizeTopLeft, DragMode.ResizeTopRight, DragMode.ResizeBottomLeft, - DragMode.ResizeBottomRight -> updateExpandedDrag(mouseX, mouseY, viewportWidth, viewportHeight) + DragMode.ResizeBottomRight, + -> updateExpandedDrag(mouseX, mouseY, viewportWidth, viewportHeight) DragMode.ScrollbarThumb -> updateScrollbarDrag(mouseY) DragMode.None -> Unit @@ -726,16 +733,16 @@ class InspectorController( if (!active || viewportWidth <= 0 || viewportHeight <= 0) { resetNativePresentation() panelActions.clear() - dropdownOverlays.clear() dropdownLayouts.clear() return null } viewportW = viewportWidth viewportH = viewportHeight - val currentRoot = root ?: run { - resetNativePresentation() - return null - } + val currentRoot = + root ?: run { + resetNativePresentation() + return null + } rebindSelection() expandedRect = clampExpandedRect(expandedRect, viewportWidth, viewportHeight) clampMinimizedPosition(viewportWidth, viewportHeight) @@ -756,7 +763,7 @@ class InspectorController( private fun buildExpandedDomSnapshot( root: DOMNode, viewportWidth: Int, - viewportHeight: Int + viewportHeight: Int, ): InspectorDomSnapshot { val clamped = clampExpandedRect(expandedRect, viewportWidth, viewportHeight) expandedRect = clamped @@ -764,14 +771,17 @@ class InspectorController( minimizedBounds = Rect(0, 0, 0, 0) resetNativePresentation() panelActions.clear() - dropdownOverlays.clear() dropdownLayouts.clear() val headerRect = Rect(clamped.x + 6, clamped.y + 5, clamped.width - 12, (titleFontSizePx + 16).coerceAtLeast(44)) headerBounds = headerRect val pickOn = mode == InspectorMode.Pick - val selectedShort = selectedNode?.key?.toString()?.take(18) ?: "none" + val selectedShort = + selectedNode + ?.key + ?.toString() + ?.take(18) ?: "none" val headerText = "Inspector Pick:${if (pickOn) "ON" else "OFF"} Sel:$selectedShort" val headerButtonHeight = (secondaryFontSizePx + 10).coerceAtLeast(26) @@ -782,12 +792,13 @@ class InspectorController( panelActions += PanelAction(minimizeRect, ActionKind.Minimize) val bodyTop = headerRect.y + headerRect.height + 6 - val bodyRect = Rect( - clamped.x + 6, - bodyTop, - clamped.width - 12, - (clamped.height - (bodyTop - clamped.y) - 4).coerceAtLeast(24) - ) + val bodyRect = + Rect( + clamped.x + 6, + bodyTop, + clamped.width - 12, + (clamped.height - (bodyTop - clamped.y) - 4).coerceAtLeast(24), + ) contentBounds = bodyRect val infoLines = ArrayList(128) @@ -796,11 +807,27 @@ class InspectorController( var styleLines: List = emptyList() var y = bodyRect.y - val maxChars = estimateMaxChars(bodyRect.width - 12, textFontSizePx) - val buttonLabelMaxChars = estimateMaxChars((clamped.width - 32).coerceAtLeast(40), secondaryFontSizePx) - y = appendDomLine(infoLines, y, "F8 toggle, F9 mode, Esc cancel pick", maxChars) - y = appendDomLine(infoLines, y, "Hovered: ${hoveredNode?.let { nodeLabel(it) } ?: "none"}", maxChars) - y = appendDomLine(infoLines, y, "Selected: ${selectedNode?.let { nodeLabel(it) } ?: "none"}", maxChars) + val maxChars = InspectorPresentationSupport.estimateMaxChars(bodyRect.width - 12, textFontSizePx) + val buttonLabelMaxChars = + InspectorPresentationSupport.estimateMaxChars( + (clamped.width - 32).coerceAtLeast(40), + secondaryFontSizePx, + ) + y = appendDomLine(infoLines, y, "F12 toggle, F9 mode, Esc cancel pick", maxChars) + y = + appendDomLine( + infoLines, + y, + "Hovered: ${hoveredNode?.let { InspectorPresentationSupport.nodeLabel(it) } ?: "none"}", + maxChars, + ) + y = + appendDomLine( + infoLines, + y, + "Selected: ${selectedNode?.let { InspectorPresentationSupport.nodeLabel(it) } ?: "none"}", + maxChars, + ) y = appendDomLine(infoLines, y, "Inspector handled last: $lastHandledPointerEvent", maxChars) y = appendDomLine(infoLines, y, "Pointer over Inspector: $pointerOverInspectorUi", maxChars) y = appendDomLine(infoLines, y, "Hover pick enabled: $hoverPickEnabled", maxChars) @@ -823,27 +850,50 @@ class InspectorController( parentLabel = null, childLabels = emptyList(), styleEditorHeight = 0, - styleLines = emptyList() + styleLines = emptyList(), ) } - val pathLines = wrapPathLines(pathToNode(root, selected), maxChars) + val pathLines = + InspectorPresentationSupport.wrapPathLines( + InspectorPresentationSupport.pathToNode(root, selected), + maxChars, + ) pathLines.forEach { line -> infoLines += line y += lineHeightPx } - val boxes = computeBoxes(selected) - y = appendDomLine(infoLines, y, "Border box: ${rectLabel(boxes.border)}", maxChars) - y = appendDomLine(infoLines, y, "Content box: ${rectLabel(boxes.content)}", maxChars) - y = appendDomLine(infoLines, y, "Margin box: ${rectLabel(boxes.margin)}", maxChars) + val boxes = InspectorGeometrySupport.computeBoxes(selected) + y = + appendDomLine( + infoLines, + y, + "Border box: ${InspectorPresentationSupport.rectLabel(boxes.border)}", + maxChars, + ) + y = + appendDomLine( + infoLines, + y, + "Content box: ${InspectorPresentationSupport.rectLabel(boxes.content)}", + maxChars, + ) + y = + appendDomLine( + infoLines, + y, + "Margin box: ${InspectorPresentationSupport.rectLabel(boxes.margin)}", + maxChars, + ) boxes.parentContent?.let { - y = appendDomLine(infoLines, y, "Parent content: ${rectLabel(it)}", maxChars) + y = appendDomLine(infoLines, y, "Parent content: ${InspectorPresentationSupport.rectLabel(it)}", maxChars) } - val localPos = selected.parent?.let { parent -> - val parentContent = contentRect(parent) - "${selected.bounds.x - parentContent.x},${selected.bounds.y - parentContent.y}" - } ?: "${selected.bounds.x},${selected.bounds.y}" + val localPos = + selected.parent?.let { parent -> + val parentContent = InspectorGeometrySupport.contentRect(parent) + "${selected.bounds.x - parentContent.x},${selected.bounds.y - parentContent.y}" + } ?: "${selected.bounds.x},${selected.bounds.y}" y = appendDomLine(infoLines, y, "Local pos: $localPos", maxChars) selected.inspectorScrollOffset()?.let { (sx, sy) -> y = appendDomLine(infoLines, y, "Scroll: x=$sx y=$sy", maxChars) @@ -851,7 +901,7 @@ class InspectorController( val parent = selected.parent if (parent != null) { - parentLabel = ellipsize("[Parent] ${nodeLabel(parent)}", buttonLabelMaxChars) + parentLabel = ellipsize("[Parent] ${InspectorPresentationSupport.nodeLabel(parent)}", buttonLabelMaxChars) val row = Rect(clamped.x + 10, y - panelScrollY, clamped.width - 20, rowHeightPx) panelActions += PanelAction(row, ActionKind.Parent) y += rowHeightPx + 2 @@ -862,7 +912,8 @@ class InspectorController( y = appendDomLine(infoLines, y, "Children:", maxChars) for (index in children.indices) { val child = children[index] - childLabels += ellipsize("[$index] ${nodeLabel(child)}", buttonLabelMaxChars) + childLabels += + ellipsize("[$index] ${InspectorPresentationSupport.nodeLabel(child)}", buttonLabelMaxChars) val row = Rect(clamped.x + 10, y - panelScrollY, clamped.width - 20, rowHeightPx) panelActions += PanelAction(row, ActionKind.Child, index) y += rowHeightPx + 2 @@ -871,18 +922,57 @@ class InspectorController( val inspection = selectionStyle(selected) val styleEditorStartY = y + 2 - y = buildNativeStyleEditorSection( - panelRect = clamped, - selected = selected, - inspection = inspection, - startY = styleEditorStartY - ) + val styleEditorSnapshots = + styleEditorSnapshotBuilder.build( + InspectorStyleEditorSnapshotBuildContext( + panelRect = clamped, + panelBounds = panelBounds, + selected = selected, + inspection = inspection, + editableProperties = editablePropertiesFor(selected), + startY = styleEditorStartY, + lineHeightPx = lineHeightPx, + rowHeightPx = rowHeightPx, + secondaryFontSizePx = secondaryFontSizePx, + pointerProjectionScrollY = resolvedNativePointerProjectionScrollY(), + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportW, + viewportHeight = viewportH, + openValueSelectProperty = openValueSelectProperty, + openUnitSelectProperty = openUnitSelectProperty, + openValueSelectScrollIndex = openValueSelectScrollIndex, + openUnitSelectScrollIndex = openUnitSelectScrollIndex, + ), + ) + y = styleEditorSnapshots.endY + nativeVariableTooltip = styleEditorSnapshots.variableTooltip + nativeStyleEditorRows.addAll(styleEditorSnapshots.rows) + nativeDropdowns.addAll(styleEditorSnapshots.dropdowns) + styleEditorSnapshots.dropdownLayouts.forEach { layout -> + dropdownLayouts += + DropdownLayout( + rect = layout.rect, + property = layout.property, + isUnit = layout.unitSelect, + totalOptions = layout.totalOptions, + visibleRows = layout.visibleRows, + ) + } + styleEditorSnapshots.actionSpecs.forEach { action -> + panelActions += toPanelAction(action) + } + nativeStyleEditorResetRect = styleEditorSnapshots.resetRect + nativeStyleEditorClearRect = styleEditorSnapshots.clearRect + openValueSelectScrollIndex = styleEditorSnapshots.openValueSelectScrollIndex + openUnitSelectScrollIndex = styleEditorSnapshots.openUnitSelectScrollIndex val styleEditorHeight = (y - styleEditorStartY).coerceAtLeast(0) y = appendDomLine(infoLines, y, "Computed styles:", maxChars) - styleLines = styleRows(inspection).flatMap { line -> - wrapText(line, maxChars) - } + styleLines = + styleRows(inspection).flatMap { line -> + InspectorPresentationSupport.wrapText(line, maxChars) + } y += styleLines.size * lineHeightPx panelContentHeight = (y - bodyRect.y).coerceAtLeast(0) @@ -901,14 +991,11 @@ class InspectorController( parentLabel = parentLabel, childLabels = childLabels, styleEditorHeight = styleEditorHeight, - styleLines = styleLines + styleLines = styleLines, ) } - private fun buildMinimizedDomSnapshot( - viewportWidth: Int, - viewportHeight: Int - ): InspectorDomSnapshot { + private fun buildMinimizedDomSnapshot(viewportWidth: Int, viewportHeight: Int): InspectorDomSnapshot { val chipWidth = minimizedWidth().coerceAtLeast(minChipWidth) val chipHeight = minimizedHeight() clampMinimizedPosition(viewportWidth, viewportHeight) @@ -919,7 +1006,6 @@ class InspectorController( contentBounds = Rect(0, 0, 0, 0) resetNativePresentation() panelActions.clear() - dropdownOverlays.clear() dropdownLayouts.clear() panelScrollY = 0 panelContentHeight = 0 @@ -928,9 +1014,18 @@ class InspectorController( scrollbarThumbRect = Rect(0, 0, 0, 0) val badge = if (mode == InspectorMode.Pick) "[Pick]" else "[Locked]" - val selectedShort = selectedNode?.key?.toString()?.let { " $it" } ?: "" - val maxChars = estimateMaxChars(chipRect.width - 12, secondaryFontSizePx) - val lines = wrapMinimizedLabel("Inspector $badge$selectedShort", maxChars, maxLines = 2) + val selectedShort = + selectedNode + ?.key + ?.toString() + ?.let { " $it" } ?: "" + val maxChars = InspectorPresentationSupport.estimateMaxChars(chipRect.width - 12, secondaryFontSizePx) + val lines = + InspectorPresentationSupport.wrapMinimizedLabel( + "Inspector $badge$selectedShort", + maxChars, + maxLines = 2, + ) return InspectorDomSnapshot( panelState = InspectorPanelState.Minimized, panelRect = chipRect, @@ -942,7 +1037,7 @@ class InspectorController( parentLabel = null, childLabels = emptyList(), styleEditorHeight = 0, - styleLines = emptyList() + styleLines = emptyList(), ) } @@ -950,40 +1045,38 @@ class InspectorController( lines: MutableList, y: Int, text: String, - maxChars: Int + maxChars: Int, ): Int { - val wrapped = wrapText(text, maxChars) + val wrapped = InspectorPresentationSupport.wrapText(text, maxChars) lines += wrapped return y + wrapped.size * lineHeightPx } - private fun captureNativeHighlightsAndTooltips( - selected: DOMNode?, - viewportWidth: Int, - viewportHeight: Int - ) { - nativeSelectedHighlight = selected?.let { node -> - val boxes = computeHighlightBoxes(node) - InspectorHighlightSnapshot( - marginRect = boxes.margin, - borderRect = boxes.border, - paddingRect = boxes.padding, - contentRect = boxes.content, - parentContentRect = boxes.parentContent - ) - } - if (hoverPickEnabled) { - val hovered = hoveredNode - nativeHoveredHighlight = hovered?.let { node -> - val boxes = computeHighlightBoxes(node) + private fun captureNativeHighlightsAndTooltips(selected: DOMNode?, viewportWidth: Int, viewportHeight: Int) { + nativeSelectedHighlight = + selected?.let { node -> + val boxes = InspectorGeometrySupport.computeHighlightBoxes(node) InspectorHighlightSnapshot( marginRect = boxes.margin, borderRect = boxes.border, paddingRect = boxes.padding, contentRect = boxes.content, - parentContentRect = boxes.parentContent + parentContentRect = boxes.parentContent, ) } + if (hoverPickEnabled) { + val hovered = hoveredNode + nativeHoveredHighlight = + hovered?.let { node -> + val boxes = InspectorGeometrySupport.computeHighlightBoxes(node) + InspectorHighlightSnapshot( + marginRect = boxes.margin, + borderRect = boxes.border, + paddingRect = boxes.padding, + contentRect = boxes.content, + parentContentRect = boxes.parentContent, + ) + } if (hovered != null) { val label = resolveTooltipLabel(hovered) val boxW = (label.length * (secondaryFontSizePx / 2) + 18).coerceIn(140, viewportWidth - 8) @@ -994,299 +1087,39 @@ class InspectorController( } } - private fun buildNativeStyleEditorSection( - panelRect: Rect, - selected: DOMNode, - inspection: StyleInspection, - startY: Int - ): Int { - var y = startY + lineHeightPx - nativeVariableTooltip = null - val pointerProjectionScrollY = resolvedNativePointerProjectionScrollY() - - val rowLeft = panelRect.x + 10 - val rowWidth = panelRect.width - 20 - val btnWidth = 40 - val gap = 6 - val controlHeight = (rowHeightPx - 8).coerceAtLeast(22) - val labelLineHeight = (secondaryFontSizePx - 2).coerceAtLeast(18) - val properties = editablePropertiesFor(selected) - for (property in properties) { - val overrideExpr = StyleEngine.inspectorOverrideFor(selected, property) - val effectiveValue = overrideExpr?.let(::expressionLabel) ?: literalFromComputed(inspection.computed, property) - val sourceTag = if (overrideExpr != null) "ins" else (inspection.propertySources[property]?.source ?: "default") - - val labelText = "${property.key} [$sourceTag]" - val buttonsRight = rowLeft + rowWidth - 8 - val maxLabelWidth = (rowWidth - btnWidth - gap - 36).coerceAtLeast(80) - val labelWidth = (rowWidth * 0.40f).toInt().coerceIn(80, maxLabelWidth) - val labelMaxChars = estimateMaxChars((labelWidth - 12).coerceAtLeast(24), labelLineHeight) - val labelLineCount = wrapText(labelText, labelMaxChars).size.coerceAtLeast(1) - val rowHeight = maxOf(rowHeightPx, labelLineCount * labelLineHeight + 10, controlHeight + 8) - val rowRect = Rect(rowLeft, y, rowWidth, rowHeight) - val controlX = rowRect.x + labelWidth - val controlWidth = (buttonsRight - controlX - btnWidth - gap).coerceAtLeast(36) - val controlY = rowRect.y + ((rowRect.height - controlHeight) / 2) - val resetRect = Rect(buttonsRight - btnWidth, controlY, btnWidth, controlHeight) - panelActions += PanelAction( - bounds = resetRect, + private fun toPanelAction(action: InspectorStyleEditorActionSpec): PanelAction { + fun editAction(operation: EditOperation): PanelAction = + PanelAction( + bounds = action.bounds, kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ResetProperty - ) - - val editor = InspectorEditorRegistry.describe( - property = property, - literal = effectiveValue, - expression = overrideExpr - ) - val contentRect = Rect(controlX, controlY, controlWidth, controlHeight) - - var rowSnapshot = InspectorStyleEditorRowSnapshot( - property = property, - sourceTag = sourceTag, - rowRect = rowRect, - labelText = labelText, - resetRect = resetRect, - editorKind = editor.kind, - controlRect = contentRect, - controlValue = effectiveValue, - controlOpen = false, - controlHovered = false, - inputActive = false, - decrementRect = null, - inputRect = null, - incrementRect = null, - unitRect = null, - unitValue = null, - unitOpen = false, - colorPreviewRect = null, - colorPreviewColor = null + property = requireNotNull(action.property), + editOperation = operation, + step = action.step, + payload = action.payload, ) - - when (editor.kind) { - InspectorEditorKind.EnumSelect, - InspectorEditorKind.FontSelect -> { - val isOpen = openValueSelectProperty == property - val projectedControlRect = projectRectForNativePointer(contentRect, pointerProjectionScrollY) - panelActions += PanelAction( - bounds = contentRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ToggleValueSelect - ) - rowSnapshot = rowSnapshot.copy( - controlOpen = isOpen, - controlHovered = projectedControlRect.contains(mouseX, mouseY) - ) - } - - InspectorEditorKind.StringInput -> { - var previewRect: Rect? = null - var previewColor: Int? = null - if (editor.showColorPreview) { - previewRect = Rect( - x = contentRect.x + contentRect.width - (contentRect.height - 8).coerceAtLeast(10) - 6, - y = contentRect.y + 4, - width = (contentRect.height - 8).coerceAtLeast(10), - height = (contentRect.height - 8).coerceAtLeast(10) - ) - previewColor = runCatching { parseColor(effectiveValue) }.getOrNull() - panelActions += PanelAction( - bounds = previewRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.OpenColorPicker - ) - } - rowSnapshot = rowSnapshot.copy( - controlValue = effectiveValue, - inputActive = false, - colorPreviewRect = previewRect, - colorPreviewColor = previewColor - ) - } - - InspectorEditorKind.NumericInput -> { - val step = StylePropertyRegistry.descriptor(property).numericStep - val parsed = InspectorEditorRegistry.parseNumericLiteral(property, effectiveValue) - val numericValue = parsed?.numberText ?: "0" - val unit = parsed?.unit ?: InspectorEditorRegistry.defaultNumericUnit(property) - val buttonWidth = 34 - val unitWidth = if (editor.supportsUnits) 68 else 0 - val inputWidth = - (contentRect.width - buttonWidth * 2 - unitWidth - (if (editor.supportsUnits) 12 else 6)) - .coerceAtLeast(64) - val decRect = Rect(contentRect.x, contentRect.y, buttonWidth, contentRect.height) - val inputRect = Rect(decRect.x + decRect.width + 4, contentRect.y, inputWidth, contentRect.height) - val incRect = Rect(inputRect.x + inputRect.width + 4, contentRect.y, buttonWidth, contentRect.height) - panelActions += PanelAction( - decRect, - ActionKind.EditProperty, - property = property, - editOperation = EditOperation.Decrement, - step = step - ) - - panelActions += PanelAction( - incRect, - ActionKind.EditProperty, - property = property, - editOperation = EditOperation.Increment, - step = step - ) - var unitRect: Rect? = null - if (editor.supportsUnits) { - unitRect = Rect(incRect.x + incRect.width + 4, contentRect.y, unitWidth, contentRect.height) - panelActions += PanelAction( - bounds = unitRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ToggleUnitSelect - ) - } - rowSnapshot = rowSnapshot.copy( - controlValue = numericValue, - inputActive = false, - decrementRect = decRect, - inputRect = inputRect, - incrementRect = incRect, - unitRect = unitRect, - unitValue = if (editor.supportsUnits) unit?.token else null, - unitOpen = openUnitSelectProperty == property, - controlOpen = false - ) - } - } - - val projectedRowRect = projectRectForNativePointer(rowRect, pointerProjectionScrollY) - if (overrideExpr is StyleExpression.VariableRef && projectedRowRect.contains(mouseX, mouseY)) { - val resolved = StyleEngine.resolveInspectorVariable(overrideExpr.name) - val body = resolved.getOrElse { "unresolved (${it.message ?: "unknown error"})" } - nativeVariableTooltip = InspectorTooltipSnapshot( - text = "${overrideExpr.name} = $body", - rect = Rect( - x = (projectedRowRect.x + projectedRowRect.width - 360).coerceAtLeast(panelBounds.x + 8), - y = (projectedRowRect.y - lineHeightPx - 8).coerceAtLeast(panelBounds.y + 8), - width = 352, - height = lineHeightPx + 10 - ) + return when (action.type) { + InspectorStyleEditorActionType.ResetProperty -> editAction(EditOperation.ResetProperty) + InspectorStyleEditorActionType.ToggleValueSelect -> editAction(EditOperation.ToggleValueSelect) + InspectorStyleEditorActionType.SelectValueOption -> editAction(EditOperation.SelectValueOption) + InspectorStyleEditorActionType.OpenColorPicker -> editAction(EditOperation.OpenColorPicker) + InspectorStyleEditorActionType.Decrement -> editAction(EditOperation.Decrement) + InspectorStyleEditorActionType.Increment -> editAction(EditOperation.Increment) + InspectorStyleEditorActionType.ToggleUnitSelect -> editAction(EditOperation.ToggleUnitSelect) + InspectorStyleEditorActionType.SelectUnitOption -> editAction(EditOperation.SelectUnitOption) + InspectorStyleEditorActionType.ResetSelectedOverrides -> + PanelAction( + bounds = action.bounds, + kind = ActionKind.ResetSelectedOverrides, ) - } - if (openValueSelectProperty == property && editor.options.isNotEmpty()) { - buildNativeDropdownSnapshot( - x = contentRect.x, - y = contentRect.y + contentRect.height + 2, - width = contentRect.width, - options = editor.options, - property = property, - operation = EditOperation.SelectValueOption, - pointerProjectionScrollY = pointerProjectionScrollY - ) - rowSnapshot = rowSnapshot.copy(controlOpen = true) - } - if (openUnitSelectProperty == property && editor.supportsUnits) { - val units = InspectorEditorRegistry.unitOptions().map { it.token } - buildNativeDropdownSnapshot( - x = contentRect.x + contentRect.width - 90, - y = contentRect.y + contentRect.height + 2, - width = 90, - options = units, - property = property, - operation = EditOperation.SelectUnitOption, - pointerProjectionScrollY = pointerProjectionScrollY + InspectorStyleEditorActionType.ClearAllOverrides -> + PanelAction( + bounds = action.bounds, + kind = ActionKind.ClearAllOverrides, ) - rowSnapshot = rowSnapshot.copy(unitOpen = true) - } - - nativeStyleEditorRows += rowSnapshot - y += rowHeight + 4 } - - val actionHeight = (secondaryFontSizePx + 10).coerceAtLeast(28) - val resetRect = Rect(rowLeft, y, 140, actionHeight) - val clearRect = Rect(rowLeft + 148, y, 160, actionHeight) - panelActions += PanelAction(resetRect, ActionKind.ResetSelectedOverrides) - panelActions += PanelAction(clearRect, ActionKind.ClearAllOverrides) - nativeStyleEditorResetRect = resetRect - nativeStyleEditorClearRect = clearRect - y += actionHeight + 4 - - return y } - private fun buildNativeDropdownSnapshot( - x: Int, - y: Int, - width: Int, - options: List, - property: StyleProperty, - operation: EditOperation, - pointerProjectionScrollY: Int - ) { - if (options.isEmpty()) return - val maxRows = 8 - val visibleRows = minOf(maxRows, options.size) - val maxFirst = (options.size - visibleRows).coerceAtLeast(0) - val isUnit = operation == EditOperation.SelectUnitOption - val rawFirst = if (isUnit) openUnitSelectScrollIndex else openValueSelectScrollIndex - val first = rawFirst.coerceIn(0, maxFirst) - if (isUnit) { - openUnitSelectScrollIndex = first - } else { - openValueSelectScrollIndex = first - } - val shown = options.subList(first, first + visibleRows) - val optionHeight = rowHeightPx - val popupHeight = optionHeight * shown.size + 6 - val viewportWidth = viewportW.coerceAtLeast(1) - val viewportHeight = viewportH.coerceAtLeast(1) - val clampedX = x.coerceIn(2, (viewportWidth - width - 2).coerceAtLeast(2)) - val clampedY = y.coerceIn(2, (viewportHeight - popupHeight - 2).coerceAtLeast(2)) - val popupRect = Rect(clampedX, clampedY, width, popupHeight) - dropdownLayouts += DropdownLayout( - rect = popupRect, - property = property, - isUnit = isUnit, - totalOptions = options.size, - visibleRows = visibleRows - ) - - var optionY = popupRect.y + 3 - val optionSnapshots = ArrayList(shown.size) - shown.forEach { option -> - val optionRect = Rect(popupRect.x + 3, optionY, popupRect.width - 6, optionHeight - 2) - val hovered = projectRectForNativePointer(optionRect, pointerProjectionScrollY).contains(mouseX, mouseY) - optionSnapshots += InspectorDropdownOptionSnapshot( - rect = optionRect, - text = ellipsize(option, 30), - value = option, - hovered = hovered - ) - panelActions += PanelAction( - bounds = optionRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = operation, - payload = option - ) - optionY += optionHeight - } - - val footer = if (options.size > visibleRows) { - "${first + 1}-${first + visibleRows}/${options.size}" - } else { - null - } - nativeDropdowns += InspectorDropdownSnapshot( - popupRect = popupRect, - property = property, - unitSelect = isUnit, - options = optionSnapshots, - footerText = footer - ) - } private fun updateScrollbarGeometry(bodyRect: Rect) { if (panelContentHeight <= bodyRect.height || bodyRect.height <= 0) { scrollbarTrackRect = Rect(0, 0, 0, 0) @@ -1294,44 +1127,44 @@ class InspectorController( return } val trackWidth = 4 - val track = Rect( - bodyRect.x + bodyRect.width - trackWidth - 2, - bodyRect.y + 2, - trackWidth, - (bodyRect.height - 4).coerceAtLeast(8) - ) + val track = + Rect( + bodyRect.x + bodyRect.width - trackWidth - 2, + bodyRect.y + 2, + trackWidth, + (bodyRect.height - 4).coerceAtLeast(8), + ) val maxScroll = (panelContentHeight - bodyRect.height).coerceAtLeast(1) val thumbHeight = ((track.height.toFloat() * bodyRect.height.toFloat() / panelContentHeight.toFloat()).toInt()).coerceIn( 10, - track.height + track.height, ) val travel = (track.height - thumbHeight).coerceAtLeast(0) val thumbY = track.y + ((panelScrollY.toFloat() / maxScroll.toFloat()) * travel.toFloat()).toInt() scrollbarTrackRect = track scrollbarThumbRect = Rect(track.x, thumbY, track.width, thumbHeight) } - internal fun debugPickToggleBounds(): Rect? { - return panelActions.lastOrNull { it.kind == ActionKind.TogglePick }?.bounds - } - internal fun debugMinimizeBounds(): Rect? { - return panelActions.lastOrNull { it.kind == ActionKind.Minimize }?.bounds - } + internal fun portalPickToggleBounds(): Rect? = panelActions.lastOrNull { it.kind == ActionKind.TogglePick }?.bounds - internal fun debugContentRect(): Rect = contentBounds + internal fun portalMinimizeBounds(): Rect? = panelActions.lastOrNull { it.kind == ActionKind.Minimize }?.bounds - internal fun debugScrollbarThumbRect(): Rect = if (nativeDomBodyScrollStateActive) { - nativeDomScrollbarThumbRectOverride ?: Rect(0, 0, 0, 0) - } else { - scrollbarThumbRect - } + internal fun portalContentRect(): Rect = contentBounds - internal fun debugScrollbarTrackRect(): Rect = if (nativeDomBodyScrollStateActive) { - nativeDomScrollbarTrackRectOverride ?: Rect(0, 0, 0, 0) - } else { - scrollbarTrackRect - } + internal fun portalScrollbarThumbRect(): Rect = + if (nativeDomBodyScrollStateActive) { + nativeDomScrollbarThumbRectOverride ?: Rect(0, 0, 0, 0) + } else { + scrollbarThumbRect + } + + internal fun portalScrollbarTrackRect(): Rect = + if (nativeDomBodyScrollStateActive) { + nativeDomScrollbarTrackRectOverride ?: Rect(0, 0, 0, 0) + } else { + scrollbarTrackRect + } internal fun onNativeDomBodyScrollState(scrollY: Int, trackRect: Rect?, thumbRect: Rect?) { nativeDomBodyScrollStateActive = true @@ -1348,28 +1181,48 @@ class InspectorController( minimizedBounds = Rect(0, 0, 0, 0) } - internal fun onNativeDomMinimizedPanelPosition(x: Int, y: Int, viewportWidth: Int, viewportHeight: Int) { + internal fun onFloatingPanelRectChanged(rect: Rect, viewportWidth: Int, viewportHeight: Int) { + onNativeDomExpandedPanelRect(rect, viewportWidth, viewportHeight) + } + + internal fun onFloatingPanelPointerCaptureChanged(captured: Boolean) { + floatingPanelPointerCapture = captured + } + + internal fun setFloatingPanelAuthorityEnabled(enabled: Boolean) { + floatingPanelAuthorityEnabled = enabled + if (enabled && dragMode != DragMode.ScrollbarThumb) { + dragMode = DragMode.None + } + } + + internal fun onNativeDomMinimizedPanelPosition( + x: Int, + y: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { minimizedPosX = x minimizedPosY = y clampMinimizedPosition(viewportWidth, viewportHeight) minimizedBounds = Rect(minimizedPosX, minimizedPosY, minimizedWidth(), minimizedHeight()) } - internal fun debugSelectedHighlight(): InspectorHighlightSnapshot? = nativeSelectedHighlight + internal fun portalSelectedHighlight(): InspectorHighlightSnapshot? = nativeSelectedHighlight - internal fun debugHoveredHighlight(): InspectorHighlightSnapshot? = nativeHoveredHighlight + internal fun portalHoveredHighlight(): InspectorHighlightSnapshot? = nativeHoveredHighlight - internal fun debugCursorTooltip(): InspectorTooltipSnapshot? = nativeCursorTooltip + internal fun portalCursorTooltip(): InspectorTooltipSnapshot? = nativeCursorTooltip - internal fun debugVariableTooltip(): InspectorTooltipSnapshot? = nativeVariableTooltip + internal fun portalVariableTooltip(): InspectorTooltipSnapshot? = nativeVariableTooltip - internal fun debugStyleEditorRows(): List = nativeStyleEditorRows + internal fun portalStyleEditorRows(): List = nativeStyleEditorRows - internal fun debugStyleEditorResetRect(): Rect = nativeStyleEditorResetRect + internal fun portalStyleEditorResetRect(): Rect = nativeStyleEditorResetRect - internal fun debugStyleEditorClearRect(): Rect = nativeStyleEditorClearRect + internal fun portalStyleEditorClearRect(): Rect = nativeStyleEditorClearRect - internal fun debugStyleEditorDropdowns(): List = nativeDropdowns + internal fun portalStyleEditorDropdowns(): List = nativeDropdowns internal fun onNativeDomDropdownSnapshots(dropdowns: List) { nativeDropdowns.clear() @@ -1382,20 +1235,19 @@ class InspectorController( } val selected = selectedNode ?: return emptyList() val literal = literalForEdit(selected, property) - val descriptor = InspectorEditorRegistry.describe( - property = property, - literal = literal, - expression = StyleEngine.inspectorOverrideFor(selected, property) - ) + val descriptor = + InspectorEditorRegistry.describe( + property = property, + literal = literal, + expression = StyleEngine.inspectorOverrideFor(selected, property), + ) if (descriptor.kind != InspectorEditorKind.EnumSelect && descriptor.kind != InspectorEditorKind.FontSelect) { return emptyList() } return descriptor.options } - internal fun hasOpenStyleDropdown(): Boolean { - return openValueSelectProperty != null || openUnitSelectProperty != null - } + internal fun hasOpenStyleDropdown(): Boolean = openValueSelectProperty != null || openUnitSelectProperty != null internal fun closeOpenStyleDropdowns(): Boolean { if (!hasOpenStyleDropdown()) return false @@ -1413,11 +1265,12 @@ class InspectorController( val steps = (kotlin.math.abs(delta) / 120).coerceAtLeast(1) val current = if (openDropdown.unitSelect) openUnitSelectScrollIndex else openValueSelectScrollIndex - val next = if (delta < 0) { - (current + steps).coerceAtMost(maxFirst) - } else { - (current - steps).coerceAtLeast(0) - } + val next = + if (delta < 0) { + (current + steps).coerceAtMost(maxFirst) + } else { + (current - steps).coerceAtLeast(0) + } if (next == current) return true if (openDropdown.unitSelect) { @@ -1430,15 +1283,17 @@ class InspectorController( internal fun debugActiveEditBuffer(): String? = activeEditProperty?.let { activeEditBuffer } - internal fun debugActiveEditCaret(): Int? = activeEditProperty?.let { - activeEditState.clampToLength(activeEditBuffer.length) - activeEditState.caretIndex - } + internal fun debugActiveEditCaret(): Int? = + activeEditProperty?.let { + activeEditState.clampToLength(activeEditBuffer.length) + activeEditState.caretIndex + } - internal fun debugActiveEditSelectionRange(): Pair? = activeEditProperty?.let { - activeEditState.clampToLength(activeEditBuffer.length) - activeEditState.selectionStart() to activeEditState.selectionEnd() - } + internal fun debugActiveEditSelectionRange(): Pair? = + activeEditProperty?.let { + activeEditState.clampToLength(activeEditBuffer.length) + activeEditState.selectionStart() to activeEditState.selectionEnd() + } internal fun onPickTogglePressed() { setPickMode(mode != InspectorMode.Pick) @@ -1470,7 +1325,7 @@ class InspectorController( operation = EditOperation.ResetProperty, step = 1f, payload = null, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1484,7 +1339,7 @@ class InspectorController( operation = EditOperation.ToggleValueSelect, step = 1f, payload = null, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1498,7 +1353,7 @@ class InspectorController( operation = EditOperation.SelectValueOption, step = 1f, payload = option, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1512,7 +1367,7 @@ class InspectorController( operation = EditOperation.Increment, step = StylePropertyRegistry.descriptor(property).numericStep, payload = null, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1526,7 +1381,7 @@ class InspectorController( operation = EditOperation.Decrement, step = StylePropertyRegistry.descriptor(property).numericStep, payload = null, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1540,7 +1395,7 @@ class InspectorController( operation = EditOperation.ToggleUnitSelect, step = 1f, payload = null, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1554,7 +1409,7 @@ class InspectorController( operation = EditOperation.SelectUnitOption, step = 1f, payload = option, - actionBounds = Rect(0, 0, 0, 0) + actionBounds = Rect(0, 0, 0, 0), ) mode = InspectorMode.Locked return true @@ -1583,7 +1438,7 @@ class InspectorController( private data class OpenStyleDropdown( val unitSelect: Boolean, - val optionCount: Int + val optionCount: Int, ) private fun resolveOpenStyleDropdown(): OpenStyleDropdown? { @@ -1597,17 +1452,18 @@ class InspectorController( val valueProperty = openValueSelectProperty ?: return null val selected = selectedNode ?: return null val literal = literalForEdit(selected, valueProperty) - val editor = InspectorEditorRegistry.describe( - property = valueProperty, - literal = literal, - expression = StyleEngine.inspectorOverrideFor(selected, valueProperty) - ) + val editor = + InspectorEditorRegistry.describe( + property = valueProperty, + literal = literal, + expression = StyleEngine.inspectorOverrideFor(selected, valueProperty), + ) val optionCount = editor.options.size if (optionCount <= 0) return null return OpenStyleDropdown(unitSelect = false, optionCount = optionCount) } - internal fun debugApplyLiteralOverride(property: StyleProperty, literal: String): Boolean { + internal fun portalApplyLiteralOverride(property: StyleProperty, literal: String): Boolean { val selected = selectedNode ?: return false val normalized = literal.trim() return runCatching { @@ -1621,7 +1477,11 @@ class InspectorController( } } - internal fun debugApplyNumericOverride(property: StyleProperty, numericLiteral: String, unitToken: String?): Boolean { + internal fun portalApplyNumericOverride( + property: StyleProperty, + numericLiteral: String, + unitToken: String?, + ): Boolean { val selected = selectedNode ?: return false val numberText = numericLiteral.trim() if (numberText.isEmpty() || numberText == "-" || numberText == "." || numberText == "-.") { @@ -1653,6 +1513,7 @@ class InspectorController( nativeDomScrollbarTrackRectOverride = null nativeDomScrollbarThumbRectOverride = null } + private fun resolvedNativePointerProjectionScrollY(): Int { val current = nativeDomPanelScrollYOverride return when { @@ -1662,11 +1523,6 @@ class InspectorController( } } - private fun projectRectForNativePointer(rect: Rect, scrollY: Int): Rect { - if (scrollY <= 0) return rect - return Rect(rect.x, rect.y - scrollY, rect.width, rect.height) - } - private fun selectHovered(lock: Boolean) { val hovered = hoveredNode ?: return selectedNode = hovered @@ -1688,7 +1544,6 @@ class InspectorController( tooltipNodeRef = null tooltipLabelCache = "" panelActions.clear() - dropdownOverlays.clear() dropdownLayouts.clear() panelBounds = Rect(0, 0, 0, 0) minimizedBounds = Rect(0, 0, 0, 0) @@ -1698,6 +1553,8 @@ class InspectorController( hoverPickEnabled = true styleEditorError = null dragMode = DragMode.None + floatingPanelPointerCapture = false + floatingPanelAuthorityEnabled = false dragMoved = false panelScrollY = 0 panelContentHeight = 0 @@ -1706,20 +1563,19 @@ class InspectorController( scrollbarThumbRect = Rect(0, 0, 0, 0) scrollbarDragOffsetY = 0 editSession.resetAll() - variableTooltipText = null - variableTooltipRect = Rect(0, 0, 0, 0) resetNativePresentation() colorPickerManager.close() } private fun rebindSelection() { - val currentRoot = root ?: run { - selectedNode = null - selectedKeyToken = null - selectedClass = null - cachedStyle = null - return - } + val currentRoot = + root ?: run { + selectedNode = null + selectedKeyToken = null + selectedClass = null + cachedStyle = null + return + } val key = selectedKeyToken val klass = selectedClass if (key == null || klass == null) { @@ -1753,12 +1609,13 @@ class InspectorController( ) { return } - val currentRoot = root ?: run { - hoveredPath = emptyList() - hoveredNode = null - hoverDirty = false - return - } + val currentRoot = + root ?: run { + hoveredPath = emptyList() + hoveredNode = null + hoverDirty = false + return + } hoveredPath = collectHoverChain(currentRoot, mouseX, mouseY) hoveredNode = resolveInspectorHoverCandidate(hoveredPath) lastHoverMouseX = mouseX @@ -1767,81 +1624,10 @@ class InspectorController( hoverDirty = false } - private fun resolveInspectorHoverCandidate(path: List): DOMNode? { - return path.lastOrNull { node -> shouldInspectorPickNode(node) } - } - - private fun shouldInspectorPickNode(node: DOMNode): Boolean { - return node.display != Display.None - } - - private fun appendHighlightCommands( - node: DOMNode, - hovered: Boolean, - selected: Boolean, - out: MutableList - ) { - val boxes = computeHighlightBoxes(node) - if (selected) { - addFill(out, boxes.margin, 0x22F3B33D) - addFill(out, boxes.padding, 0x2226A69A) - addFill(out, boxes.content, 0x224285F4) - addOutline(out, boxes.margin, 0x99F3B33D.toInt()) - addOutline(out, boxes.border, 0xCCFF9800.toInt()) - addOutline(out, boxes.padding, 0x9926A69A.toInt()) - addOutline(out, boxes.content, 0x994285F4.toInt()) - boxes.parentContent?.let { addOutline(out, it, 0x66FF5252) } - return - } - if (hovered) { - addOutline(out, boxes.border, 0xCC47A0FF.toInt()) - } - } - - private fun appendCursorTooltip( - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { - if (!hoverPickEnabled) return - val node = hoveredNode ?: return - val label = resolveTooltipLabel(node) - val boxW = (label.length * (secondaryFontSizePx / 2) + 18).coerceIn(140, viewportWidth - 8) - val boxH = (secondaryFontSizePx + 10).coerceAtLeast(26) - val tooltipRect = resolveTooltipRect(viewportWidth, viewportHeight, boxW, boxH) - addFill(out, tooltipRect, 0xDD11151A.toInt()) - addOutline(out, tooltipRect, 0xCC3F4A57.toInt()) - out += RenderCommand.DrawText( - text = label, - x = tooltipRect.x + 4, - y = tooltipRect.y + 3, - color = 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - } + private fun resolveInspectorHoverCandidate(path: List): DOMNode? = + path.lastOrNull { node -> shouldInspectorPickNode(node) } - private fun appendVariableTooltip( - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { - val text = variableTooltipText ?: return - var rect = variableTooltipRect - rect = if (rect.width <= 0 || rect.height <= 0) { - resolveTooltipRect(viewportWidth, viewportHeight, 280, lineHeightPx + 10) - } else { - clampTooltipRect(rect.x, rect.y, rect.width, rect.height, viewportWidth, viewportHeight) - } - addFill(out, rect, 0xEE141A22.toInt()) - addOutline(out, rect, 0xCC60758F.toInt()) - out += RenderCommand.DrawText( - text = ellipsize(text, 54), - x = rect.x + 6, - y = rect.y + 4, - color = 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - } + private fun shouldInspectorPickNode(node: DOMNode): Boolean = node.display != Display.None private fun resolveTooltipLabel(node: DOMNode): String { val bounds = node.bounds @@ -1850,245 +1636,11 @@ class InspectorController( } tooltipNodeRef = node tooltipNodeBounds = bounds - tooltipLabelCache = "${nodeLabel(node)} ${bounds.width}x${bounds.height} @ ${bounds.x},${bounds.y}" + tooltipLabelCache = + "${InspectorPresentationSupport.nodeLabel(node)} ${bounds.width}x${bounds.height} @ ${bounds.x},${bounds.y}" return tooltipLabelCache } - private fun appendPanel( - root: DOMNode, - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { - val clamped = clampExpandedRect(expandedRect, viewportWidth, viewportHeight) - expandedRect = clamped - panelBounds = clamped - minimizedBounds = Rect(0, 0, 0, 0) - contentBounds = Rect(0, 0, 0, 0) - resetNativePresentation() - panelActions.clear() - dropdownOverlays.clear() - dropdownLayouts.clear() - variableTooltipText = null - variableTooltipRect = Rect(0, 0, 0, 0) - - addFill(out, panelBounds, 0xE0141820.toInt()) - addOutline(out, panelBounds, 0xCC425062.toInt()) - - val headerRect = - Rect(clamped.x + 6, clamped.y + 5, clamped.width - 12, (titleFontSizePx + 16).coerceAtLeast(44)) - headerBounds = headerRect - addFill(out, headerRect, 0x222D3846) - addOutline(out, headerRect, 0x553F4A57) - val pickOn = mode == InspectorMode.Pick - val selectedShort = selectedNode?.key?.toString()?.take(18) ?: "none" - out += RenderCommand.DrawText( - text = "Inspector Pick:${if (pickOn) "ON" else "OFF"} Sel:$selectedShort", - x = headerRect.x + 8, - y = headerRect.y + 7, - color = 0xFFE6EDF6.toInt(), - fontSize = titleFontSizePx - ) - val headerButtonHeight = (secondaryFontSizePx + 10).coerceAtLeast(26) - val headerButtonY = headerRect.y + ((headerRect.height - headerButtonHeight) / 2) - val minimizeRect = Rect(headerRect.x + headerRect.width - 96, headerButtonY, 86, headerButtonHeight) - val pickRect = Rect(headerRect.x + headerRect.width - 264, headerButtonY, 160, headerButtonHeight) - addFill(out, pickRect, if (pickOn) 0x33599F5D else 0x3346596E) - addOutline(out, pickRect, if (pickOn) 0x8896D49A.toInt() else 0x775E738C) - out += RenderCommand.DrawText( - "Select Element", - pickRect.x + 8, - pickRect.y + 4, - 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction(pickRect, ActionKind.TogglePick) - addFill(out, minimizeRect, 0x3346596E) - addOutline(out, minimizeRect, 0x775E738C) - out += RenderCommand.DrawText( - "Minimize", - minimizeRect.x + 8, - minimizeRect.y + 4, - 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction(minimizeRect, ActionKind.Minimize) - - val resizeHandle = Rect(clamped.x + clamped.width - 10, clamped.y + clamped.height - 10, 8, 8) - addFill(out, resizeHandle, 0x55748AA1) - addOutline(out, resizeHandle, 0xAA90A7BF.toInt()) - - val bodyTop = headerRect.y + headerRect.height + 6 - val bodyRect = Rect( - clamped.x + 6, - bodyTop, - clamped.width - 12, - (clamped.height - (bodyTop - clamped.y) - 4).coerceAtLeast(24) - ) - contentBounds = bodyRect - panelScrollY = panelScrollY.coerceIn(0, maxOf(0, panelContentHeight - bodyRect.height)) - val maxChars = estimateMaxChars(bodyRect.width - 12, textFontSizePx) - - addFill(out, bodyRect, 0x18212C39) - out += RenderCommand.PushClip(bodyRect.x, bodyRect.y, bodyRect.width, bodyRect.height) - - var y = bodyRect.y - y = appendPanelLine(out, bodyRect, y, "F8 toggle, F9 mode, Esc cancel pick", maxChars, panelScrollY) - y = appendPanelLine( - out, - bodyRect, - y, - "Hovered: ${hoveredNode?.let { nodeLabel(it) } ?: "none"}", - maxChars, - panelScrollY) - y = appendPanelLine( - out, - bodyRect, - y, - "Selected: ${selectedNode?.let { nodeLabel(it) } ?: "none"}", - maxChars, - panelScrollY) - y = appendPanelLine( - out, - bodyRect, - y, - "Inspector handled last: $lastHandledPointerEvent", - maxChars, - panelScrollY - ) - y = appendPanelLine(out, bodyRect, y, "Pointer over Inspector: $pointerOverInspectorUi", maxChars, panelScrollY) - y = appendPanelLine(out, bodyRect, y, "Hover pick enabled: $hoverPickEnabled", maxChars, panelScrollY) - y += 2 - - val selected = selectedNode - if (selected == null) { - y = appendPanelLine(out, bodyRect, y, "Click element in Pick mode to inspect.", maxChars, panelScrollY) - out += RenderCommand.PopClip - panelContentHeight = (y - bodyRect.y).coerceAtLeast(0) - panelScrollY = panelScrollY.coerceIn(0, maxOf(0, panelContentHeight - bodyRect.height)) - appendScrollbarIndicator(out, bodyRect) - appendDropdownOverlays(out) - return - } - - val selectedPath = pathToNode(root, selected) - wrapPathLines(selectedPath, maxChars).forEach { line -> - y = appendPanelLine(out, bodyRect, y, line, maxChars, panelScrollY) - } - val boxes = computeBoxes(selected) - y = appendPanelLine(out, bodyRect, y, "Border box: ${rectLabel(boxes.border)}", maxChars, panelScrollY) - y = appendPanelLine(out, bodyRect, y, "Content box: ${rectLabel(boxes.content)}", maxChars, panelScrollY) - y = appendPanelLine(out, bodyRect, y, "Margin box: ${rectLabel(boxes.margin)}", maxChars, panelScrollY) - boxes.parentContent?.let { - y = appendPanelLine(out, bodyRect, y, "Parent content: ${rectLabel(it)}", maxChars, panelScrollY) - } - val localPos = selected.parent?.let { parent -> - val parentContent = contentRect(parent) - "${selected.bounds.x - parentContent.x},${selected.bounds.y - parentContent.y}" - } ?: "${selected.bounds.x},${selected.bounds.y}" - y = appendPanelLine(out, bodyRect, y, "Local pos: $localPos", maxChars, panelScrollY) - selected.inspectorScrollOffset()?.let { (sx, sy) -> - y = appendPanelLine(out, bodyRect, y, "Scroll: x=$sx y=$sy", maxChars, panelScrollY) - } - y += 2 - - val parent = selected.parent - if (parent != null) { - val row = Rect(clamped.x + 8, y - panelScrollY, clamped.width - 16, rowHeightPx) - addFill(out, row, 0x222D3846) - addOutline(out, row, 0x553F4A57) - out += RenderCommand.DrawText( - "[Parent] ${nodeLabel(parent)}", - row.x + 8, - row.y + 4, - 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction(row, ActionKind.Parent) - y += rowHeightPx + 2 - } - - val children = selected.children.filter { it.display != Display.None } - if (children.isNotEmpty()) { - y = appendPanelLine(out, bodyRect, y, "Children:", maxChars, panelScrollY) - for (index in children.indices) { - val child = children[index] - val row = Rect(clamped.x + 10, y - panelScrollY, clamped.width - 20, rowHeightPx) - addFill(out, row, 0x1E263241) - addOutline(out, row, 0x55394654) - out += RenderCommand.DrawText( - "[$index] ${nodeLabel(child)}", - row.x + 8, - row.y + 4, - 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction(row, ActionKind.Child, index) - y += rowHeightPx + 2 - } - } - - y += 2 - val inspection = selectionStyle(selected) - y = appendStyleEditorSection(clamped, bodyRect, selected, inspection, y, out, panelScrollY, maxChars) - y += 1 - y = appendPanelLine(out, bodyRect, y, "Computed styles:", maxChars, panelScrollY) - val styleRows = styleRows(inspection) - for (line in styleRows) { - y = appendPanelLine(out, bodyRect, y, line, maxChars, panelScrollY, 2) - } - - out += RenderCommand.PopClip - panelContentHeight = (y - bodyRect.y).coerceAtLeast(0) - panelScrollY = panelScrollY.coerceIn(0, maxOf(0, panelContentHeight - bodyRect.height)) - appendScrollbarIndicator(out, bodyRect) - appendDropdownOverlays(out) - } - - private fun appendMinimizedPanel( - viewportWidth: Int, - viewportHeight: Int, - out: MutableList - ) { - val chipWidth = minimizedWidth().coerceAtLeast(minChipWidth) - val chipHeight = minimizedHeight() - clampMinimizedPosition(viewportWidth, viewportHeight) - val chipRect = Rect(minimizedPosX, minimizedPosY, chipWidth, chipHeight) - panelBounds = chipRect - minimizedBounds = chipRect - headerBounds = Rect(0, 0, 0, 0) - contentBounds = Rect(0, 0, 0, 0) - panelScrollY = 0 - panelContentHeight = 0 - nativeDomLastKnownBodyScrollY = 0 - panelActions.clear() - dropdownOverlays.clear() - dropdownLayouts.clear() - - addFill(out, chipRect, if (dragMode == DragMode.MinimizedMove) 0xEE1C2430.toInt() else 0xDD1A202A.toInt()) - addOutline(out, chipRect, 0xCC4F6076.toInt()) - val badge = if (mode == InspectorMode.Pick) "[Pick]" else "[Locked]" - val selectedShort = selectedNode?.key?.toString()?.let { " $it" } ?: "" - val title = "Inspector $badge$selectedShort" - val maxChars = estimateMaxChars(chipRect.width - 12, secondaryFontSizePx) - val lines = wrapMinimizedLabel(title, maxChars, maxLines = 2) - val compactLineHeight = (secondaryFontSizePx + 4).coerceAtLeast(20) - val startY = if (lines.size == 1) { - chipRect.y + ((chipRect.height - compactLineHeight) / 2) - } else { - chipRect.y + ((chipRect.height - compactLineHeight * lines.size) / 2) - } - lines.forEachIndexed { index, line -> - out += RenderCommand.DrawText( - text = line, - x = chipRect.x + 8, - y = startY + index * compactLineHeight, - color = 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - } - } - private fun selectionStyle(node: DOMNode): StyleInspection { val key = node.key val klass = node.javaClass @@ -2101,12 +1653,13 @@ class InspectorController( return cached.inspection } val inspection = StyleEngine.inspect(node) - cachedStyle = SelectionStyleCache( - key = key, - nodeClass = klass, - layoutVersion = layoutVersion, - inspection = inspection - ) + cachedStyle = + SelectionStyleCache( + key = key, + nodeClass = klass, + layoutVersion = layoutVersion, + inspection = inspection, + ) return inspection } @@ -2127,11 +1680,11 @@ class InspectorController( } return rows } - private fun findPanelAction(mouseX: Int, mouseY: Int): PanelAction? { - return panelActions.lastOrNull { action -> + + private fun findPanelAction(mouseX: Int, mouseY: Int): PanelAction? = + panelActions.lastOrNull { action -> isPanelActionVisibleHit(action, mouseX, mouseY) } - } private fun isPanelActionVisibleHit(action: PanelAction, mouseX: Int, mouseY: Int): Boolean { if (!action.bounds.contains(mouseX, mouseY)) return false @@ -2146,19 +1699,19 @@ class InspectorController( val expectedOp = if (layout.isUnit) EditOperation.SelectUnitOption else EditOperation.SelectValueOption return panelActions.lastOrNull { action -> action.kind == ActionKind.EditProperty && - action.property == layout.property && - action.editOperation == expectedOp && - action.bounds.contains(mouseX, mouseY) + action.property == layout.property && + action.editOperation == expectedOp && + action.bounds.contains(mouseX, mouseY) } } - internal fun debugColorPickerActionBounds(property: StyleProperty): Rect? { - return panelActions.lastOrNull { - it.kind == ActionKind.EditProperty && + internal fun portalColorPickerActionBounds(property: StyleProperty): Rect? = + panelActions + .lastOrNull { + it.kind == ActionKind.EditProperty && it.property == property && it.editOperation == EditOperation.OpenColorPicker - }?.bounds - } + }?.bounds internal fun debugOpenColorPickerForSelection(property: StyleProperty, anchorRect: Rect): Boolean { val selected = selectedNode ?: return false @@ -2166,11 +1719,16 @@ class InspectorController( return true } - internal fun debugPanelRect(): Rect? { + internal fun floatingPanelRect(): Rect? { if (!active) return null return currentInspectorRect() } + internal fun floatingExpandedPanelRect(): Rect? { + if (!active || panelState != InspectorPanelState.Expanded) return null + return expandedRect + } + private fun performPanelAction(action: PanelAction) { when (action.kind) { ActionKind.Minimize -> { @@ -2240,454 +1798,6 @@ class InspectorController( styleEditorError = null } - private fun appendStyleEditorSection( - panelRect: Rect, - bodyRect: Rect, - selected: DOMNode, - inspection: StyleInspection, - startY: Int, - out: MutableList, - scrollY: Int, - maxChars: Int - ): Int { - var y = appendPanelLine(out, bodyRect, startY, "Style editor (live overrides):", maxChars, scrollY) - - val rowLeft = panelRect.x + 10 - val rowWidth = panelRect.width - 20 - val btnWidth = 40 - val gap = 6 - val controlHeight = (rowHeightPx - 8).coerceAtLeast(22) - val labelLineHeight = (secondaryFontSizePx - 2).coerceAtLeast(18) - val properties = editablePropertiesFor(selected) - for (property in properties) { - y = appendEditablePropertyRow( - selected = selected, - inspection = inspection, - property = property, - x = rowLeft, - y = y, - width = rowWidth, - scrollY = scrollY, - out = out - ) - } - - styleEditorError?.let { error -> - y = appendPanelLine( - out, - bodyRect, - y, - "Edit error: ${error.take(58)}", - maxChars, - scrollY, - 2, - 0xFFFF6E6E.toInt() - ) - } - - val actionHeight = (secondaryFontSizePx + 10).coerceAtLeast(28) - val resetRect = Rect(rowLeft, y - scrollY, 140, actionHeight) - val clearRect = Rect(rowLeft + 148, y - scrollY, 160, actionHeight) - addFill(out, resetRect, 0x2A465968) - addOutline(out, resetRect, 0x775E738C) - addFill(out, clearRect, 0x2A4E3F56) - addOutline(out, clearRect, 0x777A5C84) - out += RenderCommand.DrawText( - "Reset node", - resetRect.x + 8, - resetRect.y + 4, - 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - out += RenderCommand.DrawText( - "Clear all", - clearRect.x + 8, - clearRect.y + 4, - 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction(resetRect, ActionKind.ResetSelectedOverrides) - panelActions += PanelAction(clearRect, ActionKind.ClearAllOverrides) - y += actionHeight + 4 - - return y - } - - private fun appendEditablePropertyRow( - selected: DOMNode, - inspection: StyleInspection, - property: StyleProperty, - x: Int, - y: Int, - width: Int, - scrollY: Int, - out: MutableList - ): Int { - val row = Rect(x, y - scrollY, width, rowHeightPx) - addFill(out, row, 0x1B293746) - addOutline(out, row, 0x553F4A57) - - val overrideExpr = StyleEngine.inspectorOverrideFor(selected, property) - val effectiveValue = overrideExpr?.let(::expressionLabel) ?: literalFromComputed(inspection.computed, property) - val sourceTag = if (overrideExpr != null) "ins" else (inspection.propertySources[property]?.source ?: "default") - val labelWidth = (width * 0.34f).toInt().coerceIn(220, 300) - out += RenderCommand.DrawText( - text = "${property.key} [$sourceTag]", - x = row.x + 8, - y = row.y + 6, - color = 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - - val buttonsRight = row.x + row.width - 8 - val btnWidth = 40 - val gap = 6 - val controlX = (row.x + labelWidth).coerceAtMost(buttonsRight - btnWidth - 40) - val controlWidth = (buttonsRight - controlX - btnWidth - gap).coerceAtLeast(36) - val resetRect = Rect(buttonsRight - btnWidth, row.y + 4, btnWidth, rowHeightPx - 8) - drawActionButton(resetRect, "x", out) - panelActions += PanelAction( - bounds = resetRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ResetProperty - ) - - val editor = InspectorEditorRegistry.describe( - property = property, - literal = effectiveValue, - expression = overrideExpr - ) - val contentRect = Rect(controlX, row.y + 4, controlWidth, rowHeightPx - 8) - - when (editor.kind) { - InspectorEditorKind.EnumSelect, - InspectorEditorKind.FontSelect -> { - drawValueSelector( - bounds = contentRect, - value = effectiveValue, - isOpen = openValueSelectProperty == property, - hovered = contentRect.contains(mouseX, mouseY), - out = out - ) - panelActions += PanelAction( - bounds = contentRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ToggleValueSelect - ) - } - - InspectorEditorKind.StringInput -> { - val isActiveInput = activeEditProperty == property && !activeEditIsNumeric - drawTextInput( - bounds = contentRect, - value = if (isActiveInput) activeEditBuffer else effectiveValue, - active = isActiveInput, - out = out - ) - panelActions += PanelAction( - bounds = contentRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.BeginTextEdit - ) - if (editor.showColorPreview) { - val previewRect = - appendColorPreview(contentRect, if (isActiveInput) activeEditBuffer else effectiveValue, out) - panelActions += PanelAction( - bounds = previewRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.OpenColorPicker - ) - } - } - - InspectorEditorKind.NumericInput -> { - val step = StylePropertyRegistry.descriptor(property).numericStep - val parsed = InspectorEditorRegistry.parseNumericLiteral(property, effectiveValue) - val numericValue = if (activeEditProperty == property && activeEditIsNumeric) { - activeEditBuffer - } else { - parsed?.numberText ?: "0" - } - val unit = if (activeEditProperty == property && activeEditIsNumeric) { - activeEditUnit ?: parsed?.unit ?: InspectorEditorRegistry.defaultNumericUnit(property) - } else { - parsed?.unit ?: InspectorEditorRegistry.defaultNumericUnit(property) - } - val buttonWidth = 34 - val unitWidth = if (editor.supportsUnits) 68 else 0 - val inputWidth = - (contentRect.width - buttonWidth * 2 - unitWidth - (if (editor.supportsUnits) 12 else 6)) - .coerceAtLeast(64) - val decRect = Rect(contentRect.x, contentRect.y, buttonWidth, contentRect.height) - val inputRect = Rect(decRect.x + decRect.width + 4, contentRect.y, inputWidth, contentRect.height) - val incRect = Rect(inputRect.x + inputRect.width + 4, contentRect.y, buttonWidth, contentRect.height) - drawActionButton(decRect, "-", out) - drawTextInput(inputRect, numericValue, activeEditProperty == property && activeEditIsNumeric, out) - drawActionButton(incRect, "+", out) - panelActions += PanelAction( - decRect, - ActionKind.EditProperty, - property = property, - editOperation = EditOperation.Decrement, - step = step - ) - panelActions += PanelAction( - inputRect, - ActionKind.EditProperty, - property = property, - editOperation = EditOperation.BeginTextEdit - ) - panelActions += PanelAction( - incRect, - ActionKind.EditProperty, - property = property, - editOperation = EditOperation.Increment, - step = step - ) - if (editor.supportsUnits) { - val unitRect = Rect(incRect.x + incRect.width + 4, contentRect.y, unitWidth, contentRect.height) - drawValueSelector( - bounds = unitRect, - value = unit?.token ?: "px", - isOpen = openUnitSelectProperty == property, - hovered = unitRect.contains(mouseX, mouseY), - out = out - ) - panelActions += PanelAction( - bounds = unitRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = EditOperation.ToggleUnitSelect - ) - } - } - } - - if (overrideExpr is StyleExpression.VariableRef && row.contains(mouseX, mouseY)) { - val resolved = StyleEngine.resolveInspectorVariable(overrideExpr.name) - val body = resolved.getOrElse { "unresolved (${it.message ?: "unknown error"})" } - variableTooltipText = "${overrideExpr.name} = $body" - variableTooltipRect = Rect( - x = (row.x + row.width - 360).coerceAtLeast(panelBounds.x + 8), - y = (row.y - lineHeightPx - 8).coerceAtLeast(panelBounds.y + 8), - width = 352, - height = lineHeightPx + 10 - ) - } - - val nextY = y + rowHeightPx + 4 - if (openValueSelectProperty == property && editor.options.isNotEmpty()) { - queueDropdownOverlay( - x = contentRect.x, - y = contentRect.y + contentRect.height + 2, - width = contentRect.width, - options = editor.options, - property = property, - operation = EditOperation.SelectValueOption - ) - } - if (openUnitSelectProperty == property && editor.supportsUnits) { - val units = InspectorEditorRegistry.unitOptions().map { it.token } - queueDropdownOverlay( - x = contentRect.x + contentRect.width - 90, - y = contentRect.y + contentRect.height + 2, - width = 90, - options = units, - property = property, - operation = EditOperation.SelectUnitOption - ) - } - return nextY - } - - private fun drawActionButton(rect: Rect, text: String, out: MutableList) { - addFill(out, rect, 0x3346596E) - addOutline(out, rect, 0x775E738C) - out += RenderCommand.DrawText( - text = text, - x = rect.x + 8, - y = rect.y + 4, - color = 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - } - - private fun drawTextInput(bounds: Rect, value: String, active: Boolean, out: MutableList) { - addFill(out, bounds, if (active) 0x334D5D70 else 0x22313D4B) - addOutline(out, bounds, if (active) 0xFFA8C6E6.toInt() else 0x77607084) - val suffix = if (active && caretVisible()) "|" else "" - out += RenderCommand.DrawText( - text = ellipsize(value + suffix, 34), - x = bounds.x + 8, - y = bounds.y + 4, - color = 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - } - - private fun caretVisible(): Boolean { - return ((System.currentTimeMillis() / caretBlinkPeriodMs) % 2L) == 0L - } - - private fun drawValueSelector( - bounds: Rect, - value: String, - isOpen: Boolean, - hovered: Boolean, - out: MutableList - ) { - val fill = when { - isOpen -> 0x334D5D70 - hovered -> 0x2A425164 - else -> 0x22313D4B - } - val stroke = when { - isOpen -> 0xFFA8C6E6.toInt() - hovered -> 0xCC89A7C8.toInt() - else -> 0x77607084 - } - addFill(out, bounds, fill) - addOutline(out, bounds, stroke) - val arrow = if (isOpen) "^" else "v" - out += RenderCommand.DrawText( - text = ellipsize(value, 26), - x = bounds.x + 8, - y = bounds.y + 4, - color = 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - out += RenderCommand.DrawText( - text = arrow, - x = bounds.x + bounds.width - 18, - y = bounds.y + 4, - color = 0xFFA8C6E6.toInt(), - fontSize = secondaryFontSizePx - ) - } - - private fun appendColorPreview(bounds: Rect, literal: String, out: MutableList): Rect { - val previewSize = (bounds.height - 8).coerceAtLeast(10) - val previewRect = Rect( - x = bounds.x + bounds.width - previewSize - 6, - y = bounds.y + 4, - width = previewSize, - height = previewSize - ) - val parsed = runCatching { parseColor(literal) }.getOrNull() - addFill(out, previewRect, parsed ?: 0x663F4A57) - addOutline(out, previewRect, 0xCC9BB2C9.toInt()) - return previewRect - } - - private fun queueDropdownOverlay( - x: Int, - y: Int, - width: Int, - options: List, - property: StyleProperty, - operation: EditOperation - ) { - if (options.isEmpty()) return - dropdownOverlays += DropdownOverlay( - x = x, - y = y, - width = width, - options = options, - property = property, - operation = operation - ) - } - - private fun appendDropdownOverlays(out: MutableList) { - if (dropdownOverlays.isEmpty()) return - dropdownOverlays.forEach { overlay -> - appendOptionsPopup( - x = overlay.x, - y = overlay.y, - width = overlay.width, - options = overlay.options, - property = overlay.property, - out = out, - operation = overlay.operation - ) - } - } - - private fun appendOptionsPopup( - x: Int, - y: Int, - width: Int, - options: List, - property: StyleProperty, - out: MutableList, - operation: EditOperation - ) { - if (options.isEmpty()) return - val maxRows = 8 - val visibleRows = minOf(maxRows, options.size) - val maxFirst = (options.size - visibleRows).coerceAtLeast(0) - val isUnit = operation == EditOperation.SelectUnitOption - val rawFirst = if (isUnit) openUnitSelectScrollIndex else openValueSelectScrollIndex - val first = rawFirst.coerceIn(0, maxFirst) - if (isUnit) { - openUnitSelectScrollIndex = first - } else { - openValueSelectScrollIndex = first - } - val shown = options.subList(first, first + visibleRows) - val optionHeight = rowHeightPx - val popupHeight = optionHeight * shown.size + 6 - val viewportWidth = viewportW.coerceAtLeast(1) - val viewportHeight = viewportH.coerceAtLeast(1) - val clampedX = x.coerceIn(2, (viewportWidth - width - 2).coerceAtLeast(2)) - val clampedY = y.coerceIn(2, (viewportHeight - popupHeight - 2).coerceAtLeast(2)) - val popupRect = Rect(clampedX, clampedY, width, popupHeight) - dropdownLayouts += DropdownLayout( - rect = popupRect, - property = property, - isUnit = isUnit, - totalOptions = options.size, - visibleRows = visibleRows - ) - addFill(out, popupRect, 0xEE202A36.toInt()) - addOutline(out, popupRect, 0xCC596A80.toInt()) - var optionY = popupRect.y + 3 - shown.forEach { option -> - val optionRect = Rect(popupRect.x + 3, optionY, popupRect.width - 6, optionHeight - 2) - val hovered = optionRect.contains(mouseX, mouseY) - addFill(out, optionRect, if (hovered) 0x2D4C6279 else 0x22313D4B) - addOutline(out, optionRect, if (hovered) 0xCC95B3D3.toInt() else 0x664F6076) - out += RenderCommand.DrawText( - text = ellipsize(option, 30), - x = optionRect.x + 6, - y = optionRect.y + 4, - color = if (hovered) 0xFFFFFFFF.toInt() else 0xFFE6EDF6.toInt(), - fontSize = secondaryFontSizePx - ) - panelActions += PanelAction( - bounds = optionRect, - kind = ActionKind.EditProperty, - property = property, - editOperation = operation, - payload = option - ) - optionY += optionHeight - } - if (options.size > visibleRows) { - out += RenderCommand.DrawText( - text = "${first + 1}-${first + visibleRows}/${options.size}", - x = popupRect.x + 6, - y = popupRect.y + popupRect.height - (secondaryFontSizePx + 4), - color = 0xFF8EA6BF.toInt(), - fontSize = secondaryFontSizePx - ) - } - } - private fun ellipsize(raw: String, maxChars: Int): String { if (maxChars <= 1) return raw.take(1) if (raw.length <= maxChars) return raw @@ -2700,13 +1810,14 @@ class InspectorController( val isTextLike = selected.styleType == "text" || selected.styleType.contains("text") if (!isTextLike) return all - val priority = listOf( - StyleProperty.FOREGROUND_COLOR, - StyleProperty.FONT_ID, - StyleProperty.FONT_SIZE, - StyleProperty.TEXT_WRAP, - StyleProperty.ALIGN - ) + val priority = + listOf( + StyleProperty.FOREGROUND_COLOR, + StyleProperty.FONT_ID, + StyleProperty.FONT_SIZE, + StyleProperty.TEXT_WRAP, + StyleProperty.ALIGN, + ) val ordered = ArrayList(all.size) priority.forEach { property -> if (property in all) ordered += property @@ -2723,27 +1834,11 @@ class InspectorController( operation: EditOperation, step: Float, payload: String?, - actionBounds: Rect + actionBounds: Rect, ) { runCatching { when (operation) { EditOperation.ResetProperty -> StyleEngine.clearInspectorOverride(selected, property) - EditOperation.CyclePrev -> { - val options = enumOptions(property) ?: error("Property '${property.key}' is not enumerable.") - val current = literalForEdit(selected, property) - val currentIndex = options.indexOfFirst { it.equals(current, ignoreCase = true) } - val nextIndex = if (currentIndex <= 0) options.lastIndex else currentIndex - 1 - StyleEngine.setInspectorOverrideLiteral(selected, property, options[nextIndex]).getOrThrow() - } - - EditOperation.CycleNext -> { - val options = enumOptions(property) ?: error("Property '${property.key}' is not enumerable.") - val current = literalForEdit(selected, property) - val currentIndex = options.indexOfFirst { it.equals(current, ignoreCase = true) } - val nextIndex = if (currentIndex == -1 || currentIndex == options.lastIndex) 0 else currentIndex + 1 - StyleEngine.setInspectorOverrideLiteral(selected, property, options[nextIndex]).getOrThrow() - } - EditOperation.Decrement -> { val next = adjustNumericLiteral(selected, property, -step) StyleEngine.setInspectorOverrideLiteral(selected, property, next).getOrThrow() @@ -2785,11 +1880,12 @@ class InspectorController( val current = literalForEdit(selected, property) val parsed = InspectorEditorRegistry.parseNumericLiteral(property, current) val numberText = parsed?.numberText ?: "0" - val nextLiteral = InspectorEditorRegistry.formatNumericLiteral( - property = property, - numberText = numberText, - unitToken = payload - ) + val nextLiteral = + InspectorEditorRegistry.formatNumericLiteral( + property = property, + numberText = numberText, + unitToken = payload, + ) StyleEngine.setInspectorOverrideLiteral(selected, property, nextLiteral).getOrThrow() openUnitSelectProperty = null openUnitSelectScrollIndex = 0 @@ -2805,25 +1901,26 @@ class InspectorController( private fun beginTextEdit(selected: DOMNode, property: StyleProperty, actionBounds: Rect) { if (activeEditProperty != property) { val current = literalForEdit(selected, property) - val descriptor = InspectorEditorRegistry.describe( - property = property, - literal = current, - expression = StyleEngine.inspectorOverrideFor(selected, property) - ) + val descriptor = + InspectorEditorRegistry.describe( + property = property, + literal = current, + expression = StyleEngine.inspectorOverrideFor(selected, property), + ) if (descriptor.kind == InspectorEditorKind.NumericInput) { val parsed = InspectorEditorRegistry.parseNumericLiteral(property, current) editSession.begin( property = property, initialBuffer = parsed?.numberText ?: "0", initialUnit = parsed?.unit ?: InspectorEditorRegistry.defaultNumericUnit(property), - isNumeric = true + isNumeric = true, ) } else { editSession.begin( property = property, initialBuffer = current, initialUnit = null, - isNumeric = false + isNumeric = false, ) } } else { @@ -2840,10 +1937,11 @@ class InspectorController( private fun updateActiveTextSelectionFromPointer(): Boolean { if (!editSession.textSelectionDragActive) return false - val property = editSession.textSelectionDragProperty ?: run { - stopActiveTextSelectionDrag() - return false - } + val property = + editSession.textSelectionDragProperty ?: run { + stopActiveTextSelectionDrag() + return false + } if (activeEditProperty != property) { stopActiveTextSelectionDrag() return false @@ -2875,12 +1973,6 @@ class InspectorController( return rawIndex.coerceIn(0, text.length) } - private fun activeBufferWithCaret(): String { - activeEditState.clampToLength(activeEditBuffer.length) - if (!caretVisible()) return activeEditBuffer - val caret = activeEditState.caretIndex.coerceIn(0, activeEditBuffer.length) - return activeEditBuffer.substring(0, caret) + "|" + activeEditBuffer.substring(caret) - } private fun openColorPicker(selected: DOMNode, property: StyleProperty, anchorRect: Rect) { val literal = literalForEdit(selected, property) val parsedByStyle = runCatching { RgbaColor.fromArgbInt(parseColor(literal)) }.getOrNull() @@ -2890,13 +1982,14 @@ class InspectorController( colorPickerManager.open( anchorRect = anchorRect, title = "Edit ${property.key}", - state = ColorPickerState( - color = initialColor, - previous = initialColor, - mode = initialMode, - alphaEnabled = true, - closeOnSelect = false - ), + state = + ColorPickerState( + color = initialColor, + previous = initialColor, + mode = initialMode, + alphaEnabled = true, + closeOnSelect = false, + ), closeOnOutsideClick = false, onPreview = { color -> applyInspectorColorLiteral(selected, property, color) @@ -2906,13 +1999,19 @@ class InspectorController( }, onCommit = { color -> applyInspectorColorLiteral(selected, property, color) - } + }, ) } private fun applyInspectorColorLiteral(selected: DOMNode, property: StyleProperty, color: RgbaColor) { runCatching { - val argb = color.toArgbInt().toUInt().toString(16).uppercase().padStart(8, '0') + val argb = + color + .toArgbInt() + .toUInt() + .toString(16) + .uppercase() + .padStart(8, '0') StyleEngine.setInspectorOverrideLiteral(selected, property, "#$argb").getOrThrow() cachedStyle = null styleEditorError = null @@ -2933,20 +2032,22 @@ class InspectorController( val selected = selectedNode ?: return val property = activeEditProperty ?: return runCatching { - val literal = if (activeEditIsNumeric) { - InspectorEditorRegistry.formatNumericLiteral( - property = property, - numberText = activeEditBuffer, - unitToken = activeEditUnit?.token - ) - } else { - activeEditBuffer.trim() - } - val normalized = if (literal.isEmpty()) { - if (activeEditIsNumeric) InspectorEditorRegistry.defaultNumericLiteral(property) else "" - } else { - literal - } + val literal = + if (activeEditIsNumeric) { + InspectorEditorRegistry.formatNumericLiteral( + property = property, + numberText = activeEditBuffer, + unitToken = activeEditUnit?.token, + ) + } else { + activeEditBuffer.trim() + } + val normalized = + if (literal.isEmpty()) { + if (activeEditIsNumeric) InspectorEditorRegistry.defaultNumericLiteral(property) else "" + } else { + literal + } StyleEngine.setInspectorOverrideLiteral(selected, property, normalized).getOrThrow() cachedStyle = null styleEditorError = null @@ -2956,18 +2057,6 @@ class InspectorController( editSession.clearActiveEdit() } - private fun enumOptions(property: StyleProperty): List? { - val descriptor = StylePropertyRegistry.descriptor(property) - return when (descriptor.valueType) { - StyleEditorValueType.EnumChoice, - StyleEditorValueType.ColorHex, - StyleEditorValueType.StringPreset, - StyleEditorValueType.LineHeight -> descriptor.enumOptions - - else -> null - } - } - private fun literalForEdit(selected: DOMNode, property: StyleProperty): String { val override = StyleEngine.inspectorOverrideFor(selected, property) if (override != null) return expressionLabel(override) @@ -2985,12 +2074,13 @@ class InspectorController( } StyleEditorValueType.LengthPx -> { - val base = runCatching { - parseLengthPxInt( - raw = current, - allowNegative = false - ) - }.getOrElse { descriptor.minInt } + val base = + runCatching { + parseLengthPxInt( + raw = current, + allowNegative = false, + ) + }.getOrElse { descriptor.minInt } val next = (base + delta.toInt()).coerceAtLeast(descriptor.minInt) pxLiteral(next) } @@ -3000,8 +2090,9 @@ class InspectorController( if (normalized == "normal") { CssLength(delta.coerceAtLeast(0f), CssUnit.Px).toCssLiteral() } else { - val base = runCatching { parseCssLength(current, allowUnitlessZero = true) } - .getOrElse { CssLength.ZERO_PX } + val base = + runCatching { parseCssLength(current, allowUnitlessZero = true) } + .getOrElse { CssLength.ZERO_PX } val next = (base.value + delta).coerceAtLeast(0f) CssLength(next, base.unit).toCssLiteral() } @@ -3014,10 +2105,11 @@ class InspectorController( } StyleEditorValueType.OptionalLengthPx -> { - val base = parseOptionalLengthPxInt( - raw = current, - allowNegative = false - ) ?: descriptor.minInt + val base = + parseOptionalLengthPxInt( + raw = current, + allowNegative = false, + ) ?: descriptor.minInt val next = (base + delta.toInt()).coerceAtLeast(descriptor.minInt) pxLiteral(next) } @@ -3030,10 +2122,11 @@ class InspectorController( StyleEditorValueType.SpacingLengthPx -> { val allowNegative = property == StyleProperty.MARGIN - val currentInsets = parseSpacingShorthand( - raw = current, - allowNegative = allowNegative - ) + val currentInsets = + parseSpacingShorthand( + raw = current, + allowNegative = allowNegative, + ) val rawNext = currentInsets.top + delta.toInt() val next = if (allowNegative) rawNext else rawNext.coerceAtLeast(0) pxLiteral(next) @@ -3049,15 +2142,14 @@ class InspectorController( } } - private fun expressionLabel(expression: StyleExpression): String { - return when (expression) { + private fun expressionLabel(expression: StyleExpression): String = + when (expression) { is StyleExpression.Literal -> expression.value is StyleExpression.VariableRef -> "var(${expression.name})" } - } - private fun literalFromComputed(style: ComputedStyle, property: StyleProperty): String { - return when (property) { + private fun literalFromComputed(style: ComputedStyle, property: StyleProperty): String = + when (property) { StyleProperty.MARGIN -> spacingLiteral(style.margin) StyleProperty.PADDING -> spacingLiteral(style.padding) StyleProperty.BACKGROUND_COLOR -> style.backgroundColor?.let(::colorLabel) ?: "none" @@ -3067,21 +2159,30 @@ class InspectorController( StyleProperty.BORDER_RADIUS -> style.borderRadius.toCssLiteral() StyleProperty.FOREGROUND_COLOR -> colorLabel(style.foregroundColor) StyleProperty.FONT_ID -> style.fontId ?: "minecraft" - StyleProperty.FONT_SIZE -> style.fontSizeValue?.toCssLiteral() ?: (style.fontSize?.let(::pxLiteral) - ?: "auto") - StyleProperty.LINE_HEIGHT -> when (val lineHeightValue = style.lineHeight) { - is LineHeightValue.Length -> lineHeightValue.value.toCssLiteral() - LineHeightValue.Normal -> "normal" - } + StyleProperty.FONT_SIZE -> + style.fontSizeValue?.toCssLiteral() ?: ( + style.fontSize?.let(::pxLiteral) + ?: "auto" + ) + StyleProperty.LINE_HEIGHT -> + when (val lineHeightValue = style.lineHeight) { + is LineHeightValue.Length -> lineHeightValue.value.toCssLiteral() + LineHeightValue.Normal -> "normal" + } - StyleProperty.FONT_WEIGHT -> style.fontWeight.name.lowercase() - StyleProperty.FONT_STYLE -> style.fontStyle.name.lowercase() - StyleProperty.TEXT_DECORATION -> when (style.textDecoration) { - TextDecoration.None -> "none" - TextDecoration.Underline -> "underline" - TextDecoration.Strikethrough -> "strikethrough" - TextDecoration.UnderlineStrikethrough -> "underline-strikethrough" - } + StyleProperty.FONT_WEIGHT -> + style.fontWeight.name + .lowercase() + StyleProperty.FONT_STYLE -> + style.fontStyle.name + .lowercase() + StyleProperty.TEXT_DECORATION -> + when (style.textDecoration) { + TextDecoration.None -> "none" + TextDecoration.Underline -> "underline" + TextDecoration.Strikethrough -> "strikethrough" + TextDecoration.UnderlineStrikethrough -> "underline-strikethrough" + } StyleProperty.OBFUSCATED -> style.obfuscated.toString() StyleProperty.WIDTH -> style.width?.toCssLiteral() ?: "auto" @@ -3090,68 +2191,90 @@ class InspectorController( StyleProperty.MIN_HEIGHT -> style.minHeight?.toCssLiteral() ?: "auto" StyleProperty.MAX_WIDTH -> style.maxWidth?.toCssLiteral() ?: "auto" StyleProperty.MAX_HEIGHT -> style.maxHeight?.toCssLiteral() ?: "auto" - StyleProperty.ALIGN -> style.align.name.lowercase() - StyleProperty.DISPLAY -> style.display.name.lowercase() - StyleProperty.POSITION -> style.position.name.lowercase() + StyleProperty.ALIGN -> + style.align.name + .lowercase() + StyleProperty.DISPLAY -> + style.display.name + .lowercase() + StyleProperty.POSITION -> + style.position.name + .lowercase() StyleProperty.LEFT -> style.left?.toCssLiteral() ?: "auto" StyleProperty.TOP -> style.top?.toCssLiteral() ?: "auto" StyleProperty.RIGHT -> style.right?.toCssLiteral() ?: "auto" StyleProperty.BOTTOM -> style.bottom?.toCssLiteral() ?: "auto" StyleProperty.Z_INDEX -> style.zIndex.toString() - StyleProperty.OVERFLOW -> if (style.overflowX == style.overflowY) { - style.overflowX.name.lowercase() - } else { - "${style.overflowX.name.lowercase()} ${style.overflowY.name.lowercase()}" - } - StyleProperty.OVERFLOW_X -> style.overflowX.name.lowercase() - StyleProperty.OVERFLOW_Y -> style.overflowY.name.lowercase() - StyleProperty.FLEX_DIRECTION -> style.flexDirection.name.lowercase() - StyleProperty.JUSTIFY_CONTENT -> style.justifyContent.name - .replace(Regex("([a-z])([A-Z])"), "$1-$2") - .lowercase() - - StyleProperty.ALIGN_ITEMS -> style.alignItems.name.lowercase() - StyleProperty.JUSTIFY_ITEMS -> style.justifyItems.name.lowercase() + StyleProperty.OVERFLOW -> + if (style.overflowX == style.overflowY) { + style.overflowX.name + .lowercase() + } else { + "${style.overflowX.name.lowercase()} ${style.overflowY.name.lowercase()}" + } + StyleProperty.OVERFLOW_X -> + style.overflowX.name + .lowercase() + StyleProperty.OVERFLOW_Y -> + style.overflowY.name + .lowercase() + StyleProperty.FLEX_DIRECTION -> + style.flexDirection.name + .lowercase() + StyleProperty.JUSTIFY_CONTENT -> + style.justifyContent.name + .replace(Regex("([a-z])([A-Z])"), "$1-$2") + .lowercase() + + StyleProperty.ALIGN_ITEMS -> + style.alignItems.name + .lowercase() + StyleProperty.JUSTIFY_ITEMS -> + style.justifyItems.name + .lowercase() StyleProperty.GAP -> style.gap.toCssLiteral() StyleProperty.FLEX_GROW -> formatFloatLiteral(style.flexGrow) StyleProperty.FLEX_SHRINK -> formatFloatLiteral(style.flexShrink) StyleProperty.FLEX_BASIS -> style.flexBasis?.toCssLiteral() ?: "auto" StyleProperty.GRID_COLUMNS -> style.gridColumns.toString() StyleProperty.GRID_ROWS -> style.gridRows?.toString() ?: "auto" - StyleProperty.GRID_AUTO_FLOW -> style.gridAutoFlow.name.lowercase() + StyleProperty.GRID_AUTO_FLOW -> + style.gridAutoFlow.name + .lowercase() StyleProperty.GRID_COLUMN_SPAN -> style.gridColumnSpan.toString() StyleProperty.GRID_ROW_SPAN -> style.gridRowSpan.toString() StyleProperty.TEXT_WRAP -> if (style.textWrap == TextWrap.Wrap) "wrap" else "nowrap" - StyleProperty.TEXT_FORMATTING -> when (style.textFormatting) { - TextFormatting.None -> "none" - TextFormatting.Minecraft -> "minecraft" - } + StyleProperty.TEXT_FORMATTING -> + when (style.textFormatting) { + TextFormatting.None -> "none" + TextFormatting.Minecraft -> "minecraft" + } - StyleProperty.TRANSFORM -> buildString { - append("translate(") - append(style.transform.translateX) - append(",") - append(style.transform.translateY) - append(") scale(") - append(style.transform.scaleX) - append(",") - append(style.transform.scaleY) - append(") rotate(") - append(style.transform.rotateDeg) - append("deg)") - } + StyleProperty.TRANSFORM -> + buildString { + append("translate(") + append(style.transform.translateX) + append(",") + append(style.transform.translateY) + append(") scale(") + append(style.transform.scaleX) + append(",") + append(style.transform.scaleY) + append(") rotate(") + append(style.transform.rotateDeg) + append("deg)") + } StyleProperty.TRANSFORM_ORIGIN -> "${style.transformOrigin.originX} ${style.transformOrigin.originY}" StyleProperty.OPACITY -> formatFloatLiteral(style.opacity) } - } private fun spacingLiteral(value: LengthInsets): String { - return "${value.top.toCssLiteral()} ${value.right.toCssLiteral()} ${value.bottom.toCssLiteral()} ${value.left.toCssLiteral()}" - } - - private fun spacingLengthLabel(value: LengthInsets): String { - return "${value.top.toCssLiteral()} ${value.right.toCssLiteral()} ${value.bottom.toCssLiteral()} ${value.left.toCssLiteral()}" + val top = value.top.toCssLiteral() + val right = value.right.toCssLiteral() + val bottom = value.bottom.toCssLiteral() + val left = value.left.toCssLiteral() + return "$top $right $bottom $left" } private fun pxLiteral(value: Int): String = "${value}px" @@ -3161,150 +2284,6 @@ class InspectorController( return if (rounded % 1f == 0f) rounded.toInt().toString() else rounded.toString() } - private fun appendPanelLine( - out: MutableList, - x: Int, - y: Int, - text: String - ): Int { - out += RenderCommand.DrawText( - text = text.take(74), - x = x, - y = y, - color = 0xFFDCE5EF.toInt(), - fontSize = secondaryFontSizePx - ) - return y + lineHeightPx - } - - private fun appendPanelLine( - out: MutableList, - bodyRect: Rect, - y: Int, - text: String, - maxChars: Int, - scrollY: Int, - leftInset: Int = 0, - color: Int = 0xFFDCE5EF.toInt() - ): Int { - val lines = wrapText(text, maxChars) - var logicalY = y - for (line in lines) { - val drawY = logicalY - scrollY - out += RenderCommand.DrawText( - line, - bodyRect.x + 2 + leftInset, - drawY, - color, - fontSize = textFontSizePx - ) - logicalY += lineHeightPx - } - return logicalY - } - - private fun wrapPathLines(path: List, maxChars: Int): List { - if (path.isEmpty()) return listOf("Path: ") - val tokens = path.map(::pathToken) - val lines = ArrayList() - var current = "Path: " - tokens.forEachIndexed { index, token -> - val segment = if (index == 0) token else " > $token" - if (current.length + segment.length <= maxChars) { - current += segment - return@forEachIndexed - } - if (current.isNotBlank()) { - lines += current - } - val continued = if (index == 0) token else "> $token" - val wrapped = wrapText(continued, maxChars - 2) - if (wrapped.isEmpty()) { - current = " " - } else { - lines += wrapped.dropLast(1).map { " $it" } - current = " ${wrapped.last()}" - } - } - lines += current - return lines - } - - private fun estimateMaxChars(pixelWidth: Int, fontSize: Int): Int { - val approxCharWidth = (fontSize * 0.56f).toInt().coerceAtLeast(6) - return (pixelWidth / approxCharWidth).coerceAtLeast(8) - } - - private fun wrapText(text: String, maxChars: Int): List { - val limit = maxChars.coerceAtLeast(1) - if (text.length <= limit) return listOf(text) - val result = ArrayList() - var cursor = 0 - while (cursor < text.length) { - val end = (cursor + limit).coerceAtMost(text.length) - var cut = end - if (end < text.length) { - val ws = text.lastIndexOf(' ', end - 1) - if (ws >= cursor + 1) { - cut = ws - } - } - if (cut <= cursor) { - cut = end - } - result += text.substring(cursor, cut).trimEnd() - cursor = cut - while (cursor < text.length && text[cursor] == ' ') cursor++ - } - return if (result.isEmpty()) listOf("") else result - } - - private fun appendScrollbarIndicator(out: MutableList, bodyRect: Rect) { - if (panelContentHeight <= bodyRect.height || bodyRect.height <= 0) { - scrollbarTrackRect = Rect(0, 0, 0, 0) - scrollbarThumbRect = Rect(0, 0, 0, 0) - return - } - val trackWidth = 4 - val track = Rect( - bodyRect.x + bodyRect.width - trackWidth - 2, - bodyRect.y + 2, - trackWidth, - (bodyRect.height - 4).coerceAtLeast(8) - ) - val maxScroll = (panelContentHeight - bodyRect.height).coerceAtLeast(1) - val thumbHeight = ((track.height.toFloat() * bodyRect.height.toFloat() / panelContentHeight.toFloat()).toInt()) - .coerceIn(10, track.height) - val travel = (track.height - thumbHeight).coerceAtLeast(0) - val thumbY = track.y + ((panelScrollY.toFloat() / maxScroll.toFloat()) * travel.toFloat()).toInt() - val thumb = Rect(track.x, thumbY, track.width, thumbHeight) - scrollbarTrackRect = track - scrollbarThumbRect = thumb - addFill(out, track, 0x22384A5D) - addFill(out, thumb, 0x887E97B1.toInt()) - addOutline(out, thumb, 0xCC9BB2C9.toInt()) - } - - private fun pathToNode(root: DOMNode, target: DOMNode): List { - val path = ArrayList(8) - if (collectPath(root, target, path)) { - return path - } - return listOf(target) - } - - private fun collectPath(node: DOMNode, target: DOMNode, path: MutableList): Boolean { - path += node - if (node === target) return true - for (child in node.children) { - if (collectPath(child, target, path)) { - return true - } - } - path.removeAt(path.lastIndex) - return false - } - private fun startMinimizedMoveDrag(mouseX: Int, mouseY: Int) { dragMode = DragMode.MinimizedMove dragStartMouseX = mouseX @@ -3375,11 +2354,13 @@ class InspectorController( mouseX: Int, mouseY: Int, viewportWidth: Int, - viewportHeight: Int + viewportHeight: Int, ) { val nextX = mouseX - dragStartOffsetX val nextY = mouseY - dragStartOffsetY - if (!dragMoved && (kotlin.math.abs(nextX - minimizedPosX) >= 2 || kotlin.math.abs(nextY - minimizedPosY) >= 2)) { + if (!dragMoved && + (kotlin.math.abs(nextX - minimizedPosX) >= 2 || kotlin.math.abs(nextY - minimizedPosY) >= 2) + ) { dragMoved = true } minimizedPosX = nextX @@ -3391,7 +2372,7 @@ class InspectorController( mouseX: Int, mouseY: Int, viewportWidth: Int, - viewportHeight: Int + viewportHeight: Int, ) { val dx = mouseX - dragStartMouseX val dy = mouseY - dragStartMouseY @@ -3402,13 +2383,14 @@ class InspectorController( when (dragMode) { DragMode.Move -> { - val movedRect = paneMoveDrag.update( - mouseX = mouseX, - mouseY = mouseY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight, - clamp = ::clampExpandedRect - ) + val movedRect = + paneMoveDrag.update( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + clamp = ::clampExpandedRect, + ) expandedRect = movedRect if (paneMoveDrag.moved) { dragMoved = true @@ -3429,8 +2411,9 @@ class InspectorController( if (right - left < minPanelWidth) { when (dragMode) { DragMode.ResizeLeft, DragMode.ResizeTopLeft, DragMode.ResizeBottomLeft -> left = right - minPanelWidth - DragMode.ResizeRight, DragMode.ResizeTopRight, DragMode.ResizeBottomRight -> right = - left + minPanelWidth + DragMode.ResizeRight, DragMode.ResizeTopRight, DragMode.ResizeBottomRight -> + right = + left + minPanelWidth else -> Unit } @@ -3438,20 +2421,32 @@ class InspectorController( if (bottom - top < minPanelHeight) { when (dragMode) { DragMode.ResizeTop, DragMode.ResizeTopLeft, DragMode.ResizeTopRight -> top = bottom - minPanelHeight - DragMode.ResizeBottom, DragMode.ResizeBottomLeft, DragMode.ResizeBottomRight -> bottom = - top + minPanelHeight + DragMode.ResizeBottom, DragMode.ResizeBottomLeft, DragMode.ResizeBottomRight -> + bottom = + top + minPanelHeight else -> Unit } } - val resized = clampExpandedRect( - Rect(left, top, (right - left).coerceAtLeast(minPanelWidth), (bottom - top).coerceAtLeast(minPanelHeight)), - viewportWidth, - viewportHeight - ) - if (!dragMoved && (kotlin.math.abs(resized.width - dragStartRect.width) >= 2 || kotlin.math.abs(resized.height - dragStartRect.height) >= 2 || - kotlin.math.abs(resized.x - dragStartRect.x) >= 2 || kotlin.math.abs(resized.y - dragStartRect.y) >= 2) + val resized = + clampExpandedRect( + Rect( + left, + top, + (right - left).coerceAtLeast(minPanelWidth), + (bottom - top).coerceAtLeast(minPanelHeight), + ), + viewportWidth, + viewportHeight, + ) + if (!dragMoved && + ( + kotlin.math.abs(resized.width - dragStartRect.width) >= 2 || + kotlin.math.abs(resized.height - dragStartRect.height) >= 2 || + kotlin.math.abs(resized.x - dragStartRect.x) >= 2 || + kotlin.math.abs(resized.y - dragStartRect.y) >= 2 + ) ) { dragMoved = true } @@ -3494,16 +2489,17 @@ class InspectorController( viewportWidth: Int, viewportHeight: Int, boxWidth: Int, - boxHeight: Int + boxHeight: Int, ): Rect { val inspectorRect = currentInspectorRect() - val candidates = listOf( - mouseX + 12 to mouseY + 12, - mouseX + 12 to mouseY - boxHeight - 12, - mouseX - boxWidth - 12 to mouseY + 12, - mouseX - boxWidth - 12 to mouseY - boxHeight - 12, - mouseX + 16 to mouseY - boxHeight / 2 - ) + val candidates = + listOf( + mouseX + 12 to mouseY + 12, + mouseX + 12 to mouseY - boxHeight - 12, + mouseX - boxWidth - 12 to mouseY + 12, + mouseX - boxWidth - 12 to mouseY - boxHeight - 12, + mouseX + 16 to mouseY - boxHeight / 2, + ) candidates.forEach { (rawX, rawY) -> val candidate = clampTooltipRect(rawX, rawY, boxWidth, boxHeight, viewportWidth, viewportHeight) if (!rectIntersects(candidate, inspectorRect)) { @@ -3529,7 +2525,7 @@ class InspectorController( width: Int, height: Int, viewportWidth: Int, - viewportHeight: Int + viewportHeight: Int, ): Rect { val clampedX = x.coerceIn(2, (viewportWidth - width - 2).coerceAtLeast(2)) val clampedY = y.coerceIn(2, (viewportHeight - height - 2).coerceAtLeast(2)) @@ -3539,9 +2535,9 @@ class InspectorController( private fun rectIntersects(a: Rect, b: Rect): Boolean { if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false return a.x < b.x + b.width && - a.x + a.width > b.x && - a.y < b.y + b.height && - a.y + a.height > b.y + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y } private fun isInsideInspectorUi(mouseX: Int, mouseY: Int): Boolean { @@ -3551,12 +2547,14 @@ class InspectorController( if (panelActions.any { action -> isPanelActionVisibleHit(action, mouseX, mouseY) }) { return true } - val bounds = when (panelState) { - InspectorPanelState.Expanded -> expandedRect - InspectorPanelState.Minimized -> Rect(minimizedPosX, minimizedPosY, minimizedWidth(), minimizedHeight()) - } + val bounds = + when (panelState) { + InspectorPanelState.Expanded -> expandedRect + InspectorPanelState.Minimized -> Rect(minimizedPosX, minimizedPosY, minimizedWidth(), minimizedHeight()) + } return bounds.contains(mouseX, mouseY) } + private fun clampExpandedRect(rect: Rect, viewportWidth: Int, viewportHeight: Int): Rect { val safeViewportW = viewportWidth.coerceAtLeast(minPanelWidth + viewportMargin * 2) val safeViewportH = viewportHeight.coerceAtLeast(minPanelHeight + viewportMargin * 2) @@ -3584,43 +2582,6 @@ class InspectorController( private fun minimizedHeight(): Int = chipHeight - private fun wrapMinimizedLabel( - text: String, - maxCharsPerLine: Int, - maxLines: Int - ): List { - val source = text.trim() - if (source.isEmpty()) return listOf("") - if (maxCharsPerLine <= 0 || maxLines <= 0) return listOf("") - - val lines = ArrayList(maxLines) - var cursor = 0 - while (cursor < source.length && lines.size < maxLines) { - var end = (cursor + maxCharsPerLine).coerceAtMost(source.length) - if (end < source.length) { - val breakAt = source.lastIndexOf(' ', end - 1) - if (breakAt >= cursor + 1) { - end = breakAt - } - } - var line = source.substring(cursor, end).trim() - if (line.isEmpty()) { - end = (cursor + maxCharsPerLine).coerceAtMost(source.length) - line = source.substring(cursor, end) - } - lines += line - cursor = end - while (cursor < source.length && source[cursor] == ' ') cursor++ - } - if (cursor < source.length && lines.isNotEmpty()) { - val last = lines.last() - val keep = (maxCharsPerLine - 3).coerceAtLeast(0) - val trimmed = last.take(keep).trimEnd() - lines[lines.lastIndex] = if (trimmed.isEmpty()) "..." else "$trimmed..." - } - return lines - } - private fun containsReference(root: DOMNode, target: DOMNode): Boolean { if (root === target) return true root.children.forEach { child -> @@ -3629,11 +2590,7 @@ class InspectorController( return false } - private fun findByKeyAndClass( - node: DOMNode, - key: Any, - klass: Class - ): DOMNode? { + private fun findByKeyAndClass(node: DOMNode, key: Any, klass: Class): DOMNode? { if (node.key == key && node.javaClass == klass) return node node.children.forEach { child -> val found = findByKeyAndClass(child, key, klass) @@ -3647,124 +2604,15 @@ class InspectorController( return "${node.styleType}[$key]" } - private fun pathToken(node: DOMNode): String { - val key = node.key?.toString() ?: "?" - return "${node.styleType}:$key" - } - - private fun rectLabel(rect: Rect): String { - return "${rect.x},${rect.y},${rect.width}x${rect.height}" - } - - private fun spacingLabel(value: Insets): String { - return "${value.top}/${value.right}/${value.bottom}/${value.left}" - } + private fun rectLabel(rect: Rect): String = "${rect.x},${rect.y},${rect.width}x${rect.height}" private fun colorLabel(color: Int): String { - val hex = color.toUInt().toString(16).uppercase().padStart(8, '0') + val hex = + color + .toUInt() + .toString(16) + .uppercase() + .padStart(8, '0') return "#$hex" } - - private fun computeBoxes(node: DOMNode): NodeBoxes { - val borderRect = node.bounds - val marginRect = Rect( - x = borderRect.x - node.margin.left, - y = borderRect.y - node.margin.top, - width = (borderRect.width + node.margin.horizontal).coerceAtLeast(0), - height = (borderRect.height + node.margin.vertical).coerceAtLeast(0) - ) - val paddingRect = Rect( - x = borderRect.x + node.border.left, - y = borderRect.y + node.border.top, - width = (borderRect.width - node.border.horizontal).coerceAtLeast(0), - height = (borderRect.height - node.border.vertical).coerceAtLeast(0) - ) - val contentRect = Rect( - x = paddingRect.x + node.padding.left, - y = paddingRect.y + node.padding.top, - width = (paddingRect.width - node.padding.horizontal).coerceAtLeast(0), - height = (paddingRect.height - node.padding.vertical).coerceAtLeast(0) - ) - val parentContent = node.parent?.let { parent -> contentRect(parent) } - return NodeBoxes( - margin = marginRect, - border = borderRect, - padding = paddingRect, - content = contentRect, - parentContent = parentContent - ) - } - - private fun computeHighlightBoxes(node: DOMNode): NodeBoxes { - val geometry = UsedInteractionGeometryResolver.resolveNodeGeometry(node) - val usedClip = geometry.usedClipRect - val borderRect = clipRectToUsedClip(geometry.usedBorderRect, usedClip) - val marginRect = clipRectToUsedClip( - Rect( - x = geometry.usedBorderRect.x - node.margin.left, - y = geometry.usedBorderRect.y - node.margin.top, - width = (geometry.usedBorderRect.width + node.margin.horizontal).coerceAtLeast(0), - height = (geometry.usedBorderRect.height + node.margin.vertical).coerceAtLeast(0) - ), - usedClip - ) - val paddingRect = clipRectToUsedClip( - Rect( - x = geometry.usedBorderRect.x + node.border.left, - y = geometry.usedBorderRect.y + node.border.top, - width = (geometry.usedBorderRect.width - node.border.horizontal).coerceAtLeast(0), - height = (geometry.usedBorderRect.height - node.border.vertical).coerceAtLeast(0) - ), - usedClip - ) - val contentRect = clipRectToUsedClip( - Rect( - x = paddingRect.x + node.padding.left, - y = paddingRect.y + node.padding.top, - width = (paddingRect.width - node.padding.horizontal).coerceAtLeast(0), - height = (paddingRect.height - node.padding.vertical).coerceAtLeast(0) - ), - usedClip - ) - val parentContent = node.parent?.let { parent -> - clipRectToUsedClip(contentRect(parent), usedClip) - } - return NodeBoxes( - margin = marginRect, - border = borderRect, - padding = paddingRect, - content = contentRect, - parentContent = parentContent - ) - } - - private fun clipRectToUsedClip(rect: Rect, clip: Rect?): Rect { - if (rect.width <= 0 || rect.height <= 0) { - return Rect(0, 0, 0, 0) - } - if (clip == null) return rect - return rect.intersection(clip) ?: Rect(0, 0, 0, 0) - } - - private fun contentRect(node: DOMNode): Rect { - return Rect( - x = node.bounds.x + node.border.left + node.padding.left, - y = node.bounds.y + node.border.top + node.padding.top, - width = (node.bounds.width - node.border.horizontal - node.padding.horizontal).coerceAtLeast(0), - height = (node.bounds.height - node.border.vertical - node.padding.vertical).coerceAtLeast(0) - ) - } - - private fun addFill(out: MutableList, rect: Rect, color: Int) { - if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, rect.height, color) - } - - private fun addOutline(out: MutableList, rect: Rect, color: Int) { - if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y + rect.height - 1, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y, 1, rect.height, color) - out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) - } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorDomSnapshot.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorDomSnapshot.kt index a5a618f..d198b80 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorDomSnapshot.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorDomSnapshot.kt @@ -13,5 +13,5 @@ internal data class InspectorDomSnapshot( val parentLabel: String?, val childLabels: List, val styleEditorHeight: Int, - val styleLines: List + val styleLines: List, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSession.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSession.kt index 357051d..7444add 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSession.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSession.kt @@ -23,7 +23,7 @@ internal class InspectorEditSession { property: StyleProperty, initialBuffer: String, initialUnit: CssUnit?, - isNumeric: Boolean + isNumeric: Boolean, ) { activeProperty = property activeBuffer = initialBuffer diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistry.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistry.kt index 4e01fa4..818ebf7 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistry.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistry.kt @@ -18,31 +18,32 @@ enum class InspectorEditorKind { EnumSelect, FontSelect, StringInput, - NumericInput + NumericInput, } data class InspectorEditorDescriptor( val kind: InspectorEditorKind, val options: List = emptyList(), val supportsUnits: Boolean = false, - val showColorPreview: Boolean = false + val showColorPreview: Boolean = false, ) data class InspectorNumberUnitValue( val numberText: String, val unit: CssUnit?, - val isAuto: Boolean + val isAuto: Boolean, ) object InspectorEditorRegistry { - private val unitOptions: List = listOf( - CssUnit.Px, - CssUnit.Em, - CssUnit.Rem, - CssUnit.Vw, - CssUnit.Vh, - CssUnit.Percent - ) + private val unitOptions: List = + listOf( + CssUnit.Px, + CssUnit.Em, + CssUnit.Rem, + CssUnit.Vw, + CssUnit.Vh, + CssUnit.Percent, + ) fun describe(property: StyleProperty, literal: String, expression: StyleExpression?): InspectorEditorDescriptor { val descriptor = StylePropertyRegistry.descriptor(property) @@ -50,14 +51,14 @@ object InspectorEditorRegistry { StyleInspectorEditorKind.FontSelect -> { InspectorEditorDescriptor( kind = InspectorEditorKind.FontSelect, - options = fontOptions() + options = fontOptions(), ) } StyleInspectorEditorKind.EnumSelect -> { InspectorEditorDescriptor( kind = InspectorEditorKind.EnumSelect, - options = descriptor.enumOptions + options = descriptor.enumOptions, ) } @@ -65,17 +66,19 @@ object InspectorEditorRegistry { InspectorEditorDescriptor( kind = InspectorEditorKind.NumericInput, options = descriptor.enumOptions, - supportsUnits = descriptor.grammarKind == StyleValueGrammarKind.LengthLike || - descriptor.grammarKind == StyleValueGrammarKind.LineHeight + supportsUnits = + descriptor.grammarKind == StyleValueGrammarKind.LengthLike || + descriptor.grammarKind == StyleValueGrammarKind.LineHeight, ) } StyleInspectorEditorKind.StringInput -> { InspectorEditorDescriptor( kind = InspectorEditorKind.StringInput, - showColorPreview = isColorProperty(property) || - looksLikeColorLiteral(literal) || - expression is StyleExpression.VariableRef + showColorPreview = + isColorProperty(property) || + looksLikeColorLiteral(literal) || + expression is StyleExpression.VariableRef, ) } } @@ -86,29 +89,28 @@ object InspectorEditorRegistry { return when (descriptor.valueType) { StyleEditorValueType.LengthPx, StyleEditorValueType.OptionalLengthPx, - StyleEditorValueType.SpacingLengthPx -> parseLengthLikeNumberUnit(rawLiteral) + StyleEditorValueType.SpacingLengthPx, + -> parseLengthLikeNumberUnit(rawLiteral) StyleEditorValueType.LineHeight -> parseLineHeightNumberUnit(rawLiteral) StyleEditorValueType.IntNumber -> parseUnitlessInt(rawLiteral, allowAuto = false) StyleEditorValueType.OptionalIntNumber -> parseUnitlessInt(rawLiteral, allowAuto = true) StyleEditorValueType.FloatNumber, - StyleEditorValueType.Spacing -> parseUnitlessFloat(rawLiteral) + StyleEditorValueType.Spacing, + -> parseUnitlessFloat(rawLiteral) else -> null } } - fun formatNumericLiteral( - property: StyleProperty, - numberText: String, - unitToken: String? - ): String { + fun formatNumericLiteral(property: StyleProperty, numberText: String, unitToken: String?): String { val descriptor = StylePropertyRegistry.descriptor(property) return when (descriptor.valueType) { StyleEditorValueType.LengthPx, StyleEditorValueType.OptionalLengthPx, - StyleEditorValueType.SpacingLengthPx -> { + StyleEditorValueType.SpacingLengthPx, + -> { val unit = parseCssUnitToken(unitToken) ?: CssUnit.Px formatNumberUnit(numberText, unit) } @@ -138,7 +140,8 @@ object InspectorEditorRegistry { } StyleEditorValueType.FloatNumber, - StyleEditorValueType.Spacing -> { + StyleEditorValueType.Spacing, + -> { val normalized = numberText.trim() if (normalized.isEmpty()) { "0" @@ -165,14 +168,13 @@ object InspectorEditorRegistry { val descriptor = StylePropertyRegistry.descriptor(property) return when (descriptor.grammarKind) { StyleValueGrammarKind.LengthLike, - StyleValueGrammarKind.LineHeight -> CssUnit.Px + StyleValueGrammarKind.LineHeight, + -> CssUnit.Px else -> null } } - fun parseNumberUnit(rawLiteral: String): InspectorNumberUnitValue? { - return parseLengthLikeNumberUnit(rawLiteral) - } + fun parseNumberUnit(rawLiteral: String): InspectorNumberUnitValue? = parseLengthLikeNumberUnit(rawLiteral) fun formatNumberUnit(numberText: String, unit: CssUnit?): String { val trimmed = numberText.trim() @@ -185,11 +187,10 @@ object InspectorEditorRegistry { fun unitOptions(): List = unitOptions - fun isColorProperty(property: StyleProperty): Boolean { - return property == StyleProperty.BACKGROUND_COLOR || + fun isColorProperty(property: StyleProperty): Boolean = + property == StyleProperty.BACKGROUND_COLOR || property == StyleProperty.BORDER_COLOR || property == StyleProperty.FOREGROUND_COLOR - } fun looksLikeColorLiteral(literal: String): Boolean { val value = literal.trim() @@ -198,9 +199,7 @@ object InspectorEditorRegistry { return hex.length == 3 || hex.length == 6 || hex.length == 8 } - private fun fontOptions(): List { - return FontRegistry.allFontIds().sortedBy { it.lowercase() } - } + private fun fontOptions(): List = FontRegistry.allFontIds().sortedBy { it.lowercase() } private fun parseLengthLikeNumberUnit(rawLiteral: String): InspectorNumberUnitValue? { val normalized = rawLiteral.trim() @@ -208,14 +207,19 @@ object InspectorEditorRegistry { if (normalized.equals("auto", ignoreCase = true)) { return InspectorNumberUnitValue(numberText = "0", unit = CssUnit.Px, isAuto = true) } - val token = normalized.split(Regex("\\s+")).firstOrNull()?.trim().orEmpty() + val token = + normalized + .split(Regex("\\s+")) + .firstOrNull() + ?.trim() + .orEmpty() if (token.isEmpty()) return null return runCatching { val parsed = parseCssLength(token, allowUnitlessZero = true) InspectorNumberUnitValue( numberText = stripTrailingZeros(parsed.value), unit = parsed.unit, - isAuto = false + isAuto = false, ) }.getOrNull() } @@ -232,7 +236,7 @@ object InspectorEditorRegistry { InspectorNumberUnitValue( numberText = stripTrailingZeros(parsed.value), unit = parsed.unit, - isAuto = false + isAuto = false, ) }.getOrNull() } @@ -248,7 +252,7 @@ object InspectorEditorRegistry { InspectorNumberUnitValue( numberText = parsed.toString(), unit = null, - isAuto = false + isAuto = false, ) }.getOrNull() } @@ -261,7 +265,7 @@ object InspectorEditorRegistry { InspectorNumberUnitValue( numberText = stripTrailingZeros(parsed), unit = null, - isAuto = false + isAuto = false, ) }.getOrNull() } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorGeometrySupport.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorGeometrySupport.kt new file mode 100644 index 0000000..50b76da --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorGeometrySupport.kt @@ -0,0 +1,111 @@ +package org.dreamfinity.dsgl.core.inspector + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.UsedInteractionGeometryResolver +import org.dreamfinity.dsgl.core.dom.layout.Rect + +internal data class InspectorNodeBoxes( + val margin: Rect, + val border: Rect, + val padding: Rect, + val content: Rect, + val parentContent: Rect?, +) + +internal object InspectorGeometrySupport { + fun computeBoxes(node: DOMNode): InspectorNodeBoxes { + val borderRect = node.bounds + val marginRect = + Rect( + x = borderRect.x - node.margin.left, + y = borderRect.y - node.margin.top, + width = (borderRect.width + node.margin.horizontal).coerceAtLeast(0), + height = (borderRect.height + node.margin.vertical).coerceAtLeast(0), + ) + val paddingRect = + Rect( + x = borderRect.x + node.border.left, + y = borderRect.y + node.border.top, + width = (borderRect.width - node.border.horizontal).coerceAtLeast(0), + height = (borderRect.height - node.border.vertical).coerceAtLeast(0), + ) + val contentRect = + Rect( + x = paddingRect.x + node.padding.left, + y = paddingRect.y + node.padding.top, + width = (paddingRect.width - node.padding.horizontal).coerceAtLeast(0), + height = (paddingRect.height - node.padding.vertical).coerceAtLeast(0), + ) + val parentContent = node.parent?.let(::contentRect) + return InspectorNodeBoxes( + margin = marginRect, + border = borderRect, + padding = paddingRect, + content = contentRect, + parentContent = parentContent, + ) + } + + fun computeHighlightBoxes(node: DOMNode): InspectorNodeBoxes { + val geometry = UsedInteractionGeometryResolver.resolveNodeGeometry(node) + val usedClip = geometry.usedClipRect + val borderRect = clipRectToUsedClip(geometry.usedBorderRect, usedClip) + val marginRect = + clipRectToUsedClip( + Rect( + x = geometry.usedBorderRect.x - node.margin.left, + y = geometry.usedBorderRect.y - node.margin.top, + width = (geometry.usedBorderRect.width + node.margin.horizontal).coerceAtLeast(0), + height = (geometry.usedBorderRect.height + node.margin.vertical).coerceAtLeast(0), + ), + usedClip, + ) + val paddingRect = + clipRectToUsedClip( + Rect( + x = geometry.usedBorderRect.x + node.border.left, + y = geometry.usedBorderRect.y + node.border.top, + width = (geometry.usedBorderRect.width - node.border.horizontal).coerceAtLeast(0), + height = (geometry.usedBorderRect.height - node.border.vertical).coerceAtLeast(0), + ), + usedClip, + ) + val contentRect = + clipRectToUsedClip( + Rect( + x = paddingRect.x + node.padding.left, + y = paddingRect.y + node.padding.top, + width = (paddingRect.width - node.padding.horizontal).coerceAtLeast(0), + height = (paddingRect.height - node.padding.vertical).coerceAtLeast(0), + ), + usedClip, + ) + val parentContent = + node.parent?.let { parent -> + clipRectToUsedClip(contentRect(parent), usedClip) + } + return InspectorNodeBoxes( + margin = marginRect, + border = borderRect, + padding = paddingRect, + content = contentRect, + parentContent = parentContent, + ) + } + + private fun clipRectToUsedClip(rect: Rect, clip: Rect?): Rect { + if (rect.width <= 0 || rect.height <= 0) { + return Rect(0, 0, 0, 0) + } + if (clip == null) return rect + return rect.intersection(clip) ?: Rect(0, 0, 0, 0) + } + + fun contentRect(node: DOMNode): Rect = + Rect( + x = node.bounds.x + node.border.left + node.padding.left, + y = node.bounds.y + node.border.top + node.padding.top, + width = (node.bounds.width - node.border.horizontal - node.padding.horizontal).coerceAtLeast(0), + height = (node.bounds.height - node.border.vertical - node.padding.vertical).coerceAtLeast(0), + ) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorNativePresentation.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorNativePresentation.kt index b4919fc..a2ad830 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorNativePresentation.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorNativePresentation.kt @@ -8,12 +8,12 @@ internal data class InspectorHighlightSnapshot( val borderRect: Rect, val paddingRect: Rect, val contentRect: Rect, - val parentContentRect: Rect? + val parentContentRect: Rect?, ) internal data class InspectorTooltipSnapshot( val text: String, - val rect: Rect + val rect: Rect, ) internal data class InspectorStyleEditorRowSnapshot( @@ -35,14 +35,14 @@ internal data class InspectorStyleEditorRowSnapshot( val unitValue: String?, val unitOpen: Boolean, val colorPreviewRect: Rect?, - val colorPreviewColor: Int? + val colorPreviewColor: Int?, ) internal data class InspectorDropdownOptionSnapshot( val rect: Rect, val text: String, val value: String, - val hovered: Boolean + val hovered: Boolean, ) internal data class InspectorDropdownSnapshot( @@ -50,5 +50,5 @@ internal data class InspectorDropdownSnapshot( val property: StyleProperty, val unitSelect: Boolean, val options: List, - val footerText: String? + val footerText: String?, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorPresentationSupport.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorPresentationSupport.kt new file mode 100644 index 0000000..375f59a --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorPresentationSupport.kt @@ -0,0 +1,127 @@ +package org.dreamfinity.dsgl.core.inspector + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect + +internal object InspectorPresentationSupport { + fun nodeLabel(node: DOMNode): String { + val key = node.key?.toString() ?: "" + return "${node.styleType}[$key]" + } + + fun rectLabel(rect: Rect): String = "${rect.x},${rect.y},${rect.width}x${rect.height}" + + fun estimateMaxChars(pixelWidth: Int, fontSize: Int): Int { + val approxCharWidth = (fontSize * 0.56f).toInt().coerceAtLeast(6) + return (pixelWidth / approxCharWidth).coerceAtLeast(8) + } + + fun wrapText(text: String, maxChars: Int): List { + val limit = maxChars.coerceAtLeast(1) + if (text.length <= limit) return listOf(text) + val result = ArrayList() + var cursor = 0 + while (cursor < text.length) { + val end = (cursor + limit).coerceAtMost(text.length) + var cut = end + if (end < text.length) { + val ws = text.lastIndexOf(' ', end - 1) + if (ws >= cursor + 1) { + cut = ws + } + } + if (cut <= cursor) { + cut = end + } + result += text.substring(cursor, cut).trimEnd() + cursor = cut + while (cursor < text.length && text[cursor] == ' ') cursor++ + } + return if (result.isEmpty()) listOf("") else result + } + + fun pathToNode(root: DOMNode, target: DOMNode): List { + val path = ArrayList(8) + if (collectPath(root, target, path)) { + return path + } + return listOf(target) + } + + fun wrapPathLines(path: List, maxChars: Int): List { + if (path.isEmpty()) return listOf("Path: ") + val tokens = path.map(::pathToken) + val lines = ArrayList() + var current = "Path: " + tokens.forEachIndexed { index, token -> + val segment = if (index == 0) token else " > $token" + if (current.length + segment.length <= maxChars) { + current += segment + return@forEachIndexed + } + if (current.isNotBlank()) { + lines += current + } + val continued = if (index == 0) token else "> $token" + val wrapped = wrapText(continued, maxChars - 2) + if (wrapped.isEmpty()) { + current = " " + } else { + lines += wrapped.dropLast(1).map { " $it" } + current = " ${wrapped.last()}" + } + } + lines += current + return lines + } + + fun wrapMinimizedLabel(text: String, maxCharsPerLine: Int, maxLines: Int): List { + val source = text.trim() + if (source.isEmpty()) return listOf("") + if (maxCharsPerLine <= 0 || maxLines <= 0) return listOf("") + + val lines = ArrayList(maxLines) + var cursor = 0 + while (cursor < source.length && lines.size < maxLines) { + var end = (cursor + maxCharsPerLine).coerceAtMost(source.length) + if (end < source.length) { + val breakAt = source.lastIndexOf(' ', end - 1) + if (breakAt >= cursor + 1) { + end = breakAt + } + } + var line = source.substring(cursor, end).trim() + if (line.isEmpty()) { + end = (cursor + maxCharsPerLine).coerceAtMost(source.length) + line = source.substring(cursor, end) + } + lines += line + cursor = end + while (cursor < source.length && source[cursor] == ' ') cursor++ + } + if (cursor < source.length && lines.isNotEmpty()) { + val last = lines.last() + val keep = (maxCharsPerLine - 3).coerceAtLeast(0) + val trimmed = last.take(keep).trimEnd() + lines[lines.lastIndex] = if (trimmed.isEmpty()) "..." else "$trimmed..." + } + return lines + } + + private fun collectPath(node: DOMNode, target: DOMNode, path: MutableList): Boolean { + path += node + if (node === target) return true + for (child in node.children) { + if (collectPath(child, target, path)) { + return true + } + } + path.removeAt(path.lastIndex) + return false + } + + private fun pathToken(node: DOMNode): String { + val key = node.key?.toString() ?: "?" + return "${node.styleType}:$key" + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilder.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilder.kt new file mode 100644 index 0000000..d53f8ce --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilder.kt @@ -0,0 +1,472 @@ +package org.dreamfinity.dsgl.core.inspector + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.style.ComputedStyle +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.StyleExpression +import org.dreamfinity.dsgl.core.style.StyleInspection +import org.dreamfinity.dsgl.core.style.StyleProperty +import org.dreamfinity.dsgl.core.style.StylePropertyRegistry +import org.dreamfinity.dsgl.core.style.parseColor + +internal enum class InspectorStyleEditorActionType { + ResetProperty, + ToggleValueSelect, + SelectValueOption, + OpenColorPicker, + Decrement, + Increment, + ToggleUnitSelect, + SelectUnitOption, + ResetSelectedOverrides, + ClearAllOverrides, +} + +internal data class InspectorStyleEditorActionSpec( + val bounds: Rect, + val type: InspectorStyleEditorActionType, + val property: StyleProperty? = null, + val step: Float = 1f, + val payload: String? = null, +) + +internal data class InspectorStyleEditorDropdownLayout( + val rect: Rect, + val property: StyleProperty, + val unitSelect: Boolean, + val totalOptions: Int, + val visibleRows: Int, +) + +internal data class InspectorStyleEditorSnapshotBuildContext( + val panelRect: Rect, + val panelBounds: Rect, + val selected: DOMNode, + val inspection: StyleInspection, + val editableProperties: List, + val startY: Int, + val lineHeightPx: Int, + val rowHeightPx: Int, + val secondaryFontSizePx: Int, + val pointerProjectionScrollY: Int, + val mouseX: Int, + val mouseY: Int, + val viewportWidth: Int, + val viewportHeight: Int, + val openValueSelectProperty: StyleProperty?, + val openUnitSelectProperty: StyleProperty?, + val openValueSelectScrollIndex: Int, + val openUnitSelectScrollIndex: Int, +) + +internal data class InspectorStyleEditorSnapshotBuildResult( + val endY: Int, + val variableTooltip: InspectorTooltipSnapshot?, + val rows: List, + val dropdowns: List, + val dropdownLayouts: List, + val actionSpecs: List, + val resetRect: Rect, + val clearRect: Rect, + val openValueSelectScrollIndex: Int, + val openUnitSelectScrollIndex: Int, +) + +internal class InspectorStyleEditorSnapshotBuilder( + private val resolveLiteralFromComputed: (ComputedStyle, StyleProperty) -> String, + private val renderExpressionLabel: (StyleExpression) -> String, +) { + fun build(context: InspectorStyleEditorSnapshotBuildContext): InspectorStyleEditorSnapshotBuildResult { + var y = context.startY + context.lineHeightPx + var variableTooltip: InspectorTooltipSnapshot? = null + var openValueSelectScrollIndex = context.openValueSelectScrollIndex + var openUnitSelectScrollIndex = context.openUnitSelectScrollIndex + + val rowSnapshots = ArrayList(context.editableProperties.size) + val dropdownSnapshots = ArrayList(2) + val dropdownLayouts = ArrayList(2) + val actionSpecs = ArrayList(context.editableProperties.size * 4) + + val rowLeft = context.panelRect.x + 10 + val rowWidth = context.panelRect.width - 20 + val btnWidth = 40 + val gap = 6 + val controlHeight = (context.rowHeightPx - 8).coerceAtLeast(22) + val labelLineHeight = (context.secondaryFontSizePx - 2).coerceAtLeast(18) + + context.editableProperties.forEach { property -> + val overrideExpr = StyleEngine.inspectorOverrideFor(context.selected, property) + val effectiveValue = + overrideExpr?.let(renderExpressionLabel) + ?: resolveLiteralFromComputed(context.inspection.computed, property) + val sourceTag = + if (overrideExpr != null) { + "ins" + } else { + context.inspection.propertySources[property] + ?.source ?: "default" + } + + val labelText = "${property.key} [$sourceTag]" + val buttonsRight = rowLeft + rowWidth - 8 + val maxLabelWidth = (rowWidth - btnWidth - gap - 36).coerceAtLeast(80) + val labelWidth = (rowWidth * 0.40f).toInt().coerceIn(80, maxLabelWidth) + val labelMaxChars = + InspectorPresentationSupport.estimateMaxChars((labelWidth - 12).coerceAtLeast(24), labelLineHeight) + val labelLineCount = + InspectorPresentationSupport + .wrapText(labelText, labelMaxChars) + .size + .coerceAtLeast(1) + val rowHeight = maxOf(context.rowHeightPx, labelLineCount * labelLineHeight + 10, controlHeight + 8) + val rowRect = Rect(rowLeft, y, rowWidth, rowHeight) + val controlX = rowRect.x + labelWidth + val controlWidth = (buttonsRight - controlX - btnWidth - gap).coerceAtLeast(36) + val controlY = rowRect.y + ((rowRect.height - controlHeight) / 2) + val resetRect = Rect(buttonsRight - btnWidth, controlY, btnWidth, controlHeight) + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = resetRect, + type = InspectorStyleEditorActionType.ResetProperty, + property = property, + ) + + val editor = + InspectorEditorRegistry.describe( + property = property, + literal = effectiveValue, + expression = overrideExpr, + ) + val controlRect = Rect(controlX, controlY, controlWidth, controlHeight) + + var rowSnapshot = + InspectorStyleEditorRowSnapshot( + property = property, + sourceTag = sourceTag, + rowRect = rowRect, + labelText = labelText, + resetRect = resetRect, + editorKind = editor.kind, + controlRect = controlRect, + controlValue = effectiveValue, + controlOpen = false, + controlHovered = false, + inputActive = false, + decrementRect = null, + inputRect = null, + incrementRect = null, + unitRect = null, + unitValue = null, + unitOpen = false, + colorPreviewRect = null, + colorPreviewColor = null, + ) + + when (editor.kind) { + InspectorEditorKind.EnumSelect, + InspectorEditorKind.FontSelect, + -> { + val isOpen = context.openValueSelectProperty == property + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = controlRect, + type = InspectorStyleEditorActionType.ToggleValueSelect, + property = property, + ) + rowSnapshot = + rowSnapshot.copy( + controlOpen = isOpen, + controlHovered = + projectRectForPointer(controlRect, context.pointerProjectionScrollY).contains( + context.mouseX, + context.mouseY, + ), + ) + } + + InspectorEditorKind.StringInput -> { + var previewRect: Rect? = null + var previewColor: Int? = null + if (editor.showColorPreview) { + previewRect = + Rect( + x = controlRect.x + controlRect.width - (controlRect.height - 8).coerceAtLeast(10) - 6, + y = controlRect.y + 4, + width = (controlRect.height - 8).coerceAtLeast(10), + height = (controlRect.height - 8).coerceAtLeast(10), + ) + previewColor = runCatching { parseColor(effectiveValue) }.getOrNull() + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = previewRect, + type = InspectorStyleEditorActionType.OpenColorPicker, + property = property, + ) + } + rowSnapshot = + rowSnapshot.copy( + controlValue = effectiveValue, + inputActive = false, + colorPreviewRect = previewRect, + colorPreviewColor = previewColor, + ) + } + + InspectorEditorKind.NumericInput -> { + val step = StylePropertyRegistry.descriptor(property).numericStep + val parsed = InspectorEditorRegistry.parseNumericLiteral(property, effectiveValue) + val numericValue = parsed?.numberText ?: "0" + val unit = parsed?.unit ?: InspectorEditorRegistry.defaultNumericUnit(property) + val buttonWidth = 34 + val unitWidth = if (editor.supportsUnits) 68 else 0 + val inputWidth = + (controlRect.width - buttonWidth * 2 - unitWidth - (if (editor.supportsUnits) 12 else 6)) + .coerceAtLeast(64) + val decRect = Rect(controlRect.x, controlRect.y, buttonWidth, controlRect.height) + val inputRect = Rect(decRect.x + decRect.width + 4, controlRect.y, inputWidth, controlRect.height) + val incRect = + Rect(inputRect.x + inputRect.width + 4, controlRect.y, buttonWidth, controlRect.height) + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = decRect, + type = InspectorStyleEditorActionType.Decrement, + property = property, + step = step, + ) + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = incRect, + type = InspectorStyleEditorActionType.Increment, + property = property, + step = step, + ) + var unitRect: Rect? = null + if (editor.supportsUnits) { + unitRect = Rect(incRect.x + incRect.width + 4, controlRect.y, unitWidth, controlRect.height) + actionSpecs += + InspectorStyleEditorActionSpec( + bounds = unitRect, + type = InspectorStyleEditorActionType.ToggleUnitSelect, + property = property, + ) + } + rowSnapshot = + rowSnapshot.copy( + controlValue = numericValue, + inputActive = false, + decrementRect = decRect, + inputRect = inputRect, + incrementRect = incRect, + unitRect = unitRect, + unitValue = if (editor.supportsUnits) unit?.token else null, + unitOpen = context.openUnitSelectProperty == property, + controlOpen = false, + ) + } + } + + val projectedRowRect = projectRectForPointer(rowRect, context.pointerProjectionScrollY) + if (overrideExpr is StyleExpression.VariableRef && + projectedRowRect.contains(context.mouseX, context.mouseY) + ) { + val resolved = StyleEngine.resolveInspectorVariable(overrideExpr.name) + val body = resolved.getOrElse { "unresolved (${it.message ?: "unknown error"})" } + variableTooltip = + InspectorTooltipSnapshot( + text = "${overrideExpr.name} = $body", + rect = + Rect( + x = + (projectedRowRect.x + projectedRowRect.width - 360).coerceAtLeast( + context.panelBounds.x + 8, + ), + y = + (projectedRowRect.y - context.lineHeightPx - 8).coerceAtLeast( + context.panelBounds.y + 8, + ), + width = 352, + height = context.lineHeightPx + 10, + ), + ) + } + + if (context.openValueSelectProperty == property && editor.options.isNotEmpty()) { + val dropdown = + buildDropdownSnapshot( + x = controlRect.x, + y = controlRect.y + controlRect.height + 2, + width = controlRect.width, + options = editor.options, + property = property, + unitSelect = false, + pointerProjectionScrollY = context.pointerProjectionScrollY, + rowHeightPx = context.rowHeightPx, + viewportWidth = context.viewportWidth, + viewportHeight = context.viewportHeight, + mouseX = context.mouseX, + mouseY = context.mouseY, + currentScrollIndex = openValueSelectScrollIndex, + ) + openValueSelectScrollIndex = dropdown.nextScrollIndex + dropdownLayouts += dropdown.layout + dropdownSnapshots += dropdown.snapshot + actionSpecs += dropdown.optionActionSpecs + rowSnapshot = rowSnapshot.copy(controlOpen = true) + } + if (context.openUnitSelectProperty == property && editor.supportsUnits) { + val units = InspectorEditorRegistry.unitOptions().map { it.token } + val dropdown = + buildDropdownSnapshot( + x = controlRect.x + controlRect.width - 90, + y = controlRect.y + controlRect.height + 2, + width = 90, + options = units, + property = property, + unitSelect = true, + pointerProjectionScrollY = context.pointerProjectionScrollY, + rowHeightPx = context.rowHeightPx, + viewportWidth = context.viewportWidth, + viewportHeight = context.viewportHeight, + mouseX = context.mouseX, + mouseY = context.mouseY, + currentScrollIndex = openUnitSelectScrollIndex, + ) + openUnitSelectScrollIndex = dropdown.nextScrollIndex + dropdownLayouts += dropdown.layout + dropdownSnapshots += dropdown.snapshot + actionSpecs += dropdown.optionActionSpecs + rowSnapshot = rowSnapshot.copy(unitOpen = true) + } + + rowSnapshots += rowSnapshot + y += rowHeight + 4 + } + + val actionHeight = (context.secondaryFontSizePx + 10).coerceAtLeast(28) + val resetRect = Rect(rowLeft, y, 140, actionHeight) + val clearRect = Rect(rowLeft + 148, y, 160, actionHeight) + actionSpecs += InspectorStyleEditorActionSpec(resetRect, InspectorStyleEditorActionType.ResetSelectedOverrides) + actionSpecs += InspectorStyleEditorActionSpec(clearRect, InspectorStyleEditorActionType.ClearAllOverrides) + y += actionHeight + 4 + + return InspectorStyleEditorSnapshotBuildResult( + endY = y, + variableTooltip = variableTooltip, + rows = rowSnapshots, + dropdowns = dropdownSnapshots, + dropdownLayouts = dropdownLayouts, + actionSpecs = actionSpecs, + resetRect = resetRect, + clearRect = clearRect, + openValueSelectScrollIndex = openValueSelectScrollIndex, + openUnitSelectScrollIndex = openUnitSelectScrollIndex, + ) + } + + private fun projectRectForPointer(rect: Rect, pointerProjectionScrollY: Int): Rect { + if (pointerProjectionScrollY <= 0) return rect + return Rect(rect.x, rect.y - pointerProjectionScrollY, rect.width, rect.height) + } + + private fun buildDropdownSnapshot( + x: Int, + y: Int, + width: Int, + options: List, + property: StyleProperty, + unitSelect: Boolean, + pointerProjectionScrollY: Int, + rowHeightPx: Int, + viewportWidth: Int, + viewportHeight: Int, + mouseX: Int, + mouseY: Int, + currentScrollIndex: Int, + ): BuiltDropdownSnapshot { + val maxRows = 8 + val visibleRows = minOf(maxRows, options.size) + val maxFirst = (options.size - visibleRows).coerceAtLeast(0) + val first = currentScrollIndex.coerceIn(0, maxFirst) + val shown = options.subList(first, first + visibleRows) + val optionHeight = rowHeightPx + val popupHeight = optionHeight * shown.size + 6 + val safeViewportW = viewportWidth.coerceAtLeast(1) + val safeViewportH = viewportHeight.coerceAtLeast(1) + val clampedX = x.coerceIn(2, (safeViewportW - width - 2).coerceAtLeast(2)) + val clampedY = y.coerceIn(2, (safeViewportH - popupHeight - 2).coerceAtLeast(2)) + val popupRect = Rect(clampedX, clampedY, width, popupHeight) + + val layout = + InspectorStyleEditorDropdownLayout( + rect = popupRect, + property = property, + unitSelect = unitSelect, + totalOptions = options.size, + visibleRows = visibleRows, + ) + + var optionY = popupRect.y + 3 + val optionSnapshots = ArrayList(shown.size) + val optionActionSpecs = ArrayList(shown.size) + shown.forEach { option -> + val optionRect = Rect(popupRect.x + 3, optionY, popupRect.width - 6, optionHeight - 2) + val hovered = projectRectForPointer(optionRect, pointerProjectionScrollY).contains(mouseX, mouseY) + optionSnapshots += + InspectorDropdownOptionSnapshot( + rect = optionRect, + text = ellipsize(option, 30), + value = option, + hovered = hovered, + ) + optionActionSpecs += + InspectorStyleEditorActionSpec( + bounds = optionRect, + type = + if (unitSelect) { + InspectorStyleEditorActionType.SelectUnitOption + } else { + InspectorStyleEditorActionType.SelectValueOption + }, + property = property, + payload = option, + ) + optionY += optionHeight + } + + val footer = + if (options.size > visibleRows) { + "${first + 1}-${first + visibleRows}/${options.size}" + } else { + null + } + val snapshot = + InspectorDropdownSnapshot( + popupRect = popupRect, + property = property, + unitSelect = unitSelect, + options = optionSnapshots, + footerText = footer, + ) + return BuiltDropdownSnapshot( + snapshot = snapshot, + layout = layout, + optionActionSpecs = optionActionSpecs, + nextScrollIndex = first, + ) + } + + private fun ellipsize(raw: String, maxChars: Int): String { + if (maxChars <= 1) return raw.take(1) + if (raw.length <= maxChars) return raw + val keep = (maxChars - 3).coerceAtLeast(0) + return raw.take(keep) + "..." + } + + private data class BuiltDropdownSnapshot( + val snapshot: InspectorDropdownSnapshot, + val layout: InspectorStyleEditorDropdownLayout, + val optionActionSpecs: List, + val nextScrollIndex: Int, + ) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayNode.kt deleted file mode 100644 index aad8a40..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayNode.kt +++ /dev/null @@ -1,1237 +0,0 @@ -package org.dreamfinity.dsgl.core.inspector.internal - -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.ScrollSessionSnapshot -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.TextInputNode -import org.dreamfinity.dsgl.core.dom.layout.Border -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.button -import org.dreamfinity.dsgl.core.dsl.div -import org.dreamfinity.dsgl.core.dsl.text -import org.dreamfinity.dsgl.core.event.* -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorDomSnapshot -import org.dreamfinity.dsgl.core.inspector.InspectorDropdownOptionSnapshot -import org.dreamfinity.dsgl.core.inspector.InspectorDropdownSnapshot -import org.dreamfinity.dsgl.core.inspector.InspectorStyleEditorRowSnapshot -import org.dreamfinity.dsgl.core.inspector.InspectorEditorKind -import org.dreamfinity.dsgl.core.inspector.InspectorPanelState -import org.dreamfinity.dsgl.core.inspector.InspectorTooltipSnapshot -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.Overflow -import org.dreamfinity.dsgl.core.style.StyleProperty -import org.dreamfinity.dsgl.core.style.TextWrap -import java.util.LinkedHashMap - -internal class SystemInspectorOverlayNode( - private val controller: InspectorController, - key: Any? = "dsgl-system-inspector" -) : DOMNode(key) { - override val styleType: String = "dsgl-system-inspector" - override val focusable: Boolean = true - - private var inspectedRoot: DOMNode? = null - private var inspectedLayoutRevision: Long = 0L - private var cursorX: Int = 0 - private var cursorY: Int = 0 - private var lastViewportWidth: Int = 1 - private var lastViewportHeight: Int = 1 - private var persistedBodyScrollSession: ScrollSessionSnapshot? = null - private val persistedDropdownScrollSession: MutableMap = LinkedHashMap() - private var activeDomDropdown: ActiveDomDropdown? = null - private var panelDragSession: PanelDragSession? = null - - private data class ActiveDomDropdown( - val property: StyleProperty, - val unitSelect: Boolean - ) - - private enum class PanelDragMode { - Move, - ResizeLeft, - ResizeRight, - ResizeTop, - ResizeBottom, - ResizeTopLeft, - ResizeTopRight, - ResizeBottomLeft, - ResizeBottomRight, - MinimizedMove - } - - private data class PanelDragSession( - val mode: PanelDragMode, - val startPointerX: Int, - val startPointerY: Int, - val startRect: Rect, - var moved: Boolean = false - ) - - init { - EventBus.run { - this@SystemInspectorOverlayNode.addEventListener(Events.MOUSEDOWN) { event: MouseDownEvent -> - if ( - event.mouseButton == MouseButton.LEFT && - activeDomDropdown != null && - shouldDismissOpenDropdownOnPointerDown(event.target) - ) { - closeActiveDomDropdown() - } - if (isDomOwnedInteractionTarget(event.target)) return@addEventListener - if (controller.handleMouseDown(event.mouseX, event.mouseY, event.mouseButton)) { - event.cancelled = true - } - } - this@SystemInspectorOverlayNode.addEventListener(Events.MOUSEUP) { event: MouseUpEvent -> - val routeToController = controller.isPointerCaptured || !isDomOwnedInteractionTarget(event.target) - if (!routeToController) return@addEventListener - if (controller.handleMouseUp(event.mouseX, event.mouseY, event.mouseButton)) { - event.cancelled = true - } - } - this@SystemInspectorOverlayNode.addEventListener(Events.DRAG) { event: MouseDragEvent -> - if (isDomOwnedInteractionTarget(event.target)) return@addEventListener - if (!controller.isPointerCaptured) return@addEventListener - val nextMouseX = event.lastMouseX + event.dx - val nextMouseY = event.lastMouseY + event.dy - controller.onCapturedPointerMove(nextMouseX, nextMouseY, bounds.width, bounds.height) - event.cancelled = true - } - this@SystemInspectorOverlayNode.addEventListener(Events.WHEEL) { event: MouseWheelEvent -> - if (handleActiveDomDropdownWheel(event.dWheel)) { - event.cancelled = true - } - } - } - } - - fun bindInspectedTree(root: DOMNode?, layoutRevision: Long) { - inspectedRoot = root - inspectedLayoutRevision = layoutRevision - } - - @Suppress("UNUSED_PARAMETER") - fun updateCursor(mouseX: Int, mouseY: Int, pointerCaptured: Boolean) { - cursorX = mouseX - cursorY = mouseY - } - - internal fun isDomPanelDragActive(): Boolean = panelDragSession != null - - fun syncInputBounds(viewportWidth: Int, viewportHeight: Int) { - val viewportRect = Rect(0, 0, viewportWidth.coerceAtLeast(0), viewportHeight.coerceAtLeast(0)) - bounds = resolveInputBounds(viewportRect, controller.debugPanelRect()) - } - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - val viewportRect = Rect(x, y, width, height) - bounds = resolveInputBounds(viewportRect, controller.debugPanelRect()) - inspectedRoot?.let { root -> - controller.onLayoutCommitted(root, inspectedLayoutRevision) - } - controller.onCursorMoved(cursorX, cursorY) - lastViewportWidth = viewportRect.width.coerceAtLeast(1) - lastViewportHeight = viewportRect.height.coerceAtLeast(1) - val retainInspectorFocus = shouldRetainInspectorSubtreeFocus() - - val snapshot = controller.buildDomSnapshot(viewportRect.width, viewportRect.height) - if (snapshot == null) { - clearTree() - persistedBodyScrollSession = null - persistedDropdownScrollSession.clear() - closeActiveDomDropdown() - clearPanelDragSession() - controller.onNativeDomBodyScrollState(0, null, null) - return - } - reconcilePanelDragSession(snapshot.panelState) - bounds = resolveInputBounds(viewportRect, snapshot.panelRect) - - capturePersistedScrollStateFromCurrentTree() - clearTree() - when (snapshot.panelState) { - InspectorPanelState.Minimized -> renderMinimized(ctx, snapshot) - InspectorPanelState.Expanded -> renderExpanded(ctx, snapshot, viewportRect.width, viewportRect.height) - } - if (retainInspectorFocus) { - FocusManager.retainFocus(this, updateRootReference = false) - } - } - - private fun resolveInputBounds(viewportRect: Rect, panelRect: Rect?): Rect { - if (controller.blocksUnderlyingInput() || panelDragSession != null) { - return viewportRect - } - return panelRect ?: viewportRect - } - - private fun clearTree() { - EventBus.run { - children.forEach { child -> - child.clearListenersDeep() - child.parent = null - } - } - children.clear() - markRenderCommandsDirty() - } - - private fun renderMinimized(ctx: UiMeasureContext, snapshot: InspectorDomSnapshot) { - closeActiveDomDropdown() - val scope = UiScope(this) - - renderHighlights(scope, ctx) - val chip = scope.div({ - key = "dsgl-system-inspector-chip" - style = { - display = Display.Block - } - }) - chip.backgroundColor = 0xDD1A202A.toInt() - chip.border = Border.all(1, 0xCC4F6076.toInt()) - chip.onMouseDown = { event -> - if (event.mouseButton == MouseButton.LEFT) { - startPanelDrag(PanelDragMode.MinimizedMove, snapshot.panelRect, event.mouseX, event.mouseY) - event.cancelled = true - } - } - chip.onMouseDrag = { event -> - val currentX = event.lastMouseX + event.dx - val currentY = event.lastMouseY + event.dy - continuePanelDrag(currentX, currentY) - event.cancelled = true - } - chip.onMouseUp = { event -> - if (event.mouseButton == MouseButton.LEFT) { - endPanelDrag(event.mouseX, event.mouseY) - event.cancelled = true - } - } - renderNode(ctx, chip, snapshot.panelRect) - - val compactLineHeight = 20 - var lineY = snapshot.panelRect.y + ((snapshot.panelRect.height - compactLineHeight * snapshot.minimizedLines.size) / 2) - snapshot.minimizedLines.forEachIndexed { index, line -> - val lineNode = scope.text(props = { - key = "dsgl-system-inspector-chip-line-$index" - value = line - style = { - textWrap = TextWrap.NoWrap - } - }) - lineNode.color = 0xFFE6EDF6.toInt() - lineNode.fontSize = 14 - renderNode( - ctx, - lineNode, - Rect( - snapshot.panelRect.x + 8, - lineY, - (snapshot.panelRect.width - 16).coerceAtLeast(1), - compactLineHeight - ) - ) - lineY += compactLineHeight - } - } - - private fun renderExpanded(ctx: UiMeasureContext, snapshot: InspectorDomSnapshot, viewportWidth: Int, viewportHeight: Int) { - val panelRect = snapshot.panelRect - val headerRect = snapshot.headerRect ?: Rect(panelRect.x, panelRect.y, panelRect.width, 42) - val bodyRect = snapshot.bodyRect ?: Rect(panelRect.x, panelRect.y + 42, panelRect.width, panelRect.height - 42) - val scope = UiScope(this) - - renderHighlights(scope, ctx) - - val panel = scope.div({ - key = "dsgl-system-inspector-panel" - style = { - display = Display.Block - } - }) - panel.backgroundColor = 0xE0141820.toInt() - panel.border = Border.all(1, 0xCC425062.toInt()) - renderNode(ctx, panel, panelRect) - - val header = scope.div({ - key = "dsgl-system-inspector-header" - style = { - display = Display.Block - } - }) - header.backgroundColor = 0x222D3846 - header.border = Border.all(1, 0x553F4A57) - renderNode(ctx, header, headerRect) - - val pickRect = controller.debugPickToggleBounds() - ?: Rect(headerRect.x + headerRect.width - 264, headerRect.y + 8, 160, (headerRect.height - 16).coerceAtLeast(22)) - val minimizeRect = controller.debugMinimizeBounds() - ?: Rect(headerRect.x + headerRect.width - 96, headerRect.y + 8, 86, (headerRect.height - 16).coerceAtLeast(22)) - - val titleNode = scope.text(props = { - key = "dsgl-system-inspector-header-title" - value = snapshot.headerText - style = { - textWrap = TextWrap.NoWrap - } - }) - titleNode.color = 0xFFE6EDF6.toInt() - titleNode.fontSize = 24 - renderNode( - ctx, - titleNode, - Rect( - headerRect.x + 8, - headerRect.y + 6, - (pickRect.x - headerRect.x - 14).coerceAtLeast(40), - (headerRect.height - 10).coerceAtLeast(24) - ) - ) - val headerDragRect = Rect( - headerRect.x + 8, - headerRect.y + 6, - (pickRect.x - headerRect.x - 14).coerceAtLeast(40), - (headerRect.height - 10).coerceAtLeast(24) - ) - renderPanelDragHandle(scope, ctx, panelRect, headerDragRect) - renderPanelResizeHandles(scope, ctx, panelRect) - - val pickButton = scope.button("Select Element", { - key = "dsgl-system-inspector-pick-toggle" - }) - pickButton.backgroundColor = 0x3346596E - pickButton.border = Border.all(1, 0x775E738C) - pickButton.textColor = 0xFFE6EDF6.toInt() - pickButton.fontSize = 18 - pickButton.onClick { - controller.onPickTogglePressed() - } - renderNode(ctx, pickButton, pickRect) - - val minimizeButton = scope.button("Minimize", { - key = "dsgl-system-inspector-minimize" - }) - minimizeButton.backgroundColor = 0x3346596E - minimizeButton.border = Border.all(1, 0x775E738C) - minimizeButton.textColor = 0xFFE6EDF6.toInt() - minimizeButton.fontSize = 18 - minimizeButton.onClick { - controller.onPanelMinimizeTogglePressed() - } - renderNode(ctx, minimizeButton, minimizeRect) - - val body = scope.div({ - key = "dsgl-system-inspector-body" - style = { - display = Display.Block - } - }) - body.backgroundColor = 0x18212C39 - body.overflow = Overflow.Hidden - body.overflowX = Overflow.Hidden - body.overflowY = Overflow.Auto - val bodyScope = UiScope(body) - renderNode(ctx, body, bodyRect) - - val lineHeightPx = 32 - val rowHeightPx = 34 - val contentX = bodyRect.x + 4 - val contentW = (bodyRect.width - 10).coerceAtLeast(1) - val bodyScrollY = persistedBodyScrollSession?.resolvedY?.coerceAtLeast(0) ?: 0 - var y = bodyRect.y + 2 - bodyScrollY - - snapshot.infoLines.forEachIndexed { index, line -> - val lineNode = bodyScope.text(props = { - key = "dsgl-system-inspector-info-line-$index" - value = line - style = { - textWrap = TextWrap.NoWrap - } - }) - lineNode.color = 0xFFDCE5EF.toInt() - lineNode.fontSize = 24 - renderNode( - ctx, - lineNode, - Rect(contentX, y, contentW, lineHeightPx), - ) - y += lineHeightPx - } - - snapshot.parentLabel?.let { label -> - val parentButton = bodyScope.button(label, { - key = "dsgl-system-inspector-parent-row" - }) - parentButton.backgroundColor = 0x1E263241 - parentButton.border = Border.all(1, 0x55394654) - parentButton.textColor = 0xFFDCE5EF.toInt() - parentButton.fontSize = 22 - parentButton.onClick { - controller.onSelectParentPressed() - } - renderNode( - ctx, - parentButton, - Rect(contentX, y, contentW, rowHeightPx), - ) - y += rowHeightPx + 2 - } - - snapshot.childLabels.forEachIndexed { index, label -> - val childButton = bodyScope.button(label, { - key = "dsgl-system-inspector-child-row-$index" - }) - childButton.backgroundColor = 0x1E263241 - childButton.border = Border.all(1, 0x55394654) - childButton.textColor = 0xFFDCE5EF.toInt() - childButton.fontSize = 22 - childButton.onClick { - controller.onSelectChildPressed(index) - } - renderNode( - ctx, - childButton, - Rect(contentX, y, contentW, rowHeightPx), - ) - y += rowHeightPx + 2 - } - val styleEditorHeader = bodyScope.text(props = { - key = "dsgl-system-inspector-editor-header" - value = "Style editor (live overrides):" - style = { - textWrap = TextWrap.NoWrap - } - }) - styleEditorHeader.color = 0xFFDCE5EF.toInt() - styleEditorHeader.fontSize = 24 - renderNode( - ctx, - styleEditorHeader, - Rect(contentX, y, contentW, lineHeightPx), - ) - - val styleRows = controller.debugStyleEditorRows() - reconcileActiveDomDropdown(styleRows) - renderStyleEditorRows(bodyScope, body, ctx, bodyScrollY, styleRows) - y += snapshot.styleEditorHeight - - snapshot.styleLines.forEachIndexed { index, line -> - val lineNode = bodyScope.text(props = { - key = "dsgl-system-inspector-style-line-$index" - value = line - style = { - textWrap = TextWrap.NoWrap - } - }) - lineNode.color = 0xFFDCE5EF.toInt() - lineNode.fontSize = 24 - renderNode( - ctx, - lineNode, - Rect(contentX, y, contentW, lineHeightPx), - ) - y += lineHeightPx - } - - renderDropdowns(scope, ctx, styleRows, bodyScrollY, viewportWidth, viewportHeight) - body.restoreScrollSessionSnapshot(persistedBodyScrollSession) - val bodyState = body.scrollContainerState() - persistedBodyScrollSession = body.captureScrollSessionSnapshot() - val bodyScrollbarVisual = body.debugScrollbarVisualState().vertical - controller.onNativeDomBodyScrollState( - scrollY = bodyState.scrollY, - trackRect = bodyScrollbarVisual?.trackRect, - thumbRect = bodyScrollbarVisual?.thumbRect - ) - renderTooltip(scope, ctx, "dsgl-system-inspector-variable-tooltip", controller.debugVariableTooltip(), 0xEE141A22.toInt(), 0xCC60758F.toInt()) - renderTooltip(scope, ctx, "dsgl-system-inspector-cursor-tooltip", controller.debugCursorTooltip(), 0xDD11151A.toInt(), 0xCC3F4A57.toInt()) - } - - private fun renderHighlights(scope: UiScope, ctx: UiMeasureContext) { - controller.debugSelectedHighlight()?.let { highlight -> - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-margin-fill", highlight.marginRect, 0x44F3B33D, null) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-padding-fill", highlight.paddingRect, 0x4426A69A, null) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-content-fill", highlight.contentRect, 0x444285F4, null) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-margin-outline", highlight.marginRect, null, 0x99F3B33D.toInt()) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-border-outline", highlight.borderRect, null, 0xCCFF9800.toInt()) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-padding-outline", highlight.paddingRect, null, 0x9926A69A.toInt()) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-content-outline", highlight.contentRect, null, 0x994285F4.toInt()) - highlight.parentContentRect?.let { parentRect -> - renderHighlightRect(scope, ctx, "dsgl-system-inspector-selected-parent-outline", parentRect, null, 0x66FF5252) - } - } - controller.debugHoveredHighlight()?.let { highlight -> - renderHighlightRect(scope, ctx, "dsgl-system-inspector-hovered-content-fill", highlight.contentRect, 0x3A47A0FF, null) - renderHighlightRect(scope, ctx, "dsgl-system-inspector-hovered-border-outline", highlight.borderRect, null, 0xCC47A0FF.toInt()) - } - } - - private fun renderHighlightRect( - scope: UiScope, - ctx: UiMeasureContext, - key: String, - rect: Rect, - fillColor: Int?, - borderColor: Int? - ) { - if (rect.width <= 0 || rect.height <= 0) return - val layer = scope.div({ - this.key = key - style = { - display = Display.Block - } - }) - layer.backgroundColor = fillColor ?: 0 - layer.border = if (borderColor != null) Border.all(1, borderColor) else Border.NONE - renderNode(ctx, layer, rect) - } - - private fun renderStyleEditorRows( - scope: UiScope, - parentNode: DOMNode, - ctx: UiMeasureContext, - bodyScrollY: Int, - rows: List - ) { - rows.forEachIndexed { index, row -> - val rowRect = translateRectY(row.rowRect, -bodyScrollY) - val rowNode = scope.div({ - key = "dsgl-system-inspector-editor-row-$index" - style = { - display = Display.Block - } - }) - rowNode.backgroundColor = 0x1B293746 - rowNode.border = Border.all(1, 0x553F4A57) - renderNode(ctx, rowNode, rowRect) - - val labelNode = scope.text(props = { - key = "dsgl-system-inspector-editor-label-$index" - value = row.labelText - style = { - textWrap = TextWrap.Wrap - } - }) - labelNode.color = 0xFFDCE5EF.toInt() - labelNode.fontSize = 18 - renderNode( - ctx, - labelNode, - Rect(rowRect.x + 8, rowRect.y + 5, (row.controlRect.x - row.rowRect.x - 14).coerceAtLeast(40), rowRect.height - 10), - ) - - val resetButton = scope.button("x", { - key = "dsgl-system-inspector-editor-reset-$index" - }) - resetButton.backgroundColor = 0x3346596E - resetButton.border = Border.all(1, 0x775E738C) - resetButton.textColor = 0xFFDCE5EF.toInt() - resetButton.fontSize = 18 - resetButton.onClick { - controller.onResetPropertyPressed(row.property) - } - renderNode(ctx, resetButton, translateRectY(row.resetRect, -bodyScrollY)) - - when (row.editorKind) { - InspectorEditorKind.EnumSelect, - InspectorEditorKind.FontSelect -> { - val valueOpen = isDomDropdownOpen(row.property, unitSelect = false) - val selector = scope.button(row.controlValue, { - key = "dsgl-system-inspector-editor-select-$index" - }) - selector.backgroundColor = if (valueOpen) 0x334D5D70 else if (row.controlHovered) 0x2A425164 else 0x22313D4B - selector.border = Border.all(1, if (valueOpen) 0xFFA8C6E6.toInt() else 0x77607084) - selector.textColor = 0xFFE6EDF6.toInt() - selector.fontSize = 18 - selector.onClick { - toggleDomDropdown(row.property, unitSelect = false) - } - renderNode(ctx, selector, translateRectY(row.controlRect, -bodyScrollY)) - } - - InspectorEditorKind.StringInput -> { - val input = TextInputNode( - text = row.controlValue.replace("|", ""), - key = "dsgl-system-inspector-editor-input-${row.property.key}" - ) - input.backgroundColor = if (row.inputActive) 0x334D5D70 else 0x22313D4B - input.focusedBackgroundColor = input.backgroundColor - input.border = Border.all(1, if (row.inputActive) 0xFFA8C6E6.toInt() else 0x77607084) - input.textColor = 0xFFE6EDF6.toInt() - input.placeholderColor = 0xAA9AAFC6.toInt() - input.fontSize = 18 - input.onInput = { - controller.debugApplyLiteralOverride(row.property, it.value) - } - input.onValueChange = { - controller.debugApplyLiteralOverride(row.property, it.value) - } - input.applyParent(parentNode) - renderNode(ctx, input, translateRectY(row.controlRect, -bodyScrollY)) - - row.colorPreviewRect?.let { previewRect -> - val shiftedPreviewRect = translateRectY(previewRect, -bodyScrollY) - val preview = scope.button("", { - key = "dsgl-system-inspector-editor-color-preview-$index" - }) - preview.backgroundColor = row.colorPreviewColor ?: 0x663F4A57 - preview.border = Border.all(1, 0xCC9BB2C9.toInt()) - preview.onClick { - controller.onOpenColorPickerPressed(row.property, shiftedPreviewRect) - } - renderNode(ctx, preview, shiftedPreviewRect) - } - } - - InspectorEditorKind.NumericInput -> { - row.decrementRect?.let { rect -> - val dec = scope.button("-", { - key = "dsgl-system-inspector-editor-dec-$index" - }) - dec.backgroundColor = 0x3346596E - dec.border = Border.all(1, 0x775E738C) - dec.textColor = 0xFFDCE5EF.toInt() - dec.fontSize = 18 - dec.onClick { - controller.onNumericDecrementPressed(row.property) - } - renderNode(ctx, dec, translateRectY(rect, -bodyScrollY)) - } - row.inputRect?.let { rect -> - val input = TextInputNode( - text = row.controlValue.replace("|", ""), - key = "dsgl-system-inspector-editor-numeric-input-${row.property.key}" - ) - input.allowedChars = "-0123456789." - input.backgroundColor = if (row.inputActive) 0x334D5D70 else 0x22313D4B - input.focusedBackgroundColor = input.backgroundColor - input.border = Border.all(1, if (row.inputActive) 0xFFA8C6E6.toInt() else 0x77607084) - input.textColor = 0xFFE6EDF6.toInt() - input.placeholderColor = 0xAA9AAFC6.toInt() - input.fontSize = 18 - input.onInput = { - controller.debugApplyNumericOverride(row.property, it.value, row.unitValue) - } - input.onValueChange = { - controller.debugApplyNumericOverride(row.property, it.value, row.unitValue) - } - input.applyParent(parentNode) - renderNode(ctx, input, translateRectY(rect, -bodyScrollY)) - } - - row.incrementRect?.let { rect -> - val inc = scope.button("+", { - key = "dsgl-system-inspector-editor-inc-$index" - }) - inc.backgroundColor = 0x3346596E - inc.border = Border.all(1, 0x775E738C) - inc.textColor = 0xFFDCE5EF.toInt() - inc.fontSize = 18 - inc.onClick { - controller.onNumericIncrementPressed(row.property) - } - renderNode(ctx, inc, translateRectY(rect, -bodyScrollY)) - } - row.unitRect?.let { rect -> - val unitOpen = isDomDropdownOpen(row.property, unitSelect = true) - val unit = scope.button(row.unitValue ?: "px", { - key = "dsgl-system-inspector-editor-unit-$index" - }) - unit.backgroundColor = if (unitOpen) 0x334D5D70 else 0x22313D4B - unit.border = Border.all(1, if (unitOpen) 0xFFA8C6E6.toInt() else 0x77607084) - unit.textColor = 0xFFE6EDF6.toInt() - unit.fontSize = 18 - unit.onClick { - toggleDomDropdown(row.property, unitSelect = true) - } - renderNode(ctx, unit, translateRectY(rect, -bodyScrollY)) - } - } - } - } - - val resetRect = controller.debugStyleEditorResetRect() - if (resetRect.width > 0 && resetRect.height > 0) { - val resetButton = scope.button("Reset node", { - key = "dsgl-system-inspector-reset-node" - }) - resetButton.backgroundColor = 0x2A465968 - resetButton.border = Border.all(1, 0x775E738C) - resetButton.textColor = 0xFFDCE5EF.toInt() - resetButton.fontSize = 18 - resetButton.onClick { - controller.onResetSelectedOverridesPressed() - } - renderNode(ctx, resetButton, translateRectY(resetRect, -bodyScrollY)) - } - - val clearRect = controller.debugStyleEditorClearRect() - if (clearRect.width > 0 && clearRect.height > 0) { - val clearButton = scope.button("Clear all", { - key = "dsgl-system-inspector-clear-all" - }) - clearButton.backgroundColor = 0x2A4E3F56 - clearButton.border = Border.all(1, 0x777A5C84) - clearButton.textColor = 0xFFDCE5EF.toInt() - clearButton.fontSize = 18 - clearButton.onClick { - controller.onClearAllOverridesPressed() - } - renderNode(ctx, clearButton, translateRectY(clearRect, -bodyScrollY)) - } - } - - private fun renderDropdowns( - scope: UiScope, - ctx: UiMeasureContext, - rows: List, - bodyScrollY: Int, - viewportWidth: Int, - viewportHeight: Int - ) { - val dropdown = resolveDomDropdownSnapshot(rows, bodyScrollY, viewportWidth, viewportHeight) - if (dropdown == null) { - controller.onNativeDomDropdownSnapshots(emptyList()) - return - } - - val dropdownKey = dropdownScrollKey(dropdown.property, dropdown.unitSelect) - val persistedDropdownSession = persistedDropdownScrollSession[dropdownKey] - val persistedDropdownY = persistedDropdownSession?.resolvedY?.coerceAtLeast(0) ?: 0 - val popup = scope.div({ - key = dropdownKey - style = { - display = Display.Block - } - }) - popup.backgroundColor = 0xEE202A36.toInt() - popup.border = Border.all(1, 0xCC596A80.toInt()) - popup.overflowY = Overflow.Auto - renderNode(ctx, popup, dropdown.popupRect) - - dropdown.options.forEachIndexed { optionIndex, option -> - val optionRect = Rect( - option.rect.x, - option.rect.y - persistedDropdownY, - option.rect.width, - option.rect.height - ) - val hovered = optionRect.contains(cursorX, cursorY) - val button = scope.button(option.text, { - key = "$dropdownKey-option-$optionIndex" - }) - button.backgroundColor = if (hovered) 0x2D4C6279 else 0x22313D4B - button.border = Border.all(1, if (hovered) 0xCC95B3D3.toInt() else 0x664F6076) - button.textColor = if (hovered) 0xFFFFFFFF.toInt() else 0xFFE6EDF6.toInt() - button.fontSize = 18 - button.onClick { - if (dropdown.unitSelect) { - controller.onSelectUnitOptionPressed(dropdown.property, option.value) - } else { - controller.onSelectValueOptionPressed(dropdown.property, option.value) - } - closeActiveDomDropdown() - } - renderNode(ctx, button, optionRect) - } - - dropdown.footerText?.let { footer -> - val footerNode = scope.text(props = { - key = "$dropdownKey-footer" - value = footer - style = { - textWrap = TextWrap.NoWrap - } - }) - footerNode.color = 0xFF8EA6BF.toInt() - footerNode.fontSize = 18 - renderNode( - ctx, - footerNode, - Rect( - dropdown.popupRect.x + 6, - dropdown.popupRect.y + dropdown.popupRect.height - 22 - persistedDropdownY, - (dropdown.popupRect.width - 12).coerceAtLeast(20), - 20 - ) - ) - } - - popup.restoreScrollSessionSnapshot(persistedDropdownSession) - popup.scrollContainerState() - persistedDropdownScrollSession[dropdownKey] = popup.captureScrollSessionSnapshot() - controller.onNativeDomDropdownSnapshots(listOf(dropdown)) - } - - private fun resolveDomDropdownSnapshot( - rows: List, - bodyScrollY: Int, - viewportWidth: Int, - viewportHeight: Int - ): InspectorDropdownSnapshot? { - val activeDropdown = activeDomDropdown ?: return null - val row = rows.firstOrNull { it.property == activeDropdown.property } ?: run { - closeActiveDomDropdown() - return null - } - if (activeDropdown.unitSelect && row.unitRect == null) { - closeActiveDomDropdown() - return null - } - - val options = controller.resolveDropdownOptionsForProperty(activeDropdown.property, activeDropdown.unitSelect) - if (options.isEmpty()) { - closeActiveDomDropdown() - return null - } - - val triggerRect = if (activeDropdown.unitSelect) { - row.unitRect ?: row.controlRect - } else { - row.controlRect - } - val visibleTriggerRect = translateRectY(triggerRect, -bodyScrollY) - - val maxChars = options.maxOfOrNull { it.length } ?: 0 - val estimatedTextWidth = (maxChars * 8 + 22).coerceAtLeast(120) - val popupWidth = maxOf(triggerRect.width, estimatedTextWidth).coerceAtLeast(120) - val optionHeight = 24 - val maxVisibleRows = 8 - val visibleRows = minOf(maxVisibleRows, options.size) - val footerText = if (options.size > visibleRows) "Scroll for more" else null - val footerHeight = if (footerText != null) 22 else 0 - val popupHeight = (visibleRows * optionHeight + footerHeight + 4).coerceAtLeast(optionHeight + 4) - - val rawX = if (activeDropdown.unitSelect) { - visibleTriggerRect.x + visibleTriggerRect.width - popupWidth - } else { - visibleTriggerRect.x - } - val rawY = visibleTriggerRect.y + visibleTriggerRect.height + 2 - val clampedX = rawX.coerceIn(2, (viewportWidth - popupWidth - 2).coerceAtLeast(2)) - val clampedY = rawY.coerceIn(2, (viewportHeight - popupHeight - 2).coerceAtLeast(2)) - val popupRect = Rect(clampedX, clampedY, popupWidth, popupHeight) - - val optionWidth = (popupRect.width - 4).coerceAtLeast(20) - val optionSnapshots = options.mapIndexed { index, option -> - InspectorDropdownOptionSnapshot( - rect = Rect( - popupRect.x + 2, - popupRect.y + 2 + index * optionHeight, - optionWidth, - optionHeight - ), - text = option, - value = option, - hovered = false - ) - } - - return InspectorDropdownSnapshot( - popupRect = popupRect, - property = activeDropdown.property, - unitSelect = activeDropdown.unitSelect, - options = optionSnapshots, - footerText = footerText - ) - } - - private fun handleActiveDomDropdownWheel(delta: Int): Boolean { - if (delta == 0 || KeyModifiers.shiftDown) return false - val activeDropdown = activeDomDropdown ?: return false - val dropdownKey = dropdownScrollKey(activeDropdown.property, activeDropdown.unitSelect) - val current = persistedDropdownScrollSession[dropdownKey] - val baseResolved = current?.resolvedY?.coerceAtLeast(0) ?: 0 - val steps = (kotlin.math.abs(delta) / 120).coerceAtLeast(1) - val amount = steps * 18 - val nextResolved = if (delta < 0) { - baseResolved + amount - } else { - (baseResolved - amount).coerceAtLeast(0) - } - val nextTarget = if (delta < 0) { - (current?.targetY?.coerceAtLeast(0) ?: baseResolved) + amount - } else { - ((current?.targetY?.coerceAtLeast(0) ?: baseResolved) - amount).coerceAtLeast(0) - } - persistedDropdownScrollSession[dropdownKey] = ScrollSessionSnapshot( - targetX = current?.targetX?.coerceAtLeast(0) ?: 0, - targetY = nextTarget, - displayedX = current?.displayedX?.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.0, - displayedY = nextResolved.toDouble(), - resolvedX = current?.resolvedX?.coerceAtLeast(0) ?: 0, - resolvedY = nextResolved, - dragSession = current?.dragSession - ) - return true - } - - private fun reconcileActiveDomDropdown(rows: List) { - val activeDropdown = activeDomDropdown ?: return - val row = rows.firstOrNull { it.property == activeDropdown.property } ?: run { - closeActiveDomDropdown() - return - } - if (activeDropdown.unitSelect && row.unitRect == null) { - closeActiveDomDropdown() - return - } - val options = controller.resolveDropdownOptionsForProperty(activeDropdown.property, activeDropdown.unitSelect) - if (options.isEmpty()) { - closeActiveDomDropdown() - } - } - - private fun isDomDropdownOpen(property: StyleProperty, unitSelect: Boolean): Boolean { - val activeDropdown = activeDomDropdown ?: return false - return activeDropdown.property == property && activeDropdown.unitSelect == unitSelect - } - - private fun toggleDomDropdown(property: StyleProperty, unitSelect: Boolean) { - val activeDropdown = activeDomDropdown - if (activeDropdown != null && activeDropdown.property == property && activeDropdown.unitSelect == unitSelect) { - closeActiveDomDropdown() - return - } - activeDomDropdown = ActiveDomDropdown(property = property, unitSelect = unitSelect) - } - - private fun closeActiveDomDropdown() { - if (activeDomDropdown == null) return - activeDomDropdown = null - controller.onNativeDomDropdownSnapshots(emptyList()) - } - - private fun shouldDismissOpenDropdownOnPointerDown(target: DOMNode?): Boolean { - var current = target - while (current != null && current !== this) { - val key = current.key?.toString() ?: "" - if (key.startsWith("dsgl-system-inspector-dropdown-")) { - return false - } - if ( - key.startsWith("dsgl-system-inspector-editor-select-") || - key.startsWith("dsgl-system-inspector-editor-unit-") - ) { - return false - } - current = current.parent - } - return true - } - - private fun renderPanelDragHandle(scope: UiScope, ctx: UiMeasureContext, panelRect: Rect, headerDragRect: Rect) { - val handle = scope.div({ - key = "dsgl-system-inspector-header-drag-handle" - style = { - display = Display.Block - } - }) - handle.backgroundColor = 0 - handle.onMouseDown = { event -> - if (event.mouseButton == MouseButton.LEFT) { - startPanelDrag(PanelDragMode.Move, panelRect, event.mouseX, event.mouseY) - event.cancelled = true - } - } - handle.onMouseDrag = { event -> - val currentX = event.lastMouseX + event.dx - val currentY = event.lastMouseY + event.dy - continuePanelDrag(currentX, currentY) - event.cancelled = true - } - handle.onMouseUp = { event -> - if (event.mouseButton == MouseButton.LEFT) { - endPanelDrag(event.mouseX, event.mouseY) - event.cancelled = true - } - } - renderNode(ctx, handle, headerDragRect) - } - - private fun renderPanelResizeHandles(scope: UiScope, ctx: UiMeasureContext, panelRect: Rect) { - val edge = 6 - val corner = 10 - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-left", Rect(panelRect.x, panelRect.y + corner, edge, (panelRect.height - corner * 2).coerceAtLeast(1)), PanelDragMode.ResizeLeft, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-right", Rect(panelRect.x + panelRect.width - edge, panelRect.y + corner, edge, (panelRect.height - corner * 2).coerceAtLeast(1)), PanelDragMode.ResizeRight, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-top", Rect(panelRect.x + corner, panelRect.y, (panelRect.width - corner * 2).coerceAtLeast(1), edge), PanelDragMode.ResizeTop, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-bottom", Rect(panelRect.x + corner, panelRect.y + panelRect.height - edge, (panelRect.width - corner * 2).coerceAtLeast(1), edge), PanelDragMode.ResizeBottom, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-top-left", Rect(panelRect.x, panelRect.y, corner, corner), PanelDragMode.ResizeTopLeft, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-top-right", Rect(panelRect.x + panelRect.width - corner, panelRect.y, corner, corner), PanelDragMode.ResizeTopRight, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-bottom-left", Rect(panelRect.x, panelRect.y + panelRect.height - corner, corner, corner), PanelDragMode.ResizeBottomLeft, panelRect) - renderResizeHandle(scope, ctx, "dsgl-system-inspector-resize-handle-bottom-right", Rect(panelRect.x + panelRect.width - corner, panelRect.y + panelRect.height - corner, corner, corner), PanelDragMode.ResizeBottomRight, panelRect) - } - - private fun renderResizeHandle( - scope: UiScope, - ctx: UiMeasureContext, - key: String, - rect: Rect, - mode: PanelDragMode, - panelRect: Rect - ) { - val handle = scope.div({ - this.key = key - style = { - display = Display.Block - } - }) - handle.backgroundColor = 0 - handle.onMouseDown = { event -> - if (event.mouseButton == MouseButton.LEFT) { - startPanelDrag(mode, panelRect, event.mouseX, event.mouseY) - event.cancelled = true - } - } - handle.onMouseDrag = { event -> - val currentX = event.lastMouseX + event.dx - val currentY = event.lastMouseY + event.dy - continuePanelDrag(currentX, currentY) - event.cancelled = true - } - handle.onMouseUp = { event -> - if (event.mouseButton == MouseButton.LEFT) { - endPanelDrag(event.mouseX, event.mouseY) - event.cancelled = true - } - } - renderNode(ctx, handle, rect) - } - - private fun startPanelDrag(mode: PanelDragMode, panelRect: Rect, mouseX: Int, mouseY: Int) { - panelDragSession = PanelDragSession( - mode = mode, - startPointerX = mouseX, - startPointerY = mouseY, - startRect = panelRect, - moved = false - ) - } - - private fun continuePanelDrag(mouseX: Int, mouseY: Int) { - val session = panelDragSession ?: return - val dx = mouseX - session.startPointerX - val dy = mouseY - session.startPointerY - if (!session.moved && (kotlin.math.abs(dx) >= 2 || kotlin.math.abs(dy) >= 2)) { - session.moved = true - } - when (session.mode) { - PanelDragMode.MinimizedMove -> { - controller.onNativeDomMinimizedPanelPosition( - x = session.startRect.x + dx, - y = session.startRect.y + dy, - viewportWidth = lastViewportWidth, - viewportHeight = lastViewportHeight - ) - } - - PanelDragMode.Move -> { - controller.onNativeDomExpandedPanelRect( - rect = Rect( - session.startRect.x + dx, - session.startRect.y + dy, - session.startRect.width, - session.startRect.height - ), - viewportWidth = lastViewportWidth, - viewportHeight = lastViewportHeight - ) - } - - else -> { - controller.onNativeDomExpandedPanelRect( - rect = resolveResizedPanelRect(session, dx, dy), - viewportWidth = lastViewportWidth, - viewportHeight = lastViewportHeight - ) - } - } - } - - private fun endPanelDrag(mouseX: Int, mouseY: Int) { - val session = panelDragSession ?: return - continuePanelDrag(mouseX, mouseY) - if (session.mode == PanelDragMode.MinimizedMove && !session.moved) { - controller.restore() - } - panelDragSession = null - } - - private fun clearPanelDragSession() { - panelDragSession = null - } - - private fun reconcilePanelDragSession(panelState: InspectorPanelState) { - val session = panelDragSession ?: return - if (panelState == InspectorPanelState.Minimized && session.mode != PanelDragMode.MinimizedMove) { - panelDragSession = null - return - } - if (panelState == InspectorPanelState.Expanded && session.mode == PanelDragMode.MinimizedMove) { - panelDragSession = null - } - } - - private fun resolveResizedPanelRect(session: PanelDragSession, dx: Int, dy: Int): Rect { - var left = session.startRect.x - var top = session.startRect.y - var right = session.startRect.x + session.startRect.width - var bottom = session.startRect.y + session.startRect.height - - when (session.mode) { - PanelDragMode.ResizeLeft, - PanelDragMode.ResizeTopLeft, - PanelDragMode.ResizeBottomLeft -> left += dx - - PanelDragMode.ResizeRight, - PanelDragMode.ResizeTopRight, - PanelDragMode.ResizeBottomRight -> right += dx - - else -> Unit - } - - when (session.mode) { - PanelDragMode.ResizeTop, - PanelDragMode.ResizeTopLeft, - PanelDragMode.ResizeTopRight -> top += dy - - PanelDragMode.ResizeBottom, - PanelDragMode.ResizeBottomLeft, - PanelDragMode.ResizeBottomRight -> bottom += dy - - else -> Unit - } - - return Rect( - x = left, - y = top, - width = (right - left).coerceAtLeast(1), - height = (bottom - top).coerceAtLeast(1) - ) - } - private fun renderTooltip( - scope: UiScope, - ctx: UiMeasureContext, - keyPrefix: String, - tooltip: InspectorTooltipSnapshot?, - backgroundColor: Int, - borderColor: Int - ) { - if (tooltip == null) return - val box = scope.div({ - key = "$keyPrefix-box" - style = { - display = Display.Block - } - }) - box.backgroundColor = backgroundColor - box.border = Border.all(1, borderColor) - renderNode(ctx, box, tooltip.rect) - - val textNode = scope.text(props = { - key = "$keyPrefix-text" - value = tooltip.text - style = { - textWrap = TextWrap.NoWrap - } - }) - textNode.color = 0xFFE6EDF6.toInt() - textNode.fontSize = 18 - renderNode( - ctx, - textNode, - Rect( - tooltip.rect.x + 6, - tooltip.rect.y + 4, - (tooltip.rect.width - 10).coerceAtLeast(20), - (tooltip.rect.height - 8).coerceAtLeast(16) - ) - ) - } - - private fun isDomOwnedInteractionTarget(target: DOMNode?): Boolean { - var current = target - while (current != null && current !== this) { - when (current.styleType) { - "input", "textarea", "select", "toggle", "button" -> return true - } - val nodeKey = current.key?.toString() - if (nodeKey?.startsWith("dsgl-system-inspector-") == true) { - return true - } - current = current.parent - } - return current === this && target !== this - } - - private fun capturePersistedScrollStateFromCurrentTree() { - findNodeByKey("dsgl-system-inspector-body")?.let { bodyNode -> - persistedBodyScrollSession = bodyNode.captureScrollSessionSnapshot() - } - val nextDropdownScroll = LinkedHashMap() - collectNodes(this).forEach { node -> - val nodeKey = node.key?.toString() ?: return@forEach - if (!nodeKey.startsWith("dsgl-system-inspector-dropdown-")) return@forEach - nextDropdownScroll[nodeKey] = node.captureScrollSessionSnapshot() - } - persistedDropdownScrollSession.clear() - persistedDropdownScrollSession.putAll(nextDropdownScroll) - } - - private fun findNodeByKey(targetKey: String): DOMNode? { - return collectNodes(this).firstOrNull { it.key == targetKey } - } - - private fun collectNodes(root: DOMNode): List { - val out = ArrayList() - fun walk(node: DOMNode) { - out += node - node.children.forEach(::walk) - } - walk(root) - return out - } - - private fun dropdownScrollKey(property: StyleProperty, unitSelect: Boolean): String { - return "dsgl-system-inspector-dropdown-${property.key}-${if (unitSelect) "unit" else "value"}" - } - - private fun shouldRetainInspectorSubtreeFocus(): Boolean { - val focused = FocusManager.focusedNode() ?: return false - return isSameOrAncestor(this, focused) - } - - private fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { - var current = node - while (current != null) { - if (current === candidate) return true - current = current.parent - } - return false - } - - private fun translateRectY(rect: Rect, deltaY: Int): Rect { - return Rect(rect.x, rect.y + deltaY, rect.width, rect.height) - } - private fun renderNode( - ctx: UiMeasureContext, - node: DOMNode, - rect: Rect - ) { - if (rect.width <= 0 || rect.height <= 0) { - node.display = Display.None - node.render(ctx, 0, 0, 0, 0) - return - } - node.display = Display.Block - node.render(ctx, rect.x, rect.y, rect.width, rect.height) - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalNode.kt new file mode 100644 index 0000000..8885c1f --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalNode.kt @@ -0,0 +1,1258 @@ +package org.dreamfinity.dsgl.core.inspector.internal + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.ScrollSessionSnapshot +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.TextInputNode +import org.dreamfinity.dsgl.core.dom.layout.Border +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.event.* +import org.dreamfinity.dsgl.core.inspector.* +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanel +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragSession +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelState +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.Overflow +import org.dreamfinity.dsgl.core.style.TextWrap + +internal class SystemInspectorPortalNode( + private val controller: InspectorController, + private val floatingPanel: FloatingPanel, + key: Any? = "dsgl-system-inspector", +) : DOMNode(key) { + override val styleType: String = "dsgl-system-inspector" + override val focusable: Boolean = true + + internal constructor( + controller: InspectorController, + key: Any? = "dsgl-system-inspector", + ) : this( + controller = controller, + floatingPanel = + FloatingPanel( + ownerId = "standalone-system-inspector", + panelState = FloatingPanelState(), + dragSession = FloatingPanelDragSession(), + ), + key = key, + ) + + private var inspectedRoot: DOMNode? = null + private var inspectedLayoutRevision: Long = 0L + private var cursorX: Int = 0 + private var cursorY: Int = 0 + private var lastViewportWidth: Int = 1 + private var lastViewportHeight: Int = 1 + private var persistedBodyScrollSession: ScrollSessionSnapshot? = null + private var floatingPanelDragUpdatedByDomInput: Boolean = false + private val panelNode: DOMNode = floatingPanel.node().applyParent(this) + private var minimizedChipDragSession: MinimizedChipDragSession? = null + + private data class MinimizedChipDragSession( + val startPointerX: Int, + val startPointerY: Int, + val startRect: Rect, + var currentPointerX: Int, + var currentPointerY: Int, + var moved: Boolean = false, + ) + + init { + EventBus.run { + this@SystemInspectorPortalNode.addEventListener(Events.MOUSEDOWN) { event: MouseDownEvent -> + if (handleFloatingPanelMouseDown(event)) { + event.cancelled = true + return@addEventListener + } + val domOwnedTarget = isDomOwnedInteractionTarget(event.target) + if (domOwnedTarget) return@addEventListener + if (controller.handleMouseDown(event.mouseX, event.mouseY, event.mouseButton)) { + event.cancelled = true + } + } + this@SystemInspectorPortalNode.addEventListener(Events.MOUSEUP) { event: MouseUpEvent -> + if (handleFloatingPanelMouseUp(event)) { + event.cancelled = true + return@addEventListener + } + val routeToController = controller.isPointerCaptured || !isDomOwnedInteractionTarget(event.target) + if (!routeToController) return@addEventListener + if (controller.handleMouseUp(event.mouseX, event.mouseY, event.mouseButton)) { + event.cancelled = true + } + } + this@SystemInspectorPortalNode.addEventListener(Events.DRAG) { event: MouseDragEvent -> + val nextMouseX = event.lastMouseX + event.dx + val nextMouseY = event.lastMouseY + event.dy + if (handleFloatingPanelDrag(nextMouseX, nextMouseY)) { + event.cancelled = true + return@addEventListener + } + if (isDomOwnedInteractionTarget(event.target)) return@addEventListener + if (!controller.isPointerCaptured) return@addEventListener + controller.onCapturedPointerMove(nextMouseX, nextMouseY, bounds.width, bounds.height) + event.cancelled = true + } + } + } + + private fun handleFloatingPanelMouseDown(event: MouseDownEvent): Boolean { + if (event.mouseButton != MouseButton.LEFT) return false + val bodyRect = controller.portalContentRect() + val pointerInsideBody = + bodyRect.width > 0 && + bodyRect.height > 0 && + bodyRect.contains(event.mouseX, event.mouseY) + if (pointerInsideBody) return false + val handled = + floatingPanel.handleMouseDown( + mouseX = event.mouseX, + mouseY = event.mouseY, + button = event.mouseButton, + includeCloseButton = false, + ) + if (handled) { + controller.onFloatingPanelPointerCaptureChanged(true) + } + return handled + } + + private fun handleFloatingPanelDrag(mouseX: Int, mouseY: Int): Boolean { + val handled = + floatingPanel.handleMouseMove( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = lastViewportWidth, + viewportHeight = lastViewportHeight, + ) { rect -> + controller.onFloatingPanelRectChanged(rect, lastViewportWidth, lastViewportHeight) + } + if (handled) { + floatingPanelDragUpdatedByDomInput = true + controller.onFloatingPanelPointerCaptureChanged(true) + } + return handled + } + + private fun handleFloatingPanelMouseUp(event: MouseUpEvent): Boolean { + val handled = + floatingPanel.handleMouseUp( + mouseX = event.mouseX, + mouseY = event.mouseY, + button = event.mouseButton, + viewportWidth = lastViewportWidth, + viewportHeight = lastViewportHeight, + ) { rect -> + controller.onFloatingPanelRectChanged(rect, lastViewportWidth, lastViewportHeight) + } + if (handled) { + floatingPanelDragUpdatedByDomInput = false + controller.onFloatingPanelPointerCaptureChanged(false) + } + return handled + } + + internal fun consumeFloatingPanelDomDragUpdate(): Boolean { + val consumed = floatingPanelDragUpdatedByDomInput + floatingPanelDragUpdatedByDomInput = false + return consumed + } + + fun bindInspectedTree(root: DOMNode?, layoutRevision: Long) { + inspectedRoot = root + inspectedLayoutRevision = layoutRevision + } + + @Suppress("UNUSED_PARAMETER") + fun updateCursor(mouseX: Int, mouseY: Int, pointerCaptured: Boolean) { + cursorX = mouseX + cursorY = mouseY + updateMinimizedChipDragPointer(mouseX, mouseY) + } + + fun syncInputBounds(viewportWidth: Int, viewportHeight: Int) { + val viewportRect = Rect(0, 0, viewportWidth.coerceAtLeast(0), viewportHeight.coerceAtLeast(0)) + bounds = resolveInputBounds(viewportRect, controller.floatingPanelRect()) + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + val viewportRect = Rect(x, y, width, height) + bounds = resolveInputBounds(viewportRect, controller.floatingPanelRect()) + inspectedRoot?.let { root -> + controller.onLayoutCommitted(root, inspectedLayoutRevision) + } + controller.onCursorMoved(cursorX, cursorY) + lastViewportWidth = viewportRect.width.coerceAtLeast(1) + lastViewportHeight = viewportRect.height.coerceAtLeast(1) + applyMinimizedChipDragFrame() + val retainInspectorFocus = shouldRetainInspectorSubtreeFocus() + + val snapshot = controller.buildDomSnapshot(viewportRect.width, viewportRect.height) + if (snapshot == null) { + clearTree() + panelNode.render(ctx, 0, 0, 0, 0) + children.remove(panelNode) + panelNode.parent = null + persistedBodyScrollSession = null + controller.onNativeDomDropdownSnapshots(emptyList()) + clearMinimizedChipDragSession() + controller.onNativeDomBodyScrollState(0, null, null) + controller.onFloatingPanelPointerCaptureChanged(false) + return + } + bounds = resolveInputBounds(viewportRect, snapshot.panelRect) + + capturePersistedScrollStateFromCurrentTree() + clearTree() + when (snapshot.panelState) { + InspectorPanelState.Minimized -> renderMinimized(ctx, snapshot) + InspectorPanelState.Expanded -> renderExpanded(ctx, snapshot, viewportRect) + } + if (retainInspectorFocus) { + FocusManager.retainFocus(this, updateRootReference = false) + } + } + + private fun resolveInputBounds(viewportRect: Rect, panelRect: Rect?): Rect { + if (controller.blocksUnderlyingInput() || floatingPanel.isDragging() || minimizedChipDragSession != null) { + return viewportRect + } + val base = panelRect ?: viewportRect + val dropdownBounds = resolveRenderedDropdownInputBounds() + if (dropdownBounds == null) { + return base + } + return unionRect(base, dropdownBounds) + } + + private fun resolveRenderedDropdownInputBounds(): Rect? { + val dropdowns = controller.portalStyleEditorDropdowns() + if (dropdowns.isNotEmpty()) { + return dropdowns + .map { it.popupRect } + .reduce(::unionRect) + } + return null + } + + private fun unionRect(a: Rect, b: Rect): Rect { + val left = minOf(a.x, b.x) + val top = minOf(a.y, b.y) + val right = maxOf(a.x + a.width, b.x + b.width) + val bottom = maxOf(a.y + a.height, b.y + b.height) + return Rect( + x = left, + y = top, + width = (right - left).coerceAtLeast(0), + height = (bottom - top).coerceAtLeast(0), + ) + } + + private fun clearTree() { + EventBus.run { + children.filter { child -> child !== panelNode }.forEach { child -> + child.clearListenersDeep() + child.parent = null + } + } + children.retainAll(listOf(panelNode)) + if (panelNode.parent !== this) { + panelNode.applyParent(this) + } + markRenderCommandsDirty() + } + + private fun renderMinimized(ctx: UiMeasureContext, snapshot: InspectorDomSnapshot) { + controller.onNativeDomDropdownSnapshots(emptyList()) + panelNode.render(ctx, 0, 0, 0, 0) + val scope = UiScope(this) + + renderHighlights(scope, ctx) + renderMinimizedChip(scope, ctx, snapshot) + } + + private fun renderExpanded(ctx: UiMeasureContext, snapshot: InspectorDomSnapshot, viewportRect: Rect) { + val panelRect = snapshot.panelRect + val bodyRect = + snapshot.bodyRect + ?: floatingPanel.bodyRect() + ?: Rect( + panelRect.x + 6, + panelRect.y + 58, + panelRect.width - 12, + (panelRect.height - 64).coerceAtLeast(24), + ) + val scope = UiScope(this) + + renderHighlights(scope, ctx) + renderPanelOccluder(scope, ctx, panelRect) + panelNode.render(ctx, viewportRect.x, viewportRect.y, viewportRect.width, viewportRect.height) + + renderExpandedChrome(scope, ctx, panelRect) + + val body = + scope.div({ + key = "dsgl-system-inspector-body" + style = { + display = Display.Block + } + }) + body.backgroundColor = 0x18212C39 + body.overflow = Overflow.Hidden + body.overflowX = Overflow.Hidden + body.overflowY = Overflow.Auto + val bodyScope = UiScope(body) + renderNode(ctx, body, bodyRect) + + val lineHeightPx = 32 + val rowHeightPx = 34 + val contentX = bodyRect.x + 4 + val contentW = (bodyRect.width - 10).coerceAtLeast(1) + val bodyScrollY = persistedBodyScrollSession?.resolvedY?.coerceAtLeast(0) ?: 0 + var y = bodyRect.y + 2 - bodyScrollY + + y = + renderBodyInfoLines( + scope = bodyScope, + ctx = ctx, + infoLines = snapshot.infoLines, + contentX = contentX, + contentW = contentW, + startY = y, + lineHeightPx = lineHeightPx, + ) + + y = + renderParentRow( + scope = bodyScope, + ctx = ctx, + parentLabel = snapshot.parentLabel, + contentX = contentX, + contentW = contentW, + startY = y, + rowHeightPx = rowHeightPx, + ) + y = + renderChildRows( + scope = bodyScope, + ctx = ctx, + childLabels = snapshot.childLabels, + contentX = contentX, + contentW = contentW, + startY = y, + rowHeightPx = rowHeightPx, + ) + renderStyleEditorHeading( + scope = bodyScope, + ctx = ctx, + contentX = contentX, + contentW = contentW, + y = y, + lineHeightPx = lineHeightPx, + ) + + val styleRows = controller.portalStyleEditorRows() + renderStyleEditorRows(bodyScope, body, ctx, bodyScrollY, styleRows) + y += snapshot.styleEditorHeight + + y = + renderComputedStyleLines( + scope = bodyScope, + ctx = ctx, + styleLines = snapshot.styleLines, + contentX = contentX, + contentW = contentW, + startY = y, + lineHeightPx = lineHeightPx, + ) + controller.onNativeDomDropdownSnapshots(emptyList()) + body.restoreScrollSessionSnapshot(persistedBodyScrollSession) + val bodyState = body.scrollContainerState() + persistedBodyScrollSession = body.captureScrollSessionSnapshot() + val bodyScrollbarVisual = body.debugScrollbarVisualState().vertical + controller.onNativeDomBodyScrollState( + scrollY = bodyState.scrollY, + trackRect = bodyScrollbarVisual?.trackRect, + thumbRect = bodyScrollbarVisual?.thumbRect, + ) + renderTooltip( + scope, + ctx, + "dsgl-system-inspector-variable-tooltip", + controller.portalVariableTooltip(), + 0xEE141A22.toInt(), + 0xCC60758F.toInt(), + ) + renderTooltip( + scope, + ctx, + "dsgl-system-inspector-cursor-tooltip", + controller.portalCursorTooltip(), + 0xDD11151A.toInt(), + 0xCC3F4A57.toInt(), + ) + } + + private fun renderMinimizedChip(scope: UiScope, ctx: UiMeasureContext, snapshot: InspectorDomSnapshot) { + val chip = + scope.div({ + key = "dsgl-system-inspector-chip" + style = { + display = Display.Block + } + }) + chip.backgroundColor = 0xDD1A202A.toInt() + chip.border = Border.all(1, 0xCC4F6076.toInt()) + chip.onMouseDown = { event -> + if (event.mouseButton == MouseButton.LEFT) { + startMinimizedChipDrag(snapshot.panelRect, event.mouseX, event.mouseY) + event.cancelled = true + } + } + chip.onMouseDrag = { event -> + val currentX = event.lastMouseX + event.dx + val currentY = event.lastMouseY + event.dy + updateMinimizedChipDragPointer(currentX, currentY) + event.cancelled = true + } + chip.onMouseUp = { event -> + if (event.mouseButton == MouseButton.LEFT) { + endMinimizedChipDrag(event.mouseX, event.mouseY) + event.cancelled = true + } + } + renderNode(ctx, chip, snapshot.panelRect) + + val compactLineHeight = 20 + var lineY = + snapshot.panelRect.y + ((snapshot.panelRect.height - compactLineHeight * snapshot.minimizedLines.size) / 2) + snapshot.minimizedLines.forEachIndexed { index, line -> + val lineNode = + scope.text(props = { + key = "dsgl-system-inspector-chip-line-$index" + value = line + style = { + textWrap = TextWrap.NoWrap + } + }) + lineNode.color = 0xFFE6EDF6.toInt() + lineNode.fontSize = 14 + renderNode( + ctx, + lineNode, + Rect( + snapshot.panelRect.x + 8, + lineY, + (snapshot.panelRect.width - 16).coerceAtLeast(1), + compactLineHeight, + ), + ) + lineY += compactLineHeight + } + } + + private fun renderExpandedChrome(scope: UiScope, ctx: UiMeasureContext, panelRect: Rect) { + val pickRect = + controller.portalPickToggleBounds() + ?: Rect(panelRect.x + panelRect.width - 264, panelRect.y + 8, 160, 36) + val minimizeRect = + controller.portalMinimizeBounds() + ?: Rect(panelRect.x + panelRect.width - 96, panelRect.y + 8, 86, 36) + renderPickToggleButton(scope, ctx, pickRect) + renderMinimizeButton(scope, ctx, minimizeRect) + } + + private fun renderPickToggleButton(scope: UiScope, ctx: UiMeasureContext, rect: Rect) { + val pickButton = + scope.button("Select Element", { + key = "dsgl-system-inspector-pick-toggle" + }) + pickButton.backgroundColor = 0x3346596E + pickButton.border = Border.all(1, 0x775E738C) + pickButton.textColor = 0xFFE6EDF6.toInt() + pickButton.fontSize = 18 + pickButton.onClick { + controller.onPickTogglePressed() + } + renderNode(ctx, pickButton, rect) + } + + private fun renderMinimizeButton(scope: UiScope, ctx: UiMeasureContext, rect: Rect) { + val minimizeButton = + scope.button("Minimize", { + key = "dsgl-system-inspector-minimize" + }) + minimizeButton.backgroundColor = 0x3346596E + minimizeButton.border = Border.all(1, 0x775E738C) + minimizeButton.textColor = 0xFFE6EDF6.toInt() + minimizeButton.fontSize = 18 + minimizeButton.onClick { + controller.onPanelMinimizeTogglePressed() + } + renderNode(ctx, minimizeButton, rect) + } + + private fun renderBodyInfoLines( + scope: UiScope, + ctx: UiMeasureContext, + infoLines: List, + contentX: Int, + contentW: Int, + startY: Int, + lineHeightPx: Int, + ): Int { + var y = startY + infoLines.forEachIndexed { index, line -> + val lineNode = + scope.text(props = { + key = "dsgl-system-inspector-info-line-$index" + value = line + style = { + textWrap = TextWrap.NoWrap + } + }) + lineNode.color = 0xFFDCE5EF.toInt() + lineNode.fontSize = 24 + renderNode( + ctx, + lineNode, + Rect(contentX, y, contentW, lineHeightPx), + ) + y += lineHeightPx + } + return y + } + + private fun renderParentRow( + scope: UiScope, + ctx: UiMeasureContext, + parentLabel: String?, + contentX: Int, + contentW: Int, + startY: Int, + rowHeightPx: Int, + ): Int { + var y = startY + parentLabel?.let { label -> + val parentButton = + scope.button(label, { + key = "dsgl-system-inspector-parent-row" + }) + parentButton.backgroundColor = 0x1E263241 + parentButton.border = Border.all(1, 0x55394654) + parentButton.textColor = 0xFFDCE5EF.toInt() + parentButton.fontSize = 22 + parentButton.onClick { + controller.onSelectParentPressed() + } + renderNode( + ctx, + parentButton, + Rect(contentX, y, contentW, rowHeightPx), + ) + y += rowHeightPx + 2 + } + return y + } + + private fun renderChildRows( + scope: UiScope, + ctx: UiMeasureContext, + childLabels: List, + contentX: Int, + contentW: Int, + startY: Int, + rowHeightPx: Int, + ): Int { + var y = startY + childLabels.forEachIndexed { index, label -> + val childButton = + scope.button(label, { + key = "dsgl-system-inspector-child-row-$index" + }) + childButton.backgroundColor = 0x1E263241 + childButton.border = Border.all(1, 0x55394654) + childButton.textColor = 0xFFDCE5EF.toInt() + childButton.fontSize = 22 + childButton.onClick { + controller.onSelectChildPressed(index) + } + renderNode( + ctx, + childButton, + Rect(contentX, y, contentW, rowHeightPx), + ) + y += rowHeightPx + 2 + } + return y + } + + private fun renderStyleEditorHeading( + scope: UiScope, + ctx: UiMeasureContext, + contentX: Int, + contentW: Int, + y: Int, + lineHeightPx: Int, + ) { + val styleEditorHeader = + scope.text(props = { + key = "dsgl-system-inspector-editor-header" + value = "Style editor (live overrides):" + style = { + textWrap = TextWrap.NoWrap + } + }) + styleEditorHeader.color = 0xFFDCE5EF.toInt() + styleEditorHeader.fontSize = 24 + renderNode( + ctx, + styleEditorHeader, + Rect(contentX, y, contentW, lineHeightPx), + ) + } + + private fun renderComputedStyleLines( + scope: UiScope, + ctx: UiMeasureContext, + styleLines: List, + contentX: Int, + contentW: Int, + startY: Int, + lineHeightPx: Int, + ): Int { + var y = startY + styleLines.forEachIndexed { index, line -> + val lineNode = + scope.text(props = { + key = "dsgl-system-inspector-style-line-$index" + value = line + style = { + textWrap = TextWrap.NoWrap + } + }) + lineNode.color = 0xFFDCE5EF.toInt() + lineNode.fontSize = 24 + renderNode( + ctx, + lineNode, + Rect(contentX, y, contentW, lineHeightPx), + ) + y += lineHeightPx + } + return y + } + + private fun renderPanelOccluder(scope: UiScope, ctx: UiMeasureContext, panelRect: Rect) { + val occluder = + scope.div({ + key = "dsgl-system-inspector-panel-occluder" + style = { + display = Display.Block + } + }) + occluder.backgroundColor = 0xFF141820.toInt() + occluder.border = Border.NONE + renderNode(ctx, occluder, panelRect) + } + + private fun renderHighlights(scope: UiScope, ctx: UiMeasureContext) { + controller.portalSelectedHighlight()?.let { highlight -> + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-margin-fill", + highlight.marginRect, + 0x44F3B33D, + null, + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-padding-fill", + highlight.paddingRect, + 0x4426A69A, + null, + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-content-fill", + highlight.contentRect, + 0x444285F4, + null, + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-margin-outline", + highlight.marginRect, + null, + 0x99F3B33D.toInt(), + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-border-outline", + highlight.borderRect, + null, + 0xCCFF9800.toInt(), + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-padding-outline", + highlight.paddingRect, + null, + 0x9926A69A.toInt(), + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-content-outline", + highlight.contentRect, + null, + 0x994285F4.toInt(), + ) + highlight.parentContentRect?.let { parentRect -> + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-selected-parent-outline", + parentRect, + null, + 0x66FF5252, + ) + } + } + controller.portalHoveredHighlight()?.let { highlight -> + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-hovered-content-fill", + highlight.contentRect, + 0x3A47A0FF, + null, + ) + renderHighlightRect( + scope, + ctx, + "dsgl-system-inspector-hovered-border-outline", + highlight.borderRect, + null, + 0xCC47A0FF.toInt(), + ) + } + } + + private fun renderHighlightRect( + scope: UiScope, + ctx: UiMeasureContext, + key: String, + rect: Rect, + fillColor: Int?, + borderColor: Int?, + ) { + if (rect.width <= 0 || rect.height <= 0) return + val layer = + scope.div({ + this.key = key + style = { + display = Display.Block + } + }) + layer.backgroundColor = fillColor ?: 0 + layer.border = if (borderColor != null) Border.all(1, borderColor) else Border.NONE + renderNode(ctx, layer, rect) + } + + private fun renderStyleEditorRows( + scope: UiScope, + parentNode: DOMNode, + ctx: UiMeasureContext, + bodyScrollY: Int, + rows: List, + ) { + rows.forEachIndexed { index, row -> + val rowRect = translateRectY(row.rowRect, -bodyScrollY) + renderStyleEditorRowContainer(scope, ctx, rowRect, index) + renderStyleEditorRowLabel(scope, ctx, rowRect, row, index) + renderStyleEditorRowResetButton(scope, ctx, bodyScrollY, row, index) + + when (row.editorKind) { + InspectorEditorKind.EnumSelect, + InspectorEditorKind.FontSelect, + -> { + renderStyleEditorSelectButton(scope, ctx, bodyScrollY, row, index) + } + + InspectorEditorKind.StringInput -> { + renderStyleEditorStringInput(parentNode, ctx, bodyScrollY, row) + renderStyleEditorColorPreview(scope, ctx, bodyScrollY, row, index) + } + + InspectorEditorKind.NumericInput -> { + renderStyleEditorNumericControls(scope, parentNode, ctx, bodyScrollY, row, index) + } + } + } + + renderStyleEditorFooterActions(scope, ctx, bodyScrollY) + } + + private fun renderStyleEditorRowContainer( + scope: UiScope, + ctx: UiMeasureContext, + rowRect: Rect, + index: Int, + ) { + val rowNode = + scope.div({ + key = "dsgl-system-inspector-editor-row-$index" + style = { + display = Display.Block + } + }) + rowNode.backgroundColor = 0x1B293746 + rowNode.border = Border.all(1, 0x553F4A57) + renderNode(ctx, rowNode, rowRect) + } + + private fun renderStyleEditorRowLabel( + scope: UiScope, + ctx: UiMeasureContext, + rowRect: Rect, + row: InspectorStyleEditorRowSnapshot, + index: Int, + ) { + val labelNode = + scope.text(props = { + key = "dsgl-system-inspector-editor-label-$index" + value = row.labelText + style = { + textWrap = TextWrap.Wrap + } + }) + labelNode.color = 0xFFDCE5EF.toInt() + labelNode.fontSize = 18 + renderNode( + ctx, + labelNode, + Rect( + rowRect.x + 8, + rowRect.y + 5, + (row.controlRect.x - row.rowRect.x - 14).coerceAtLeast(40), + rowRect.height - 10, + ), + ) + } + + private fun renderStyleEditorRowResetButton( + scope: UiScope, + ctx: UiMeasureContext, + bodyScrollY: Int, + row: InspectorStyleEditorRowSnapshot, + index: Int, + ) { + val resetButton = + scope.button("x", { + key = "dsgl-system-inspector-editor-reset-$index" + }) + resetButton.backgroundColor = 0x3346596E + resetButton.border = Border.all(1, 0x775E738C) + resetButton.textColor = 0xFFDCE5EF.toInt() + resetButton.fontSize = 18 + resetButton.onClick { + controller.onResetPropertyPressed(row.property) + } + renderNode(ctx, resetButton, translateRectY(row.resetRect, -bodyScrollY)) + } + + private fun renderStyleEditorSelectButton( + scope: UiScope, + ctx: UiMeasureContext, + bodyScrollY: Int, + row: InspectorStyleEditorRowSnapshot, + index: Int, + ) { + val selector = + buildSystemOwnedSelectControl( + scope = scope, + key = "dsgl-system-inspector-editor-select-$index", + selectedValue = row.controlValue, + options = controller.resolveDropdownOptionsForProperty(row.property, unitSelect = false), + hovered = row.controlHovered, + ) { selected -> + controller.onSelectValueOptionPressed(row.property, selected) + } + renderNode(ctx, selector, translateRectY(row.controlRect, -bodyScrollY)) + } + + private fun renderStyleEditorStringInput( + parentNode: DOMNode, + ctx: UiMeasureContext, + bodyScrollY: Int, + row: InspectorStyleEditorRowSnapshot, + ) { + val input = + TextInputNode( + text = row.controlValue.replace("|", ""), + key = "dsgl-system-inspector-editor-input-${row.property.key}", + ) + input.backgroundColor = if (row.inputActive) 0x334D5D70 else 0x22313D4B + input.focusedBackgroundColor = input.backgroundColor + input.border = Border.all(1, if (row.inputActive) 0xFFA8C6E6.toInt() else 0x77607084) + input.textColor = 0xFFE6EDF6.toInt() + input.placeholderColor = 0xAA9AAFC6.toInt() + input.fontSize = 18 + input.onInput = { + controller.portalApplyLiteralOverride(row.property, it.value) + } + input.onValueChange = { + controller.portalApplyLiteralOverride(row.property, it.value) + } + input.applyParent(parentNode) + renderNode(ctx, input, translateRectY(row.controlRect, -bodyScrollY)) + } + + private fun renderStyleEditorColorPreview( + scope: UiScope, + ctx: UiMeasureContext, + bodyScrollY: Int, + row: InspectorStyleEditorRowSnapshot, + index: Int, + ) { + row.colorPreviewRect?.let { previewRect -> + val shiftedPreviewRect = translateRectY(previewRect, -bodyScrollY) + val preview = + scope.button("", { + key = "dsgl-system-inspector-editor-color-preview-$index" + }) + preview.backgroundColor = row.colorPreviewColor ?: 0x663F4A57 + preview.border = Border.all(1, 0xCC9BB2C9.toInt()) + preview.onClick { + controller.onOpenColorPickerPressed(row.property, shiftedPreviewRect) + } + renderNode(ctx, preview, shiftedPreviewRect) + } + } + + private fun renderStyleEditorNumericControls( + scope: UiScope, + parentNode: DOMNode, + ctx: UiMeasureContext, + bodyScrollY: Int, + row: InspectorStyleEditorRowSnapshot, + index: Int, + ) { + row.decrementRect?.let { rect -> + val dec = + scope.button("-", { + key = "dsgl-system-inspector-editor-dec-$index" + }) + dec.backgroundColor = 0x3346596E + dec.border = Border.all(1, 0x775E738C) + dec.textColor = 0xFFDCE5EF.toInt() + dec.fontSize = 18 + dec.onClick { + controller.onNumericDecrementPressed(row.property) + } + renderNode(ctx, dec, translateRectY(rect, -bodyScrollY)) + } + row.inputRect?.let { rect -> + val input = + TextInputNode( + text = row.controlValue.replace("|", ""), + key = "dsgl-system-inspector-editor-numeric-input-${row.property.key}", + ) + input.allowedChars = "-0123456789." + input.backgroundColor = if (row.inputActive) 0x334D5D70 else 0x22313D4B + input.focusedBackgroundColor = input.backgroundColor + input.border = Border.all(1, if (row.inputActive) 0xFFA8C6E6.toInt() else 0x77607084) + input.textColor = 0xFFE6EDF6.toInt() + input.placeholderColor = 0xAA9AAFC6.toInt() + input.fontSize = 18 + input.onInput = { + controller.portalApplyNumericOverride(row.property, it.value, row.unitValue) + } + input.onValueChange = { + controller.portalApplyNumericOverride(row.property, it.value, row.unitValue) + } + input.applyParent(parentNode) + renderNode(ctx, input, translateRectY(rect, -bodyScrollY)) + } + + row.incrementRect?.let { rect -> + val inc = + scope.button("+", { + key = "dsgl-system-inspector-editor-inc-$index" + }) + inc.backgroundColor = 0x3346596E + inc.border = Border.all(1, 0x775E738C) + inc.textColor = 0xFFDCE5EF.toInt() + inc.fontSize = 18 + inc.onClick { + controller.onNumericIncrementPressed(row.property) + } + renderNode(ctx, inc, translateRectY(rect, -bodyScrollY)) + } + row.unitRect?.let { rect -> + val unitValue = row.unitValue ?: "px" + val unit = + buildSystemOwnedSelectControl( + scope = scope, + key = "dsgl-system-inspector-editor-unit-$index", + selectedValue = unitValue, + options = controller.resolveDropdownOptionsForProperty(row.property, unitSelect = true), + hovered = false, + ) { selected -> + controller.onSelectUnitOptionPressed(row.property, selected) + } + renderNode(ctx, unit, translateRectY(rect, -bodyScrollY)) + } + } + + private fun buildSystemOwnedSelectControl( + scope: UiScope, + key: String, + selectedValue: String, + options: List, + hovered: Boolean, + onSelected: (String) -> Unit, + ): DOMNode { + val open = DomainPortalServices.isSelectOpenFor(key) + val selectNode = + scope.select( + props = { + this.key = key + ownerDomain = ScreenDomainId.System + value = selectedValue + onInput = { onSelected(it.value) } + }, + ) { + options.forEach { option -> + option(id = option, label = option) + } + } + selectNode.backgroundColor = + if (open) { + 0x334D5D70 + } else if (hovered) { + 0x2A425164 + } else { + 0x22313D4B + } + selectNode.border = Border.all(1, if (open) 0xFFA8C6E6.toInt() else 0x77607084) + selectNode.textColor = 0xFFE6EDF6.toInt() + selectNode.fontSize = 18 + selectNode.placeholderColor = 0xFFE6EDF6.toInt() + return selectNode + } + + private fun renderStyleEditorFooterActions(scope: UiScope, ctx: UiMeasureContext, bodyScrollY: Int) { + val resetRect = controller.portalStyleEditorResetRect() + if (resetRect.width > 0 && resetRect.height > 0) { + val resetButton = + scope.button("Reset node", { + key = "dsgl-system-inspector-reset-node" + }) + resetButton.backgroundColor = 0x2A465968 + resetButton.border = Border.all(1, 0x775E738C) + resetButton.textColor = 0xFFDCE5EF.toInt() + resetButton.fontSize = 18 + resetButton.onClick { + controller.onResetSelectedOverridesPressed() + } + renderNode(ctx, resetButton, translateRectY(resetRect, -bodyScrollY)) + } + + val clearRect = controller.portalStyleEditorClearRect() + if (clearRect.width > 0 && clearRect.height > 0) { + val clearButton = + scope.button("Clear all", { + key = "dsgl-system-inspector-clear-all" + }) + clearButton.backgroundColor = 0x2A4E3F56 + clearButton.border = Border.all(1, 0x777A5C84) + clearButton.textColor = 0xFFDCE5EF.toInt() + clearButton.fontSize = 18 + clearButton.onClick { + controller.onClearAllOverridesPressed() + } + renderNode(ctx, clearButton, translateRectY(clearRect, -bodyScrollY)) + } + } + + private fun startMinimizedChipDrag(panelRect: Rect, mouseX: Int, mouseY: Int) { + minimizedChipDragSession = + MinimizedChipDragSession( + startPointerX = mouseX, + startPointerY = mouseY, + startRect = panelRect, + currentPointerX = mouseX, + currentPointerY = mouseY, + moved = false, + ) + controller.onFloatingPanelPointerCaptureChanged(true) + } + + private fun updateMinimizedChipDragPointer(mouseX: Int, mouseY: Int) { + val session = minimizedChipDragSession ?: return + session.currentPointerX = mouseX + session.currentPointerY = mouseY + } + + private fun applyMinimizedChipDragFrame() { + val session = minimizedChipDragSession ?: return + val dx = session.currentPointerX - session.startPointerX + val dy = session.currentPointerY - session.startPointerY + if (!session.moved && (kotlin.math.abs(dx) >= 2 || kotlin.math.abs(dy) >= 2)) { + session.moved = true + } + controller.onNativeDomMinimizedPanelPosition( + x = session.startRect.x + dx, + y = session.startRect.y + dy, + viewportWidth = lastViewportWidth, + viewportHeight = lastViewportHeight, + ) + } + + private fun continueMinimizedChipDrag(mouseX: Int, mouseY: Int) { + updateMinimizedChipDragPointer(mouseX, mouseY) + applyMinimizedChipDragFrame() + } + + private fun endMinimizedChipDrag(mouseX: Int, mouseY: Int) { + val session = minimizedChipDragSession ?: return + continueMinimizedChipDrag(mouseX, mouseY) + if (!session.moved) { + controller.restore() + } + minimizedChipDragSession = null + controller.onFloatingPanelPointerCaptureChanged(false) + } + + private fun clearMinimizedChipDragSession() { + minimizedChipDragSession = null + controller.onFloatingPanelPointerCaptureChanged(false) + } + + private fun renderTooltip( + scope: UiScope, + ctx: UiMeasureContext, + keyPrefix: String, + tooltip: InspectorTooltipSnapshot?, + backgroundColor: Int, + borderColor: Int, + ) { + if (tooltip == null) return + val box = + scope.div({ + key = "$keyPrefix-box" + style = { + display = Display.Block + } + }) + box.backgroundColor = backgroundColor + box.border = Border.all(1, borderColor) + renderNode(ctx, box, tooltip.rect) + + val textNode = + scope.text(props = { + key = "$keyPrefix-text" + value = tooltip.text + style = { + textWrap = TextWrap.NoWrap + } + }) + textNode.color = 0xFFE6EDF6.toInt() + textNode.fontSize = 18 + renderNode( + ctx, + textNode, + Rect( + tooltip.rect.x + 6, + tooltip.rect.y + 4, + (tooltip.rect.width - 10).coerceAtLeast(20), + (tooltip.rect.height - 8).coerceAtLeast(16), + ), + ) + } + + private fun isDomOwnedInteractionTarget(target: DOMNode?): Boolean { + var current = target + while (current != null && current !== this) { + when (current.styleType) { + "input", "textarea", "select", "toggle", "button" -> return true + } + val nodeKey = current.key?.toString() + if (nodeKey?.startsWith("dsgl-system-inspector-") == true) { + return true + } + if (nodeKey?.startsWith("dsgl-floating-panel-") == true) { + return true + } + current = current.parent + } + return current === this && target !== this + } + + private fun capturePersistedScrollStateFromCurrentTree() { + findNodeByKey("dsgl-system-inspector-body")?.let { bodyNode -> + persistedBodyScrollSession = bodyNode.captureScrollSessionSnapshot() + } + } + + private fun findNodeByKey(targetKey: String): DOMNode? = collectNodes(this).firstOrNull { it.key == targetKey } + + private fun collectNodes(root: DOMNode): List { + val out = ArrayList() + + fun walk(node: DOMNode) { + out += node + node.children.forEach(::walk) + } + walk(root) + return out + } + + private fun shouldRetainInspectorSubtreeFocus(): Boolean { + val focused = FocusManager.focusedNode() ?: return false + return isSameOrAncestor(this, focused) + } + + private fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { + var current = node + while (current != null) { + if (current === candidate) return true + current = current.parent + } + return false + } + + private fun translateRectY(rect: Rect, deltaY: Int): Rect = Rect(rect.x, rect.y + deltaY, rect.width, rect.height) + + private fun renderNode(ctx: UiMeasureContext, node: DOMNode, rect: Rect) { + if (rect.width <= 0 || rect.height <= 0) { + node.display = Display.None + node.render(ctx, 0, 0, 0, 0) + return + } + node.display = Display.Block + node.render(ctx, rect.x, rect.y, rect.width, rect.height) + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayHost.kt deleted file mode 100644 index 421a773..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayHost.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import org.dreamfinity.dsgl.core.DomTree -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.style.StyleApplicationScope - -class ApplicationOverlayHost : OverlayLayerHost { - override val layerId: UiLayerId = UiLayerId.ApplicationOverlay - - private val rootNode: ApplicationOverlayRootNode = ApplicationOverlayRootNode() - private val tree: DomTree = DomTree( - root = rootNode, - styleScope = StyleApplicationScope.Application - ) - - override fun render(ctx: UiMeasureContext, width: Int, height: Int) { - rootNode.setViewportBounds(width, height) - tree.render(ctx, width, height) - } - - override fun paint(ctx: UiMeasureContext): List { - return tree.paint(ctx, applyStyles = true) - } - - override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = false - - override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false - - override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false - - override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = false - - override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false - - override fun clearRefs() { - tree.clearRefs() - } - - internal fun debugRootBounds(): Rect { - return rootNode.bounds - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ColorPickerPopupOverlayOwnership.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ColorPickerPopupOverlayOwnership.kt deleted file mode 100644 index 43e3c8b..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ColorPickerPopupOverlayOwnership.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest - -object ColorPickerPopupOverlayOwnership { - fun resolveLayer(request: ColorPickerPopupRequest): UiLayerId { - return OverlayLayerContracts.resolveTransientLayer(request.ownerScope) - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualization.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualization.kt deleted file mode 100644 index 43bde73..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualization.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -object OverlayDebugVisualization { - var applicationOverlayFillColor: Int = 0x22307BC8 - var applicationOverlayBorderColor: Int = 0xAA4DA4FF.toInt() - var systemOverlayFillColor: Int = 0x22A84BD8 - var systemOverlayBorderColor: Int = 0xAAE18BFF.toInt() - val enabled: Boolean - get() { - return testOverride ?: java.lang.Boolean.getBoolean("dsgl.overlay.debug") - } - - - private var testOverride: Boolean? = null - - internal fun setTestOverride(value: Boolean?) { - testOverride = value - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContracts.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContracts.kt deleted file mode 100644 index 27828b3..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContracts.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import org.dreamfinity.dsgl.core.render.RenderCommand - -enum class UiLayerId { - Debug, - ApplicationRoot, - ApplicationOverlay, - SystemOverlay -} - -enum class OverlayOwnerScope { - Application, - System -} - -object OverlayLayerContracts { - val paintOrder: List = listOf( - UiLayerId.ApplicationRoot, - UiLayerId.ApplicationOverlay, - UiLayerId.SystemOverlay, - UiLayerId.Debug - ) - - val inputPriority: List = listOf( - UiLayerId.Debug, - UiLayerId.SystemOverlay, - UiLayerId.ApplicationOverlay, - UiLayerId.ApplicationRoot - ) - - fun resolveTransientLayer(ownerScope: OverlayOwnerScope): UiLayerId { - return when (ownerScope) { - OverlayOwnerScope.Application -> UiLayerId.ApplicationOverlay - OverlayOwnerScope.System -> UiLayerId.SystemOverlay - } - } - - fun resolveTransientLayer(ownerScope: OverlayOwnerScope, cursorX: Int, cursorY: Int): UiLayerId { - return resolveTransientLayer(ownerScope) - } - - fun firstInputConsumer( - canConsume: (UiLayerId) -> Boolean, - isLayerInputEnabled: (UiLayerId) -> Boolean = { true } - ): UiLayerId? { - inputPriority.forEach { layer -> - if (!isLayerInputEnabled(layer)) return@forEach - if (canConsume(layer)) return layer - } - return null - } - - fun composePaintCommands( - applicationRoot: List, - applicationOverlay: List, - systemOverlay: List, - debug: List, - out: MutableList, - shouldRenderLayer: (UiLayerId) -> Boolean = { true } - ) { - out.clear() - paintOrder.forEach { layer -> - if (!shouldRenderLayer(layer)) return@forEach - when (layer) { - UiLayerId.ApplicationRoot -> out.addAll(applicationRoot) - UiLayerId.ApplicationOverlay -> out.addAll(applicationOverlay) - UiLayerId.SystemOverlay -> out.addAll(systemOverlay) - UiLayerId.Debug -> out.addAll(debug) - } - } - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanel.kt deleted file mode 100644 index 4b48dee..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanel.kt +++ /dev/null @@ -1,443 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.panel - -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ButtonNode -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.elements.TextNode -import org.dreamfinity.dsgl.core.dom.elements.TextSource -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.button -import org.dreamfinity.dsgl.core.dsl.div -import org.dreamfinity.dsgl.core.dsl.text -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.FlexDirection -import org.dreamfinity.dsgl.core.style.TextWrap - -data class OverlayPanelStyle( - val headerHeight: Int = 26, - val panelPadding: Int = 6, - val closeButtonWidth: Int = 16, - val closeButtonHeight: Int = 16, - val closeButtonMarginTop: Int = 4, - val closeButtonMarginRight: Int = 4, - val panelBackgroundColor: Int = 0xFF1E252F.toInt(), - val panelBorderColor: Int = 0xFF5A6C80.toInt(), - val panelShadowColor: Int = 0x7A0C1118, - val headerBackgroundColor: Int = 0xFF2E3A49.toInt(), - val headerBorderColor: Int = 0xFF607A95.toInt(), - val closeButtonBackgroundColor: Int = 0xFF2E3A49.toInt(), - val closeButtonBorderColor: Int = 0xFF607A95.toInt(), - val textColor: Int = 0xFFFFFFFF.toInt(), - val fontSize: Int = 20, - val closeGlyph: String = "x" -) - -data class OverlayPanelFrame( - val panelRect: Rect, - val headerRect: Rect, - val bodyRect: Rect, - val closeRect: Rect -) - -class OverlayPanel( - private val ownerId: Any, - private val panelState: OverlayPanelState, - private val dragSession: OverlayPanelDragSession, - private var style: OverlayPanelStyle = OverlayPanelStyle() -) { - private val rootNode: OverlayPanelRootNode = OverlayPanelRootNode( - owner = this, - key = "dsgl-overlay-panel-$ownerId" - ) - private val shadowNode: ContainerNode - private val panelNode: ContainerNode - private val headerNode: ContainerNode - private var titleNode: TextNode - private val closeButtonNode: ButtonNode - private var bodyContentNode: DOMNode? = null - private var overlayContentNode: DOMNode? = null - var title: String = "" - private set - var draggable: Boolean = true - private set - private var onClose: (() -> Unit)? = null - private var frame: OverlayPanelFrame? = null - - init { - val scope = UiScope(rootNode) - shadowNode = scope.div({ - key = "dsgl-overlay-panel-shadow-$ownerId" - style = { - display = Display.Block - } - }) - panelNode = scope.div({ - key = "dsgl-overlay-panel-frame-$ownerId" - style = { - display = Display.Block - } - }) - headerNode = scope.div({ - key = "dsgl-overlay-panel-header-$ownerId" - style = { - display = Display.Flex - flexDirection = FlexDirection.Row - } - }) - titleNode = scope.text(props = { - key = "dsgl-overlay-panel-title-$ownerId" - value = "" - style = { - textWrap = TextWrap.NoWrap - } - }) - closeButtonNode = scope.button(style.closeGlyph, { - key = "dsgl-overlay-panel-close-$ownerId" - onMouseClick = { - onClose?.invoke() - it.cancelled = true - } - }) - applyStyleToNodes(style) - rebuildFrameFromState() - } - - fun node(): DOMNode = rootNode - - fun setBodyContent(node: DOMNode?) { - if (bodyContentNode === node) return - detachNode(bodyContentNode) - bodyContentNode = node - attachNodeBeforeOverlay(node) - } - - fun setOverlayContent(node: DOMNode?) { - if (overlayContentNode === node) return - detachNode(overlayContentNode) - overlayContentNode = node - attachNodeAtTop(node) - } - - fun configure( - title: String, - draggable: Boolean, - style: OverlayPanelStyle = this.style, - onClose: (() -> Unit)? = this.onClose - ) { - val titleChanged = this.title != title - val styleChanged = this.style != style - this.title = title - this.draggable = draggable - this.style = style - this.onClose = onClose - if (titleChanged) { - replaceTitleNode() - } - if (styleChanged) { - applyStyleToNodes(style) - } - closeButtonNode.text = style.closeGlyph - rebuildFrameFromState() - } - - fun syncPanelRect(panelRect: Rect?) { - if (panelRect == null) { - panelState.hide() - frame = null - return - } - panelState.updateFromRect(panelRect) - rebuildFrameFromState() - } - - fun panelRect(): Rect? = frame?.panelRect - - fun headerRect(): Rect? = frame?.headerRect - - fun closeRect(): Rect? = frame?.closeRect - - fun bodyRect(): Rect? = frame?.bodyRect - - fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - val localFrame = frame ?: return false - if (button != MouseButton.LEFT) return false - if (localFrame.closeRect.contains(mouseX, mouseY)) { - onClose?.invoke() - return true - } - if (!draggable) return false - if (!localFrame.headerRect.contains(mouseX, mouseY)) return false - dragSession.begin( - ownerId = ownerId, - type = OverlayPanelDragType.PanelMove, - pointerX = mouseX, - pointerY = mouseY, - panelState = panelState - ) - return true - } - - fun handleMouseMove( - mouseX: Int, - mouseY: Int, - viewportWidth: Int, - viewportHeight: Int, - onDragRectChanged: (Rect) -> Unit - ): Boolean { - if (!dragSession.active) return false - dragSession.update(mouseX, mouseY) - val rect = buildDraggedRect(viewportWidth, viewportHeight) - panelState.updateFromRect(rect) - rebuildFrameFromState() - onDragRectChanged(rect) - return true - } - - fun handleMouseUp( - mouseX: Int, - mouseY: Int, - button: MouseButton, - viewportWidth: Int, - viewportHeight: Int, - onDragRectChanged: (Rect) -> Unit - ): Boolean { - if (button != MouseButton.LEFT || !dragSession.active) return false - dragSession.update(mouseX, mouseY) - val rect = buildDraggedRect(viewportWidth, viewportHeight) - panelState.updateFromRect(rect) - rebuildFrameFromState() - onDragRectChanged(rect) - dragSession.end() - return true - } - - private fun buildDraggedRect(viewportWidth: Int, viewportHeight: Int): Rect { - val dx = dragSession.currentPointerX - dragSession.startPointerX - val dy = dragSession.currentPointerY - dragSession.startPointerY - val raw = Rect( - x = dragSession.startPanelX + dx, - y = dragSession.startPanelY + dy, - width = dragSession.startPanelWidth, - height = dragSession.startPanelHeight - ) - return clampPanel(raw, viewportWidth, viewportHeight) - } - - private fun rebuildFrameFromState() { - val panelRect = panelState.currentRectOrNull() - frame = if (panelRect == null) { - null - } else { - buildFrame(panelRect) - } - } - - private fun buildFrame(panelRect: Rect): OverlayPanelFrame { - val headerRect = Rect(panelRect.x, panelRect.y, panelRect.width, style.headerHeight) - val bodyRect = Rect( - x = panelRect.x + style.panelPadding, - y = panelRect.y + style.headerHeight + style.panelPadding, - width = (panelRect.width - style.panelPadding * 2).coerceAtLeast(1), - height = (panelRect.height - style.headerHeight - style.panelPadding * 2).coerceAtLeast(1) - ) - val closeRect = Rect( - x = panelRect.x + panelRect.width - style.closeButtonMarginRight - style.closeButtonWidth, - y = panelRect.y + style.closeButtonMarginTop, - width = style.closeButtonWidth, - height = style.closeButtonHeight - ) - return OverlayPanelFrame( - panelRect = panelRect, - headerRect = headerRect, - bodyRect = bodyRect, - closeRect = closeRect - ) - } - - private fun clampPanel(rect: Rect, viewportWidth: Int, viewportHeight: Int): Rect { - val minX = 2 - val minY = 2 - val maxX = (viewportWidth - rect.width - 2).coerceAtLeast(2) - val maxY = (viewportHeight - rect.height - 2).coerceAtLeast(2) - return Rect( - x = rect.x.coerceIn(minX, maxX), - y = rect.y.coerceIn(minY, maxY), - width = rect.width, - height = rect.height - ) - } - - private fun applyStyleToNodes(style: OverlayPanelStyle) { - shadowNode.applyStyle { - backgroundColor = style.panelShadowColor - border { width = 0.px } - } - panelNode.applyStyle { - backgroundColor = style.panelBackgroundColor - border { width = 1.px; color = style.panelBorderColor } - } - headerNode.applyStyle { - backgroundColor = style.headerBackgroundColor - border { width = 1.px; color = style.headerBorderColor } - } - titleNode.applyStyle { - color = style.textColor - fontSize = style.fontSize.px - textWrap = TextWrap.NoWrap - } - closeButtonNode.applyStyle { - backgroundColor = style.closeButtonBackgroundColor - border { width = 1.px; color = style.closeButtonBorderColor } - color = style.textColor - fontSize = style.fontSize.px - width = style.closeButtonWidth.px - height = style.closeButtonHeight.px - padding = 0.px - } - } - - private fun replaceTitleNode() { - val oldNode = titleNode - val oldIndex = rootNode.children.indexOf(oldNode) - detachNode(oldNode) - val newNode = TextNode( - textSource = TextSource.Static(title), - key = "dsgl-overlay-panel-title-$ownerId" - ) - newNode.applyStyle { - textWrap = TextWrap.NoWrap - } - titleNode = newNode - if (oldIndex >= 0 && oldIndex <= rootNode.children.size) { - rootNode.children.add(oldIndex, newNode) - newNode.parent = rootNode - } else { - newNode.applyParent(rootNode) - } - applyStyleToNodes(style) - } - - private fun detachNode(node: DOMNode?) { - val local = node ?: return - val parent = local.parent - if (parent != null) { - parent.children.remove(local) - local.parent = null - } - } - - private fun attachNodeBeforeOverlay(node: DOMNode?) { - val local = node ?: return - detachNode(local) - val overlay = overlayContentNode - if (overlay != null && overlay.parent === rootNode) { - val overlayIndex = rootNode.children.indexOf(overlay) - if (overlayIndex >= 0) { - rootNode.children.add(overlayIndex, local) - local.parent = rootNode - return - } - } - local.applyParent(rootNode) - } - - private fun attachNodeAtTop(node: DOMNode?) { - val local = node ?: return - detachNode(local) - local.applyParent(rootNode) - } - - private fun renderInto(ctx: UiMeasureContext, viewportRect: Rect) { - val localFrame = frame - if (localFrame == null) { - shadowNode.render(ctx, 0, 0, 0, 0) - panelNode.render(ctx, 0, 0, 0, 0) - headerNode.render(ctx, 0, 0, 0, 0) - titleNode.render(ctx, 0, 0, 0, 0) - closeButtonNode.render(ctx, 0, 0, 0, 0) - bodyContentNode?.render(ctx, 0, 0, 0, 0) - overlayContentNode?.render(ctx, 0, 0, 0, 0) - return - } - - val panelRect = localFrame.panelRect - val headerRect = localFrame.headerRect - val closeRect = localFrame.closeRect - val bodyRect = localFrame.bodyRect - val titleX = headerRect.x + 6 - val titleY = headerRect.y + 3 - val titleWidth = (closeRect.x - titleX - 4).coerceAtLeast(1) - val titleHeight = (headerRect.height - 6).coerceAtLeast(1) - - shadowNode.render( - ctx, - panelRect.x + 2, - panelRect.y + 2, - panelRect.width, - panelRect.height - ) - panelNode.render( - ctx, - panelRect.x, - panelRect.y, - panelRect.width, - panelRect.height - ) - headerNode.render( - ctx, - headerRect.x, - headerRect.y, - headerRect.width, - headerRect.height - ) - titleNode.render( - ctx, - titleX, - titleY, - titleWidth, - titleHeight - ) - closeButtonNode.render( - ctx, - closeRect.x, - closeRect.y, - closeRect.width, - closeRect.height - ) - bodyContentNode?.render( - ctx, - bodyRect.x, - bodyRect.y, - bodyRect.width, - bodyRect.height - ) - overlayContentNode?.render( - ctx, - viewportRect.x, - viewportRect.y, - viewportRect.width, - viewportRect.height - ) - } - - private class OverlayPanelRootNode( - private val owner: OverlayPanel, - key: Any? - ) : DOMNode(key) { - override val styleType: String = "dsgl-overlay-panel" - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - owner.renderInto(ctx, bounds) - } - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntries.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntries.kt deleted file mode 100644 index 96edee6..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntries.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelDragSession -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelState -import java.util.IdentityHashMap - -enum class SystemOverlayEntryId { - Inspector, - ColorPickerPopup, - ColorPickerTransient, - PanelDemo, - TransientSession -} - -enum class SystemOverlayLane( - val zOrder: Int -) { - PanelContent(0), - Transient(1) -} - -class SystemOverlayEntryState( - val id: SystemOverlayEntryId, - val order: Int, - val lane: SystemOverlayLane = SystemOverlayLane.PanelContent, - val panelState: OverlayPanelState = OverlayPanelState(), - val dragSession: OverlayPanelDragSession = OverlayPanelDragSession() -) { - var active: Boolean = false - internal set -} - -internal data class SystemOverlayFrameContext( - val inspectedRoot: DOMNode?, - val inspectedLayoutRevision: Long, - val cursorX: Int, - val cursorY: Int, - val inspectorPointerCaptured: Boolean -) - -internal interface SystemOverlayEntry { - val state: SystemOverlayEntryState - val node: DOMNode - - fun participatesInDomInput(): Boolean = false - - fun sync(frame: SystemOverlayFrameContext) - - fun onInputFrame(viewportWidth: Int, viewportHeight: Int) = Unit - - fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = false - - fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false - - fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false - - fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = false - - fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false -} - -class SystemOverlayTransientSession( - val ownerToken: Any, - val entryState: SystemOverlayEntryState = SystemOverlayEntryState( - id = SystemOverlayEntryId.TransientSession, - order = Int.MAX_VALUE - ) -) - -class SystemOverlayTransientOwnershipRegistry { - private val sessions: IdentityHashMap = IdentityHashMap() - - fun resolve(ownerToken: Any): SystemOverlayTransientSession { - return sessions.getOrPut(ownerToken) { - SystemOverlayTransientSession(ownerToken = ownerToken) - } - } - - fun resolve(ownerToken: Any, cursorX: Int, cursorY: Int): SystemOverlayTransientSession { - return resolve(ownerToken) - } - - fun release(ownerToken: Any): Boolean { - return sessions.remove(ownerToken) != null - } - - fun clear() { - sessions.clear() - } - - fun activeSessions(): List { - return sessions.values.toList() - } -} - -internal class SystemOverlayEntryRegistry( - entries: List -) { - private val orderedEntries: List = entries.sortedBy { it.state.order } - private val byId: Map = orderedEntries.associateBy { it.state.id } - - fun allEntries(): List = orderedEntries - - fun entry(id: SystemOverlayEntryId): SystemOverlayEntry? = byId[id] -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayHost.kt deleted file mode 100644 index b2a91f6..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayHost.kt +++ /dev/null @@ -1,684 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import org.dreamfinity.dsgl.core.DomTree -import org.dreamfinity.dsgl.core.colorpicker.* -import org.dreamfinity.dsgl.core.colorpicker.internal.InspectorColorPickerHost -import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerOverlayNode -import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerTransientOverlayNode -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorOverlayNode -import org.dreamfinity.dsgl.core.overlay.OverlayLayerHost -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope -import org.dreamfinity.dsgl.core.overlay.UiLayerId -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanel -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelDragType -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelStyle -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.StyleApplicationScope - -class SystemOverlayHost( - private val inspectorController: InspectorController -) : OverlayLayerHost { - override val layerId: UiLayerId = UiLayerId.SystemOverlay - - private val rootNode: SystemOverlayRootNode = SystemOverlayRootNode() - private val inspectorEntry: SystemOverlayEntry = InspectorOverlayEntry(inspectorController) - private val colorPickerEntry: ColorPickerOverlayEntry = ColorPickerOverlayEntry() - private val colorPickerTransientEntry: SystemOverlayEntry = ColorPickerTransientOverlayEntry(colorPickerEntry) - private val overlayPanelDemoEntry: OverlayPanelDemoOverlayEntry = OverlayPanelDemoOverlayEntry() - private val entryRegistry: SystemOverlayEntryRegistry = SystemOverlayEntryRegistry( - listOf(inspectorEntry, colorPickerEntry, colorPickerTransientEntry, overlayPanelDemoEntry) - ) - private val transientOwnershipRegistry: SystemOverlayTransientOwnershipRegistry = - SystemOverlayTransientOwnershipRegistry() - private val tree: DomTree = DomTree( - root = rootNode, - styleScope = StyleApplicationScope.SystemOverlay - ) - private var frameContext: SystemOverlayFrameContext = SystemOverlayFrameContext( - inspectedRoot = null, - inspectedLayoutRevision = 0L, - cursorX = 0, - cursorY = 0, - inspectorPointerCaptured = false - ) - private var knownViewportWidth: Int = 1 - private var knownViewportHeight: Int = 1 - private val domInputRouter: LayerDomInputRouter = LayerDomInputRouter( - rootProvider = { - if (activeEntriesTopFirst().any { it.participatesInDomInput() }) rootNode else null - } - ) - - fun systemInspectorColorPickerPopupHost(): InspectorColorPickerHost { - return colorPickerEntry - } - - fun isSystemColorPickerOpen(): Boolean { - return colorPickerEntry.isOpen() - } - - fun captureSystemColorPickerEyedropperSample() { - colorPickerEntry.captureEyedropperSample() - } - - fun togglePanelDemo(anchorX: Int, anchorY: Int) { - overlayPanelDemoEntry.toggle(anchorX, anchorY, knownViewportWidth, knownViewportHeight) - } - - fun isOverlayPanelDemoOpen(): Boolean { - return overlayPanelDemoEntry.isOpen() - } - - fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { - knownViewportWidth = viewportWidth.coerceAtLeast(1) - knownViewportHeight = viewportHeight.coerceAtLeast(1) - rootNode.setViewportBounds(knownViewportWidth, knownViewportHeight) - entryRegistry.allEntries().forEach { entry -> - entry.onInputFrame(viewportWidth, viewportHeight) - } - } - - fun syncFrame( - inspectedRoot: DOMNode?, - inspectedLayoutRevision: Long, - cursorX: Int, - cursorY: Int, - inspectorPointerCaptured: Boolean - ) { - frameContext = SystemOverlayFrameContext( - inspectedRoot = inspectedRoot, - inspectedLayoutRevision = inspectedLayoutRevision, - cursorX = cursorX, - cursorY = cursorY, - inspectorPointerCaptured = inspectorPointerCaptured - ) - rootNode.setViewportBounds(knownViewportWidth, knownViewportHeight) - entryRegistry.allEntries().forEach { entry -> - entry.sync(frameContext) - } - reconcileMountedEntries() - } - - override fun render(ctx: UiMeasureContext, width: Int, height: Int) { - knownViewportWidth = width.coerceAtLeast(1) - knownViewportHeight = height.coerceAtLeast(1) - rootNode.setViewportBounds(width, height) - tree.render(ctx, width, height) - } - - override fun paint(ctx: UiMeasureContext): List { - return tree.paint(ctx, applyStyles = true) - } - - override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { - if (dispatchManualInput { entry -> entry.handleMouseMove(mouseX, mouseY) }) { - return true - } - return domInputRouter.handleMouseMove(mouseX, mouseY) - } - - override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (dispatchManualInput { entry -> entry.handleMouseDown(mouseX, mouseY, button) }) { - return true - } - return domInputRouter.handleMouseDown(mouseX, mouseY, button) - } - - override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (dispatchManualInput { entry -> entry.handleMouseUp(mouseX, mouseY, button) }) { - return true - } - return domInputRouter.handleMouseUp(mouseX, mouseY, button) - } - - override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { - if (dispatchManualInput { entry -> entry.handleMouseWheel(mouseX, mouseY, delta) }) { - return true - } - return domInputRouter.handleMouseWheel(mouseX, mouseY, delta) - } - - override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { - if (dispatchManualInput { entry -> entry.handleKeyDown(keyCode, keyChar) }) { - return true - } - return domInputRouter.handleKeyDown(keyCode, keyChar) - } - - override fun clearRefs() { - tree.clearRefs() - transientOwnershipRegistry.clear() - colorPickerEntry.close() - overlayPanelDemoEntry.close() - domInputRouter.clear() - } - - internal fun debugEntryState(id: SystemOverlayEntryId): SystemOverlayEntryState? { - return entryRegistry.entry(id)?.state - } - - internal fun debugEntryNode(id: SystemOverlayEntryId): DOMNode? { - return entryRegistry.entry(id)?.node - } - - internal fun debugRegisteredEntryIds(): List { - return entryRegistry.allEntries().map { it.state.id } - } - - internal fun debugMountedEntryIds(): List { - val entriesByNode = entryRegistry.allEntries().associateBy { it.node } - val mountedNodes = buildList { - addAll(rootNode.mountedLaneNodes(SystemOverlayLane.PanelContent)) - addAll(rootNode.mountedLaneNodes(SystemOverlayLane.Transient)) - } - return mountedNodes.mapNotNull { node -> - entriesByNode[node]?.state?.id - } - } - - internal fun resolveTransientSession(ownerToken: Any): SystemOverlayTransientSession { - return transientOwnershipRegistry.resolve(ownerToken) - } - - internal fun resolveTransientSession(ownerToken: Any, cursorX: Int, cursorY: Int): SystemOverlayTransientSession { - return transientOwnershipRegistry.resolve(ownerToken, cursorX, cursorY) - } - - internal fun releaseTransientSession(ownerToken: Any): Boolean { - return transientOwnershipRegistry.release(ownerToken) - } - - internal fun debugTransientSessionCount(): Int { - return transientOwnershipRegistry.activeSessions().size - } - - internal fun debugSystemColorPickerHeaderRect(): Rect? { - return colorPickerEntry.debugHeaderRect() - } - - internal fun debugSystemColorPickerCloseRect(): Rect? { - return colorPickerEntry.debugCloseRect() - } - - internal fun debugSystemColorPickerBodyLayout(): ColorPickerLayout? { - return colorPickerEntry.debugBodyLayout() - } - - internal fun debugSystemColorPickerState(): ColorPickerState? { - return colorPickerEntry.debugState() - } - - internal fun debugSystemColorPickerPopupOwnerScope(): OverlayOwnerScope? { - return colorPickerEntry.debugOwnerScope() - } - - internal fun debugRootBounds(): Rect { - return rootNode.bounds - } - - private fun reconcileMountedEntries() { - val activeEntries = entryRegistry.allEntries().filter { it.state.active } - val panelNodes = activeEntries - .filter { it.state.lane == SystemOverlayLane.PanelContent } - .map { it.node } - val transientNodes = activeEntries - .filter { it.state.lane == SystemOverlayLane.Transient } - .map { it.node } - rootNode.setLaneChildren( - panelNodes = panelNodes, - transientNodes = transientNodes - ) - } - - private fun activeEntriesTopFirst(): List { - return entryRegistry.allEntries() - .filter { it.state.active } - .sortedWith( - compareBy { it.state.lane.zOrder } - .thenBy { it.state.order } - ) - .asReversed() - } - - private inline fun dispatchManualInput(handler: (SystemOverlayEntry) -> Boolean): Boolean { - return activeEntriesTopFirst() - .asSequence() - .filter { !it.participatesInDomInput() } - .any(handler) - } - - private class InspectorOverlayEntry( - private val inspectorController: InspectorController - ) : SystemOverlayEntry { - override val state: SystemOverlayEntryState = SystemOverlayEntryState( - id = SystemOverlayEntryId.Inspector, - order = 100, - lane = SystemOverlayLane.PanelContent - ) - override val node: SystemInspectorOverlayNode = SystemInspectorOverlayNode(inspectorController) - private var viewportWidth: Int = 1 - private var viewportHeight: Int = 1 - - override fun participatesInDomInput(): Boolean = true - - override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { - this.viewportWidth = viewportWidth.coerceAtLeast(1) - this.viewportHeight = viewportHeight.coerceAtLeast(1) - } - - override fun sync(frame: SystemOverlayFrameContext) { - node.syncInputBounds(viewportWidth, viewportHeight) - node.bindInspectedTree(frame.inspectedRoot, frame.inspectedLayoutRevision) - node.updateCursor(frame.cursorX, frame.cursorY, frame.inspectorPointerCaptured) - frame.inspectedRoot?.let { root -> - inspectorController.onLayoutCommitted(root, frame.inspectedLayoutRevision) - } - state.active = inspectorController.active - if (!state.active) { - state.panelState.hide() - state.dragSession.end() - return - } - val panelRect = inspectorController.debugPanelRect() - if (panelRect != null) { - state.panelState.updateFromRect(panelRect) - } else { - state.panelState.show() - } - syncDragSession( - entryState = state, - dragging = node.isDomPanelDragActive(), - dragType = OverlayPanelDragType.PanelMove, - pointerX = frame.cursorX, - pointerY = frame.cursorY - ) - } - } - - private class ColorPickerOverlayEntry : SystemOverlayEntry, InspectorColorPickerHost { - override val state: SystemOverlayEntryState = SystemOverlayEntryState( - id = SystemOverlayEntryId.ColorPickerPopup, - order = 200, - lane = SystemOverlayLane.PanelContent - ) - private val ownerToken: Any = Any() - private val popupEngine: ColorPickerPopupEngine = ColorPickerPopupEngine() - private val overlayPanel: OverlayPanel = OverlayPanel( - ownerId = state.id, - panelState = state.panelState, - dragSession = state.dragSession - ) - override val node: SystemColorPickerOverlayNode = SystemColorPickerOverlayNode( - popupEngine = popupEngine, - overlayPanel = overlayPanel - ) - private val transientNode: SystemColorPickerTransientOverlayNode = - SystemColorPickerTransientOverlayNode(popupEngine = popupEngine) - private var draggable: Boolean = true - private var viewportWidth: Int = 1 - private var viewportHeight: Int = 1 - - override fun sync(frame: SystemOverlayFrameContext) { - node.updateCursor(frame.cursorX, frame.cursorY) - state.active = popupEngine.isOpenFor(ownerToken) - if (!state.active) { - state.panelState.hide() - state.dragSession.end() - return - } - overlayPanel.configure( - title = popupEngine.debugTitle(ownerToken) ?: "Color Picker", - draggable = draggable, - style = popupEngine.debugStyle(ownerToken) - ?.let { toOverlayPanelStyle(it) } - ?: OverlayPanelStyle(), - onClose = ::close - ) - val panelRect = popupEngine.debugPanelRect(ownerToken) - if (panelRect != null) { - overlayPanel.syncPanelRect(panelRect) - } else { - state.panelState.show() - overlayPanel.syncPanelRect(state.panelState.currentRectOrNull()) - } - if (overlayPanel.handleMouseMove( - mouseX = frame.cursorX, - mouseY = frame.cursorY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - popupEngine.forcePanelRect(ownerToken, rect) - } - ) { - popupEngine.onCursorPosition(frame.cursorX, frame.cursorY) - } - } - - override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { - this.viewportWidth = viewportWidth - this.viewportHeight = viewportHeight - popupEngine.onFrame(viewportWidth, viewportHeight) - } - - override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { - if (!state.active) return false - popupEngine.onCursorPosition(mouseX, mouseY) - if (overlayPanel.handleMouseMove( - mouseX = mouseX, - mouseY = mouseY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - popupEngine.forcePanelRect(ownerToken, rect) - } - ) { - popupEngine.onCursorPosition(mouseX, mouseY) - return true - } - return popupEngine.handleMouseMove(mouseX, mouseY) - } - - override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (!state.active) return false - if (overlayPanel.handleMouseDown(mouseX, mouseY, button)) { - return true - } - return popupEngine.handleMouseDown(mouseX, mouseY, button) - } - - override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (!state.active) return false - if (overlayPanel.handleMouseUp( - mouseX = mouseX, - mouseY = mouseY, - button = button, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - popupEngine.forcePanelRect(ownerToken, rect) - } - ) { - return true - } - return popupEngine.handleMouseUp(mouseX, mouseY, button) - } - - override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { - if (!state.active) return false - return popupEngine.handleMouseWheel(mouseX, mouseY, delta) - } - - override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { - if (!state.active) return false - return popupEngine.handleKeyDown(keyCode, keyChar) - } - - override fun open( - anchorRect: Rect, - title: String, - state: ColorPickerState, - style: ColorPickerStyle, - width: Int, - draggable: Boolean, - closeOnOutsideClick: Boolean, - onPreview: ((RgbaColor) -> Unit)?, - onChange: ((RgbaColor) -> Unit)?, - onCommit: ((RgbaColor) -> Unit)?, - onClose: (() -> Unit)? - ) { - this.draggable = draggable - popupEngine.open( - ColorPickerPopupRequest( - owner = ownerToken, - ownerScope = OverlayOwnerScope.System, - anchorRect = anchorRect, - title = title, - state = state, - style = style, - width = width, - draggable = false, - closeOnOutsideClick = closeOnOutsideClick, - onPreview = onPreview, - onChange = onChange, - onCommit = onCommit, - onClose = onClose - ) - ) - } - - override fun close() { - popupEngine.close(ownerToken) - state.dragSession.end() - state.panelState.hide() - state.active = false - } - - override fun isOpen(): Boolean { - return popupEngine.isOpenFor(ownerToken) - } - - fun transientOverlayNode(): DOMNode { - return transientNode - } - - fun isTransientActive(): Boolean { - val controller = popupEngine.debugController(ownerToken) ?: return false - return controller.viewModeDropdownOpen() || controller.isEyedropperActive() - } - - fun debugHeaderRect(): Rect? { - return overlayPanel.headerRect() - } - - fun debugCloseRect(): Rect? { - return overlayPanel.closeRect() - } - - fun debugBodyLayout(): ColorPickerLayout? { - return popupEngine.debugBodyLayout(ownerToken) - } - - fun debugState(): ColorPickerState? { - return popupEngine.debugController(ownerToken)?.snapshot() - } - - fun captureEyedropperSample() { - popupEngine.captureEyedropperSample() - } - - fun debugOwnerScope(): OverlayOwnerScope? { - return popupEngine.debugOwnerScope(ownerToken) - } - } - - - private class ColorPickerTransientOverlayEntry( - private val panelEntry: ColorPickerOverlayEntry - ) : SystemOverlayEntry { - override val state: SystemOverlayEntryState = SystemOverlayEntryState( - id = SystemOverlayEntryId.ColorPickerTransient, - order = 210, - lane = SystemOverlayLane.Transient - ) - override val node: DOMNode = panelEntry.transientOverlayNode() - - override fun sync(frame: SystemOverlayFrameContext) { - state.active = panelEntry.state.active && panelEntry.isTransientActive() - } - } - - private class OverlayPanelDemoOverlayEntry : SystemOverlayEntry { - override val state: SystemOverlayEntryState = SystemOverlayEntryState( - id = SystemOverlayEntryId.PanelDemo, - order = 300, - lane = SystemOverlayLane.PanelContent - ) - private val overlayPanel: OverlayPanel = OverlayPanel( - ownerId = state.id, - panelState = state.panelState, - dragSession = state.dragSession - ) - private val demoNode: SystemOverlayPanelDemoNode = SystemOverlayPanelDemoNode(overlayPanel) - override val node: DOMNode = demoNode - private var opened: Boolean = false - private var viewportWidth: Int = 1 - private var viewportHeight: Int = 1 - private var buttonClicks: Int = 0 - - fun toggle(anchorX: Int, anchorY: Int, viewportWidth: Int, viewportHeight: Int) { - if (opened) { - close() - return - } - this.viewportWidth = viewportWidth.coerceAtLeast(1) - this.viewportHeight = viewportHeight.coerceAtLeast(1) - val width = 300 - val height = 190 - val maxX = (this.viewportWidth - width - 2).coerceAtLeast(2) - val maxY = (this.viewportHeight - height - 2).coerceAtLeast(2) - val x = anchorX.coerceIn(2, maxX) - val y = anchorY.coerceIn(2, maxY) - state.panelState.updateFromRect(Rect(x, y, width, height)) - opened = true - state.active = true - } - - fun close() { - opened = false - state.active = false - state.dragSession.end() - state.panelState.hide() - } - - fun isOpen(): Boolean = opened - - override fun sync(frame: SystemOverlayFrameContext) { - state.active = opened - if (!state.active) { - state.panelState.hide() - state.dragSession.end() - return - } - overlayPanel.configure( - title = "Overlay PanelF", - draggable = true, - style = OverlayPanelStyle(fontSize = 16), - onClose = ::close - ) - overlayPanel.syncPanelRect(state.panelState.currentRectOrNull()) - demoNode.setButtonClicks(buttonClicks) - overlayPanel.handleMouseMove( - mouseX = frame.cursorX, - mouseY = frame.cursorY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - state.panelState.updateFromRect(rect) - } - } - - override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { - this.viewportWidth = viewportWidth - this.viewportHeight = viewportHeight - } - - override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { - if (!state.active) return false - if (overlayPanel.handleMouseMove( - mouseX = mouseX, - mouseY = mouseY, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - state.panelState.updateFromRect(rect) - } - ) { - return true - } - val panelRect = state.panelState.currentRectOrNull() ?: return false - return panelRect.contains(mouseX, mouseY) - } - - override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (!state.active) return false - if (overlayPanel.handleMouseDown(mouseX, mouseY, button)) { - return true - } - val panelRect = state.panelState.currentRectOrNull() ?: return false - if (!panelRect.contains(mouseX, mouseY)) { - return false - } - val buttonRect = demoNode.buttonRect() - if (button == MouseButton.LEFT && buttonRect != null && buttonRect.contains(mouseX, mouseY)) { - buttonClicks += 1 - demoNode.setButtonClicks(buttonClicks) - return true - } - return true - } - - override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - if (!state.active) return false - if (overlayPanel.handleMouseUp( - mouseX = mouseX, - mouseY = mouseY, - button = button, - viewportWidth = viewportWidth, - viewportHeight = viewportHeight - ) { rect -> - state.panelState.updateFromRect(rect) - } - ) { - return true - } - val panelRect = state.panelState.currentRectOrNull() ?: return false - return panelRect.contains(mouseX, mouseY) - } - } - - private companion object { - private fun syncDragSession( - entryState: SystemOverlayEntryState, - dragging: Boolean, - dragType: OverlayPanelDragType, - pointerX: Int, - pointerY: Int - ) { - if (dragging) { - if (!entryState.dragSession.active) { - entryState.dragSession.begin( - ownerId = entryState.id, - type = dragType, - pointerX = pointerX, - pointerY = pointerY, - panelState = entryState.panelState - ) - } else { - entryState.dragSession.update(pointerX, pointerY) - } - return - } - entryState.dragSession.end() - } - - private fun toOverlayPanelStyle(style: ColorPickerStyle): OverlayPanelStyle { - return OverlayPanelStyle( - panelBackgroundColor = style.panelBackgroundColor, - panelBorderColor = style.panelBorderColor, - panelShadowColor = style.panelShadowColor, - headerBackgroundColor = style.buttonBackgroundColor, - headerBorderColor = style.inputBorderColor, - closeButtonBackgroundColor = style.buttonBackgroundColor, - closeButtonBorderColor = style.inputBorderColor, - textColor = style.textColor, - fontSize = style.fontSize - ) - } - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoNode.kt deleted file mode 100644 index bf44dd9..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoNode.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.Size -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanel -import org.dreamfinity.dsgl.core.render.RenderCommand - -internal class SystemOverlayPanelDemoNode( - private val overlayPanel: OverlayPanel, - key: Any? = "dsgl-system-panel-panel-demo" -) : DOMNode(key) { - override val styleType: String = "dsgl-system-panel-panel-demo" - - private val panelNode: DOMNode = overlayPanel.node().applyParent(this) - private val bodyNode: DemoBodyNode = DemoBodyNode().also(overlayPanel::setBodyContent) - - fun setButtonClicks(value: Int) { - bodyNode.setButtonClicks(value) - } - - fun currentButtonClicks(): Int = bodyNode.currentButtonClicks() - - fun buttonRect(): Rect? = bodyNode.buttonRect() - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - panelNode.render(ctx, x, y, width, height) - } - - private class DemoBodyNode( - key: Any? = "dsgl-system-panel-demo-body" - ) : DOMNode(key) { - override val styleType: String = "dsgl-system-panel-demo-body" - - private val commandBuffer: MutableList = ArrayList(64) - private var renderCommandsRevision: Long = 0L - private var buttonClicks: Int = 0 - private var actionButtonRect: Rect? = null - - fun setButtonClicks(value: Int) { - buttonClicks = value - } - - fun currentButtonClicks(): Int = buttonClicks - - fun buttonRect(): Rect? = actionButtonRect - - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } - - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { - bounds = Rect(x, y, width, height) - commandBuffer.clear() - actionButtonRect = null - commandBuffer += RenderCommand.DrawText( - text = "Reusable panel demo", - x = bounds.x + 6, - y = bounds.y + 4, - color = 0xFFFFFFFF.toInt(), - fontSize = 16 - ) - commandBuffer += RenderCommand.DrawText( - text = "Button clicks: $buttonClicks", - x = bounds.x + 6, - y = bounds.y + 22, - color = 0xFFB8C9DA.toInt(), - fontSize = 16 - ) - val buttonRect = Rect(bounds.x + 6, bounds.y + 44, 120, 24) - actionButtonRect = buttonRect - commandBuffer += RenderCommand.DrawRect( - buttonRect.x, - buttonRect.y, - buttonRect.width, - buttonRect.height, - 0xFF314154.toInt() - ) - drawBorder(commandBuffer, buttonRect, 0xFF6F879E.toInt()) - commandBuffer += RenderCommand.DrawText( - text = "Click me", - x = buttonRect.x + 8, - y = buttonRect.y + 4, - color = 0xFFFFFFFF.toInt(), - fontSize = 16 - ) - commandBuffer += RenderCommand.DrawImage( - resource = "minecraft:textures/gui/options_background.png", - x = bounds.x + bounds.width - 52, - y = bounds.y + 6, - width = 44, - height = 44 - ) - commandBuffer += RenderCommand.DrawText( - text = "Drag the title bar to move.", - x = bounds.x + 6, - y = bounds.y + 78, - color = 0xFFB8C9DA.toInt(), - fontSize = 16 - ) - if (SystemOverlayCommandDslRenderer.rebuildInto(this, commandBuffer, "system-panel-panel-demo-body")) { - renderCommandsRevision += 1L - markRenderCommandsDirty() - } - children.forEach { child -> - child.render(ctx, bounds.x, bounds.y, bounds.width, bounds.height) - } - } - - override fun volatileRenderCommandsSignature(nowMs: Long): Long { - return renderCommandsRevision - } - - private fun drawBorder(out: MutableList, rect: Rect, color: Int) { - if (rect.width <= 0 || rect.height <= 0) return - out += RenderCommand.DrawRect(rect.x, rect.y, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y + rect.height - 1, rect.width, 1, color) - out += RenderCommand.DrawRect(rect.x, rect.y, 1, rect.height, color) - out += RenderCommand.DrawRect(rect.x + rect.width - 1, rect.y, 1, rect.height, color) - } - } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModel.kt index 797c8e9..6ef2c67 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModel.kt @@ -4,7 +4,7 @@ import org.dreamfinity.dsgl.core.dom.layout.Rect import kotlin.math.abs class FloatingPaneDragModel( - private val moveThresholdPx: Int = 2 + private val moveThresholdPx: Int = 2, ) { private var startRect: Rect = Rect(0, 0, 0, 0) private var dragOffsetX: Int = 0 @@ -29,17 +29,20 @@ class FloatingPaneDragModel( mouseY: Int, viewportWidth: Int, viewportHeight: Int, - clamp: (Rect, Int, Int) -> Rect + clamp: (Rect, Int, Int) -> Rect, ): Rect { if (!dragging) return startRect - val target = Rect( - x = mouseX - dragOffsetX, - y = mouseY - dragOffsetY, - width = startRect.width, - height = startRect.height - ) + val target = + Rect( + x = mouseX - dragOffsetX, + y = mouseY - dragOffsetY, + width = startRect.width, + height = startRect.height, + ) val clamped = clamp(target, viewportWidth, viewportHeight) - if (!moved && (abs(clamped.x - startRect.x) >= moveThresholdPx || abs(clamped.y - startRect.y) >= moveThresholdPx)) { + if (!moved && + (abs(clamped.x - startRect.x) >= moveThresholdPx || abs(clamped.y - startRect.y) >= moveThresholdPx) + ) { moved = true } return clamped diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalController.kt new file mode 100644 index 0000000..1f577e5 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalController.kt @@ -0,0 +1,468 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ImageNode +import org.dreamfinity.dsgl.core.dom.elements.TextNode +import org.dreamfinity.dsgl.core.dom.elements.TextSource +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanel +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragSession +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelState +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelStyle + +internal class ApplicationFloatingWindowPortalController { + private val portalHost: PortalHost = PortalHost(ScreenDomainSurfaces.ApplicationPortal) + private val entry: ApplicationFloatingWindowPortalEntry = ApplicationFloatingWindowPortalEntry() + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + + init { + portalHost.register(entry) + } + + fun toggle(anchorX: Int, anchorY: Int) { + entry.toggle(anchorX, anchorY, viewportWidth, viewportHeight) + } + + val open: Boolean + get() = entry.isOpen() + + fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + portalHost.onInputFrame(PortalFrameContext(Rect(0, 0, this.viewportWidth, this.viewportHeight))) + } + + fun onFrameCursor( + viewportWidth: Int, + viewportHeight: Int, + mouseX: Int, + mouseY: Int, + ) { + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + entry.updateActiveDrag( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, + ) + } + + fun sync(rootNode: DOMNode, viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + entry.sync(this.viewportWidth, this.viewportHeight) + reconcileMountedNode(rootNode) + } + + fun close() { + entry.close() + } + + fun clearRefs() { + entry.close() + detachEntry() + } + + internal fun debugNode(): ApplicationFloatingWindowNode = entry.node + + internal fun debugState(): PortalEntryState = entry.state + + private fun reconcileMountedNode(rootNode: DOMNode) { + val activeNodes = portalHost.entriesInPaintOrder().mapNotNull { it.node } + if (entry.node !in activeNodes) { + detachEntry() + } + activeNodes.forEach { node -> + if (node.parent !== rootNode) { + node.parent + ?.children + ?.remove(node) + node.parent = rootNode + } + } + rootNode.children.removeAll(activeNodes.toSet()) + rootNode.children.addAll(activeNodes) + } + + private fun detachEntry() { + entry.node.parent + ?.children + ?.remove(entry.node) + entry.node.parent = null + } +} + +private class ApplicationFloatingWindowPortalEntry : PortalEntry { + private val panelState: FloatingPanelState = FloatingPanelState() + private val dragSession: FloatingPanelDragSession = FloatingPanelDragSession() + private val floatingPanel: FloatingPanel = + FloatingPanel( + ownerId = "application.f10-floating-window", + panelState = panelState, + dragSession = dragSession, + ) + override val node: ApplicationFloatingWindowNode = + ApplicationFloatingWindowNode( + floatingPanel = floatingPanel, + onPositionChanged = panelState::updateFromRect, + onCaptureCancelled = dragSession::end, + ) + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("application.f10-floating-window"), + ownerToken = this, + surface = ScreenDomainSurfaces.ApplicationPortal, + order = PortalEntryOrder(zIndex = 100), + dismissPolicy = PortalDismissPolicy.None, + inputPolicy = PortalInputPolicy.DomOnly, + focusPolicy = PortalFocusPolicy.Preserve, + insidePointerPolicy = PortalInsidePointerPolicy.ConsumePointerDown, + ) + private var opened: Boolean = false + + fun toggle( + anchorX: Int, + anchorY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + if (opened) { + close() + return + } + val width = PANEL_WIDTH + val height = PANEL_HEIGHT + val maxX = (viewportWidth - width - PANEL_MARGIN).coerceAtLeast(PANEL_MARGIN) + val maxY = (viewportHeight - height - PANEL_MARGIN).coerceAtLeast(PANEL_MARGIN) + val x = anchorX.coerceIn(PANEL_MARGIN, maxX) + val y = anchorY.coerceIn(PANEL_MARGIN, maxY) + panelState.updateFromRect(Rect(x, y, width, height)) + opened = true + activate(viewportWidth, viewportHeight) + } + + fun isOpen(): Boolean = opened + + fun sync(viewportWidth: Int, viewportHeight: Int) { + if (!opened) { + panelState.hide() + dragSession.end() + state.deactivate() + return + } + floatingPanel.configure( + title = "Application Portal", + draggable = true, + style = FloatingPanelStyle(fontSize = 16), + onClose = ::close, + ) + floatingPanel.syncPanelRect(panelState.currentRectOrNull()) + activate(viewportWidth, viewportHeight) + } + + fun updateActiveDrag( + mouseX: Int, + mouseY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + if (!opened) return + if (!floatingPanel.isDragging()) return + node.updateActiveDrag( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) + activate(viewportWidth, viewportHeight) + } + + override fun close() { + opened = false + panelState.hide() + dragSession.end() + state.deactivate() + } + + private fun activate(viewportWidth: Int, viewportHeight: Int) { + val panelRect = panelState.currentRectOrNull() ?: return + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + entryBounds = panelRect, + ), + ), + ) + } + + private companion object { + const val PANEL_WIDTH: Int = 300 + const val PANEL_HEIGHT: Int = 190 + const val PANEL_MARGIN: Int = 2 + } +} + +internal class ApplicationFloatingWindowNode( + private val floatingPanel: FloatingPanel, + private val onPositionChanged: (Rect) -> Unit, + private val onCaptureCancelled: () -> Unit, + key: Any? = "dsgl-application-f10-floating-window", +) : DOMNode(key) { + override val styleType: String = "dsgl-application-f10-floating-window" + + private val hitTargetNode: DOMNode = + FloatingWindowPanelHitNode( + floatingPanel = floatingPanel, + viewportBoundsProvider = { viewportBounds }, + onPositionChanged = onPositionChanged, + onCaptureCancelled = onCaptureCancelled, + onDragUpdated = ::invalidatePanelRenderCommands, + ).applyParent(this) + private val panelNode: DOMNode = floatingPanel.node().applyParent(this) + private val bodyNode: FloatingWindowBodyNode = + FloatingWindowBodyNode().also(floatingPanel::setBodyContent) + private var viewportBounds: Rect = Rect(0, 0, 1, 1) + + fun currentButtonClicks(): Int = bodyNode.currentButtonClicks() + + fun buttonRect(): Rect? = bodyNode.buttonRect() + + fun panelRect(): Rect? = floatingPanel.panelRect() + + fun bodyRect(): Rect? = floatingPanel.bodyRect() + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + viewportBounds = Rect(x, y, width.coerceAtLeast(1), height.coerceAtLeast(1)) + bounds = viewportBounds + val panelRect = floatingPanel.panelRect() + if (panelRect == null) { + hitTargetNode.render(ctx, 0, 0, 0, 0) + } else { + hitTargetNode.render(ctx, panelRect.x, panelRect.y, panelRect.width, panelRect.height) + } + panelNode.render(ctx, x, y, width, height) + } + + fun updateActiveDrag( + mouseX: Int, + mouseY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + if ( + floatingPanel.handleMouseMove( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportWidth.coerceAtLeast(1), + viewportHeight = viewportHeight.coerceAtLeast(1), + onDragRectChanged = onPositionChanged, + ) + ) { + invalidatePanelRenderCommands() + } + } + + private fun invalidatePanelRenderCommands() { + requestRenderCommandsInvalidation() + hitTargetNode.requestRenderCommandsInvalidation() + panelNode.requestRenderCommandsInvalidation() + } +} + +private class FloatingWindowPanelHitNode( + private val floatingPanel: FloatingPanel, + private val viewportBoundsProvider: () -> Rect, + private val onPositionChanged: (Rect) -> Unit, + private val onCaptureCancelled: () -> Unit, + private val onDragUpdated: () -> Unit, + key: Any? = "dsgl-application-f10-floating-window-hit-target", +) : DOMNode(key) { + override val styleType: String = "dsgl-application-f10-floating-window-hit-target" + + init { + onMouseMove = { it.cancelled = true } + onMouseDown = { it.cancelled = true } + onMouseUp = { it.cancelled = true } + onMouseClick = { it.cancelled = true } + onMouseWheel = { it.cancelled = true } + } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + } + + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean { + val header = floatingPanel.headerRect() ?: return false + val close = floatingPanel.closeRect() + return header.contains(mouseX, mouseY) && close?.contains(mouseX, mouseY) != true + } + + override fun beginPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT) return + floatingPanel.beginHeaderDrag(mouseX, mouseY) + } + + override fun continuePointerCapture( + mouseX: Int, + mouseY: Int, + mouseDX: Int, + mouseDY: Int, + button: MouseButton, + ) { + if (button != MouseButton.LEFT) return + updateActiveDrag(mouseX, mouseY) + } + + override fun endPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (button != MouseButton.LEFT) return + val viewport = viewportBoundsProvider() + if ( + floatingPanel.handleMouseUp( + mouseX = mouseX, + mouseY = mouseY, + button = button, + viewportWidth = viewport.width.coerceAtLeast(1), + viewportHeight = viewport.height.coerceAtLeast(1), + onDragRectChanged = onPositionChanged, + ) + ) { + onDragUpdated() + } + } + + override fun cancelPointerCapture() { + onCaptureCancelled() + } + + private fun updateActiveDrag(mouseX: Int, mouseY: Int) { + val viewport = viewportBoundsProvider() + if ( + floatingPanel.handleMouseMove( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewport.width.coerceAtLeast(1), + viewportHeight = viewport.height.coerceAtLeast(1), + onDragRectChanged = onPositionChanged, + ) + ) { + onDragUpdated() + } + } +} + +private class FloatingWindowBodyNode( + key: Any? = "dsgl-application-f10-floating-window-body", +) : DOMNode(key) { + override val styleType: String = "dsgl-application-f10-floating-window-body" + + private val titleNode: TextNode = + TextNode(TextSource.Static("Reusable panel demo"), key = "application-f10-title").applyParent(this) + private val counterNode: TextNode = + TextNode(TextSource.Static("Button clicks: 0"), key = "application-f10-counter").applyParent(this) + private val actionButton: ButtonNode = + ButtonNode("Click me", key = "application-f10-button") + .apply { + onClick { + buttonClicks += 1 + syncCounter() + it.cancelled = true + } + }.applyParent(this) + private val imageNode: ImageNode = + ImageNode( + url = "minecraft:textures/gui/options_background.png", + imageWidth = IMAGE_SIZE, + imageHeight = IMAGE_SIZE, + key = "application-f10-image", + ).applyParent(this) + private val hintNode: TextNode = + TextNode(TextSource.Static("Drag the title bar to move."), key = "application-f10-hint").applyParent(this) + private var buttonClicks: Int = 0 + + fun currentButtonClicks(): Int = buttonClicks + + fun buttonRect(): Rect? = actionButton.bounds.takeIf { it.width > 0 && it.height > 0 } + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + titleNode.render( + ctx, + bounds.x + CONTENT_PADDING, + bounds.y + TITLE_TOP, + bounds.width - IMAGE_COLUMN_WIDTH, + TEXT_HEIGHT, + ) + counterNode.render( + ctx, + bounds.x + CONTENT_PADDING, + bounds.y + COUNTER_TOP, + bounds.width - IMAGE_COLUMN_WIDTH, + TEXT_HEIGHT, + ) + actionButton.render(ctx, bounds.x + CONTENT_PADDING, bounds.y + BUTTON_TOP, BUTTON_WIDTH, BUTTON_HEIGHT) + imageNode.render(ctx, bounds.x + bounds.width - IMAGE_OFFSET, bounds.y + IMAGE_TOP, IMAGE_SIZE, IMAGE_SIZE) + hintNode.render( + ctx, + bounds.x + CONTENT_PADDING, + bounds.y + HINT_TOP, + bounds.width - CONTENT_PADDING * 2, + TEXT_HEIGHT, + ) + } + + private fun syncCounter() { + counterNode.setText("Button clicks: $buttonClicks") + } + + private companion object { + const val CONTENT_PADDING: Int = 6 + const val TITLE_TOP: Int = 4 + const val COUNTER_TOP: Int = 22 + const val TEXT_HEIGHT: Int = 18 + const val BUTTON_TOP: Int = 44 + const val BUTTON_WIDTH: Int = 120 + const val BUTTON_HEIGHT: Int = 24 + const val IMAGE_SIZE: Int = 44 + const val IMAGE_TOP: Int = 6 + const val IMAGE_OFFSET: Int = 52 + const val IMAGE_COLUMN_WIDTH: Int = 64 + const val HINT_TOP: Int = 78 + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalHost.kt new file mode 100644 index 0000000..c3fa05c --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalHost.kt @@ -0,0 +1,526 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPortalController +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalController +import org.dreamfinity.dsgl.core.contextmenu.ContextMenuEngine +import org.dreamfinity.dsgl.core.dnd.DndEngine +import org.dreamfinity.dsgl.core.dnd.DndRuntime +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.select.SelectEngine +import org.dreamfinity.dsgl.core.select.SelectPortalController +import org.dreamfinity.dsgl.core.style.StyleApplicationScope + +@Suppress("TooManyFunctions") +class ApplicationPortalHost( + contextMenuEngine: ContextMenuEngine = DomainPortalServices.applicationContextMenuEngine, + selectEngine: SelectEngine = DomainPortalServices.applicationSelectEngine, + colorPickerEngine: ColorPickerPopupEngine = DomainPortalServices.applicationColorPickerEngine, + dndEngine: DndEngine = DndRuntime.engine, +) : DomainSurfaceHost { + override val surface: ScreenDomainSurface = ScreenDomainSurfaces.ApplicationPortal + + internal val rootNode: ApplicationPortalRootNode = ApplicationPortalRootNode() + private val tree: DomTree = + DomTree( + root = rootNode, + styleScope = StyleApplicationScope.Application, + ) + internal val domInputRouter: SurfaceDomInputRouter = + SurfaceDomInputRouter( + rootProvider = { rootNode }, + ) + internal val contextMenuPortal: ContextMenuPortalController = + ContextMenuPortalController(contextMenuEngine) + internal val applicationSelectPortal: SelectPortalController = + SelectPortalController( + engine = selectEngine, + ownerDomain = ScreenDomainId.Application, + entryId = "application.select", + ) + internal val applicationColorPickerPortal: ColorPickerPortalController = + ColorPickerPortalController(colorPickerEngine) + internal val modalPortal: ModalPortalController = ModalPortalController() + internal val floatingWindowPortal: ApplicationFloatingWindowPortalController = + ApplicationFloatingWindowPortalController() + internal val dndGhostPortal: ApplicationDndGhostPortalController = + ApplicationDndGhostPortalController(dndEngine) + private var modalPortalWasActive: Boolean = false + + override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + rootNode.setViewportBounds( + width = viewportWidth.coerceAtLeast(1), + height = viewportHeight.coerceAtLeast(1), + ) + floatingWindowPortal.onInputFrame(viewportWidth, viewportHeight) + } + + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + rootNode.setViewportBounds(width, height) + modalPortal.sync(rootNode, width, height) + closeStaleFloatingPortalsAfterModalOpen() + floatingWindowPortal.sync(rootNode, width, height) + tree.render(ctx, width, height) + modalPortal.commitActivePortals() + } + + override fun paint(ctx: UiMeasureContext): List = tree.paint(ctx, applyStyles = true) + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + if (modalPortal.hasActivePortal()) { + domInputRouter.clear() + modalPortal.handleMouseMove(mouseX, mouseY) + } else { + domInputRouter.handleMouseMove(mouseX, mouseY) + } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + if (modalPortal.hasActivePortal()) { + domInputRouter.clear() + return modalPortal.handleMouseDown(mouseX, mouseY, button) + } + val isConsumedByDOM = domInputRouter.handleMouseDown(mouseX, mouseY, button) + val isConsumedByPolicy = modalPortal.handlePointerPolicy(mouseX, mouseY, button, pressed = true) + return isConsumedByDOM || isConsumedByPolicy + } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + if (modalPortal.hasActivePortal()) { + domInputRouter.clear() + return modalPortal.handleMouseUp(mouseX, mouseY, button) + } + val isConsumedByDOM = domInputRouter.handleMouseUp(mouseX, mouseY, button) + val isConsumedByPolicy = modalPortal.handlePointerPolicy(mouseX, mouseY, button, pressed = false) + return isConsumedByDOM || isConsumedByPolicy + } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + if (modalPortal.hasActivePortal()) { + modalPortal.handleMouseWheel(mouseX, mouseY, delta) + } else { + domInputRouter.handleMouseWheel(mouseX, mouseY, delta) + } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = domInputRouter.handleKeyDown(keyCode, keyChar) + + override fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = domInputRouter.handleKeyUp(keyCode, keyChar) + + override fun clearRefs() { + tree.clearRefs() + domInputRouter.clear() + contextMenuPortal.close() + applicationSelectPortal.close() + applicationColorPickerPortal.close() + modalPortal.close() + floatingWindowPortal.clearRefs() + dndGhostPortal.clearRefs() + modalPortalWasActive = false + } + + private fun closeStaleFloatingPortalsAfterModalOpen() { + val modalActive = modalPortal.hasActivePortal() + if (modalActive && !modalPortalWasActive) { + closeFloatingPortals() + } + modalPortalWasActive = modalActive + } +} + +internal fun ApplicationPortalHost.debugRootBounds(): Rect = rootNode.bounds + +fun ApplicationPortalHost.syncPortalFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + mouseX: Int, + mouseY: Int, +) { + contextMenuPortal.onFrame(measureContext, viewportWidth, viewportHeight, viewportScale) + applicationSelectPortal.onFrame(measureContext, viewportWidth, viewportHeight, viewportScale) + applicationColorPickerPortal.onFrame(viewportWidth, viewportHeight, mouseX, mouseY) + floatingWindowPortal.onFrameCursor(viewportWidth, viewportHeight, mouseX, mouseY) +} + +fun ApplicationPortalHost.appendFloatingPortalCommands( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, +) { + applicationSelectPortal.appendCommands(measureContext, viewportWidth, viewportHeight, out) + contextMenuPortal.appendCommands(measureContext, viewportWidth, viewportHeight, out) + applicationColorPickerPortal.appendCommands(measureContext, viewportWidth, viewportHeight, out) +} + +fun ApplicationPortalHost.appendDndGhostPortalCommands( + root: DOMNode, + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, +) { + dndGhostPortal.appendCommands(root, measureContext, viewportWidth, viewportHeight, out) +} + +fun ApplicationPortalHost.closeFloatingPortals() { + contextMenuPortal.close() + applicationSelectPortal.close() + applicationColorPickerPortal.close() + floatingWindowPortal.close() +} + +fun ApplicationPortalHost.hasOpenContextMenuPortal(): Boolean = contextMenuPortal.isOpen() + +fun ApplicationPortalHost.hasOpenSelectPortal(): Boolean = applicationSelectPortal.isOpen() + +fun ApplicationPortalHost.hasOpenColorPickerPortal(): Boolean = applicationColorPickerPortal.isOpen + +fun ApplicationPortalHost.hasActiveModalPortal(): Boolean = modalPortal.hasActivePortal() + +fun ApplicationPortalHost.hasActiveColorPickerEyedropper(): Boolean = applicationColorPickerPortal.hasActiveEyedropper + +fun ApplicationPortalHost.captureColorPickerEyedropperSample() { + applicationColorPickerPortal.captureEyedropperSample() +} + +fun ApplicationPortalHost.toggleFloatingWindowDemo(anchorX: Int, anchorY: Int) { + if (hasActiveModalPortal()) return + floatingWindowPortal.toggle(anchorX, anchorY) +} + +fun ApplicationPortalHost.isFloatingWindowDemoOpen(): Boolean = floatingWindowPortal.open + +internal fun ApplicationPortalHost.debugDndGhostPortalState(): PortalEntryState = dndGhostPortal.debugState() + +fun ApplicationPortalHost.hasDomPointerTargetAt(mouseX: Int, mouseY: Int): Boolean = + domInputRouter.hasPointerTargetAt(mouseX, mouseY) + +fun ApplicationPortalHost.handlePortalKeyDownBeforeDom(keyCode: Int, keyChar: Char): Boolean = + applicationColorPickerPortal.handleKeyDown(keyCode, keyChar) || + modalPortal.handleKeyDown(keyCode, keyChar) + +fun ApplicationPortalHost.handlePortalKeyDownAfterDom(keyCode: Int, keyChar: Char): Boolean = + applicationSelectPortal.handleKeyDown(keyCode, keyChar) || + contextMenuPortal.handleKeyDown(keyCode) + +fun ApplicationPortalHost.handlePortalKeyUpBeforeDom(keyCode: Int, keyChar: Char): Boolean = + applicationColorPickerPortal.handleKeyUp(keyCode, keyChar) + +fun ApplicationPortalHost.handlePortalKeyUpAfterDom(keyCode: Int, keyChar: Char): Boolean = + applicationSelectPortal.handleKeyUp(keyCode, keyChar) || + contextMenuPortal.handleKeyUp(keyCode, keyChar) + +fun ApplicationPortalHost.handlePortalPointerBeforeDom( + mouseX: Int, + mouseY: Int, + dWheel: Int, + button: MouseButton?, + pressed: Boolean, +): Boolean = handlePortalPointer(applicationColorPickerPortal, mouseX, mouseY, dWheel, button, pressed) + +fun ApplicationPortalHost.handlePortalPointerAfterDom( + mouseX: Int, + mouseY: Int, + dWheel: Int, + button: MouseButton?, + pressed: Boolean, +): Boolean = + handlePortalPointer(contextMenuPortal, mouseX, mouseY, dWheel, button, pressed) || + handlePortalPointer(applicationSelectPortal, mouseX, mouseY, dWheel, button, pressed) + +private fun handlePortalPointer( + portal: PortalPointerDispatch, + mouseX: Int, + mouseY: Int, + dWheel: Int, + button: MouseButton?, + pressed: Boolean, +): Boolean { + if (dWheel != 0 && portal.handleMouseWheel(mouseX, mouseY, dWheel)) return true + if (button != null) { + return if (pressed) { + portal.handleMouseDown(mouseX, mouseY, button) + } else { + portal.handleMouseUp(mouseX, mouseY, button) + } + } + return portal.handleMouseMove(mouseX, mouseY) +} + +internal interface PortalPointerDispatch { + fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean + + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + + fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean + + fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean +} + +internal class ApplicationDndGhostPortalController( + private val engine: DndEngine, +) { + private val portalHost: PortalHost = PortalHost(ScreenDomainSurfaces.ApplicationPortal) + private val entry: ApplicationDndGhostPortalEntry = ApplicationDndGhostPortalEntry(engine) + + init { + portalHost.register(entry) + } + + fun appendCommands( + root: DOMNode, + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, + ) { + entry.updatePaintContext( + root = root, + measureContext = measureContext, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) + out += portalHost.paint(measureContext) + } + + fun clearRefs() { + entry.clearRefs() + } + + internal fun debugState(): PortalEntryState = entry.state +} + +private class ApplicationDndGhostPortalEntry( + private val engine: DndEngine, +) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("application.dnd-ghost"), + ownerToken = engine, + surface = ScreenDomainSurfaces.ApplicationPortal, + order = PortalEntryOrder(zIndex = 80), + dismissPolicy = PortalDismissPolicy.None, + inputPolicy = PortalInputPolicy.None, + focusPolicy = PortalFocusPolicy.Preserve, + ) + override val node: DOMNode? = null + private var root: DOMNode? = null + private var measureContext: UiMeasureContext? = null + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + + fun updatePaintContext( + root: DOMNode, + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + ) { + this.root = root + this.measureContext = measureContext + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + syncActivePlacement() + } + + override fun paint(ctx: UiMeasureContext): List { + val activeRoot = root + if (activeRoot == null || !engine.isDragging) { + state.deactivate() + return emptyList() + } + syncActivePlacement() + val commands = ArrayList() + engine.appendPlaceholderCommands(commands) + engine.appendPortalCommands( + root = activeRoot, + ctx = measureContext ?: ctx, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + out = commands, + ) + return commands + } + + override fun clearRefs() { + root = null + measureContext = null + state.deactivate() + } + + private fun syncActivePlacement() { + if (!engine.isDragging) { + state.deactivate() + return + } + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth, viewportHeight), + entryBounds = Rect(0, 0, viewportWidth, viewportHeight), + ), + ), + ) + } +} + +internal class ContextMenuPortalController( + private val engine: ContextMenuEngine, +) : PortalPointerDispatch { + private val portalHost: PortalHost = + PortalHost(ScreenDomainSurfaces.ApplicationPortal) + private val entry: ContextMenuPortalEntry = ContextMenuPortalEntry(engine) + + init { + portalHost.register(entry) + } + + fun onFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + ) { + entry.onFrame(measureContext, viewportWidth, viewportHeight, viewportScale) + } + + fun appendCommands( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, + ) { + entry.updatePaintContext(measureContext, viewportWidth, viewportHeight) + out += portalHost.paint(measureContext) + } + + fun close() { + entry.close() + } + + fun isOpen(): Boolean = engine.isOpen() + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + portalHost.dispatchInput { it.handleMouseMove(mouseX, mouseY) } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseDown(mouseX, mouseY, button) } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseUp(mouseX, mouseY, button) } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + portalHost.dispatchInput { it.handleMouseWheel(mouseX, mouseY, delta) } + + fun handleKeyDown(keyCode: Int): Boolean = + portalHost.dispatchInput { + it.handleKeyDown(keyCode, Char.MIN_VALUE) + } + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { + it.handleKeyUp(keyCode, keyChar) + } +} + +private class ContextMenuPortalEntry( + private val engine: ContextMenuEngine, +) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("application.context-menu"), + ownerToken = engine, + surface = ScreenDomainSurfaces.ApplicationPortal, + order = PortalEntryOrder(zIndex = 0), + dismissPolicy = PortalDismissPolicy.EscapeOrOutsidePointerDown, + inputPolicy = PortalInputPolicy.ManualOnly, + focusPolicy = PortalFocusPolicy.Preserve, + ) + override val node: DOMNode? = null + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + private var measureContext: UiMeasureContext? = null + + fun onFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + ) { + updatePaintContext(measureContext, viewportWidth, viewportHeight) + engine.onFrame( + measureContext = measureContext, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, + viewportScale = viewportScale, + ) + syncActivePlacement() + } + + fun updatePaintContext(measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int) { + this.measureContext = measureContext + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + } + + override fun paint(ctx: UiMeasureContext): List { + if (!engine.isOpen()) { + state.deactivate() + return emptyList() + } + val commands = ArrayList() + engine.appendPortalCommands( + measureContext = measureContext ?: ctx, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + out = commands, + ) + syncActivePlacement() + return commands + } + + override fun close() { + engine.closeAll() + state.deactivate() + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = engine.handleMouseMove(mouseX, mouseY) + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + engine.handleMouseDown(mouseX, mouseY, button).also { syncActivePlacement() } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + engine.handleMouseUp(mouseX, mouseY, button).also { syncActivePlacement() } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + engine.handleMouseWheel(mouseX, mouseY, delta).also { syncActivePlacement() } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + engine.handleKeyDown(keyCode).also { syncActivePlacement() } + + private fun syncActivePlacement() { + if (!engine.isOpen()) { + state.deactivate() + return + } + val panelRect = engine.debugPanelRect(0) ?: return + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + entryBounds = panelRect, + ), + ), + ) + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayRootNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalRootNode.kt similarity index 63% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayRootNode.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalRootNode.kt index c071746..cc131b3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/ApplicationOverlayRootNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalRootNode.kt @@ -1,35 +1,37 @@ -package org.dreamfinity.dsgl.core.overlay +package org.dreamfinity.dsgl.core.portal import org.dreamfinity.dsgl.core.DsglColors -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.debug.OverlayLayerDebugState.isTintEnabled +import org.dreamfinity.dsgl.core.debug.DomainSurfaceDebugState.isTintEnabled import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Border import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.UiScope import org.dreamfinity.dsgl.core.dsl.div import org.dreamfinity.dsgl.core.font.FontRegistry import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.StyleEngine -class ApplicationOverlayRootNode( - key: Any? = "dsgl-application-overlay-root" +class ApplicationPortalRootNode( + key: Any? = "dsgl-application-portal-root", ) : DOMNode(key) { - override val styleType: String = "dsgl-application-overlay-root" + override val styleType: String = "dsgl-application-portal-root" private var viewportWidth: Int = 0 private var viewportHeight: Int = 0 - private val debugTintNode: ContainerNode = UiScope(this).div({ - this.key = "dsgl-application-overlay-debug-tint" - style = { - display = Display.None - } - }) + private val debugTintNode: ContainerNode = + UiScope(this).div({ + this.key = "dsgl-application-portal-debug-tint" + style = { + display = Display.None + } + }) internal fun setViewportBounds(width: Int, height: Int) { viewportWidth = width.coerceAtLeast(0) viewportHeight = height.coerceAtLeast(0) + bounds = Rect(0, 0, viewportWidth, viewportHeight) } override fun measure(ctx: UiMeasureContext): Size { @@ -37,18 +39,25 @@ class ApplicationOverlayRootNode( val resolvedHeight = if (viewportHeight > 0) viewportHeight else StyleEngine.viewportHeightPx().coerceAtLeast(0) return Size( width = resolvedWidth, - height = resolvedHeight + height = resolvedHeight, ) } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { setViewportBounds(width, height) bounds = Rect(0, 0, viewportWidth, viewportHeight) - val tintEnabled = OverlayDebugVisualization.enabled && isTintEnabled(UiLayerId.ApplicationOverlay) + val tintEnabled = + DomainSurfaceDebugVisualization.enabled && isTintEnabled(ScreenDomainSurfaces.ApplicationPortal) if (tintEnabled) { debugTintNode.display = Display.Block - debugTintNode.backgroundColor = OverlayDebugVisualization.applicationOverlayFillColor - debugTintNode.border = Border.all(1, OverlayDebugVisualization.applicationOverlayBorderColor) + debugTintNode.backgroundColor = DomainSurfaceDebugVisualization.applicationPortalFillColor + debugTintNode.border = Border.all(1, DomainSurfaceDebugVisualization.applicationPortalBorderColor) debugTintNode.render(ctx, bounds.x, bounds.y, bounds.width, bounds.height) } else { debugTintNode.display = Display.None @@ -56,6 +65,7 @@ class ApplicationOverlayRootNode( } children.forEach { child -> if (child === debugTintNode) return@forEach + child.resolveLayoutStyleValues(ctx, bounds.width, bounds.height) child.render(ctx, bounds.x, bounds.y, bounds.width, bounds.height) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ColorPickerPopupPortalOwnership.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ColorPickerPopupPortalOwnership.kt new file mode 100644 index 0000000..943b025 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ColorPickerPopupPortalOwnership.kt @@ -0,0 +1,8 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest + +object ColorPickerPopupPortalOwnership { + fun resolveSurface(request: ColorPickerPopupRequest): ScreenDomainSurface = + ScreenDomainSurfaces.portalSurfaceForDomain(request.ownerDomain) +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainPortalServices.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainPortalServices.kt new file mode 100644 index 0000000..e3f9ab2 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainPortalServices.kt @@ -0,0 +1,45 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine +import org.dreamfinity.dsgl.core.contextmenu.ContextMenuEngine +import org.dreamfinity.dsgl.core.select.SelectEngine +import org.dreamfinity.dsgl.core.select.SelectOpenRequest + +object DomainPortalServices { + val applicationContextMenuEngine: ContextMenuEngine = ContextMenuEngine() + val applicationSelectEngine: SelectEngine = SelectEngine() + val systemSelectEngine: SelectEngine = SelectEngine() + val applicationColorPickerEngine: ColorPickerPopupEngine = ColorPickerPopupEngine() + + fun selectEngineFor(ownerDomain: ScreenDomainId): SelectEngine = + when (ownerDomain) { + ScreenDomainId.Application -> applicationSelectEngine + ScreenDomainId.System -> systemSelectEngine + ScreenDomainId.Debug -> error("Debug domain select portals are not supported yet") + } + + fun openSelect(request: SelectOpenRequest) { + val target = selectEngineFor(request.ownerDomain) + val other = if (target === applicationSelectEngine) systemSelectEngine else applicationSelectEngine + other.close(request.owner) + target.open(request) + } + + fun closeSelect(owner: Any) { + applicationSelectEngine.close(owner) + systemSelectEngine.close(owner) + } + + fun closeAllSelects() { + applicationSelectEngine.closeAll() + systemSelectEngine.closeAll() + } + + fun isSelectClosingFor(owner: Any): Boolean = + applicationSelectEngine.isClosingFor(owner) || systemSelectEngine.isClosingFor(owner) + + fun isSelectOpenFor(owner: Any): Boolean = + applicationSelectEngine.isOpenFor(owner) || systemSelectEngine.isOpenFor(owner) + + fun isAnySelectOpen(): Boolean = applicationSelectEngine.isOpen() || systemSelectEngine.isOpen() +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualization.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualization.kt new file mode 100644 index 0000000..12fecbf --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualization.kt @@ -0,0 +1,19 @@ +package org.dreamfinity.dsgl.core.portal + +object DomainSurfaceDebugVisualization { + var applicationPortalFillColor: Int = 0x22307BC8 + var applicationPortalBorderColor: Int = 0xAA4DA4FF.toInt() + var systemPortalFillColor: Int = 0x22A84BD8 + var systemPortalBorderColor: Int = 0xAAE18BFF.toInt() + val enabled: Boolean + get() { + return testOverride ?: java.lang.Boolean + .getBoolean("dsgl.domain.debug") + } + + private var testOverride: Boolean? = null + + internal fun setTestOverride(value: Boolean?) { + testOverride = value + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceHost.kt similarity index 74% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerHost.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceHost.kt index 6a11dfa..47d3ca5 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerHost.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceHost.kt @@ -1,11 +1,13 @@ -package org.dreamfinity.dsgl.core.overlay +package org.dreamfinity.dsgl.core.portal import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.render.RenderCommand -interface OverlayLayerHost { - val layerId: UiLayerId +interface DomainSurfaceHost { + val surface: ScreenDomainSurface + + fun onInputFrame(viewportWidth: Int, viewportHeight: Int) {} fun render(ctx: UiMeasureContext, width: Int, height: Int) @@ -22,4 +24,6 @@ interface OverlayLayerHost { fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = false fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = false } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContracts.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContracts.kt new file mode 100644 index 0000000..f6c2ba2 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContracts.kt @@ -0,0 +1,280 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.render.RenderCommand + +internal data class PortalEntryId( + val value: String, +) { + init { + require(value.isNotBlank()) { "Portal entry id must not be blank." } + } +} + +internal data class PortalEntryOrder( + val zIndex: Int, + val sequence: Int = 0, +) : Comparable { + override fun compareTo(other: PortalEntryOrder): Int = + compareValuesBy(this, other, PortalEntryOrder::zIndex, PortalEntryOrder::sequence) +} + +internal data class PortalEntryBounds( + val viewportBounds: Rect, + val entryBounds: Rect, +) { + init { + require(viewportBounds.width > 0 && viewportBounds.height > 0) { + "Portal viewport bounds must be explicit and non-empty." + } + require(entryBounds.width > 0 && entryBounds.height > 0) { + "Portal entry bounds must be explicit and non-empty." + } + } +} + +internal data class PortalEntryPlacement( + val anchorBounds: Rect?, + val bounds: PortalEntryBounds, +) + +internal enum class PortalDismissPolicy { + None, + OutsidePointerDown, + EscapeOrOutsidePointerDown, +} + +internal enum class PortalBackdropPolicy { + None, + ConsumeOutsidePointerDown, +} + +internal enum class PortalInsidePointerPolicy { + PassThrough, + ConsumePointerDown, +} + +internal enum class PortalPointerContainmentPolicy { + DomOrEntryBounds, + ProtectedBoundsOnly, +} + +internal enum class PortalInputPolicy { + None, + DomOnly, + ManualOnly, + ManualThenDomFallback, +} + +internal enum class PortalFocusPolicy { + Preserve, + RequestFocus, + TrapFocus, +} + +internal enum class PortalLifecyclePolicy { + Manual, + CloseOnUnmount, +} + +internal enum class PortalPointerRegion { + InsideEntry, + OutsideEntry, +} + +internal data class PortalPointerPolicyResult( + val entry: PortalEntry, + val region: PortalPointerRegion, + val shouldClose: Boolean, + val consumed: Boolean, +) + +internal data class PortalEntryState( + val id: PortalEntryId, + val ownerToken: Any, + val surface: ScreenDomainSurface, + val order: PortalEntryOrder, + var dismissPolicy: PortalDismissPolicy = PortalDismissPolicy.None, + val inputPolicy: PortalInputPolicy = PortalInputPolicy.DomOnly, + val focusPolicy: PortalFocusPolicy = PortalFocusPolicy.Preserve, + var backdropPolicy: PortalBackdropPolicy = PortalBackdropPolicy.None, + val insidePointerPolicy: PortalInsidePointerPolicy = PortalInsidePointerPolicy.PassThrough, + val pointerContainmentPolicy: PortalPointerContainmentPolicy = PortalPointerContainmentPolicy.DomOrEntryBounds, + val lifecyclePolicy: PortalLifecyclePolicy = PortalLifecyclePolicy.CloseOnUnmount, +) { + var active: Boolean = false + internal set + var placement: PortalEntryPlacement? = null + internal set + var dismissAction: (() -> Unit)? = null + private var protectedBounds: List = emptyList() + + fun activate(placement: PortalEntryPlacement) { + this.placement = placement + active = true + } + + fun deactivate() { + active = false + placement = null + protectedBounds = emptyList() + } + + fun updateProtectedBounds(bounds: List) { + protectedBounds = bounds.filter { it.width > 0 && it.height > 0 } + } + + fun containsPointer(mouseX: Int, mouseY: Int, node: DOMNode?): Boolean { + if (pointerContainmentPolicy == PortalPointerContainmentPolicy.ProtectedBoundsOnly) { + return protectedBounds.any { it.contains(mouseX, mouseY) } + } + if (node?.containsGlobalPoint(mouseX, mouseY) == true) return true + val activePlacement = placement ?: return false + if (activePlacement.bounds.entryBounds + .contains(mouseX, mouseY) + ) { + return true + } + return protectedBounds.any { it.contains(mouseX, mouseY) } + } + + fun dismiss(entry: PortalEntry) { + dismissAction?.invoke() ?: entry.close() + } +} + +internal data class PortalFrameContext( + val viewportBounds: Rect, +) { + init { + require(viewportBounds.width > 0 && viewportBounds.height > 0) { + "Portal frame viewport bounds must be explicit and non-empty." + } + } +} + +@Suppress("TooManyFunctions") +internal interface PortalEntry { + val state: PortalEntryState + val node: DOMNode? + + fun onInputFrame(context: PortalFrameContext) = Unit + + fun render(ctx: UiMeasureContext, width: Int, height: Int) = Unit + + fun paint(ctx: UiMeasureContext): List = emptyList() + + fun clearRefs() = Unit + + fun close() { + state.deactivate() + } + + fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = false + + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = false + + fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = false +} + +internal class PortalHost( + val surface: ScreenDomainSurface, +) { + private val entriesById: LinkedHashMap = LinkedHashMap() + + fun register(entry: PortalEntry) { + require(entry.state.surface == surface) { + "Portal entry ${entry.state.id.value} belongs to ${entry.state.surface}, not $surface." + } + require(entriesById.putIfAbsent(entry.state.id, entry) == null) { + "Portal entry ${entry.state.id.value} is already registered." + } + } + + fun unregister(id: PortalEntryId): Boolean { + val entry = entriesById.remove(id) ?: return false + entry.clearRefs() + entry.close() + return true + } + + fun entriesInPaintOrder(): List = + entriesById.values + .filter { it.state.active } + .sortedBy { it.state.order } + + fun entriesInInputOrder(): List = entriesInPaintOrder().asReversed() + + fun onInputFrame(context: PortalFrameContext) { + entriesById.values.forEach { it.onInputFrame(context) } + } + + fun render(ctx: UiMeasureContext, width: Int, height: Int) { + entriesInPaintOrder().forEach { it.render(ctx, width, height) } + } + + fun paint(ctx: UiMeasureContext): List = entriesInPaintOrder().flatMap { it.paint(ctx) } + + fun clearRefs() { + entriesById.values.forEach { entry -> + entry.clearRefs() + if (entry.state.lifecyclePolicy == PortalLifecyclePolicy.CloseOnUnmount) { + entry.close() + } else { + entry.state.deactivate() + } + } + entriesById.clear() + } + + fun dispatchInput(handler: (PortalEntry) -> Boolean): Boolean = entriesInInputOrder().any(handler) +} + +internal fun PortalHost.handleOutsidePointerDownPolicy(mouseX: Int, mouseY: Int): Boolean { + val result = evaluateOutsidePointerDown(mouseX, mouseY) ?: return false + if (result.shouldClose) { + result.entry.state + .dismiss(result.entry) + } + return result.consumed +} + +internal fun PortalHost.evaluateOutsidePointerDown(mouseX: Int, mouseY: Int): PortalPointerPolicyResult? = + entriesInInputOrder().firstNotNullOfOrNull { entry -> + if (entry.state.containsPointer(mouseX, mouseY, entry.node)) { + return@firstNotNullOfOrNull PortalPointerPolicyResult( + entry = entry, + region = PortalPointerRegion.InsideEntry, + shouldClose = false, + consumed = entry.state.insidePointerPolicy == PortalInsidePointerPolicy.ConsumePointerDown, + ) + } + val shouldClose = + entry.state.dismissPolicy == PortalDismissPolicy.OutsidePointerDown || + entry.state.dismissPolicy == PortalDismissPolicy.EscapeOrOutsidePointerDown + val consumed = shouldClose || entry.state.backdropPolicy == PortalBackdropPolicy.ConsumeOutsidePointerDown + if (!consumed) { + return@firstNotNullOfOrNull null + } + PortalPointerPolicyResult( + entry = entry, + region = PortalPointerRegion.OutsideEntry, + shouldClose = shouldClose, + consumed = true, + ) + } + +internal data class DomainSurfacePortalHostAdapter( + val surfaceHost: DomainSurfaceHost, +) { + val surface: ScreenDomainSurface = surfaceHost.surface +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContracts.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContracts.kt new file mode 100644 index 0000000..f240ed3 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContracts.kt @@ -0,0 +1,124 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.render.RenderCommand + +enum class ScreenDomainId { + Application, + System, + Debug, +} + +enum class ScreenDomainSurfaceRole { + Root, + Portal, +} + +data class ScreenDomainSurface( + val domain: ScreenDomainId, + val role: ScreenDomainSurfaceRole, +) + +object ScreenDomainSurfaces { + val ApplicationRoot: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.Application, ScreenDomainSurfaceRole.Root) + val ApplicationPortal: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.Application, ScreenDomainSurfaceRole.Portal) + val SystemRoot: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.System, ScreenDomainSurfaceRole.Root) + val SystemPortal: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.System, ScreenDomainSurfaceRole.Portal) + val DebugRoot: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.Debug, ScreenDomainSurfaceRole.Root) + val DebugPortal: ScreenDomainSurface = + ScreenDomainSurface(ScreenDomainId.Debug, ScreenDomainSurfaceRole.Portal) + + val allDomains: List = + listOf( + ScreenDomainId.Application, + ScreenDomainId.System, + ScreenDomainId.Debug, + ) + + val allSurfaces: List = + allDomains.flatMap { domain -> + listOf( + rootSurface(domain), + portalSurface(domain), + ) + } + + val paintOrder: List = + listOf( + ApplicationRoot, + ApplicationPortal, + SystemRoot, + SystemPortal, + DebugRoot, + DebugPortal, + ) + + val inputPriority: List = + listOf( + DebugPortal, + DebugRoot, + SystemPortal, + SystemRoot, + ApplicationPortal, + ApplicationRoot, + ) + + fun rootSurface(domain: ScreenDomainId): ScreenDomainSurface = + when (domain) { + ScreenDomainId.Application -> ApplicationRoot + ScreenDomainId.System -> SystemRoot + ScreenDomainId.Debug -> DebugRoot + } + + fun portalSurface(domain: ScreenDomainId): ScreenDomainSurface = + when (domain) { + ScreenDomainId.Application -> ApplicationPortal + ScreenDomainId.System -> SystemPortal + ScreenDomainId.Debug -> DebugPortal + } + + fun portalSurfaceForDomain(ownerDomain: ScreenDomainId): ScreenDomainSurface = portalSurface(ownerDomain) + + @Suppress("UnusedParameter") + fun portalSurfaceForDomain(ownerDomain: ScreenDomainId, cursorX: Int, cursorY: Int): ScreenDomainSurface = + portalSurfaceForDomain(ownerDomain) + + fun firstInputConsumer( + canConsume: (ScreenDomainSurface) -> Boolean, + isSurfaceInputEnabled: (ScreenDomainSurface) -> Boolean = { true }, + ): ScreenDomainSurface? { + inputPriority.forEach { surface -> + if (!isSurfaceInputEnabled(surface)) return@forEach + if (canConsume(surface)) return surface + } + return null + } + + fun composePaintCommands( + applicationRoot: List, + applicationPortal: List, + systemRoot: List = emptyList(), + systemPortal: List, + debugRoot: List, + debugPortal: List = emptyList(), + out: MutableList, + shouldRenderSurface: (ScreenDomainSurface) -> Boolean = { true }, + ) { + out.clear() + paintOrder.forEach { surface -> + if (!shouldRenderSurface(surface)) return@forEach + when (surface) { + ApplicationRoot -> out.addAll(applicationRoot) + ApplicationPortal -> out.addAll(applicationPortal) + SystemRoot -> out.addAll(systemRoot) + SystemPortal -> out.addAll(systemPortal) + DebugRoot -> out.addAll(debugRoot) + DebugPortal -> out.addAll(debugPortal) + } + } + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSession.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSession.kt new file mode 100644 index 0000000..251c569 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSession.kt @@ -0,0 +1,122 @@ +package org.dreamfinity.dsgl.core.portal.input + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +import org.dreamfinity.dsgl.core.dom.elements.TextAreaNode +import org.dreamfinity.dsgl.core.event.FocusManager + +/** + * Shared DOM-node pointer capture bookkeeping for domain root and portal dispatchers. + */ +class PointerCaptureSession { + var target: DOMNode? = null + private set + + private var targetKey: Any? = null + private var targetClass: Class? = null + private var focusKey: Any? = null + + val hasCapture: Boolean + get() = target != null + + fun capture(target: DOMNode) { + this.target = target + targetKey = target.key + targetClass = target.javaClass + focusKey = FocusManager.focusedNode()?.key + } + + fun release() { + target?.cancelPointerCapture() + reset() + } + + fun restore(root: DOMNode, pointerPressed: Boolean) { + if (target == null) return + val cls = targetClass + if (cls == null) { + release() + return + } + + val key = targetKey + if (key == null) { + val captured = target + if (shouldKeepUnkeyedCapture(captured, cls, root, pointerPressed)) { + return + } + release() + return + } + + val restored = findByKeyAndClass(root, key, cls) + if (restored != null) { + target = restored + } else { + release() + } + } + + fun hasFocusChanged(): Boolean { + if (focusKey == null) return false + val captured = target + val currentFocus = FocusManager.focusedNode() + if (captured != null && isSameOrAncestor(captured, currentFocus)) return false + return currentFocus?.key != focusKey + } + + private fun reset() { + target = null + targetKey = null + targetClass = null + focusKey = null + } + + private fun shouldKeepUnkeyedCapture( + captured: DOMNode?, + cls: Class, + root: DOMNode, + pointerPressed: Boolean, + ): Boolean { + if (captured == null) return false + if (captured.javaClass != cls) return false + return pointerPressed || isSameOrAncestor(root, captured) + } + + companion object { + fun resolveCaptureTarget(start: DOMNode?, mouseX: Int, mouseY: Int): DOMNode? { + var current = start + while (current != null) { + when (current) { + is RangeInputNode -> return current + is SingleLineInputNode -> if (current.shouldCaptureTextSelectionDrag(mouseX, mouseY)) return current + is TextAreaNode -> if (current.shouldCaptureAnyDrag(mouseX, mouseY)) return current + } + if (current.shouldCapturePointerDrag(mouseX, mouseY)) { + return current + } + current = current.parent + } + return null + } + + fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { + var current = node + while (current != null) { + if (current === candidate) return true + current = current.parent + } + return false + } + + private fun findByKeyAndClass(node: DOMNode, key: Any, cls: Class): DOMNode? { + if (node.key == key && node.javaClass == cls) return node + node.children.forEach { child -> + val found = findByKeyAndClass(child, key, cls) + if (found != null) return found + } + return null + } + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouter.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouter.kt similarity index 70% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouter.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouter.kt index 895e24f..7a3ced2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouter.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouter.kt @@ -1,34 +1,30 @@ -package org.dreamfinity.dsgl.core.overlay.input +package org.dreamfinity.dsgl.core.portal.input import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode -import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode import org.dreamfinity.dsgl.core.dom.elements.TextAreaNode import org.dreamfinity.dsgl.core.dom.layout.AffineTransform2D import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.event.EventBus import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent +import org.dreamfinity.dsgl.core.event.KeyboardKeyUpEvent import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent -import org.dreamfinity.dsgl.core.event.MouseDragEvent import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.event.MouseDragEvent import org.dreamfinity.dsgl.core.event.MouseEnterEvent import org.dreamfinity.dsgl.core.event.MouseLeaveEvent import org.dreamfinity.dsgl.core.event.MouseMoveEvent import org.dreamfinity.dsgl.core.event.MouseOverEvent import org.dreamfinity.dsgl.core.event.MouseUpEvent import org.dreamfinity.dsgl.core.event.MouseWheelEvent -import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent -class LayerDomInputRouter( - private val rootProvider: () -> DOMNode? +class SurfaceDomInputRouter( + private val rootProvider: () -> DOMNode?, ) { private val hoverChain: MutableList = ArrayList() private var hoverTarget: DOMNode? = null - private var dragCaptureTarget: DOMNode? = null - private var dragCaptureKey: Any? = null - private var dragCaptureClass: Class? = null - private var dragCaptureFocusKey: Any? = null + private val pointerCapture: PointerCaptureSession = PointerCaptureSession() private var activeTarget: DOMNode? = null private var pressedButton: MouseButton? = null private var draggedSincePress: Boolean = false @@ -36,10 +32,11 @@ class LayerDomInputRouter( private var lastMoveY: Int = Int.MIN_VALUE fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { - val root = rootProvider() ?: run { - clear() - return false - } + val root = + rootProvider() ?: run { + clear() + return false + } restoreDragCapture(root) val prevX = if (lastMoveX == Int.MIN_VALUE) mouseX else lastMoveX val prevY = if (lastMoveY == Int.MIN_VALUE) mouseY else lastMoveY @@ -48,39 +45,40 @@ class LayerDomInputRouter( updateHoverLocal(root, hoverChain, mouseX, mouseY, dx, dy) hoverTarget = hoverChain.lastOrNull() - if (dragCaptureTarget != null && hasFocusChangedSinceCapture()) { + if (pointerCapture.hasCapture && pointerCapture.hasFocusChanged()) { releaseDragCapture() } if (dx != 0 || dy != 0) { val move = MouseMoveEvent(mouseX, mouseY, prevX, prevY) - move.target = dragCaptureTarget ?: hoverTarget + move.target = pointerCapture.target ?: hoverTarget EventBus.post(move) val button = pressedButton if (button != null) { draggedSincePress = true val drag = MouseDragEvent(prevX, prevY, dx, dy, button) - drag.target = dragCaptureTarget ?: hoverTarget + drag.target = pointerCapture.target ?: hoverTarget EventBus.post(drag) - dragCaptureTarget?.continuePointerCapture( + pointerCapture.target?.continuePointerCapture( mouseX = mouseX, mouseY = mouseY, mouseDX = dx, mouseDY = dy, - button = button + button = button, ) } } lastMoveX = mouseX lastMoveY = mouseY - return dragCaptureTarget != null || hoverTarget != null + return pointerCapture.hasCapture || hoverTarget != null } fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - val root = rootProvider() ?: run { - clear() - return false - } + val root = + rootProvider() ?: run { + clear() + return false + } restoreDragCapture(root) updateHoverLocal(root, hoverChain, mouseX, mouseY, 0, 0) hoverTarget = hoverChain.lastOrNull() @@ -92,7 +90,7 @@ class LayerDomInputRouter( if (button == MouseButton.LEFT) { setActiveTarget(target) - val capture = resolveDragCaptureTarget(target, mouseX, mouseY) + val capture = PointerCaptureSession.resolveCaptureTarget(target, mouseX, mouseY) if (capture != null) { setDragCapture(capture) capture.beginPointerCapture(mouseX, mouseY, button) @@ -108,15 +106,16 @@ class LayerDomInputRouter( } fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { - val root = rootProvider() ?: run { - clear() - return false - } + val root = + rootProvider() ?: run { + clear() + return false + } restoreDragCapture(root) updateHoverLocal(root, hoverChain, mouseX, mouseY, 0, 0) hoverTarget = hoverChain.lastOrNull() - val releaseTarget = dragCaptureTarget ?: hoverTarget ?: activeTarget - val hadCapture = dragCaptureTarget != null + val releaseTarget = pointerCapture.target ?: hoverTarget ?: activeTarget + val hadCapture = pointerCapture.hasCapture val pressed = pressedButton if (pressed != button && releaseTarget == null) { return false @@ -125,7 +124,7 @@ class LayerDomInputRouter( val up = MouseUpEvent(mouseX, mouseY, button) up.target = releaseTarget EventBus.post(up) - dragCaptureTarget?.endPointerCapture(mouseX, mouseY, button) + pointerCapture.target?.endPointerCapture(mouseX, mouseY, button) if (!hadCapture && !draggedSincePress && pressed == button) { val click = MouseClickEvent(mouseX, mouseY, button) click.target = hoverTarget @@ -143,10 +142,11 @@ class LayerDomInputRouter( fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { if (delta == 0) return false - val root = rootProvider() ?: run { - clear() - return false - } + val root = + rootProvider() ?: run { + clear() + return false + } restoreDragCapture(root) updateHoverLocal(root, hoverChain, mouseX, mouseY, 0, 0) hoverTarget = hoverChain.lastOrNull() @@ -159,18 +159,45 @@ class LayerDomInputRouter( } fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { - val root = rootProvider() ?: run { - clear() - return false - } - val focused = FocusManager.focusedNode() ?: return false - if (!isSameOrAncestor(root, focused)) return false + val root = + rootProvider() ?: run { + clear() + return false + } + val focused = FocusManager.focusedNodeWithin(root) ?: return false val event = KeyboardKeyDownEvent(keyChar = keyChar, keyCode = keyCode) event.target = focused EventBus.post(event) return true } + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean { + val root = + rootProvider() ?: run { + clear() + return false + } + val focused = FocusManager.focusedNodeWithin(root) ?: return false + val event = KeyboardKeyUpEvent(keyChar = keyChar, keyCode = keyCode) + event.target = focused + EventBus.post(event) + return true + } + + fun hasPointerTargetAt(mouseX: Int, mouseY: Int): Boolean { + val root = rootProvider() ?: return false + if (pointerCapture.hasCapture) return true + val chain = ArrayList(hoverChain.size + 4) + return collectHoverChainLocal( + root = root, + mouseX = mouseX, + mouseY = mouseY, + parentTransform = AffineTransform2D.IDENTITY, + parentInputClipRect = null, + out = chain, + ) + } + fun clear() { clearHoverChainStates() hoverTarget = null @@ -182,82 +209,16 @@ class LayerDomInputRouter( lastMoveY = Int.MIN_VALUE } - private fun resolveDragCaptureTarget(start: DOMNode?, mouseX: Int, mouseY: Int): DOMNode? { - var current = start - while (current != null) { - when (current) { - is RangeInputNode -> return current - is SingleLineInputNode -> if (current.shouldCaptureTextSelectionDrag(mouseX, mouseY)) return current - is TextAreaNode -> if (current.shouldCaptureAnyDrag(mouseX, mouseY)) return current - } - if (current.shouldCapturePointerDrag(mouseX, mouseY)) { - return current - } - current = current.parent - } - return null - } - private fun setDragCapture(target: DOMNode) { - dragCaptureTarget = target - dragCaptureKey = target.key - dragCaptureClass = target.javaClass - dragCaptureFocusKey = FocusManager.focusedNode()?.key + pointerCapture.capture(target) } private fun releaseDragCapture() { - dragCaptureTarget?.cancelPointerCapture() - RangeInputNode.clearActiveDrag() - SingleLineInputNode.clearActiveDrag() - TextAreaNode.clearActiveDrag() - dragCaptureTarget = null - dragCaptureKey = null - dragCaptureClass = null - dragCaptureFocusKey = null + pointerCapture.release() } private fun restoreDragCapture(root: DOMNode) { - if (dragCaptureTarget == null) return - val key = dragCaptureKey - val cls = dragCaptureClass - if (cls == null) { - releaseDragCapture() - return - } - if (key == null) { - val captured = dragCaptureTarget - if (captured != null && captured.javaClass == cls) { - if (pressedButton != null) { - return - } - if (isSameOrAncestor(root, captured)) { - return - } - } - releaseDragCapture() - return - } - val restored = findByKeyAndClass(root, key, cls) - if (restored != null) { - dragCaptureTarget = restored - return - } - releaseDragCapture() - } - - private fun findByKeyAndClass(node: DOMNode, key: Any, cls: Class): DOMNode? { - if (node.key == key && node.javaClass == cls) return node - node.children.forEach { child -> - val found = findByKeyAndClass(child, key, cls) - if (found != null) return found - } - return null - } - - private fun hasFocusChangedSinceCapture(): Boolean { - if (dragCaptureFocusKey == null) return false - val currentFocusKey = FocusManager.focusedNode()?.key - return currentFocusKey != dragCaptureFocusKey + pointerCapture.restore(root, pointerPressed = pressedButton != null) } private fun setActiveTarget(target: DOMNode?) { @@ -277,10 +238,15 @@ class LayerDomInputRouter( val hovered = hoverTarget if (hovered != null) return hovered val focused = FocusManager.focusedNode() - return if (focused is TextAreaNode) focused else null + return focused as? TextAreaNode } - private fun bubbleGenericWheel(target: DOMNode, mouseX: Int, mouseY: Int, delta: Int): Boolean { + private fun bubbleGenericWheel( + target: DOMNode, + mouseX: Int, + mouseY: Int, + delta: Int, + ): Boolean { var current: DOMNode? = target while (current != null) { if (current.handleGenericWheel(mouseX, mouseY, delta)) { @@ -291,15 +257,6 @@ class LayerDomInputRouter( return false } - private fun isSameOrAncestor(candidate: DOMNode, node: DOMNode?): Boolean { - var current = node - while (current != null) { - if (current === candidate) return true - current = current.parent - } - return false - } - private fun clearHoverChainStates() { hoverChain.forEach { node -> node.setHoveredState(false) @@ -313,7 +270,7 @@ class LayerDomInputRouter( mouseY: Int, parentTransform: AffineTransform2D, parentInputClipRect: Rect?, - out: MutableList + out: MutableList, ): Boolean { if (root.styleDisabled) return false if (!root.isHitTestVisible()) return false @@ -332,7 +289,7 @@ class LayerDomInputRouter( mouseY = mouseY, parentTransform = worldTransform, parentInputClipRect = childInputClipRect, - out = out + out = out, ) ) { return true @@ -368,7 +325,7 @@ class LayerDomInputRouter( mouseX: Int, mouseY: Int, mouseDX: Int, - mouseDY: Int + mouseDY: Int, ) { val currHoverChain = ArrayList(prevHoverChain.size + 4) collectHoverChainLocal( @@ -377,7 +334,7 @@ class LayerDomInputRouter( mouseY = mouseY, parentTransform = AffineTransform2D.IDENTITY, parentInputClipRect = null, - out = currHoverChain + out = currHoverChain, ) val minSize = minOf(prevHoverChain.size, currHoverChain.size) var commonPrefixLen = 0 @@ -444,4 +401,3 @@ class LayerDomInputRouter( target.onmouseover?.invoke(event) } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceInputDispatch.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceInputDispatch.kt new file mode 100644 index 0000000..9166f2c --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceInputDispatch.kt @@ -0,0 +1,11 @@ +package org.dreamfinity.dsgl.core.portal.input + +internal inline fun dispatchManualThenDomFallback( + manualDispatch: () -> Boolean, + domFallbackDispatch: () -> Boolean, +): Boolean { + if (manualDispatch()) { + return true + } + return domFallbackDispatch() +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanel.kt new file mode 100644 index 0000000..5aa8dc7 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanel.kt @@ -0,0 +1,657 @@ +package org.dreamfinity.dsgl.core.portal.panel + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.TextNode +import org.dreamfinity.dsgl.core.dom.elements.TextSource +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.core.style.TextWrap + +data class FloatingPanelStyle( + val headerHeight: Int = 26, + val panelPadding: Int = 6, + val resizeHandleSize: Int = 8, + val closeButtonWidth: Int = 16, + val closeButtonHeight: Int = 16, + val closeButtonMarginTop: Int = 4, + val closeButtonMarginRight: Int = 4, + val panelBackgroundColor: Int = 0xFF1E252F.toInt(), + val panelBorderColor: Int = 0xFF5A6C80.toInt(), + val panelShadowColor: Int = 0x7A0C1118, + val headerBackgroundColor: Int = 0xFF2E3A49.toInt(), + val headerBorderColor: Int = 0xFF607A95.toInt(), + val closeButtonBackgroundColor: Int = 0xFF2E3A49.toInt(), + val closeButtonBorderColor: Int = 0xFF607A95.toInt(), + val textColor: Int = 0xFFFFFFFF.toInt(), + val fontSize: Int = 20, + val closeGlyph: String = "x", +) + +data class FloatingPanelFrame( + val panelRect: Rect, + val headerRect: Rect, + val bodyRect: Rect, + val closeRect: Rect, +) + +class FloatingPanel( + private val ownerId: Any, + private val panelState: FloatingPanelState, + private val dragSession: FloatingPanelDragSession, + private var style: FloatingPanelStyle = FloatingPanelStyle(), +) { + private val rootNode: FloatingPanelRootNode = + FloatingPanelRootNode( + owner = this, + key = "dsgl-floating-panel-$ownerId", + ) + private val shadowNode: ContainerNode + private val panelNode: ContainerNode + private val headerNode: ContainerNode + private var titleNode: TextNode + private val closeButtonNode: ButtonNode + private var bodyContentNode: DOMNode? = null + private var floatingContentNode: DOMNode? = null + var title: String = "" + private set + var draggable: Boolean = true + private set + var resizable: Boolean = false + private set + var minWidth: Int = 120 + private set + var minHeight: Int = 80 + private set + private var onClose: (() -> Unit)? = null + private var frame: FloatingPanelFrame? = null + + init { + val scope = UiScope(rootNode) + shadowNode = + scope.div({ + key = "dsgl-floating-panel-shadow-$ownerId" + style = { + display = Display.Block + } + }) + panelNode = + scope.div({ + key = "dsgl-floating-panel-frame-$ownerId" + style = { + display = Display.Block + } + }) + headerNode = + scope.div({ + key = "dsgl-floating-panel-header-$ownerId" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) + titleNode = + scope.text(props = { + key = "dsgl-floating-panel-title-$ownerId" + value = "" + style = { + textWrap = TextWrap.NoWrap + } + }) + closeButtonNode = + scope.button(style.closeGlyph, { + key = "dsgl-floating-panel-close-$ownerId" + onMouseClick = { + onClose?.invoke() + it.cancelled = true + } + }) + applyStyleToNodes(style) + rebuildFrameFromState() + } + + fun node(): DOMNode = rootNode + + fun setBodyContent(node: DOMNode?) { + if (bodyContentNode === node) return + detachNode(bodyContentNode) + bodyContentNode = node + attachNodeBeforeFloatingContent(node) + } + + fun setFloatingContent(node: DOMNode?) { + if (floatingContentNode === node) return + detachNode(floatingContentNode) + floatingContentNode = node + attachNodeAtTop(node) + } + + fun configure( + title: String, + draggable: Boolean, + resizable: Boolean = this.resizable, + minWidth: Int = this.minWidth, + minHeight: Int = this.minHeight, + style: FloatingPanelStyle = this.style, + onClose: (() -> Unit)? = this.onClose, + ) { + val titleChanged = this.title != title + val styleChanged = this.style != style + this.title = title + this.draggable = draggable + this.resizable = resizable + this.minWidth = minWidth.coerceAtLeast(1) + this.minHeight = minHeight.coerceAtLeast(1) + this.style = style + this.onClose = onClose + if (titleChanged) { + replaceTitleNode() + } + if (styleChanged) { + applyStyleToNodes(style) + } + closeButtonNode.text = style.closeGlyph + rebuildFrameFromState() + } + + fun syncPanelRect(panelRect: Rect?) { + if (panelRect == null) { + panelState.hide() + frame = null + return + } + panelState.updateFromRect(panelRect) + rebuildFrameFromState() + } + + fun panelRect(): Rect? = frame?.panelRect + + fun headerRect(): Rect? = frame?.headerRect + + fun closeRect(): Rect? = frame?.closeRect + + fun bodyRect(): Rect? = frame?.bodyRect + + fun isDragging(): Boolean = dragSession.active + + fun beginHeaderDrag(mouseX: Int, mouseY: Int): Boolean { + val localFrame = frame ?: return false + if (!draggable) return false + if (!localFrame.headerRect.contains(mouseX, mouseY)) return false + if (localFrame.closeRect.contains(mouseX, mouseY)) return false + beginMoveDrag(mouseX, mouseY) + return true + } + + fun handleMouseDown( + mouseX: Int, + mouseY: Int, + button: MouseButton, + includeCloseButton: Boolean = true, + ): Boolean { + val localFrame = frame ?: return false + if (button != MouseButton.LEFT) return false + if (includeCloseButton && localFrame.closeRect.contains(mouseX, mouseY)) { + onClose?.invoke() + return true + } + if (resizable && !localFrame.bodyRect.contains(mouseX, mouseY)) { + val resizeHandle = resolveResizeHandle(localFrame.panelRect, mouseX, mouseY) + if (resizeHandle != null) { + dragSession.begin( + ownerId = ownerId, + type = FloatingPanelDragType.PanelResize, + pointerX = mouseX, + pointerY = mouseY, + panelState = panelState, + resizeHandle = resizeHandle, + ) + return true + } + } + if (!draggable) return false + if (!localFrame.headerRect.contains(mouseX, mouseY)) return false + beginMoveDrag(mouseX, mouseY) + return true + } + + fun handleMouseMove( + mouseX: Int, + mouseY: Int, + viewportWidth: Int, + viewportHeight: Int, + onDragRectChanged: (Rect) -> Unit, + ): Boolean { + if (!dragSession.active) return false + dragSession.update(mouseX, mouseY) + val rect = buildDraggedRect(viewportWidth, viewportHeight) + panelState.updateFromRect(rect) + rebuildFrameFromState() + onDragRectChanged(rect) + return true + } + + fun handleMouseUp( + mouseX: Int, + mouseY: Int, + button: MouseButton, + viewportWidth: Int, + viewportHeight: Int, + onDragRectChanged: (Rect) -> Unit, + ): Boolean { + if (button != MouseButton.LEFT || !dragSession.active) return false + dragSession.update(mouseX, mouseY) + val rect = buildDraggedRect(viewportWidth, viewportHeight) + panelState.updateFromRect(rect) + rebuildFrameFromState() + onDragRectChanged(rect) + dragSession.end() + return true + } + + private fun buildDraggedRect(viewportWidth: Int, viewportHeight: Int): Rect = + when (dragSession.type) { + FloatingPanelDragType.PanelResize -> buildResizedRect(viewportWidth, viewportHeight) + else -> buildMovedRect(viewportWidth, viewportHeight) + } + + private fun beginMoveDrag(mouseX: Int, mouseY: Int) { + dragSession.begin( + ownerId = ownerId, + type = FloatingPanelDragType.PanelMove, + pointerX = mouseX, + pointerY = mouseY, + panelState = panelState, + resizeHandle = null, + ) + } + + private fun buildMovedRect(viewportWidth: Int, viewportHeight: Int): Rect { + val dx = dragSession.currentPointerX - dragSession.startPointerX + val dy = dragSession.currentPointerY - dragSession.startPointerY + val raw = + Rect( + x = dragSession.startPanelX + dx, + y = dragSession.startPanelY + dy, + width = dragSession.startPanelWidth, + height = dragSession.startPanelHeight, + ) + return clampPanel(raw, viewportWidth, viewportHeight) + } + + private fun buildResizedRect(viewportWidth: Int, viewportHeight: Int): Rect { + val handle = dragSession.resizeHandle ?: return buildMovedRect(viewportWidth, viewportHeight) + val dx = dragSession.currentPointerX - dragSession.startPointerX + val dy = dragSession.currentPointerY - dragSession.startPointerY + + var left = dragSession.startPanelX + var top = dragSession.startPanelY + var right = dragSession.startPanelX + dragSession.startPanelWidth + var bottom = dragSession.startPanelY + dragSession.startPanelHeight + + when (handle) { + FloatingPanelResizeHandle.Left, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.BottomLeft, + -> left += dx + + FloatingPanelResizeHandle.Right, + FloatingPanelResizeHandle.TopRight, + FloatingPanelResizeHandle.BottomRight, + -> right += dx + + else -> Unit + } + when (handle) { + FloatingPanelResizeHandle.Top, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.TopRight, + -> top += dy + + FloatingPanelResizeHandle.Bottom, + FloatingPanelResizeHandle.BottomLeft, + FloatingPanelResizeHandle.BottomRight, + -> bottom += dy + + else -> Unit + } + + if (right - left < minWidth) { + when (handle) { + FloatingPanelResizeHandle.Left, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.BottomLeft, + -> left = right - minWidth + + FloatingPanelResizeHandle.Right, + FloatingPanelResizeHandle.TopRight, + FloatingPanelResizeHandle.BottomRight, + -> right = left + minWidth + + else -> right = left + minWidth + } + } + if (bottom - top < minHeight) { + when (handle) { + FloatingPanelResizeHandle.Top, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.TopRight, + -> top = bottom - minHeight + + FloatingPanelResizeHandle.Bottom, + FloatingPanelResizeHandle.BottomLeft, + FloatingPanelResizeHandle.BottomRight, + -> bottom = top + minHeight + + else -> bottom = top + minHeight + } + } + + val minX = 2 + val minY = 2 + val maxRight = (viewportWidth - 2).coerceAtLeast(minX + minWidth) + val maxBottom = (viewportHeight - 2).coerceAtLeast(minY + minHeight) + + when (handle) { + FloatingPanelResizeHandle.Left, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.BottomLeft, + -> left = left.coerceIn(minX, right - minWidth) + + FloatingPanelResizeHandle.Right, + FloatingPanelResizeHandle.TopRight, + FloatingPanelResizeHandle.BottomRight, + -> right = right.coerceIn(left + minWidth, maxRight) + + else -> Unit + } + when (handle) { + FloatingPanelResizeHandle.Top, + FloatingPanelResizeHandle.TopLeft, + FloatingPanelResizeHandle.TopRight, + -> top = top.coerceIn(minY, bottom - minHeight) + + FloatingPanelResizeHandle.Bottom, + FloatingPanelResizeHandle.BottomLeft, + FloatingPanelResizeHandle.BottomRight, + -> bottom = bottom.coerceIn(top + minHeight, maxBottom) + + else -> Unit + } + + val width = (right - left).coerceAtLeast(minWidth) + val height = (bottom - top).coerceAtLeast(minHeight) + val clamped = + clampPanel( + Rect(left, top, width, height), + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) + return Rect( + x = clamped.x, + y = clamped.y, + width = clamped.width.coerceAtLeast(minWidth), + height = clamped.height.coerceAtLeast(minHeight), + ) + } + + private fun rebuildFrameFromState() { + val panelRect = panelState.currentRectOrNull() + frame = + if (panelRect == null) { + null + } else { + buildFrame(panelRect) + } + } + + private fun buildFrame(panelRect: Rect): FloatingPanelFrame { + val headerRect = Rect(panelRect.x, panelRect.y, panelRect.width, style.headerHeight) + val bodyRect = + Rect( + x = panelRect.x + style.panelPadding, + y = panelRect.y + style.headerHeight + style.panelPadding, + width = (panelRect.width - style.panelPadding * 2).coerceAtLeast(1), + height = (panelRect.height - style.headerHeight - style.panelPadding * 2).coerceAtLeast(1), + ) + val closeRect = + Rect( + x = panelRect.x + panelRect.width - style.closeButtonMarginRight - style.closeButtonWidth, + y = panelRect.y + style.closeButtonMarginTop, + width = style.closeButtonWidth, + height = style.closeButtonHeight, + ) + return FloatingPanelFrame( + panelRect = panelRect, + headerRect = headerRect, + bodyRect = bodyRect, + closeRect = closeRect, + ) + } + + private fun clampPanel(rect: Rect, viewportWidth: Int, viewportHeight: Int): Rect { + val minX = 2 + val minY = 2 + val maxX = (viewportWidth - rect.width - 2).coerceAtLeast(2) + val maxY = (viewportHeight - rect.height - 2).coerceAtLeast(2) + return Rect( + x = rect.x.coerceIn(minX, maxX), + y = rect.y.coerceIn(minY, maxY), + width = rect.width, + height = rect.height, + ) + } + + private fun resolveResizeHandle(panelRect: Rect, mouseX: Int, mouseY: Int): FloatingPanelResizeHandle? { + if (!panelRect.contains(mouseX, mouseY)) return null + val edge = style.resizeHandleSize.coerceAtLeast(2) + val leftZone = mouseX <= panelRect.x + edge + val rightZone = mouseX >= panelRect.x + panelRect.width - edge + val topZone = mouseY <= panelRect.y + edge + val bottomZone = mouseY >= panelRect.y + panelRect.height - edge + + if (leftZone && topZone) return FloatingPanelResizeHandle.TopLeft + if (rightZone && topZone) return FloatingPanelResizeHandle.TopRight + if (leftZone && bottomZone) return FloatingPanelResizeHandle.BottomLeft + if (rightZone && bottomZone) return FloatingPanelResizeHandle.BottomRight + if (leftZone) return FloatingPanelResizeHandle.Left + if (rightZone) return FloatingPanelResizeHandle.Right + if (topZone) return FloatingPanelResizeHandle.Top + if (bottomZone) return FloatingPanelResizeHandle.Bottom + return null + } + + private fun applyStyleToNodes(style: FloatingPanelStyle) { + shadowNode.applyStyle { + backgroundColor = style.panelShadowColor + border { width = 0.px } + } + panelNode.applyStyle { + backgroundColor = style.panelBackgroundColor + border { + width = 1.px + color = style.panelBorderColor + } + } + headerNode.applyStyle { + backgroundColor = style.headerBackgroundColor + border { + width = 1.px + color = style.headerBorderColor + } + } + titleNode.applyStyle { + color = style.textColor + fontSize = style.fontSize.px + textWrap = TextWrap.NoWrap + } + closeButtonNode.applyStyle { + backgroundColor = style.closeButtonBackgroundColor + border { + width = 1.px + color = style.closeButtonBorderColor + } + color = style.textColor + fontSize = style.fontSize.px + width = style.closeButtonWidth.px + height = style.closeButtonHeight.px + padding = 0.px + } + } + + private fun replaceTitleNode() { + val oldNode = titleNode + val oldIndex = rootNode.children.indexOf(oldNode) + detachNode(oldNode) + val newNode = + TextNode( + textSource = TextSource.Static(title), + key = "dsgl-floating-panel-title-$ownerId", + ) + newNode.applyStyle { + textWrap = TextWrap.NoWrap + } + titleNode = newNode + if (oldIndex >= 0 && oldIndex <= rootNode.children.size) { + rootNode.children.add(oldIndex, newNode) + newNode.parent = rootNode + } else { + newNode.applyParent(rootNode) + } + applyStyleToNodes(style) + } + + private fun detachNode(node: DOMNode?) { + val local = node ?: return + val parent = local.parent + if (parent != null) { + parent.children.remove(local) + local.parent = null + } + } + + private fun attachNodeBeforeFloatingContent(node: DOMNode?) { + val local = node ?: return + detachNode(local) + val floatingContent = floatingContentNode + if (floatingContent != null && floatingContent.parent === rootNode) { + val floatingContentIndex = rootNode.children.indexOf(floatingContent) + if (floatingContentIndex >= 0) { + rootNode.children.add(floatingContentIndex, local) + local.parent = rootNode + return + } + } + local.applyParent(rootNode) + } + + private fun attachNodeAtTop(node: DOMNode?) { + val local = node ?: return + detachNode(local) + local.applyParent(rootNode) + } + + private fun renderInto(ctx: UiMeasureContext, viewportRect: Rect) { + val localFrame = frame + if (localFrame == null) { + shadowNode.render(ctx, 0, 0, 0, 0) + panelNode.render(ctx, 0, 0, 0, 0) + headerNode.render(ctx, 0, 0, 0, 0) + titleNode.render(ctx, 0, 0, 0, 0) + closeButtonNode.render(ctx, 0, 0, 0, 0) + bodyContentNode?.render(ctx, 0, 0, 0, 0) + floatingContentNode?.render(ctx, 0, 0, 0, 0) + return + } + + val panelRect = localFrame.panelRect + val headerRect = localFrame.headerRect + val closeRect = localFrame.closeRect + val bodyRect = localFrame.bodyRect + val titleX = headerRect.x + 6 + val titleY = headerRect.y + 3 + val titleWidth = (closeRect.x - titleX - 4).coerceAtLeast(1) + val titleHeight = (headerRect.height - 6).coerceAtLeast(1) + + shadowNode.render( + ctx, + panelRect.x + 2, + panelRect.y + 2, + panelRect.width, + panelRect.height, + ) + panelNode.render( + ctx, + panelRect.x, + panelRect.y, + panelRect.width, + panelRect.height, + ) + headerNode.render( + ctx, + headerRect.x, + headerRect.y, + headerRect.width, + headerRect.height, + ) + titleNode.render( + ctx, + titleX, + titleY, + titleWidth, + titleHeight, + ) + closeButtonNode.render( + ctx, + closeRect.x, + closeRect.y, + closeRect.width, + closeRect.height, + ) + bodyContentNode?.render( + ctx, + bodyRect.x, + bodyRect.y, + bodyRect.width, + bodyRect.height, + ) + floatingContentNode?.render( + ctx, + viewportRect.x, + viewportRect.y, + viewportRect.width, + viewportRect.height, + ) + } + + private class FloatingPanelRootNode( + private val owner: FloatingPanel, + key: Any?, + ) : DOMNode(key) { + override val styleType: String = "dsgl-floating-panel" + + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) + + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bounds = Rect(x, y, width, height) + owner.renderInto(ctx, bounds) + } + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelDragSession.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelDragSession.kt similarity index 68% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelDragSession.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelDragSession.kt index 7eae913..bd9f33f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelDragSession.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelDragSession.kt @@ -1,17 +1,28 @@ -package org.dreamfinity.dsgl.core.overlay.panel +package org.dreamfinity.dsgl.core.portal.panel -enum class OverlayPanelDragType { +enum class FloatingPanelDragType { PanelMove, PanelResize, - Transient + Transient, } -class OverlayPanelDragSession { +enum class FloatingPanelResizeHandle { + Left, + Right, + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +class FloatingPanelDragSession { var active: Boolean = false private set var ownerId: Any? = null private set - var type: OverlayPanelDragType? = null + var type: FloatingPanelDragType? = null private set var startPointerX: Int = 0 private set @@ -29,17 +40,21 @@ class OverlayPanelDragSession { private set var startPanelHeight: Int = 0 private set + var resizeHandle: FloatingPanelResizeHandle? = null + private set fun begin( ownerId: Any, - type: OverlayPanelDragType, + type: FloatingPanelDragType, pointerX: Int, pointerY: Int, - panelState: OverlayPanelState + panelState: FloatingPanelState, + resizeHandle: FloatingPanelResizeHandle? = null, ) { active = true this.ownerId = ownerId this.type = type + this.resizeHandle = resizeHandle startPointerX = pointerX startPointerY = pointerY currentPointerX = pointerX @@ -60,5 +75,6 @@ class OverlayPanelDragSession { active = false ownerId = null type = null + resizeHandle = null } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelState.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelState.kt similarity index 91% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelState.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelState.kt index 5ac6511..8c5a390 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelState.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelState.kt @@ -1,8 +1,8 @@ -package org.dreamfinity.dsgl.core.overlay.panel +package org.dreamfinity.dsgl.core.portal.panel import org.dreamfinity.dsgl.core.dom.layout.Rect -class OverlayPanelState { +class FloatingPanelState { var visible: Boolean = false private set var x: Int = 0 diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayCommandDslRenderer.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalCommandDslRenderer.kt similarity index 63% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayCommandDslRenderer.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalCommandDslRenderer.kt index 166643d..0c52351 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayCommandDslRenderer.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalCommandDslRenderer.kt @@ -1,43 +1,44 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.render.RenderCommand -internal object SystemOverlayCommandDslRenderer { +internal object SystemPortalCommandDslRenderer { fun rebuildInto(parent: DOMNode, commands: List, keyPrefix: String): Boolean { val children = parent.children var changed = false commands.forEachIndexed { index, command -> val existing = children.getOrNull(index) - if (existing is SystemOverlayRawRenderCommandNode) { + if (existing is SystemPortalRawRenderCommandNode) { if (existing.updateRenderCommand(command)) { changed = true } - SystemOverlayDebugCounters.onRawNodeReused() + SystemPortalDebugCounters.onRawNodeReused() return@forEachIndexed } - val replacement = SystemOverlayRawRenderCommandNode( - renderCommand = command, - key = "$keyPrefix-$index" - ) + val replacement = + SystemPortalRawRenderCommandNode( + renderCommand = command, + key = "$keyPrefix-$index", + ) replacement.parent = parent if (existing == null) { children += replacement } else { existing.parent = null children[index] = replacement - SystemOverlayDebugCounters.onRawNodeRemoved() + SystemPortalDebugCounters.onRawNodeRemoved() } - SystemOverlayDebugCounters.onRawNodeCreated() + SystemPortalDebugCounters.onRawNodeCreated() changed = true } while (children.size > commands.size) { val removed = children.removeAt(children.lastIndex) removed.parent = null - SystemOverlayDebugCounters.onRawNodeRemoved() + SystemPortalDebugCounters.onRawNodeRemoved() changed = true } return changed diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDebugCounters.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDebugCounters.kt similarity index 79% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDebugCounters.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDebugCounters.kt index 043c076..528e3cc 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDebugCounters.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDebugCounters.kt @@ -1,8 +1,10 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system -internal object SystemOverlayDebugCounters { +internal object SystemPortalDebugCounters { @Volatile - private var enabled: Boolean = java.lang.Boolean.getBoolean("dsgl.systemOverlay.debugCounters") + private var enabled: Boolean = + java.lang.Boolean + .getBoolean("dsgl.systemPortal.debugCounters") @Volatile private var rawNodeCreated: Long = 0L @@ -24,7 +26,7 @@ internal object SystemOverlayDebugCounters { val rawNodeReused: Long, val rawNodeRemoved: Long, val rawNodeActive: Long, - val rawNodePeakActive: Long + val rawNodePeakActive: Long, ) fun setEnabled(value: Boolean) { @@ -39,15 +41,14 @@ internal object SystemOverlayDebugCounters { rawNodePeakActive = 0L } - fun snapshot(): Snapshot { - return Snapshot( + fun snapshot(): Snapshot = + Snapshot( rawNodeCreated = rawNodeCreated, rawNodeReused = rawNodeReused, rawNodeRemoved = rawNodeRemoved, rawNodeActive = rawNodeActive, - rawNodePeakActive = rawNodePeakActive + rawNodePeakActive = rawNodePeakActive, ) - } fun onRawNodeCreated() { if (!enabled) return diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntries.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntries.kt new file mode 100644 index 0000000..10e6501 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntries.kt @@ -0,0 +1,197 @@ +package org.dreamfinity.dsgl.core.portal.system + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.PortalDismissPolicy +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalEntryBounds +import org.dreamfinity.dsgl.core.portal.PortalEntryId +import org.dreamfinity.dsgl.core.portal.PortalEntryOrder +import org.dreamfinity.dsgl.core.portal.PortalEntryPlacement +import org.dreamfinity.dsgl.core.portal.PortalEntryState +import org.dreamfinity.dsgl.core.portal.PortalFocusPolicy +import org.dreamfinity.dsgl.core.portal.PortalInputPolicy +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragSession +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelState +import java.util.IdentityHashMap + +enum class SystemPortalEntryId { + Inspector, + ColorPickerPopup, + ColorPickerTransient, + TransientSession, +} + +enum class SystemPortalLane( + val zOrder: Int, +) { + PanelContent(0), + Transient(1), +} + +class SystemPortalEntryState( + val id: SystemPortalEntryId, + val order: Int, + val lane: SystemPortalLane = SystemPortalLane.PanelContent, + val panelState: FloatingPanelState = FloatingPanelState(), + val dragSession: FloatingPanelDragSession = FloatingPanelDragSession(), +) { + var active: Boolean = false + internal set +} + +internal data class SystemPortalFrameContext( + val inspectedRoot: DOMNode?, + val inspectedLayoutRevision: Long, + val cursorX: Int, + val cursorY: Int, + val inspectorPointerCaptured: Boolean, +) + +internal interface SystemPortalEntry { + val state: SystemPortalEntryState + val node: DOMNode + + fun participatesInDomInput(): Boolean = false + + fun enablesDomInputFallbackRouting(): Boolean = participatesInDomInput() + + fun sync(frame: SystemPortalFrameContext) + + fun onInputFrame(viewportWidth: Int, viewportHeight: Int) = Unit + + fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = false + + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = false + + fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = false + + fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = false + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = false +} + +class SystemPortalTransientSession( + val ownerToken: Any, + val entryState: SystemPortalEntryState = + SystemPortalEntryState( + id = SystemPortalEntryId.TransientSession, + order = Int.MAX_VALUE, + ), +) + +class SystemPortalTransientOwnershipRegistry { + private val sessions: IdentityHashMap = IdentityHashMap() + + fun resolve(ownerToken: Any): SystemPortalTransientSession = + sessions.getOrPut(ownerToken) { + SystemPortalTransientSession(ownerToken = ownerToken) + } + + @Suppress("UnusedParameter") + fun resolve(ownerToken: Any, cursorX: Int, cursorY: Int): SystemPortalTransientSession = resolve(ownerToken) + + fun release(ownerToken: Any): Boolean = sessions.remove(ownerToken) != null + + fun clear() { + sessions.clear() + } + + fun activeSessions(): List = sessions.values.toList() +} + +internal class SystemPortalEntryRegistry( + entries: List, +) { + private val orderedEntries: List = entries.sortedBy { it.state.order } + private val byId: Map = orderedEntries.associateBy { it.state.id } + + fun allEntries(): List = orderedEntries + + fun entry(id: SystemPortalEntryId): SystemPortalEntry? = byId[id] +} + +internal class SystemPortalEntryAdapter( + private val entry: SystemPortalEntry, +) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("system.${entry.state.id.name}"), + ownerToken = entry.state, + surface = ScreenDomainSurfaces.SystemPortal, + order = + PortalEntryOrder( + zIndex = entry.state.lane.zOrder, + sequence = entry.state.order, + ), + dismissPolicy = PortalDismissPolicy.None, + inputPolicy = entry.inputPolicy(), + focusPolicy = PortalFocusPolicy.Preserve, + ) + override val node: DOMNode = entry.node + + val systemEntry: SystemPortalEntry + get() = entry + + fun syncPlacement(viewportWidth: Int, viewportHeight: Int) { + if (!entry.state.active) { + state.deactivate() + return + } + state.activate( + PortalEntryPlacement( + anchorBounds = null, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + entryBounds = entry.resolvePortalEntryBounds(viewportWidth, viewportHeight), + ), + ), + ) + } + + override fun close() { + entry.state.active = false + entry.state.panelState + .hide() + entry.state.dragSession + .end() + state.deactivate() + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = entry.handleMouseMove(mouseX, mouseY) + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + entry.handleMouseDown(mouseX, mouseY, button) + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + entry.handleMouseUp(mouseX, mouseY, button) + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + entry.handleMouseWheel(mouseX, mouseY, delta) + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = entry.handleKeyDown(keyCode, keyChar) + + override fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = entry.handleKeyUp(keyCode, keyChar) + + private fun SystemPortalEntry.inputPolicy(): PortalInputPolicy = + when { + participatesInDomInput() || enablesDomInputFallbackRouting() -> PortalInputPolicy.ManualThenDomFallback + else -> PortalInputPolicy.ManualOnly + } + + private fun SystemPortalEntry.resolvePortalEntryBounds(viewportWidth: Int, viewportHeight: Int): Rect { + val panelBounds = state.panelState.currentRectOrNull() + if (panelBounds != null && panelBounds.width > 0 && panelBounds.height > 0) { + return panelBounds + } + if (node.bounds.width > 0 && node.bounds.height > 0) { + return node.bounds + } + return Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)) + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalHost.kt new file mode 100644 index 0000000..57e136a --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalHost.kt @@ -0,0 +1,660 @@ +package org.dreamfinity.dsgl.core.portal.system + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.colorpicker.* +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorPickerPopupMount +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorPickerPopupPortalNode +import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerPortalService +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.inspector.InspectorPanelState +import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorPortalNode +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.DomainSurfaceHost +import org.dreamfinity.dsgl.core.portal.PortalFrameContext +import org.dreamfinity.dsgl.core.portal.PortalHost +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurface +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.dispatchManualThenDomFallback +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanel +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelStyle +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.select.SelectPortalController +import org.dreamfinity.dsgl.core.style.StyleApplicationScope + +@Suppress("TooManyFunctions") +class SystemPortalHost( + private val inspectorController: InspectorController, +) : DomainSurfaceHost { + override val surface: ScreenDomainSurface = ScreenDomainSurfaces.SystemPortal + + private val rootNode: SystemPortalRootNode = SystemPortalRootNode() + private val inspectorEntry: SystemPortalEntry = InspectorPortalEntry(inspectorController) + private val colorPickerEntry: ColorPickerPortalEntry = ColorPickerPortalEntry() + private val colorPickerTransientEntry: SystemPortalEntry = ColorPickerTransientPortalEntry(colorPickerEntry) + private val entryRegistry: SystemPortalEntryRegistry = + SystemPortalEntryRegistry( + listOf(inspectorEntry, colorPickerEntry, colorPickerTransientEntry), + ) + private val portalHost: PortalHost = + PortalHost(ScreenDomainSurfaces.SystemPortal) + private val portalEntries: List = + entryRegistry.allEntries().map(::SystemPortalEntryAdapter) + private val transientOwnershipRegistry: SystemPortalTransientOwnershipRegistry = + SystemPortalTransientOwnershipRegistry() + private val systemSelectPortal: SelectPortalController = + SelectPortalController( + engine = DomainPortalServices.systemSelectEngine, + ownerDomain = ScreenDomainId.System, + entryId = "system.select", + ) + private val tree: DomTree = + DomTree( + root = rootNode, + styleScope = StyleApplicationScope.System, + ) + private var frameContext: SystemPortalFrameContext = + SystemPortalFrameContext( + inspectedRoot = null, + inspectedLayoutRevision = 0L, + cursorX = 0, + cursorY = 0, + inspectorPointerCaptured = false, + ) + private var knownViewportWidth: Int = 1 + private var knownViewportHeight: Int = 1 + private val domInputRouter: SurfaceDomInputRouter = + SurfaceDomInputRouter( + rootProvider = { + if (activeEntriesTopFirst().any { it.enablesDomInputFallbackRouting() }) rootNode else null + }, + ) + + init { + portalEntries.forEach(portalHost::register) + } + + fun systemInspectorColorPickerService(): SystemColorPickerPortalService = colorPickerEntry + + fun isSystemColorPickerOpen(): Boolean = colorPickerEntry.isOpen() + + fun captureSystemColorPickerEyedropperSample() { + colorPickerEntry.captureEyedropperSample() + } + + fun syncPortalFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + ) { + systemSelectPortal.onFrame( + measureContext = measureContext, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + viewportScale = viewportScale, + ) + } + + fun appendFloatingPortalCommands( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, + ) { + systemSelectPortal.appendCommands( + measureContext = measureContext, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + out = out, + ) + } + + fun hasOpenPortal(): Boolean = systemSelectPortal.isOpen() + + fun handlePortalKeyDown(keyCode: Int, keyChar: Char): Boolean = systemSelectPortal.handleKeyDown(keyCode, keyChar) + + fun handlePortalKeyUp(keyCode: Int, keyChar: Char): Boolean = systemSelectPortal.handleKeyUp(keyCode, keyChar) + + fun handlePortalMouseMove(mouseX: Int, mouseY: Int): Boolean = systemSelectPortal.handleMouseMove(mouseX, mouseY) + + fun handlePortalMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + systemSelectPortal.handleMouseDown(mouseX, mouseY, button) + + fun handlePortalMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + systemSelectPortal.handleMouseUp(mouseX, mouseY, button) + + fun handlePortalMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + systemSelectPortal.handleMouseWheel(mouseX, mouseY, delta) + + override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + knownViewportWidth = viewportWidth.coerceAtLeast(1) + knownViewportHeight = viewportHeight.coerceAtLeast(1) + rootNode.setViewportBounds(knownViewportWidth, knownViewportHeight) + portalHost.onInputFrame(PortalFrameContext(Rect(0, 0, knownViewportWidth, knownViewportHeight))) + entryRegistry.allEntries().forEach { entry -> + entry.onInputFrame(viewportWidth, viewportHeight) + } + } + + fun syncFrame( + inspectedRoot: DOMNode?, + inspectedLayoutRevision: Long, + cursorX: Int, + cursorY: Int, + inspectorPointerCaptured: Boolean, + ) { + frameContext = + SystemPortalFrameContext( + inspectedRoot = inspectedRoot, + inspectedLayoutRevision = inspectedLayoutRevision, + cursorX = cursorX, + cursorY = cursorY, + inspectorPointerCaptured = inspectorPointerCaptured, + ) + rootNode.setViewportBounds(knownViewportWidth, knownViewportHeight) + entryRegistry.allEntries().forEach { entry -> + entry.sync(frameContext) + } + portalEntries.forEach { entry -> + entry.syncPlacement(knownViewportWidth, knownViewportHeight) + } + reconcileMountedEntries() + } + + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + knownViewportWidth = width.coerceAtLeast(1) + knownViewportHeight = height.coerceAtLeast(1) + rootNode.setViewportBounds(width, height) + tree.render(ctx, width, height) + } + + override fun paint(ctx: UiMeasureContext): List = tree.paint(ctx, applyStyles = true) + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleMouseMove(mouseX, mouseY) } }, + domFallbackDispatch = { domInputRouter.handleMouseMove(mouseX, mouseY) }, + ) + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleMouseDown(mouseX, mouseY, button) } }, + domFallbackDispatch = { domInputRouter.handleMouseDown(mouseX, mouseY, button) }, + ) + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleMouseUp(mouseX, mouseY, button) } }, + domFallbackDispatch = { domInputRouter.handleMouseUp(mouseX, mouseY, button) }, + ) + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleMouseWheel(mouseX, mouseY, delta) } }, + domFallbackDispatch = { domInputRouter.handleMouseWheel(mouseX, mouseY, delta) }, + ) + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleKeyDown(keyCode, keyChar) } }, + domFallbackDispatch = { domInputRouter.handleKeyDown(keyCode, keyChar) }, + ) + + override fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = + dispatchManualThenDomFallback( + manualDispatch = { dispatchManualInput { entry -> entry.handleKeyUp(keyCode, keyChar) } }, + domFallbackDispatch = { domInputRouter.handleKeyUp(keyCode, keyChar) }, + ) + + override fun clearRefs() { + tree.clearRefs() + transientOwnershipRegistry.clear() + colorPickerEntry.close() + systemSelectPortal.close() + portalEntries.forEach { it.syncPlacement(knownViewportWidth, knownViewportHeight) } + domInputRouter.clear() + } + + internal fun debugEntryState(id: SystemPortalEntryId): SystemPortalEntryState? = entryRegistry.entry(id)?.state + + internal fun debugEntryNode(id: SystemPortalEntryId): DOMNode? = entryRegistry.entry(id)?.node + + internal fun debugRegisteredEntryIds(): List = entryRegistry.allEntries().map { it.state.id } + + internal fun debugRegisteredPortalEntryIds(): List = portalEntries.map { it.state.id.value } + + internal fun debugActivePortalEntryIds(): List = portalHost.entriesInPaintOrder().map { it.state.id.value } + + internal fun debugMountedEntryIds(): List { + val entriesByNode = portalEntries.associateBy { it.node } + val mountedNodes = + buildList { + addAll(rootNode.mountedLaneNodes(SystemPortalLane.PanelContent)) + addAll(rootNode.mountedLaneNodes(SystemPortalLane.Transient)) + } + return mountedNodes.mapNotNull { node -> + entriesByNode[node] + ?.systemEntry + ?.state + ?.id + } + } + + internal fun resolveTransientSession(ownerToken: Any): SystemPortalTransientSession = + transientOwnershipRegistry.resolve(ownerToken) + + internal fun resolveTransientSession(ownerToken: Any, cursorX: Int, cursorY: Int): SystemPortalTransientSession = + transientOwnershipRegistry.resolve(ownerToken, cursorX, cursorY) + + internal fun releaseTransientSession(ownerToken: Any): Boolean = transientOwnershipRegistry.release(ownerToken) + + internal fun debugTransientSessionCount(): Int = transientOwnershipRegistry.activeSessions().size + + internal fun debugSystemColorPickerHeaderRect(): Rect? = colorPickerEntry.debugHeaderRect() + + internal fun debugSystemColorPickerCloseRect(): Rect? = colorPickerEntry.debugCloseRect() + + internal fun debugSystemColorPickerBodyLayout(): ColorPickerLayout? = colorPickerEntry.debugBodyLayout() + + internal fun debugSystemColorPickerState(): ColorPickerState? = colorPickerEntry.debugState() + + internal fun debugSystemColorPickerPopupOwnerDomain(): ScreenDomainId? = colorPickerEntry.debugOwnerDomain() + + internal fun debugRootBounds(): Rect = rootNode.bounds + + private fun reconcileMountedEntries() { + val activeEntries = + portalHost.entriesInPaintOrder().mapNotNull { + (it as? SystemPortalEntryAdapter)?.systemEntry + } + val panelNodes = + activeEntries + .filter { it.state.lane == SystemPortalLane.PanelContent } + .map { it.node } + val transientNodes = + activeEntries + .filter { it.state.lane == SystemPortalLane.Transient } + .map { it.node } + rootNode.setLaneChildren( + panelNodes = panelNodes, + transientNodes = transientNodes, + ) + } + + private fun activeEntriesTopFirst(): List = + portalHost + .entriesInInputOrder() + .mapNotNull { (it as? SystemPortalEntryAdapter)?.systemEntry } + + private inline fun dispatchManualInput(handler: (SystemPortalEntry) -> Boolean): Boolean = + activeEntriesTopFirst() + .asSequence() + .filter { entry -> !entry.participatesInDomInput() } + .any(handler) + + private class InspectorPortalEntry( + private val inspectorController: InspectorController, + ) : SystemPortalEntry { + override val state: SystemPortalEntryState = + SystemPortalEntryState( + id = SystemPortalEntryId.Inspector, + order = 100, + lane = SystemPortalLane.PanelContent, + ) + private val floatingPanel: FloatingPanel = + FloatingPanel( + ownerId = state.id, + panelState = state.panelState, + dragSession = state.dragSession, + ) + override val node: SystemInspectorPortalNode = + SystemInspectorPortalNode( + controller = inspectorController, + floatingPanel = floatingPanel, + ) + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + + override fun participatesInDomInput(): Boolean = true + + override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + } + + override fun sync(frame: SystemPortalFrameContext) { + node.syncInputBounds(viewportWidth, viewportHeight) + node.bindInspectedTree(frame.inspectedRoot, frame.inspectedLayoutRevision) + node.updateCursor(frame.cursorX, frame.cursorY, frame.inspectorPointerCaptured) + frame.inspectedRoot?.let { root -> + inspectorController.onLayoutCommitted(root, frame.inspectedLayoutRevision) + } + state.active = inspectorController.active + inspectorController.setFloatingPanelAuthorityEnabled(state.active) + if (!state.active) { + state.panelState.hide() + state.dragSession.end() + floatingPanel.syncPanelRect(null) + inspectorController.onFloatingPanelPointerCaptureChanged(false) + return + } + if (inspectorController.panelState == InspectorPanelState.Expanded) { + floatingPanel.configure( + title = "Inspector", + draggable = true, + resizable = true, + minWidth = 240, + minHeight = 160, + style = inspectorPanelStyle(), + onClose = inspectorController::onPanelMinimizeTogglePressed, + ) + val panelRect = inspectorController.floatingExpandedPanelRect() + if (panelRect != null) { + inspectorController.onFloatingPanelRectChanged(panelRect, viewportWidth, viewportHeight) + floatingPanel.syncPanelRect(inspectorController.floatingExpandedPanelRect()) + } else { + state.panelState.show() + floatingPanel.syncPanelRect(state.panelState.currentRectOrNull()) + } + val dragUpdatedByDomInput = node.consumeFloatingPanelDomDragUpdate() + if (!dragUpdatedByDomInput) { + floatingPanel.handleMouseMove( + mouseX = frame.cursorX, + mouseY = frame.cursorY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) { rect -> + inspectorController.onFloatingPanelRectChanged(rect, viewportWidth, viewportHeight) + } + } + } else { + state.panelState.hide() + state.dragSession.end() + floatingPanel.syncPanelRect(null) + inspectorController.onFloatingPanelPointerCaptureChanged(false) + } + } + } + + private class ColorPickerPortalEntry : + SystemPortalEntry, + SystemColorPickerPortalService { + override val state: SystemPortalEntryState = + SystemPortalEntryState( + id = SystemPortalEntryId.ColorPickerPopup, + order = 200, + lane = SystemPortalLane.PanelContent, + ) + private val popupMount: ColorPickerPopupMount = + ColorPickerPopupMount( + ownerId = state.id, + panelState = state.panelState, + dragSession = state.dragSession, + ) + override val node: ColorPickerPopupPortalNode = popupMount.node + private var draggable: Boolean = true + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + private var domDelegatedBodyPressActive: Boolean = false + + override fun enablesDomInputFallbackRouting(): Boolean = true + + override fun sync(frame: SystemPortalFrameContext) { + node.updateCursor(frame.cursorX, frame.cursorY) + state.active = popupMount.popupEngine.isOpenFor(popupMount.ownerToken) + if (!state.active) { + state.panelState.hide() + state.dragSession.end() + node.resetDomInputRoutingReadiness() + domDelegatedBodyPressActive = false + return + } + popupMount.floatingPanel.configure( + title = popupMount.popupEngine.debugTitle(popupMount.ownerToken) ?: "Color Picker", + draggable = draggable, + style = + popupMount.popupEngine + .debugStyle(popupMount.ownerToken) + ?.let { toFloatingPanelStyle(it) } + ?: FloatingPanelStyle(), + onClose = ::close, + ) + val panelRect = popupMount.popupEngine.debugPanelRect(popupMount.ownerToken) + if (panelRect != null) { + popupMount.floatingPanel.syncPanelRect(panelRect) + } else { + state.panelState.show() + popupMount.floatingPanel.syncPanelRect(state.panelState.currentRectOrNull()) + } + if (popupMount.floatingPanel.handleMouseMove( + mouseX = frame.cursorX, + mouseY = frame.cursorY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) { rect -> + popupMount.popupEngine.forcePanelRect(popupMount.ownerToken, rect) + } + ) { + popupMount.popupEngine.onCursorPosition(frame.cursorX, frame.cursorY) + } + node.syncInputFocusForDomEditing() + } + + override fun onInputFrame(viewportWidth: Int, viewportHeight: Int) { + this.viewportWidth = viewportWidth + this.viewportHeight = viewportHeight + popupMount.popupEngine.onFrame(viewportWidth, viewportHeight) + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean { + if (!state.active) return false + if (domDelegatedBodyPressActive) return false + popupMount.popupEngine.onCursorPosition(mouseX, mouseY) + if (popupMount.floatingPanel.handleMouseMove( + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) { rect -> + popupMount.popupEngine.forcePanelRect(popupMount.ownerToken, rect) + } + ) { + popupMount.popupEngine.onCursorPosition(mouseX, mouseY) + return true + } + return popupMount.popupEngine.handleMouseMove(mouseX, mouseY) + } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + if (!state.active) return false + if (button != MouseButton.LEFT) { + domDelegatedBodyPressActive = false + } + if (popupMount.floatingPanel.handleMouseDown(mouseX, mouseY, button)) { + return true + } + if (popupMount.popupEngine.shouldRouteSystemInputSlotMouseDownToDom(mouseX, mouseY, button)) { + return popupMount.popupEngine.focusSystemInputSlotForDomEditing(mouseX, mouseY) { index -> + node.focusInputSlot(index, mouseX, mouseY) + } + } + if ( + node.isDomInputRoutingReady() && + popupMount.popupEngine.shouldRouteSystemBodyIntentMouseDownToDom(mouseX, mouseY, button) + ) { + domDelegatedBodyPressActive = true + return false + } + return popupMount.popupEngine.handleMouseDown(mouseX, mouseY, button) + } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + if (!state.active) return false + if (domDelegatedBodyPressActive && button == MouseButton.LEFT) { + domDelegatedBodyPressActive = false + return false + } + if (popupMount.floatingPanel.handleMouseUp( + mouseX = mouseX, + mouseY = mouseY, + button = button, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ) { rect -> + popupMount.popupEngine.forcePanelRect(popupMount.ownerToken, rect) + } + ) { + return true + } + return popupMount.popupEngine.handleMouseUp(mouseX, mouseY, button) + } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean { + if (!state.active) return false + return popupMount.popupEngine.handleMouseWheel(mouseX, mouseY, delta) + } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean { + if (!state.active) return false + if (shouldRouteSystemTextInputKeyDownToDom()) { + return false + } + return popupMount.popupEngine.handleKeyDown(keyCode, keyChar) + } + + private fun shouldRouteSystemTextInputKeyDownToDom(): Boolean { + if (popupMount.popupEngine.debugOwnerDomain(popupMount.ownerToken) != ScreenDomainId.System) return false + val focused = FocusManager.focusedNode() ?: return false + if (focused !is SingleLineInputNode) return false + val key = focused.key as? String ?: return false + return key.startsWith("dsgl-system-color-picker-input-value-") + } + + override fun open( + anchorRect: Rect, + title: String, + state: ColorPickerState, + style: ColorPickerStyle, + width: Int, + draggable: Boolean, + closeOnOutsideClick: Boolean, + onPreview: ((RgbaColor) -> Unit)?, + onChange: ((RgbaColor) -> Unit)?, + onCommit: ((RgbaColor) -> Unit)?, + onClose: (() -> Unit)?, + ) { + this.draggable = draggable + popupMount.popupEngine.open( + ColorPickerPopupRequest( + owner = popupMount.ownerToken, + ownerDomain = ScreenDomainId.System, + anchorRect = anchorRect, + title = title, + state = state, + style = style, + width = width, + draggable = false, + closeOnOutsideClick = closeOnOutsideClick, + onPreview = onPreview, + onChange = onChange, + onCommit = onCommit, + onClose = onClose, + ), + ) + } + + override fun close() { + popupMount.popupEngine.close(popupMount.ownerToken) + state.dragSession.end() + state.panelState.hide() + state.active = false + node.resetDomInputRoutingReadiness() + domDelegatedBodyPressActive = false + } + + override fun isOpen(): Boolean = popupMount.popupEngine.isOpenFor(popupMount.ownerToken) + + fun transientPortalNode(): DOMNode = popupMount.transientNode + + fun isTransientActive(): Boolean { + val controller = popupMount.popupEngine.debugController(popupMount.ownerToken) ?: return false + return controller.viewModeDropdownOpen() || controller.isEyedropperActive() + } + + fun debugHeaderRect(): Rect? = popupMount.floatingPanel.headerRect() + + fun debugCloseRect(): Rect? = popupMount.floatingPanel.closeRect() + + fun debugBodyLayout(): ColorPickerLayout? = popupMount.popupEngine.debugBodyLayout(popupMount.ownerToken) + + fun debugState(): ColorPickerState? = + popupMount.popupEngine + .debugController(popupMount.ownerToken) + ?.snapshot() + + fun captureEyedropperSample() { + if (popupMount.popupEngine.captureEyedropperSample()) { + popupMount.node.invalidateColorState() + popupMount.transientNode.invalidateColorState() + } + } + + fun debugOwnerDomain(): ScreenDomainId? = popupMount.popupEngine.debugOwnerDomain(popupMount.ownerToken) + } + + private class ColorPickerTransientPortalEntry( + private val panelEntry: ColorPickerPortalEntry, + ) : SystemPortalEntry { + override val state: SystemPortalEntryState = + SystemPortalEntryState( + id = SystemPortalEntryId.ColorPickerTransient, + order = 210, + lane = SystemPortalLane.Transient, + ) + override val node: DOMNode = panelEntry.transientPortalNode() + + override fun sync(frame: SystemPortalFrameContext) { + state.active = panelEntry.state.active && panelEntry.isTransientActive() + } + } + + private companion object { + private fun inspectorPanelStyle(): FloatingPanelStyle = + FloatingPanelStyle( + headerHeight = 52, + panelPadding = 6, + resizeHandleSize = 8, + panelBackgroundColor = 0xE0141820.toInt(), + panelBorderColor = 0xCC425062.toInt(), + panelShadowColor = 0x7A0C1118, + headerBackgroundColor = 0x222D3846, + headerBorderColor = 0x553F4A57, + closeButtonBackgroundColor = 0x3346596E, + closeButtonBorderColor = 0x775E738C, + textColor = 0xFFE6EDF6.toInt(), + fontSize = 24, + closeGlyph = "-", + ) + + private fun toFloatingPanelStyle(style: ColorPickerStyle): FloatingPanelStyle = + FloatingPanelStyle( + panelBackgroundColor = style.panelBackgroundColor, + panelBorderColor = style.panelBorderColor, + panelShadowColor = style.panelShadowColor, + headerBackgroundColor = style.buttonBackgroundColor, + headerBorderColor = style.inputBorderColor, + closeButtonBackgroundColor = style.buttonBackgroundColor, + closeButtonBorderColor = style.inputBorderColor, + textColor = style.textColor, + fontSize = style.fontSize, + ) + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRawRenderCommandNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRawRenderCommandNode.kt similarity index 80% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRawRenderCommandNode.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRawRenderCommandNode.kt index 04190ed..603f8b7 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRawRenderCommandNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRawRenderCommandNode.kt @@ -1,4 +1,4 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.layout.Rect @@ -6,9 +6,9 @@ import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.render.RenderCommand -internal class SystemOverlayRawRenderCommandNode( +internal class SystemPortalRawRenderCommandNode( renderCommand: RenderCommand, - key: Any? + key: Any?, ) : DOMNode(key) { override val styleType: String = "dsgl-system-raw-render-command" private var renderCommand: RenderCommand = renderCommand @@ -24,7 +24,13 @@ internal class SystemOverlayRawRenderCommandNode( override fun measure(ctx: UiMeasureContext): Size = Size(0, 0) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) } @@ -32,7 +38,5 @@ internal class SystemOverlayRawRenderCommandNode( out += renderCommand } - override fun volatileRenderCommandsSignature(nowMs: Long): Long { - return signature - } + override fun volatileRenderCommandsSignature(nowMs: Long): Long = signature } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRootNode.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRootNode.kt similarity index 54% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRootNode.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRootNode.kt index e1476a1..277035a 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayRootNode.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalRootNode.kt @@ -1,41 +1,44 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system import org.dreamfinity.dsgl.core.DsglColors -import org.dreamfinity.dsgl.core.dsl.UiScope -import org.dreamfinity.dsgl.core.debug.OverlayLayerDebugState.isTintEnabled +import org.dreamfinity.dsgl.core.debug.DomainSurfaceDebugState.isTintEnabled import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Border import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.UiScope import org.dreamfinity.dsgl.core.dsl.div import org.dreamfinity.dsgl.core.font.FontRegistry -import org.dreamfinity.dsgl.core.overlay.OverlayDebugVisualization -import org.dreamfinity.dsgl.core.overlay.UiLayerId +import org.dreamfinity.dsgl.core.portal.DomainSurfaceDebugVisualization +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.StyleEngine -internal class SystemOverlayRootNode( - key: Any? = "dsgl-system-overlay-root" +internal class SystemPortalRootNode( + key: Any? = "dsgl-system-portal-root", ) : DOMNode(key) { - override val styleType: String = "dsgl-system-overlay-root" + override val styleType: String = "dsgl-system-portal-root" private var viewportWidth: Int = 0 private var viewportHeight: Int = 0 - private val debugTintNode: ContainerNode = UiScope(this).div({ - this.key = "dsgl-system-overlay-debug-tint" - style = { - display = Display.None - } - }) - private val panelLaneNode: SystemOverlayLaneNode = SystemOverlayLaneNode( - key = "dsgl-system-overlay-panel-lane", - laneStyleType = "dsgl-system-overlay-panel-lane" - ) - private val transientLaneNode: SystemOverlayLaneNode = SystemOverlayLaneNode( - key = "dsgl-system-overlay-transient-lane", - laneStyleType = "dsgl-system-overlay-transient-lane" - ) + private val debugTintNode: ContainerNode = + UiScope(this).div({ + this.key = "dsgl-system-portal-debug-tint" + style = { + display = Display.None + } + }) + private val panelLaneNode: SystemPortalLaneNode = + SystemPortalLaneNode( + key = "dsgl-system-portal-panel-lane", + laneStyleType = "dsgl-system-portal-panel-lane", + ) + private val transientLaneNode: SystemPortalLaneNode = + SystemPortalLaneNode( + key = "dsgl-system-portal-transient-lane", + laneStyleType = "dsgl-system-portal-transient-lane", + ) init { panelLaneNode.parent = this @@ -53,38 +56,40 @@ internal class SystemOverlayRootNode( transientLaneNode.bounds = rect } - internal fun setLaneChildren( - panelNodes: List, - transientNodes: List - ) { + internal fun setLaneChildren(panelNodes: List, transientNodes: List) { reconcileLane(panelLaneNode, panelNodes) reconcileLane(transientLaneNode, transientNodes) } - internal fun mountedLaneNodes(lane: SystemOverlayLane): List { - return when (lane) { - SystemOverlayLane.PanelContent -> panelLaneNode.children.toList() - SystemOverlayLane.Transient -> transientLaneNode.children.toList() + internal fun mountedLaneNodes(lane: SystemPortalLane): List = + when (lane) { + SystemPortalLane.PanelContent -> panelLaneNode.children.toList() + SystemPortalLane.Transient -> transientLaneNode.children.toList() } - } override fun measure(ctx: UiMeasureContext): Size { val resolvedWidth = if (viewportWidth > 0) viewportWidth else StyleEngine.viewportWidthPx().coerceAtLeast(0) val resolvedHeight = if (viewportHeight > 0) viewportHeight else StyleEngine.viewportHeightPx().coerceAtLeast(0) return Size( width = resolvedWidth, - height = resolvedHeight + height = resolvedHeight, ) } - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { setViewportBounds(width, height) bounds = Rect(0, 0, viewportWidth, viewportHeight) - val tintEnabled = OverlayDebugVisualization.enabled && isTintEnabled(UiLayerId.SystemOverlay) + val tintEnabled = DomainSurfaceDebugVisualization.enabled && isTintEnabled(ScreenDomainSurfaces.SystemPortal) if (tintEnabled) { debugTintNode.display = Display.Block - debugTintNode.backgroundColor = OverlayDebugVisualization.systemOverlayFillColor - debugTintNode.border = Border.all(1, OverlayDebugVisualization.systemOverlayBorderColor) + debugTintNode.backgroundColor = DomainSurfaceDebugVisualization.systemPortalFillColor + debugTintNode.border = Border.all(1, DomainSurfaceDebugVisualization.systemPortalBorderColor) debugTintNode.render(ctx, bounds.x, bounds.y, bounds.width, bounds.height) } else { debugTintNode.display = Display.None @@ -100,10 +105,11 @@ internal class SystemOverlayRootNode( override fun defaultFontSize(): Int = 16 - private fun reconcileLane(laneNode: SystemOverlayLaneNode, desiredNodes: List) { + private fun reconcileLane(laneNode: SystemPortalLaneNode, desiredNodes: List) { val currentNodes = laneNode.children - val unchanged = currentNodes.size == desiredNodes.size && - currentNodes.indices.all { index -> currentNodes[index] === desiredNodes[index] } + val unchanged = + currentNodes.size == desiredNodes.size && + currentNodes.indices.all { index -> currentNodes[index] === desiredNodes[index] } if (unchanged) return currentNodes.forEach { node -> node.parent = null @@ -116,17 +122,22 @@ internal class SystemOverlayRootNode( } } -private class SystemOverlayLaneNode( +private class SystemPortalLaneNode( key: Any?, - private val laneStyleType: String + private val laneStyleType: String, ) : DOMNode(key) { override val styleType: String = laneStyleType - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } + override fun measure(ctx: UiMeasureContext): Size = + Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) children.forEach { child -> child.render(ctx, x, y, width, height) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/render/RenderCommand.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/render/RenderCommand.kt index ddcfd29..ad54820 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/render/RenderCommand.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/render/RenderCommand.kt @@ -13,7 +13,7 @@ sealed class RenderCommand { val y: Int, val width: Int, val height: Int, - val color: Int + val color: Int, ) : RenderCommand() /** Saturation/value picker field for color picker UI. */ @@ -22,7 +22,7 @@ sealed class RenderCommand { val y: Int, val width: Int, val height: Int, - val hueDeg: Float + val hueDeg: Float, ) : RenderCommand() /** Hue gradient bar for color picker UI. */ @@ -30,7 +30,7 @@ sealed class RenderCommand { val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, ) : RenderCommand() /** Alpha gradient bar for color picker UI over existing checker background. */ @@ -39,7 +39,7 @@ sealed class RenderCommand { val y: Int, val width: Int, val height: Int, - val rgbColor: Int + val rgbColor: Int, ) : RenderCommand() /** Procedural checkerboard background rendered efficiently by backend. */ @@ -52,7 +52,7 @@ sealed class RenderCommand { val lightColor: Int, val darkColor: Int, val offsetX: Int = 0, - val offsetY: Int = 0 + val offsetY: Int = 0, ) : RenderCommand() /** Text draw command. */ @@ -70,14 +70,14 @@ sealed class RenderCommand { val strikethrough: Boolean = false, val obfuscated: Boolean = false, val textStyleSpans: List = emptyList(), - val sourceKey: String? = null + val sourceKey: String? = null, ) : RenderCommand() { /** * Returns a DrawText command with replaced color while preserving * all other fields. This avoids relying on Kotlin synthetic copy$default ABI. */ - fun withColor(newColor: Int): DrawText { - return DrawText( + fun withColor(newColor: Int): DrawText = + DrawText( text = text, x = x, y = y, @@ -91,9 +91,8 @@ sealed class RenderCommand { strikethrough = strikethrough, obfuscated = obfuscated, textStyleSpans = textStyleSpans, - sourceKey = sourceKey + sourceKey = sourceKey, ) - } } data class TextStyleSpan( @@ -104,7 +103,7 @@ sealed class RenderCommand { val italic: Boolean, val underline: Boolean, val strikethrough: Boolean, - val obfuscated: Boolean + val obfuscated: Boolean, ) /** Image draw command. */ @@ -113,7 +112,7 @@ sealed class RenderCommand { val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, ) : RenderCommand() /** @@ -125,7 +124,7 @@ sealed class RenderCommand { val sourceY: Int, val sourceWidth: Int, val sourceHeight: Int, - val fallbackColor: Int + val fallbackColor: Int, ) : RenderCommand() /** Draws previously captured screen region as a magnified textured quad. */ @@ -133,9 +132,18 @@ sealed class RenderCommand { val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, + val grid: CapturedGrid? = null, ) : RenderCommand() + /** Optional grid rendered by backend as part of captured-region magnifier pass. */ + data class CapturedGrid( + val columns: Int, + val rows: Int, + val magnification: Int, + val color: Int, + ) + /** Item stack draw command. */ data class DrawItemStack( val stack: ItemStackRef, @@ -144,7 +152,7 @@ sealed class RenderCommand { val width: Int, val size: Int, val rotYDeg: Double = 0.0, - val rotXDeg: Double = 0.0 + val rotXDeg: Double = 0.0, ) : RenderCommand() /** Pushes a clipping rectangle (GUI coordinates, top-left origin). */ @@ -152,7 +160,7 @@ sealed class RenderCommand { val x: Int, val y: Int, val width: Int, - val height: Int + val height: Int, ) : RenderCommand() /** Pops the current clipping rectangle. */ @@ -166,7 +174,7 @@ sealed class RenderCommand { val translateY: Float, val scaleX: Float, val scaleY: Float, - val rotateDeg: Float + val rotateDeg: Float, ) : RenderCommand() /** Pops current transform. */ @@ -174,7 +182,7 @@ sealed class RenderCommand { /** Multiplies current alpha by opacity (0..1). */ data class PushOpacity( - val opacity: Float + val opacity: Float, ) : RenderCommand() /** Pops current opacity multiplier. */ diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectDsl.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectDsl.kt index 8b19848..d4c4212 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectDsl.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectDsl.kt @@ -3,16 +3,13 @@ package org.dreamfinity.dsgl.core.select @DslMarker annotation class SelectDsl -fun selectModel( - id: String? = null, - block: SelectModelBuilder.() -> Unit -): SelectModel { +fun selectModel(id: String? = null, block: SelectModelBuilder.() -> Unit): SelectModel { val builder = SelectModelBuilder() builder.block() return SelectModel( id = id, placeholderProvider = builder.placeholderProvider, - entries = builder.buildEntries() + entries = builder.buildEntries(), ) } @@ -29,52 +26,38 @@ class SelectModelBuilder { placeholderProvider = provider } - fun option( - id: String, - label: String, - block: SelectOptionBuilder.() -> Unit = {} - ) { + fun option(id: String, label: String, block: SelectOptionBuilder.() -> Unit = {}) { option(id = id, labelProvider = { label }, block = block) } - fun option( - id: String, - labelProvider: () -> String, - block: SelectOptionBuilder.() -> Unit = {} - ) { + fun option(id: String, labelProvider: () -> String, block: SelectOptionBuilder.() -> Unit = {}) { val builder = SelectOptionBuilder() builder.block() - entries += SelectEntry.Option( - id = id, - labelProvider = labelProvider, - enabledProvider = builder.enabledProvider - ) + entries += + SelectEntry.Option( + id = id, + labelProvider = labelProvider, + enabledProvider = builder.enabledProvider, + ) } fun separator(id: String? = null) { entries += SelectEntry.Separator(id = id) } - fun group( - label: String, - id: String? = null, - block: SelectGroupBuilder.() -> Unit - ) { + fun group(label: String, id: String? = null, block: SelectGroupBuilder.() -> Unit) { group(labelProvider = { label }, id = id, block = block) } - fun group( - labelProvider: () -> String, - id: String? = null, - block: SelectGroupBuilder.() -> Unit - ) { + fun group(labelProvider: () -> String, id: String? = null, block: SelectGroupBuilder.() -> Unit) { val builder = SelectGroupBuilder() builder.block() - entries += SelectEntry.Group( - id = id, - labelProvider = labelProvider, - entries = builder.buildEntries() - ) + entries += + SelectEntry.Group( + id = id, + labelProvider = labelProvider, + entries = builder.buildEntries(), + ) } internal fun buildEntries(): List = entries.toList() @@ -84,52 +67,38 @@ class SelectModelBuilder { class SelectGroupBuilder { private val entries: MutableList = ArrayList() - fun option( - id: String, - label: String, - block: SelectOptionBuilder.() -> Unit = {} - ) { + fun option(id: String, label: String, block: SelectOptionBuilder.() -> Unit = {}) { option(id = id, labelProvider = { label }, block = block) } - fun option( - id: String, - labelProvider: () -> String, - block: SelectOptionBuilder.() -> Unit = {} - ) { + fun option(id: String, labelProvider: () -> String, block: SelectOptionBuilder.() -> Unit = {}) { val builder = SelectOptionBuilder() builder.block() - entries += SelectEntry.Option( - id = id, - labelProvider = labelProvider, - enabledProvider = builder.enabledProvider - ) + entries += + SelectEntry.Option( + id = id, + labelProvider = labelProvider, + enabledProvider = builder.enabledProvider, + ) } fun separator(id: String? = null) { entries += SelectEntry.Separator(id = id) } - fun group( - label: String, - id: String? = null, - block: SelectGroupBuilder.() -> Unit - ) { + fun group(label: String, id: String? = null, block: SelectGroupBuilder.() -> Unit) { group(labelProvider = { label }, id = id, block = block) } - fun group( - labelProvider: () -> String, - id: String? = null, - block: SelectGroupBuilder.() -> Unit - ) { + fun group(labelProvider: () -> String, id: String? = null, block: SelectGroupBuilder.() -> Unit) { val builder = SelectGroupBuilder() builder.block() - entries += SelectEntry.Group( - id = id, - labelProvider = labelProvider, - entries = builder.buildEntries() - ) + entries += + SelectEntry.Group( + id = id, + labelProvider = labelProvider, + entries = builder.buildEntries(), + ) } internal fun buildEntries(): List = entries.toList() diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectEngine.kt index 51ca007..7aa8650 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectEngine.kt @@ -12,8 +12,8 @@ import org.dreamfinity.dsgl.core.render.RenderCommand class SelectEngine( private val clock: SelectClock = SystemSelectClock, - private val measurementCache: SelectMeasurementCache = SelectMeasurementCache() -) : SelectHost { + private val measurementCache: SelectMeasurementCache = SelectMeasurementCache(), +) { private data class PopupState( val owner: Any, var modelToken: Long, @@ -31,7 +31,8 @@ class SelectEngine( var highlightedIndex: Int = -1, var scrollOffset: Int = 0, var scrollbarDragging: Boolean = false, - var scrollbarDragThumbOffsetY: Int = 0 + var scrollbarDragThumbOffsetY: Int = 0, + var pressedOptionIndex: Int = -1, ) data class Snapshot( @@ -40,14 +41,14 @@ class SelectEngine( val highlightedIndex: Int, val hoveredIndex: Int, val scrollOffset: Int, - val animationProgress: Float + val animationProgress: Float, ) private enum class VisibilityState { Hidden, Opening, Open, - Closing + Closing, } private var popup: PopupState? = null @@ -76,7 +77,7 @@ class SelectEngine( fun measurementComputeCount(): Long = measurementCache.computeCount - override fun open(request: SelectOpenRequest) { + fun open(request: SelectOpenRequest) { if (request.entries.isEmpty()) { close(request.owner) return @@ -96,18 +97,19 @@ class SelectEngine( animationProgress = 0f onClose?.invoke() } - popup = PopupState( - owner = request.owner, - modelToken = request.modelToken, - entries = request.entries, - selectedId = request.selectedId, - anchorRect = request.anchorRect, - closeOnSelect = request.closeOnSelect, - onSelect = request.onSelect, - onClose = request.onClose, - fontId = request.fontId, - fontSize = request.fontSize - ) + popup = + PopupState( + owner = request.owner, + modelToken = request.modelToken, + entries = request.entries, + selectedId = request.selectedId, + anchorRect = request.anchorRect, + closeOnSelect = request.closeOnSelect, + onSelect = request.onSelect, + onClose = request.onClose, + fontId = request.fontId, + fontSize = request.fontSize, + ) typeAheadBuffer = "" typeAheadLastInputMs = 0L ensureHighlightValid(popup!!) @@ -125,13 +127,13 @@ class SelectEngine( } } - override fun close(owner: Any) { + fun close(owner: Any) { val current = popup ?: return if (current.owner != owner) return startVisibilityTransition(0f, style.closeDurationMs) } - override fun closeAll() { + fun closeAll() { val current = popup ?: return val onClose = current.onClose popup = null @@ -143,12 +145,17 @@ class SelectEngine( onClose?.invoke() } - override fun isOpenFor(owner: Any): Boolean { + fun isOpenFor(owner: Any): Boolean { val current = popup ?: return false return current.owner == owner && visibilityState != VisibilityState.Hidden } - override fun isOpen(): Boolean = popup != null && visibilityState != VisibilityState.Hidden + internal fun isClosingFor(owner: Any): Boolean { + val current = popup ?: return false + return current.owner == owner && visibilityState == VisibilityState.Closing + } + + fun isOpen(): Boolean = popup != null && visibilityState != VisibilityState.Hidden fun snapshot(): Snapshot { val current = popup @@ -158,7 +165,7 @@ class SelectEngine( highlightedIndex = current?.highlightedIndex ?: -1, hoveredIndex = current?.hoveredIndex ?: -1, scrollOffset = current?.scrollOffset ?: 0, - animationProgress = animationProgress + animationProgress = animationProgress, ) } @@ -174,11 +181,34 @@ class SelectEngine( return current.anchorRect } + internal fun shouldCaptureScrollbarDrag(mouseX: Int, mouseY: Int): Boolean { + val current = popup ?: return false + if (visibilityState == VisibilityState.Hidden) return false + ensureLayout() + return scrollbarTrackRect(current)?.contains(mouseX, mouseY) == true + } + + internal fun isScrollbarDragging(): Boolean = popup?.scrollbarDragging == true + + internal fun cancelScrollbarDrag() { + popup?.let { + it.scrollbarDragging = false + it.pressedOptionIndex = -1 + } + } + + internal fun debugScrollbarTrackRect(owner: Any): Rect? { + val current = popup ?: return null + if (current.owner != owner) return null + ensureLayout() + return scrollbarTrackRect(current) + } + fun onFrame( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - viewportScale: Float = 1f + viewportScale: Float = 1f, ) { lastMeasureContext = measureContext if (this.viewportWidth != viewportWidth || this.viewportHeight != viewportHeight) { @@ -194,11 +224,11 @@ class SelectEngine( ensureLayout() } - fun appendOverlayCommands( + fun appendPortalCommands( measureContext: UiMeasureContext, viewportWidth: Int, viewportHeight: Int, - out: MutableList + out: MutableList, ) { if (!isOpen()) return onFrame(measureContext, viewportWidth, viewportHeight, viewportScale) @@ -215,15 +245,16 @@ class SelectEngine( if (progress < 0.999f) { val scale = style.openStartScale + (1f - style.openStartScale) * progress val translateY = style.openStartOffsetYPx * (1f - progress) - out += RenderCommand.PushTransform( - originX = panel.x + panel.width * 0.5f, - originY = panel.y.toFloat(), - translateX = 0f, - translateY = translateY, - scaleX = scale, - scaleY = scale, - rotateDeg = 0f - ) + out += + RenderCommand.PushTransform( + originX = panel.x + panel.width * 0.5f, + originY = panel.y.toFloat(), + translateX = 0f, + translateY = translateY, + scaleX = scale, + scaleY = scale, + rotateDeg = 0f, + ) out += RenderCommand.PushOpacity(progress) } @@ -268,59 +299,65 @@ class SelectEngine( val isHovered = index == current.hoveredIndex val isSelected = snapshot.optionId != null && snapshot.optionId == current.selectedId if (isHovered && snapshot.kind == SelectMeasurementCache.KIND_OPTION) { - out += RenderCommand.DrawRect( - rowRect.x, - rowRect.y, - rowRect.width, - rowRect.height, - style.optionHoverBackgroundColor - ) + out += + RenderCommand.DrawRect( + rowRect.x, + rowRect.y, + rowRect.width, + rowRect.height, + style.optionHoverBackgroundColor, + ) } else if (isSelected && snapshot.kind == SelectMeasurementCache.KIND_OPTION) { - out += RenderCommand.DrawRect( - rowRect.x, - rowRect.y, - rowRect.width, - rowRect.height, - style.optionSelectedBackgroundColor - ) + out += + RenderCommand.DrawRect( + rowRect.x, + rowRect.y, + rowRect.width, + rowRect.height, + style.optionSelectedBackgroundColor, + ) } val textY = rowRect.y + ((rowRect.height - fontHeight).coerceAtLeast(0) / 2) if (snapshot.kind == SelectMeasurementCache.KIND_OPTION) { val markerX = rowRect.x + style.rowPaddingX if (isSelected) { - out += RenderCommand.DrawText( - text = style.checkGlyph, - x = markerX, + out += + RenderCommand.DrawText( + text = style.checkGlyph, + x = markerX, + y = textY, + color = style.markerColor, + fontId = fontId, + fontSize = fontSize, + ) + } + val labelX = + markerX + + style.markerColumnWidth + + style.markerGap + + snapshot.depth * style.groupIndentX + val color = if (snapshot.enabled) style.optionTextColor else style.disabledTextColor + out += + RenderCommand.DrawText( + text = snapshot.label, + x = labelX, y = textY, - color = style.markerColor, + color = color, fontId = fontId, - fontSize = fontSize + fontSize = fontSize, ) - } - val labelX = markerX + - style.markerColumnWidth + - style.markerGap + - snapshot.depth * style.groupIndentX - val color = if (snapshot.enabled) style.optionTextColor else style.disabledTextColor - out += RenderCommand.DrawText( - text = snapshot.label, - x = labelX, - y = textY, - color = color, - fontId = fontId, - fontSize = fontSize - ) } else if (snapshot.kind == SelectMeasurementCache.KIND_GROUP) { val labelX = rowRect.x + style.rowPaddingX + snapshot.depth * style.groupIndentX - out += RenderCommand.DrawText( - text = snapshot.label, - x = labelX, - y = textY, - color = style.groupTextColor, - fontId = fontId, - fontSize = fontSize - ) + out += + RenderCommand.DrawText( + text = snapshot.label, + x = labelX, + y = textY, + color = style.groupTextColor, + fontId = fontId, + fontSize = fontSize, + ) } } } @@ -330,22 +367,24 @@ class SelectEngine( if (hasScrollbar) { val track = scrollbarTrackRect(current) if (track != null) { - out += RenderCommand.DrawRect( - track.x, - track.y, - track.width, - track.height, - style.scrollbarTrackColor - ) + out += + RenderCommand.DrawRect( + track.x, + track.y, + track.width, + track.height, + style.scrollbarTrackColor, + ) val thumb = scrollbarThumbRect(current, track) if (thumb != null) { - out += RenderCommand.DrawRect( - thumb.x, - thumb.y, - thumb.width, - thumb.height, - style.scrollbarThumbColor - ) + out += + RenderCommand.DrawRect( + thumb.x, + thumb.y, + thumb.width, + thumb.height, + style.scrollbarThumbColor, + ) } } } @@ -370,7 +409,10 @@ class SelectEngine( val hit = entryAt(current, mouseX, mouseY) current.hoveredIndex = hit if (hit >= 0) { - val snapshot = current.measurement?.snapshots?.getOrNull(hit) + val snapshot = + current.measurement + ?.snapshots + ?.getOrNull(hit) if (snapshot != null && snapshot.kind == SelectMeasurementCache.KIND_OPTION && snapshot.enabled) { current.highlightedIndex = hit } @@ -378,24 +420,39 @@ class SelectEngine( return true } - fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + handleMouseDown(mouseX, mouseY, button, closeOutside = true, consumeOutside = true) + + internal fun handlePortalMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + handleMouseDown(mouseX, mouseY, button, closeOutside = true, consumeOutside = false) + + private fun handleMouseDown( + mouseX: Int, + mouseY: Int, + button: MouseButton, + closeOutside: Boolean, + consumeOutside: Boolean, + ): Boolean { val current = popup ?: return false if (visibilityState == VisibilityState.Hidden) return false ensureLayout() - if (!current.panelRect.contains(mouseX, mouseY)) { - startVisibilityTransition(0f, style.closeDurationMs) - return true - } - if (button != MouseButton.LEFT && button != MouseButton.RIGHT) { - return true - } - if (button == MouseButton.LEFT && startScrollbarDragIfNeeded(current, mouseX, mouseY)) { - return true + current.pressedOptionIndex = -1 + return when { + !current.panelRect.contains(mouseX, mouseY) -> { + if (closeOutside) { + startVisibilityTransition(0f, style.closeDurationMs) + } + consumeOutside + } + + button != MouseButton.LEFT && button != MouseButton.RIGHT -> true + button == MouseButton.LEFT && startScrollbarDragIfNeeded(current, mouseX, mouseY) -> true + else -> { + val entryIndex = entryAt(current, mouseX, mouseY) + current.pressedOptionIndex = entryIndex + true + } } - val entryIndex = entryAt(current, mouseX, mouseY) - if (entryIndex < 0) return true - activate(current, entryIndex) - return true } fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { @@ -403,10 +460,24 @@ class SelectEngine( if (visibilityState == VisibilityState.Hidden) return false if (button == MouseButton.LEFT && current.scrollbarDragging) { current.scrollbarDragging = false + current.pressedOptionIndex = -1 return true } return when (button) { - MouseButton.LEFT, MouseButton.RIGHT, MouseButton.MIDDLE -> true + MouseButton.LEFT, MouseButton.RIGHT -> { + val pressedOptionIndex = current.pressedOptionIndex + val releaseIndex = entryAt(current, mouseX, mouseY) + current.pressedOptionIndex = -1 + if (pressedOptionIndex >= 0 && pressedOptionIndex == releaseIndex) { + activate(current, pressedOptionIndex) + } + true + } + + MouseButton.MIDDLE -> { + current.pressedOptionIndex = -1 + true + } } } @@ -483,25 +554,28 @@ class SelectEngine( val ctx = lastMeasureContext ?: return if (!layoutDirty) return - val measurement = measurementCache.measure( - modelToken = current.modelToken, - entries = current.entries, - style = style, - ctx = ctx, - dpiScale = viewportScale, - fontId = current.fontId, - fontSize = current.fontSize - ) + val measurement = + measurementCache.measure( + modelToken = current.modelToken, + entries = current.entries, + style = style, + ctx = ctx, + dpiScale = viewportScale, + fontId = current.fontId, + fontSize = current.fontSize, + ) current.measurement = measurement val desiredHeight = measurement.totalContentHeight + style.panelPaddingY * 2 val maxPanelHeight = (viewportHeight - style.maxPanelHeightPadding * 2).coerceAtLeast(1) val panelHeight = desiredHeight.coerceIn(1, maxPanelHeight) val overflow = measurement.totalContentHeight > (panelHeight - style.panelPaddingY * 2).coerceAtLeast(1) - val desiredWidth = maxOf( - current.anchorRect.width.coerceAtLeast(1), - measurement.panelWidth + style.panelPaddingX * 2 + if (overflow) scrollbarReservedWidth() else 0, - style.minPanelWidth - ) + val desiredWidth = + maxOf( + current.anchorRect.width + .coerceAtLeast(1), + measurement.panelWidth + style.panelPaddingX * 2 + if (overflow) scrollbarReservedWidth() else 0, + style.minPanelWidth, + ) val maxPanelWidth = (viewportWidth - style.maxPanelWidthPadding * 2).coerceAtLeast(1) val panelWidth = desiredWidth.coerceIn(1, maxPanelWidth) @@ -511,14 +585,15 @@ class SelectEngine( val aboveSpace = current.anchorRect.y - style.viewportPadding val preferredY = if (belowSpace < panelHeight && aboveSpace > belowSpace) aboveY else belowY - val placement = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = Rect(current.anchorRect.x, preferredY, panelWidth, panelHeight), - popupSize = Size(panelWidth, panelHeight), - viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), - padding = style.viewportPadding + val placement = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = Rect(current.anchorRect.x, preferredY, panelWidth, panelHeight), + popupSize = Size(panelWidth, panelHeight), + viewport = Rect(0, 0, viewportWidth.coerceAtLeast(1), viewportHeight.coerceAtLeast(1)), + padding = style.viewportPadding, + ), ) - ) current.panelRect = placement.rect val maxScroll = maxScroll(current) current.scrollOffset = current.scrollOffset.coerceIn(0, maxScroll) @@ -544,13 +619,14 @@ class SelectEngine( private fun moveSelection(current: PopupState, direction: Int) { val measurement = current.measurement ?: return if (measurement.snapshots.isEmpty()) return - val start = if (current.highlightedIndex >= 0) { - current.highlightedIndex - } else if (direction >= 0) { - -1 - } else { - measurement.snapshots.size - } + val start = + if (current.highlightedIndex >= 0) { + current.highlightedIndex + } else if (direction >= 0) { + -1 + } else { + measurement.snapshots.size + } var index = start repeat(measurement.snapshots.size) { index += direction @@ -642,7 +718,8 @@ class SelectEngine( val measurement = current.measurement ?: return null if (entryIndex !in measurement.entryOffsets.indices) return null val rowX = current.panelRect.x + style.panelPaddingX - val rowY = current.panelRect.y + style.panelPaddingY + measurement.entryOffsets[entryIndex] - current.scrollOffset + val rowY = + current.panelRect.y + style.panelPaddingY + measurement.entryOffsets[entryIndex] - current.scrollOffset val rowW = contentWidth(current) val rowH = measurement.entryHeights[entryIndex] return Rect(rowX, rowY, rowW, rowH) @@ -669,18 +746,15 @@ class SelectEngine( current.scrollOffset = current.scrollOffset.coerceIn(0, maxScroll(current)) } - private fun contentHeight(current: PopupState): Int { - return (current.panelRect.height - style.panelPaddingY * 2).coerceAtLeast(1) - } + private fun contentHeight(current: PopupState): Int = + (current.panelRect.height - style.panelPaddingY * 2).coerceAtLeast(1) private fun contentWidth(current: PopupState): Int { val reserved = if (maxScroll(current) > 0) scrollbarReservedWidth() else 0 return (current.panelRect.width - style.panelPaddingX * 2 - reserved).coerceAtLeast(1) } - private fun scrollbarReservedWidth(): Int { - return (style.scrollbarGap + style.scrollbarWidth).coerceAtLeast(0) - } + private fun scrollbarReservedWidth(): Int = (style.scrollbarGap + style.scrollbarWidth).coerceAtLeast(0) private fun scrollbarTrackRect(current: PopupState): Rect? { if (maxScroll(current) <= 0) return null @@ -700,15 +774,17 @@ class SelectEngine( val trackHeight = trackRect.height.coerceAtLeast(1) val visibleHeight = contentHeight(current).coerceAtLeast(1) val rawThumb = ((visibleHeight.toLong() * trackHeight.toLong()) / totalContentHeight.toLong()).toInt() - val thumbHeight = rawThumb - .coerceAtLeast(style.scrollbarMinThumbHeight.coerceAtLeast(1)) - .coerceAtMost(trackHeight) + val thumbHeight = + rawThumb + .coerceAtLeast(style.scrollbarMinThumbHeight.coerceAtLeast(1)) + .coerceAtMost(trackHeight) val travel = (trackHeight - thumbHeight).coerceAtLeast(0) - val thumbOffset = if (travel == 0) { - 0 - } else { - ((current.scrollOffset.toLong() * travel.toLong()) / maxScroll.toLong()).toInt().coerceIn(0, travel) - } + val thumbOffset = + if (travel == 0) { + 0 + } else { + ((current.scrollOffset.toLong() * travel.toLong()) / maxScroll.toLong()).toInt().coerceIn(0, travel) + } return Rect(trackRect.x, trackRect.y + thumbOffset, trackRect.width, thumbHeight) } @@ -720,11 +796,12 @@ class SelectEngine( if (thumbRect == null || maxScroll <= 0) return false current.scrollbarDragging = true current.hoveredIndex = -1 - current.scrollbarDragThumbOffsetY = if (thumbRect.contains(mouseX, mouseY)) { - (mouseY - thumbRect.y).coerceIn(0, thumbRect.height.coerceAtLeast(1) - 1) - } else { - (thumbRect.height / 2).coerceAtLeast(0) - } + current.scrollbarDragThumbOffsetY = + if (thumbRect.contains(mouseX, mouseY)) { + (mouseY - thumbRect.y).coerceIn(0, thumbRect.height.coerceAtLeast(1) - 1) + } else { + (thumbRect.height / 2).coerceAtLeast(0) + } updateScrollbarDrag(current, mouseY) return true } @@ -756,11 +833,12 @@ class SelectEngine( val duration = animationDurationMs.coerceAtLeast(1L) val elapsed = (clock.nowMs() - animationStartedAtMs).coerceAtLeast(0L) val t = (elapsed.toFloat() / duration.toFloat()).coerceIn(0f, 1f) - val eased = when (visibilityState) { - VisibilityState.Opening -> Easings.EASE_OUT.map(t) - VisibilityState.Closing -> Easings.EASE_IN.map(t) - else -> t - } + val eased = + when (visibilityState) { + VisibilityState.Opening -> Easings.EASE_OUT.map(t) + VisibilityState.Closing -> Easings.EASE_IN.map(t) + else -> t + } animationProgress = animationFrom + (animationTo - animationFrom) * eased if (t >= 1f) { if (animationTo >= 0.999f) { @@ -786,11 +864,12 @@ class SelectEngine( animationTo = target.coerceIn(0f, 1f) animationDurationMs = durationMs.coerceAtLeast(1L) animationStartedAtMs = clock.nowMs() - visibilityState = when { - animationTo >= 0.999f -> if (from >= 0.999f) VisibilityState.Open else VisibilityState.Opening - animationTo <= 0.001f -> if (from <= 0.001f) VisibilityState.Hidden else VisibilityState.Closing - else -> VisibilityState.Opening - } + visibilityState = + when { + animationTo >= 0.999f -> if (from >= 0.999f) VisibilityState.Open else VisibilityState.Opening + animationTo <= 0.001f -> if (from <= 0.001f) VisibilityState.Hidden else VisibilityState.Closing + else -> VisibilityState.Opening + } if (visibilityState == VisibilityState.Hidden) { val onClose = current.onClose popup = null diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCache.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCache.kt index 721df76..49fb31d 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCache.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCache.kt @@ -3,7 +3,7 @@ package org.dreamfinity.dsgl.core.select import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext class SelectMeasurementCache( - private val maxEntries: Int = 192 + private val maxEntries: Int = 192, ) { data class EntrySnapshot( val kind: Int, @@ -11,7 +11,7 @@ class SelectMeasurementCache( val optionId: String?, val label: String, val enabled: Boolean, - val depth: Int + val depth: Int, ) data class Measurement( @@ -21,7 +21,7 @@ class SelectMeasurementCache( val entryHeights: IntArray, val entryOffsets: IntArray, val totalContentHeight: Int, - val panelWidth: Int + val panelWidth: Int, ) data class Key( @@ -29,14 +29,13 @@ class SelectMeasurementCache( val styleHash: Int, val fontHash: Int, val dpiKey: Int, - val entriesHash: Int + val entriesHash: Int, ) private val cache: MutableMap = object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > maxEntries - } + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > maxEntries } var computeCount: Long = 0L @@ -49,19 +48,20 @@ class SelectMeasurementCache( ctx: UiMeasureContext, dpiScale: Float, fontId: String?, - fontSize: Int? + fontSize: Int?, ): Measurement { val snapshots = flattenEntries(entries) val fingerprint = fingerprint(snapshots) val resolvedFontId = fontId ?: style.fontId val resolvedFontSize = fontSize ?: style.fontSize - val key = Key( - modelToken = modelToken, - styleHash = style.hashCode(), - fontHash = 31 * (resolvedFontId?.hashCode() ?: 0) + (resolvedFontSize ?: 0), - dpiKey = (dpiScale * 1000f).toInt(), - entriesHash = fingerprint - ) + val key = + Key( + modelToken = modelToken, + styleHash = style.hashCode(), + fontHash = 31 * (resolvedFontId?.hashCode() ?: 0) + (resolvedFontSize ?: 0), + dpiKey = (dpiScale * 1000f).toInt(), + entriesHash = fingerprint, + ) synchronized(cache) { cache[key]?.let { return it } } @@ -80,26 +80,36 @@ class SelectMeasurementCache( entryHeights[index] = height offset += height + style.rowGap - val labelWidth = if (snapshot.label.isEmpty()) 0 else ctx.measureText(snapshot.label, resolvedFontId, resolvedFontSize) - val width = when (snapshot.kind) { - KIND_OPTION -> { - style.rowPaddingX + - style.markerColumnWidth + - style.markerGap + - snapshot.depth * style.groupIndentX + - labelWidth + - style.rowPaddingX + val labelWidth = + if (snapshot.label.isEmpty()) { + 0 + } else { + ctx.measureText( + snapshot.label, + resolvedFontId, + resolvedFontSize, + ) } - - KIND_GROUP -> { - style.rowPaddingX + - snapshot.depth * style.groupIndentX + - labelWidth + - style.rowPaddingX + val width = + when (snapshot.kind) { + KIND_OPTION -> { + style.rowPaddingX + + style.markerColumnWidth + + style.markerGap + + snapshot.depth * style.groupIndentX + + labelWidth + + style.rowPaddingX + } + + KIND_GROUP -> { + style.rowPaddingX + + snapshot.depth * style.groupIndentX + + labelWidth + + style.rowPaddingX + } + + else -> style.minPanelWidth } - - else -> style.minPanelWidth - } if (width > maxWidth) { maxWidth = width } @@ -109,15 +119,16 @@ class SelectMeasurementCache( offset -= style.rowGap } - val measurement = Measurement( - snapshots = snapshots, - rowHeight = rowHeight, - separatorHeight = separatorHeight, - entryHeights = entryHeights, - entryOffsets = entryOffsets, - totalContentHeight = offset, - panelWidth = maxWidth.coerceAtLeast(style.minPanelWidth) - ) + val measurement = + Measurement( + snapshots = snapshots, + rowHeight = rowHeight, + separatorHeight = separatorHeight, + entryHeights = entryHeights, + entryOffsets = entryOffsets, + totalContentHeight = offset, + panelWidth = maxWidth.coerceAtLeast(style.minPanelWidth), + ) synchronized(cache) { cache[key] = measurement } @@ -135,36 +146,39 @@ class SelectMeasurementCache( entries.forEach { entry -> when (entry) { is SelectEntry.Option -> { - out += EntrySnapshot( - kind = KIND_OPTION, - id = entry.id, - optionId = entry.id, - label = entry.labelProvider.invoke(), - enabled = entry.enabledProvider.invoke(), - depth = depth - ) + out += + EntrySnapshot( + kind = KIND_OPTION, + id = entry.id, + optionId = entry.id, + label = entry.labelProvider.invoke(), + enabled = entry.enabledProvider.invoke(), + depth = depth, + ) } is SelectEntry.Separator -> { - out += EntrySnapshot( - kind = KIND_SEPARATOR, - id = entry.id, - optionId = null, - label = "", - enabled = false, - depth = depth - ) + out += + EntrySnapshot( + kind = KIND_SEPARATOR, + id = entry.id, + optionId = null, + label = "", + enabled = false, + depth = depth, + ) } is SelectEntry.Group -> { - out += EntrySnapshot( - kind = KIND_GROUP, - id = entry.id, - optionId = null, - label = entry.labelProvider.invoke(), - enabled = false, - depth = depth - ) + out += + EntrySnapshot( + kind = KIND_GROUP, + id = entry.id, + optionId = null, + label = entry.labelProvider.invoke(), + enabled = false, + depth = depth, + ) appendEntries(entry.entries, depth = depth + 1, out = out) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectModel.kt index eb3d983..c2f814f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectModel.kt @@ -4,6 +4,7 @@ import java.util.concurrent.atomic.AtomicLong private object SelectModelIds { private val nextToken = AtomicLong(1L) + fun next(): Long = nextToken.getAndIncrement() } @@ -11,25 +12,25 @@ data class SelectModel( val id: String? = null, val placeholderProvider: (() -> String?)? = null, val entries: List, - internal val token: Long = SelectModelIds.next() + internal val token: Long = SelectModelIds.next(), ) sealed class SelectEntry( - open val id: String? + open val id: String?, ) { data class Option( override val id: String, val labelProvider: () -> String, - val enabledProvider: () -> Boolean = { true } + val enabledProvider: () -> Boolean = { true }, ) : SelectEntry(id) data class Separator( - override val id: String? = null + override val id: String? = null, ) : SelectEntry(id) data class Group( override val id: String? = null, val labelProvider: () -> String, - val entries: List + val entries: List, ) : SelectEntry(id) } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalController.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalController.kt new file mode 100644 index 0000000..b748be6 --- /dev/null +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalController.kt @@ -0,0 +1,348 @@ +package org.dreamfinity.dsgl.core.select + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.Size +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.event.MouseMoveEvent +import org.dreamfinity.dsgl.core.event.MouseUpEvent +import org.dreamfinity.dsgl.core.event.MouseWheelEvent +import org.dreamfinity.dsgl.core.portal.PortalDismissPolicy +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalEntryBounds +import org.dreamfinity.dsgl.core.portal.PortalEntryId +import org.dreamfinity.dsgl.core.portal.PortalEntryOrder +import org.dreamfinity.dsgl.core.portal.PortalEntryPlacement +import org.dreamfinity.dsgl.core.portal.PortalEntryState +import org.dreamfinity.dsgl.core.portal.PortalFocusPolicy +import org.dreamfinity.dsgl.core.portal.PortalHost +import org.dreamfinity.dsgl.core.portal.PortalInputPolicy +import org.dreamfinity.dsgl.core.portal.PortalInsidePointerPolicy +import org.dreamfinity.dsgl.core.portal.PortalPointerDispatch +import org.dreamfinity.dsgl.core.portal.PortalPointerPolicyResult +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.evaluateOutsidePointerDown +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.render.RenderCommand + +@Suppress("TooManyFunctions") +internal class SelectPortalController( + private val engine: SelectEngine, + ownerDomain: ScreenDomainId, + entryId: String, +) : PortalPointerDispatch { + private val portalHost: PortalHost = + PortalHost(ScreenDomainSurfaces.portalSurfaceForDomain(ownerDomain)) + private val entry: SelectPortalEntry = + SelectPortalEntry( + engine = engine, + ownerDomain = ownerDomain, + entryId = entryId, + ) + + init { + portalHost.register(entry) + } + + fun onFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + ) { + entry.onFrame(measureContext, viewportWidth, viewportHeight, viewportScale) + portalHost.render(measureContext, viewportWidth, viewportHeight) + } + + fun appendCommands( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + out: MutableList, + ) { + entry.onFrame(measureContext, viewportWidth, viewportHeight, viewportScale = 1f) + portalHost.render(measureContext, viewportWidth, viewportHeight) + out += portalHost.paint(measureContext) + } + + fun close() { + entry.close() + } + + fun isOpen(): Boolean = engine.isOpen() + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + portalHost.dispatchInput { it.handleMouseMove(mouseX, mouseY) } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + if (portalHost.dispatchInput { it.handleMouseDown(mouseX, mouseY, button) }) { + true + } else { + val outside = portalHost.evaluateOutsidePointerDown(mouseX, mouseY) + if (outside?.shouldClose == true) { + outside.entry.state + .dismiss(outside.entry) + } + false + } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + portalHost.dispatchInput { it.handleMouseUp(mouseX, mouseY, button) } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + portalHost.dispatchInput { it.handleMouseWheel(mouseX, mouseY, delta) } + + fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyDown(keyCode, keyChar) } + + fun handleKeyUp(keyCode: Int, keyChar: Char): Boolean = + portalHost.dispatchInput { it.handleKeyUp(keyCode, keyChar) } + + internal fun debugPortalState(mouseX: Int, mouseY: Int): SelectPortalDebugState = + SelectPortalDebugState( + node = entry.node, + state = entry.state, + outsidePointerPolicy = portalHost.evaluateOutsidePointerDown(mouseX, mouseY), + ) +} + +internal data class SelectPortalDebugState( + val node: DOMNode, + val state: PortalEntryState, + val outsidePointerPolicy: PortalPointerPolicyResult?, +) + +private class SelectPortalEntry( + private val engine: SelectEngine, + ownerDomain: ScreenDomainId, + entryId: String, +) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId(entryId), + ownerToken = engine, + surface = ScreenDomainSurfaces.portalSurfaceForDomain(ownerDomain), + order = PortalEntryOrder(zIndex = 0), + dismissPolicy = PortalDismissPolicy.EscapeOrOutsidePointerDown, + inputPolicy = PortalInputPolicy.DomOnly, + focusPolicy = PortalFocusPolicy.Preserve, + insidePointerPolicy = PortalInsidePointerPolicy.ConsumePointerDown, + ).apply { + dismissAction = { + val owner = engine.snapshot().owner + if (owner != null) { + engine.close(owner) + } else { + engine.closeAll() + } + syncActivePlacement() + } + } + private val popupNode: SelectPortalNode = + SelectPortalNode( + engine = engine, + onInputHandled = { + if (!clearingDomInputRouter) { + syncActivePlacement() + } + }, + ) + override val node: DOMNode = popupNode + private val domInputRouter: SurfaceDomInputRouter = SurfaceDomInputRouter { node } + private var viewportWidth: Int = 1 + private var viewportHeight: Int = 1 + private var measureContext: UiMeasureContext? = null + private var clearingDomInputRouter: Boolean = false + + fun onFrame( + measureContext: UiMeasureContext, + viewportWidth: Int, + viewportHeight: Int, + viewportScale: Float, + ) { + this.measureContext = measureContext + this.viewportWidth = viewportWidth.coerceAtLeast(1) + this.viewportHeight = viewportHeight.coerceAtLeast(1) + popupNode.viewportWidth = this.viewportWidth + popupNode.viewportHeight = this.viewportHeight + engine.onFrame( + measureContext = measureContext, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, + viewportScale = viewportScale, + ) + syncActivePlacement() + } + + override fun paint(ctx: UiMeasureContext): List { + if (!engine.isOpen()) { + state.deactivate() + return emptyList() + } + val commands = ArrayList() + node.appendRenderCommands(measureContext ?: ctx, commands) + syncActivePlacement() + return commands + } + + override fun close() { + clearingDomInputRouter = true + try { + domInputRouter.clear() + } finally { + clearingDomInputRouter = false + } + engine.closeAll() + state.deactivate() + } + + override fun handleMouseMove(mouseX: Int, mouseY: Int): Boolean = + dispatchWithSyncedPlacement { + domInputRouter.handleMouseMove(mouseX, mouseY) + } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + dispatchWithSyncedPlacement { + domInputRouter.handleMouseDown(mouseX, mouseY, button) + } + + override fun handleMouseUp(mouseX: Int, mouseY: Int, button: MouseButton): Boolean = + dispatchWithSyncedPlacement { + domInputRouter.handleMouseUp(mouseX, mouseY, button) + } + + override fun handleMouseWheel(mouseX: Int, mouseY: Int, delta: Int): Boolean = + dispatchWithSyncedPlacement { + domInputRouter.handleMouseWheel(mouseX, mouseY, delta) + } + + override fun handleKeyDown(keyCode: Int, keyChar: Char): Boolean = + engine.handleKeyDown(keyCode, keyChar).also { syncActivePlacement() } + + fun syncActivePlacement() { + if (!engine.isOpen()) { + clearingDomInputRouter = true + try { + domInputRouter.clear() + } finally { + clearingDomInputRouter = false + } + state.deactivate() + return + } + val owner = engine.snapshot().owner ?: return + val panelRect = engine.debugPanelRect(owner) ?: return + val anchorRect = engine.debugAnchorRect(owner) + popupNode.bounds = panelRect + state.activate( + PortalEntryPlacement( + anchorBounds = anchorRect, + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, viewportWidth, viewportHeight), + entryBounds = panelRect, + ), + ), + ) + } + + private fun dispatchWithSyncedPlacement(dispatch: () -> Boolean): Boolean { + syncActivePlacement() + val handled = + FocusManager.preservePointerFocus { + dispatch() + } + syncActivePlacement() + return handled + } +} + +private class SelectPortalNode( + private val engine: SelectEngine, + private val onInputHandled: () -> Unit, +) : DOMNode(key = "dsgl-select-portal-popup") { + override val styleType: String = "select-portal" + var viewportWidth: Int = 1 + var viewportHeight: Int = 1 + + init { + onMouseMove = ::handleMove + onMouseDown = ::handleDown + onMouseUp = ::handleUp + onMouseWheel = ::handleWheel + } + + override fun measure(ctx: UiMeasureContext): Size { + val owner = engine.snapshot().owner + val panel = owner?.let(engine::debugPanelRect) + return Size(panel?.width ?: 0, panel?.height ?: 0) + } + + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) { + if (!engine.isOpen()) return + engine.appendPortalCommands( + measureContext = ctx, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + out = out, + ) + } + + override fun shouldCapturePointerDrag(mouseX: Int, mouseY: Int): Boolean = + engine.isScrollbarDragging() || engine.shouldCaptureScrollbarDrag(mouseX, mouseY) + + override fun continuePointerCapture( + mouseX: Int, + mouseY: Int, + mouseDX: Int, + mouseDY: Int, + button: MouseButton, + ) { + if (engine.handleMouseMove(mouseX, mouseY)) { + onInputHandled() + } + } + + override fun endPointerCapture(mouseX: Int, mouseY: Int, button: MouseButton) { + if (engine.handleMouseUp(mouseX, mouseY, button)) { + onInputHandled() + } + } + + override fun cancelPointerCapture() { + engine.cancelScrollbarDrag() + onInputHandled() + } + + private fun handleMove(event: MouseMoveEvent) { + if (engine.handleMouseMove(event.mouseX, event.mouseY)) { + event.cancelled = true + onInputHandled() + } + } + + private fun handleDown(event: MouseDownEvent) { + if (engine.handlePortalMouseDown(event.mouseX, event.mouseY, event.mouseButton)) { + event.cancelled = true + onInputHandled() + } + } + + private fun handleUp(event: MouseUpEvent) { + if (engine.handleMouseUp(event.mouseX, event.mouseY, event.mouseButton)) { + event.cancelled = true + onInputHandled() + } + } + + private fun handleWheel(event: MouseWheelEvent) { + if (engine.handleMouseWheel(event.mouseX, event.mouseY, event.dWheel)) { + event.cancelled = true + onInputHandled() + } + } +} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectHost.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalRequest.kt similarity index 73% rename from core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectHost.kt rename to core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalRequest.kt index 5c2a009..d046a2f 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectHost.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalRequest.kt @@ -1,14 +1,7 @@ package org.dreamfinity.dsgl.core.select import org.dreamfinity.dsgl.core.dom.layout.Rect - -interface SelectHost { - fun open(request: SelectOpenRequest) - fun close(owner: Any) - fun closeAll() - fun isOpenFor(owner: Any): Boolean - fun isOpen(): Boolean -} +import org.dreamfinity.dsgl.core.portal.ScreenDomainId data class SelectOpenRequest( val owner: Any, @@ -20,7 +13,8 @@ data class SelectOpenRequest( val onSelect: ((String) -> Unit)? = null, val onClose: (() -> Unit)? = null, val fontId: String? = null, - val fontSize: Int? = null + val fontSize: Int? = null, + val ownerDomain: ScreenDomainId = ScreenDomainId.Application, ) fun interface SelectClock { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectRuntime.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectRuntime.kt deleted file mode 100644 index 693ae42..0000000 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectRuntime.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.dreamfinity.dsgl.core.select - -object SelectRuntime { - val engine: SelectEngine = SelectEngine() - val host: SelectHost = engine -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectStyle.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectStyle.kt index 1bcec1e..693e0f4 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectStyle.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/select/SelectStyle.kt @@ -41,5 +41,5 @@ data class SelectStyle( val openDurationMs: Long = 130L, val closeDurationMs: Long = 100L, val openStartScale: Float = 0.985f, - val openStartOffsetYPx: Float = -4f + val openStartOffsetYPx: Float = -4f, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/CssLength.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/CssLength.kt index c72b3a2..76f8651 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/CssLength.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/CssLength.kt @@ -3,22 +3,22 @@ package org.dreamfinity.dsgl.core.style import org.dreamfinity.dsgl.core.dom.layout.Insets import kotlin.math.roundToInt -enum class CssUnit(val token: String) { +enum class CssUnit( + val token: String, +) { Px("px"), Rem("rem"), Em("em"), Vw("vw"), Vh("vh"), - Percent("%") + Percent("%"), } data class CssLength( val value: Float, - val unit: CssUnit + val unit: CssUnit, ) { - override fun toString(): String { - return toCssLiteral() - } + override fun toString(): String = toCssLiteral() companion object { val ZERO_PX: CssLength = CssLength(0f, CssUnit.Px) @@ -27,53 +27,39 @@ data class CssLength( } } -fun CssLength.coerceAtLeast(minimumValue: Int): String { - return CssLength(value.coerceAtLeast(minimumValue.toFloat()), unit).toCssLiteral() -} +fun CssLength.coerceAtLeast(minimumValue: Int): String = + CssLength(value.coerceAtLeast(minimumValue.toFloat()), unit).toCssLiteral() -fun Number.px(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Px) -} +fun Number.px(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Px) -fun Number.em(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Em) -} +fun Number.em(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Em) -fun Number.rem(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Rem) -} +fun Number.rem(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Rem) -fun Number.vw(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Vw) -} +fun Number.vw(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Vw) -fun Number.vh(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Vh) -} +fun Number.vh(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Vh) -fun Number.percent(): CssLength { - return CssLength(value = this.toFloat(), unit = CssUnit.Percent) -} +fun Number.percent(): CssLength = CssLength(value = this.toFloat(), unit = CssUnit.Percent) data class LengthInsets( val top: CssLength, val right: CssLength, val bottom: CssLength, - val left: CssLength + val left: CssLength, ) { companion object { val ZERO: LengthInsets = all(CssLength.ZERO_PX) fun all(value: CssLength): LengthInsets = LengthInsets(value, value, value, value) - fun fromInsets(insets: Insets): LengthInsets { - return LengthInsets( + fun fromInsets(insets: Insets): LengthInsets = + LengthInsets( top = CssLength.px(insets.top), right = CssLength.px(insets.right), bottom = CssLength.px(insets.bottom), - left = CssLength.px(insets.left) + left = CssLength.px(insets.left), ) - } } } @@ -81,7 +67,7 @@ enum class LengthPercentBase { ContainerWidth, ContainerHeight, CurrentFontSize, - InheritedFontSize + InheritedFontSize, } data class LengthResolveContext( @@ -91,7 +77,7 @@ data class LengthResolveContext( val containingBlockHeightPx: Float? = null, val rootFontSizePx: Float = 16f, val currentFontSizePx: Float = 16f, - val inheritedFontSizePx: Float = 16f + val inheritedFontSizePx: Float = 16f, ) fun interface StyleWarningReporter { @@ -100,48 +86,58 @@ fun interface StyleWarningReporter { private val knownLengthUnits: Set = linkedSetOf("px", "rem", "em", "vw", "vh", "%") private val knownLengthUnitsPattern = knownLengthUnits.joinToString("|") { Regex.escape(it) } -private val cssLengthRegex = Regex( - pattern = """^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:($knownLengthUnitsPattern))?$""", - option = RegexOption.IGNORE_CASE -) -private val cssLengthAnyUnitRegex = Regex( - pattern = """^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:([a-zA-Z%]+))?$""", - option = RegexOption.IGNORE_CASE -) +private val cssLengthRegex = + Regex( + pattern = """^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:($knownLengthUnitsPattern))?$""", + option = RegexOption.IGNORE_CASE, + ) +private val cssLengthAnyUnitRegex = + Regex( + pattern = """^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:([a-zA-Z%]+))?$""", + option = RegexOption.IGNORE_CASE, + ) -fun parseCssLength( - raw: String, - allowUnitlessZero: Boolean = true -): CssLength { +fun parseCssLength(raw: String, allowUnitlessZero: Boolean = true): CssLength { val trimmed = raw.trim() - val match = cssLengthRegex.matchEntire(trimmed) - ?: run { - val anyUnitMatch = cssLengthAnyUnitRegex.matchEntire(trimmed) - if (anyUnitMatch != null) { - val unknownUnit = anyUnitMatch.groupValues.getOrNull(2).orEmpty().trim() - if (unknownUnit.isNotEmpty()) { - error("Unknown length unit '$unknownUnit'. Supported units: px, rem, em, vw, vh, %.") + val match = + cssLengthRegex.matchEntire(trimmed) + ?: run { + val anyUnitMatch = cssLengthAnyUnitRegex.matchEntire(trimmed) + if (anyUnitMatch != null) { + val unknownUnit = + anyUnitMatch.groupValues + .getOrNull(2) + .orEmpty() + .trim() + if (unknownUnit.isNotEmpty()) { + error("Unknown length unit '$unknownUnit'. Supported units: px, rem, em, vw, vh, %.") + } } + error("Expected CSS length but got '$raw'.") } - error("Expected CSS length but got '$raw'.") - } val value = match.groupValues[1].toFloat() - val unitToken = match.groupValues.getOrNull(2).orEmpty().trim().lowercase() + val unitToken = + match.groupValues + .getOrNull(2) + .orEmpty() + .trim() + .lowercase() if (unitToken.isEmpty()) { if (allowUnitlessZero && value == 0f) { return CssLength.ZERO_PX } error("Expected explicit unit in '$raw'.") } - val unit = when (unitToken) { - "px" -> CssUnit.Px - "rem" -> CssUnit.Rem - "em" -> CssUnit.Em - "vw" -> CssUnit.Vw - "vh" -> CssUnit.Vh - "%" -> CssUnit.Percent - else -> error("Unknown length unit '$unitToken'. Supported units: px, rem, em, vw, vh, %.") - } + val unit = + when (unitToken) { + "px" -> CssUnit.Px + "rem" -> CssUnit.Rem + "em" -> CssUnit.Em + "vw" -> CssUnit.Vw + "vh" -> CssUnit.Vh + "%" -> CssUnit.Percent + else -> error("Unknown length unit '$unitToken'. Supported units: px, rem, em, vw, vh, %.") + } return CssLength(value = value, unit = unit) } @@ -156,19 +152,17 @@ fun CssLength.toCssLiteral(): String { return "0px" } val asLong = value.toLong() - val number = if (asLong.toFloat() == value) { - asLong.toString() - } else { - value.toString() - } + val number = + if (asLong.toFloat() == value) { + asLong.toString() + } else { + value.toString() + } return number + unit.token } -fun CssLength.resolvePx( - context: LengthResolveContext, - percentBase: LengthPercentBase -): Float { - return when (unit) { +fun CssLength.resolvePx(context: LengthResolveContext, percentBase: LengthPercentBase): Float = + when (unit) { CssUnit.Px -> value CssUnit.Rem -> value * context.rootFontSizePx CssUnit.Em -> value * context.currentFontSizePx @@ -176,28 +170,27 @@ fun CssLength.resolvePx( CssUnit.Vh -> (value / 100f) * context.viewportHeightPx CssUnit.Percent -> (value / 100f) * percentBaseValue(context, percentBase) } -} -fun LengthInsets.resolveToInsets(context: LengthResolveContext): Insets { - return Insets( +fun LengthInsets.resolveToInsets(context: LengthResolveContext): Insets = + Insets( top = top.resolvePx(context, LengthPercentBase.ContainerHeight).roundToInt(), right = right.resolvePx(context, LengthPercentBase.ContainerWidth).roundToInt(), bottom = bottom.resolvePx(context, LengthPercentBase.ContainerHeight).roundToInt(), - left = left.resolvePx(context, LengthPercentBase.ContainerWidth).roundToInt() + left = left.resolvePx(context, LengthPercentBase.ContainerWidth).roundToInt(), ) -} fun parseLengthPx( raw: String, allowNegative: Boolean, percentBase: LengthPercentBase = LengthPercentBase.ContainerWidth, context: LengthResolveContext = LengthResolveContext(), - allowUnitlessZero: Boolean = true + allowUnitlessZero: Boolean = true, ): Float { - val px = parseCssLength( - raw = raw, - allowUnitlessZero = allowUnitlessZero - ).resolvePx(context, percentBase) + val px = + parseCssLength( + raw = raw, + allowUnitlessZero = allowUnitlessZero, + ).resolvePx(context, percentBase) if (!allowNegative && px < 0f) { error("Negative length is not allowed: '$raw'.") } @@ -209,23 +202,22 @@ fun parseLengthPxInt( allowNegative: Boolean, percentBase: LengthPercentBase = LengthPercentBase.ContainerWidth, context: LengthResolveContext = LengthResolveContext(), - allowUnitlessZero: Boolean = true -): Int { - return parseLengthPx( + allowUnitlessZero: Boolean = true, +): Int = + parseLengthPx( raw = raw, allowNegative = allowNegative, percentBase = percentBase, context = context, - allowUnitlessZero = allowUnitlessZero + allowUnitlessZero = allowUnitlessZero, ).roundToInt() -} fun parseOptionalLengthPxInt( raw: String, allowNegative: Boolean, percentBase: LengthPercentBase = LengthPercentBase.ContainerWidth, context: LengthResolveContext = LengthResolveContext(), - allowUnitlessZero: Boolean = true + allowUnitlessZero: Boolean = true, ): Int? { val normalized = raw.trim().lowercase() if (normalized == "auto") return null @@ -234,15 +226,14 @@ fun parseOptionalLengthPxInt( allowNegative = allowNegative, percentBase = percentBase, context = context, - allowUnitlessZero = allowUnitlessZero + allowUnitlessZero = allowUnitlessZero, ) } -private fun percentBaseValue(context: LengthResolveContext, base: LengthPercentBase): Float { - return when (base) { +private fun percentBaseValue(context: LengthResolveContext, base: LengthPercentBase): Float = + when (base) { LengthPercentBase.ContainerWidth -> context.containingBlockWidthPx ?: 0f LengthPercentBase.ContainerHeight -> context.containingBlockHeightPx ?: 0f LengthPercentBase.CurrentFontSize -> context.currentFontSizePx LengthPercentBase.InheritedFontSize -> context.inheritedFontSizePx } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/DssParser.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/DssParser.kt index d615c02..4104bc3 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/DssParser.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/DssParser.kt @@ -6,8 +6,9 @@ class DssParseException( val path: String, val line: Int, val column: Int, - message: String -) : RuntimeException("$path:$line:$column $message") + message: String, + cause: Throwable? = null, +) : RuntimeException("$path:$line:$column $message", cause) object DssParser { private val importantSuffixRegex = Regex("(?i)\\s*!important\\s*$") @@ -19,6 +20,7 @@ object DssParser { return parse(text, file.path) } + @Suppress("ThrowsCount") fun parse(sourceText: String, sourceName: String = ""): StylesheetData { val text = stripBlockComments(sourceText) var index = 0 @@ -46,37 +48,41 @@ object DssParser { index++ // '{' val declarations = StyleDeclarations() - index = parseDeclarations( - sourceName = sourceName, - text = text, - fromIndex = index, - declarations = declarations, - rootVars = rootVars, - allowVariables = selectorText == ":root", - warnings = warnings - ) - - val selector = when (selectorText) { - ":root" -> { - if (declarations.values.isEmpty()) { - null - } else { - StyleSelector.parse(ROOT_SELECTOR_ALIAS) + index = + parseDeclarations( + sourceName = sourceName, + text = text, + fromIndex = index, + declarations = declarations, + rootVars = rootVars, + allowVariables = selectorText == ":root", + warnings = warnings, + ) + + val selector = + when (selectorText) { + ":root" -> { + if (declarations.values.isEmpty()) { + null + } else { + StyleSelector.parse(ROOT_SELECTOR_ALIAS) + } } + else -> + try { + StyleSelector.parse(selectorText) + } catch (ex: IllegalArgumentException) { + throw parseError(sourceName, text, selectorStart, ex.message ?: "Invalid selector.", ex) + } } - else -> try { - StyleSelector.parse(selectorText) - } catch (ex: IllegalArgumentException) { - throw parseError(sourceName, text, selectorStart, ex.message ?: "Invalid selector.") - } - } if (selector != null) { - rules += StyleRule( - selector = selector, - declarations = declarations, - sourceOrder = sourceOrder++, - fileName = sourceName - ) + rules += + StyleRule( + selector = selector, + declarations = declarations, + sourceOrder = sourceOrder++, + fileName = sourceName, + ) } } @@ -84,10 +90,11 @@ object DssParser { rules = rules, rootVariables = rootVars, source = sourceName, - warnings = warnings.messages() + warnings = warnings.messages(), ) } + @Suppress("ThrowsCount") private fun parseDeclarations( sourceName: String, text: String, @@ -95,7 +102,7 @@ object DssParser { declarations: StyleDeclarations, rootVars: MutableMap, allowVariables: Boolean, - warnings: ParseWarnings + warnings: ParseWarnings, ): Int { var index = fromIndex while (index < text.length) { @@ -138,7 +145,7 @@ object DssParser { sourceName, text, nameStart, - "Variable declarations are only supported inside :root." + "Variable declarations are only supported inside :root.", ) } rootVars[rawName] = rawValue @@ -147,16 +154,17 @@ object DssParser { if (normalizedName == "foreground-color" || normalizedName == "foregroundcolor") { warnings.warnOnce( DEPRECATED_FOREGROUND_COLOR_WARNING_KEY, - "Property 'foreground-color' is deprecated; use 'color'." + "Property 'foreground-color' is deprecated; use 'color'.", ) } - val property = StyleProperty.fromKeyOrNull(rawName) - ?: throw parseError( - sourceName, - text, - nameStart, - "Unsupported style property '$rawName'." - ) + val property = + StyleProperty.fromKeyOrNull(rawName) + ?: throw parseError( + sourceName, + text, + nameStart, + "Unsupported style property '$rawName'.", + ) val important = importantSuffixRegex.containsMatchIn(rawValue) val normalizedValue = if (important) importantSuffixRegex.replace(rawValue, "") else rawValue if (normalizedValue.isEmpty()) { @@ -168,10 +176,12 @@ object DssParser { validateLiteralForProperty( property = property, literal = expression.value, - warningReporter = warnings + warningReporter = warnings, ) - } catch (ex: Exception) { - throw parseError(sourceName, text, valueStart, ex.message ?: "Invalid value.") + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + throw parseError(sourceName, text, valueStart, ex.message ?: "Invalid value.", ex) } } declarations.set(property, expression, important = important) @@ -194,7 +204,13 @@ object DssParser { return index } - private fun parseError(path: String, source: String, index: Int, message: String): DssParseException { + private fun parseError( + path: String, + source: String, + index: Int, + message: String, + cause: Throwable? = null, + ): DssParseException { val safeIndex = index.coerceIn(0, source.length) var line = 1 var col = 1 @@ -206,7 +222,7 @@ object DssParser { col++ } } - return DssParseException(path, line, col, message) + return DssParseException(path, line, col, message, cause) } private fun stripBlockComments(source: String): String { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleApplicationScope.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleApplicationScope.kt index e16a942..dfec0b2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleApplicationScope.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleApplicationScope.kt @@ -2,6 +2,6 @@ package org.dreamfinity.dsgl.core.style enum class StyleApplicationScope { Application, - SystemOverlay + System, + Debug, } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleEngine.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleEngine.kt index 73ae9be..a2721f6 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleEngine.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleEngine.kt @@ -8,20 +8,33 @@ import java.util.WeakHashMap import kotlin.math.roundToInt object StyleEngine { - private data class AnonymousInspectorTarget(val path: String) + private data class AnonymousInspectorTarget( + val path: String, + ) data class StyleApplyReport( val layoutDirty: Boolean, val visualDirty: Boolean, val visitedNodes: Int, val cacheHits: Int, - val recomputedNodes: Int - ) + val recomputedNodes: Int, + ) { + companion object { + val CLEAN: StyleApplyReport = + StyleApplyReport( + layoutDirty = false, + visualDirty = false, + visitedNodes = 0, + cacheHits = 0, + recomputedNodes = 0, + ) + } + } private data class MutableApplyMetrics( var visitedNodes: Int = 0, var cacheHits: Int = 0, - var recomputedNodes: Int = 0 + var recomputedNodes: Int = 0, ) private data class CacheKey( @@ -44,18 +57,20 @@ object StyleEngine { val rootFontSizePx: Int, val viewportWidthPx: Int, val viewportHeightPx: Int, - val scope: StyleApplicationScope + val scope: StyleApplicationScope, ) private data class CachedStyle( val key: CacheKey, - val style: ComputedStyle + val style: ComputedStyle, ) - private enum class StyleOrigin(val precedence: Int) { + private enum class StyleOrigin( + val precedence: Int, + ) { Stylesheet(0), Inline(1), - Inspector(2) + Inspector(2), } private data class CascadeWinner( @@ -65,22 +80,107 @@ object StyleEngine { val sourceOrder: Int, val origin: StyleOrigin, val sourceKind: StyleSourceKind, - val sourceLabel: String - ) + val sourceRule: StyleRule? = null, + ) { + fun sourceLabel(): String = + when { + sourceRule != null -> + buildString { + append(selectorLabel(sourceRule.selector)) + append(" @ ") + append(sourceRule.fileName) + if (important) append(" !important") + } + origin == StyleOrigin.Inline -> if (important) "inline !important" else "inline" + origin == StyleOrigin.Inspector -> if (important) "inspector !important" else "inspector" + else -> "unknown" + } + } - private data class NodeApplyFlags( - val layoutDirty: Boolean, - val visualDirty: Boolean - ) + private const val FLAG_LAYOUT_DIRTY = 1 + private const val FLAG_VISUAL_DIRTY = FLAG_LAYOUT_DIRTY shl 1 + private const val FLAG_CACHE_HIT = FLAG_VISUAL_DIRTY shl 1 + + private class CacheKeyProbe { + var typeName: String = "" + var nodeId: String? = null + var classesHash: Int = 0 + var inlineHash: Int = 0 + var inspectorHash: Int = 0 + var hovered: Boolean = false + var active: Boolean = false + var focused: Boolean = false + var disabled: Boolean = false + var open: Boolean = false + var stylesheetVersion: Long = 0L + var themeVersion: Long = 0L + var defaultsHash: Int = 0 + var parentInheritedHash: Int = 0 + var ancestorSelectorHash: Int = 0 + var siblingSelectorHash: Int = 0 + var rootFontSizePx: Int = 0 + var viewportWidthPx: Int = 0 + var viewportHeightPx: Int = 0 + var scope: StyleApplicationScope = StyleApplicationScope.Application + + fun matches(key: CacheKey): Boolean = matchesIdentity(key) && matchesPseudoState(key) && matchesContext(key) + + private fun matchesIdentity(key: CacheKey): Boolean = + classesHash == key.classesHash && + inlineHash == key.inlineHash && + inspectorHash == key.inspectorHash && + defaultsHash == key.defaultsHash && + typeName == key.typeName && + nodeId == key.nodeId + + private fun matchesPseudoState(key: CacheKey): Boolean = + hovered == key.hovered && + active == key.active && + focused == key.focused && + disabled == key.disabled && + open == key.open + + private fun matchesContext(key: CacheKey): Boolean = + stylesheetVersion == key.stylesheetVersion && + themeVersion == key.themeVersion && + parentInheritedHash == key.parentInheritedHash && + ancestorSelectorHash == key.ancestorSelectorHash && + siblingSelectorHash == key.siblingSelectorHash && + rootFontSizePx == key.rootFontSizePx && + viewportWidthPx == key.viewportWidthPx && + viewportHeightPx == key.viewportHeightPx && + scope == key.scope + + fun toKey(): CacheKey = + CacheKey( + typeName = typeName, + nodeId = nodeId, + classesHash = classesHash, + inlineHash = inlineHash, + inspectorHash = inspectorHash, + hovered = hovered, + active = active, + focused = focused, + disabled = disabled, + open = open, + stylesheetVersion = stylesheetVersion, + themeVersion = themeVersion, + defaultsHash = defaultsHash, + parentInheritedHash = parentInheritedHash, + ancestorSelectorHash = ancestorSelectorHash, + siblingSelectorHash = siblingSelectorHash, + rootFontSizePx = rootFontSizePx, + viewportWidthPx = viewportWidthPx, + viewportHeightPx = viewportHeightPx, + scope = scope, + ) + } - private data class NodeApplyResult( - val flags: NodeApplyFlags, - val cacheHit: Boolean - ) + private val cacheKeyProbe = CacheKeyProbe() private enum class StylePassMode { Full, - Targeted + Targeted, } private val cache: MutableMap = WeakHashMap() @@ -89,12 +189,15 @@ object StyleEngine { private val inspectorOverrideVersions: MutableMap = linkedMapOf() private val anonymousTargetCache: MutableMap = WeakHashMap() private val pseudoDirtyNodes: MutableSet = - Collections.newSetFromMap(WeakHashMap()) + Collections.newSetFromMap(WeakHashMap()) private val selectorDirtyNodes: MutableSet = - Collections.newSetFromMap(WeakHashMap()) + Collections.newSetFromMap(WeakHashMap()) private var themeVersion: Long = 0L private var inspectorOverridesVersion: Long = 0L + private var cachedResolvedVariables: Map = emptyMap() + private var cachedResolvedVariablesSnapshotVersion: Long = Long.MIN_VALUE + private var cachedResolvedVariablesThemeVersion: Long = Long.MIN_VALUE private var pseudoStateVersion: Long = 0L private var selectorStateVersion: Long = 0L private var pseudoDirtyGlobal: Boolean = false @@ -106,13 +209,14 @@ object StyleEngine { private var lastAppliedSelectorStateVersion: Long = Long.MIN_VALUE private var viewportWidthPx: Int = 0 private var viewportHeightPx: Int = 0 - private var lastApplyReport: StyleApplyReport = StyleApplyReport( - layoutDirty = false, - visualDirty = false, - visitedNodes = 0, - cacheHits = 0, - recomputedNodes = 0 - ) + private var lastApplyReport: StyleApplyReport = + StyleApplyReport( + layoutDirty = false, + visualDirty = false, + visitedNodes = 0, + cacheHits = 0, + recomputedNodes = 0, + ) private const val DEFAULT_ROOT_FONT_SIZE_PX = 16 fun inspectorOverrideTarget(node: DOMNode): Any { @@ -176,16 +280,14 @@ object StyleEngine { setInspectorOverride(inspectorOverrideTarget(node), property, expression) } - fun setInspectorOverrideLiteral(nodeKey: Any, property: StyleProperty, literal: String): Result { - return runCatching { + fun setInspectorOverrideLiteral(nodeKey: Any, property: StyleProperty, literal: String): Result = + runCatching { validateLiteralForProperty(property, literal) setInspectorOverride(nodeKey, property, StyleExpression.Literal(literal)) } - } - fun setInspectorOverrideLiteral(node: DOMNode, property: StyleProperty, literal: String): Result { - return setInspectorOverrideLiteral(inspectorOverrideTarget(node), property, literal) - } + fun setInspectorOverrideLiteral(node: DOMNode, property: StyleProperty, literal: String): Result = + setInspectorOverrideLiteral(inspectorOverrideTarget(node), property, literal) fun clearInspectorOverride(nodeKey: Any, property: StyleProperty? = null) { val existing = inspectorOverrides[nodeKey] ?: return @@ -218,30 +320,25 @@ object StyleEngine { return inspectorOverrides[nodeKey]?.let(::copyStyleDeclarations) } - fun inspectorOverridesFor(node: DOMNode): StyleDeclarations? { - return inspectorOverridesFor(inspectorOverrideTarget(node)) - } + fun inspectorOverridesFor(node: DOMNode): StyleDeclarations? = inspectorOverridesFor(inspectorOverrideTarget(node)) fun inspectorOverrideFor(nodeKey: Any?, property: StyleProperty): StyleExpression? { if (nodeKey == null) return null return inspectorOverrides[nodeKey]?.get(property) } - fun inspectorOverrideFor(node: DOMNode, property: StyleProperty): StyleExpression? { - return inspectorOverrideFor(inspectorOverrideTarget(node), property) - } + fun inspectorOverrideFor(node: DOMNode, property: StyleProperty): StyleExpression? = + inspectorOverrideFor(inspectorOverrideTarget(node), property) - fun resolveInspectorExpression(expression: StyleExpression): Result { - return runCatching { + fun resolveInspectorExpression(expression: StyleExpression): Result = + runCatching { val snapshot = StylesheetManager.snapshot() val variables = resolvedVariables(snapshot, StyleApplicationScope.Application) resolveExpressionToLiteral(expression, variables) } - } - fun resolveInspectorVariable(name: String): Result { - return resolveInspectorExpression(StyleExpression.VariableRef(name)) - } + fun resolveInspectorVariable(name: String): Result = + resolveInspectorExpression(StyleExpression.VariableRef(name)) fun inspect(node: DOMNode): StyleInspection { val snapshot = StylesheetManager.snapshot() @@ -251,94 +348,97 @@ object StyleEngine { val matchedRules = candidates.map { "${selectorLabel(it.selector)} @ ${it.fileName}" } val inspector = inspectorOverrides[inspectorOverrideTarget(node)] val parentComputed = node.parent?.appliedComputedStyleSnapshot() - val winners = resolveCascadeWinners( - node = node, - candidates = candidates, - inline = node.inlineStyleDeclarations, - inspector = inspector - ) + val winners = + resolveCascadeWinners( + candidates = candidates, + inline = node.inlineStyleDeclarations, + inspector = inspector, + ) val rootFontSizePx = rootFontSizeFor(node) val sources = linkedMapOf() StyleProperty.entries.forEach { property -> - sources[property] = StylePropertySource( - property = property, - kind = StyleSourceKind.Default, - source = "default" - ) + sources[property] = + StylePropertySource( + property = property, + kind = StyleSourceKind.Default, + source = "default", + ) } var result = defaults.toComputedStyle() for (property in StyleProperty.entries) { val winner = winners[property] if (winner != null) { - val applied = runCatching { - applyProperty( - current = result, - parentComputed = parentComputed, - property = property, - expression = winner.expression, - variables = variables, - rootFontSizePx = rootFontSizePx - ) - }.onFailure { error -> - println("[DSGL-Style] Failed to apply '${property.key}': ${error.message}") - }.getOrNull() + val applied = + runCatching { + applyProperty( + current = result, + parentComputed = parentComputed, + property = property, + expression = winner.expression, + variables = variables, + rootFontSizePx = rootFontSizePx, + ) + }.onFailure { error -> + println("[DSGL-Style] Failed to apply '${property.key}': ${error.message}") + }.getOrNull() if (applied != null) { result = applied } - sources[property] = StylePropertySource( - property = property, - kind = winner.sourceKind, - source = winner.sourceLabel - ) + sources[property] = + StylePropertySource( + property = property, + kind = winner.sourceKind, + source = winner.sourceLabel(), + ) } else if (StylePropertyRegistry.isInherited(property) && parentComputed != null) { result = inheritProperty(result, parentComputed, property) - sources[property] = StylePropertySource( - property = property, - kind = StyleSourceKind.Inherited, - source = "inherited" - ) + sources[property] = + StylePropertySource( + property = property, + kind = StyleSourceKind.Inherited, + source = "inherited", + ) } } return StyleInspection( computed = result, propertySources = sources.toMap(), - matchedRules = matchedRules + matchedRules = matchedRules, ) } fun applyStylesRecursively( root: DOMNode, - scope: StyleApplicationScope = StyleApplicationScope.Application - ): Boolean { - return applyStylesRecursivelyDetailed(root, scope).layoutDirty - } + scope: StyleApplicationScope = StyleApplicationScope.Application, + ): Boolean = applyStylesRecursivelyDetailed(root, scope).layoutDirty fun applyStylesRecursivelyDetailed( root: DOMNode, - scope: StyleApplicationScope = StyleApplicationScope.Application + scope: StyleApplicationScope = StyleApplicationScope.Application, ): StyleApplyReport { val snapshot = snapshotForScope(scope) val metrics = MutableApplyMetrics() val variables = resolvedVariables(snapshot, scope) val allowInspectorOverrides = scope == StyleApplicationScope.Application - val result = if (scope == StyleApplicationScope.Application && shouldApplyTargetedPseudoPass(snapshot)) { - applyStylesToDirtySubtrees(root, snapshot, variables, metrics) - } else { - applyStylesRecursively( - root = root, - snapshot = snapshot, - variables = variables, - metrics = metrics, - parentComputed = null, - rootFontSizePx = DEFAULT_ROOT_FONT_SIZE_PX, - passMode = StylePassMode.Full, - scope = scope, - allowInspectorOverrides = allowInspectorOverrides - ) - } + val result = + if (scope == StyleApplicationScope.Application && shouldApplyTargetedPseudoPass(snapshot)) { + applyStylesToDirtySubtrees(root, snapshot, variables, metrics) + } else { + applyStylesRecursively( + root = root, + snapshot = snapshot, + variables = variables, + metrics = metrics, + parentComputed = null, + rootFontSizePx = DEFAULT_ROOT_FONT_SIZE_PX, + passMode = StylePassMode.Full, + scope = scope, + allowInspectorOverrides = allowInspectorOverrides, + ) + } if (scope == StyleApplicationScope.Application) { pseudoDirtyNodes.clear() selectorDirtyNodes.clear() @@ -351,31 +451,32 @@ object StyleEngine { lastAppliedSelectorStateVersion = selectorStateVersion } - val report = StyleApplyReport( - layoutDirty = result.layoutDirty, - visualDirty = result.visualDirty, - visitedNodes = metrics.visitedNodes, - cacheHits = metrics.cacheHits, - recomputedNodes = metrics.recomputedNodes - ) + val report = + StyleApplyReport( + layoutDirty = result and FLAG_LAYOUT_DIRTY != 0, + visualDirty = result and FLAG_VISUAL_DIRTY != 0, + visitedNodes = metrics.visitedNodes, + cacheHits = metrics.cacheHits, + recomputedNodes = metrics.recomputedNodes, + ) lastApplyReport = report return report } fun lastStyleApplyReport(): StyleApplyReport = lastApplyReport - fun currentStyleRevision( - scope: StyleApplicationScope = StyleApplicationScope.Application - ): Long { - val base = when (scope) { - StyleApplicationScope.Application -> { - (StylesheetManager.snapshot().version shl 2) xor - (themeVersion shl 1) xor - inspectorOverridesVersion + fun currentStyleRevision(scope: StyleApplicationScope = StyleApplicationScope.Application): Long { + val snapshotVersion = snapshotForScope(scope).version + val inspectorVersion = + if (scope == StyleApplicationScope.Application) { + inspectorOverridesVersion + } else { + 0L } - - StyleApplicationScope.SystemOverlay -> 0L - } + val base = + (snapshotVersion shl 2) xor + (themeVersion shl 1) xor + inspectorVersion return base xor (pseudoStateVersion shl 3) xor (selectorStateVersion shl 4) xor @@ -410,23 +511,31 @@ object StyleEngine { return snapshot.version == lastAppliedStylesheetVersion && themeVersion == lastAppliedThemeVersion && inspectorOverridesVersion == lastAppliedInspectorOverridesVersion && - (pseudoStateVersion != lastAppliedPseudoStateVersion || - selectorStateVersion != lastAppliedSelectorStateVersion) + ( + pseudoStateVersion != lastAppliedPseudoStateVersion || + selectorStateVersion != lastAppliedSelectorStateVersion + ) } private fun applyStylesToDirtySubtrees( root: DOMNode, snapshot: StylesheetSnapshot, variables: Map, - metrics: MutableApplyMetrics - ): NodeApplyFlags { - val dirty = expandDirtyNodesForCombinators( - root = root, - snapshot = snapshot, - rawDirty = pseudoDirtyNodes + selectorDirtyNodes - ) - .filter { it.isDescendantOfOrSame(root) } - .sortedBy { it.depth() } + metrics: MutableApplyMetrics, + ): Int { + val rawDirty = + when { + pseudoDirtyNodes.isEmpty() -> selectorDirtyNodes + selectorDirtyNodes.isEmpty() -> pseudoDirtyNodes + else -> pseudoDirtyNodes + selectorDirtyNodes + } + val dirty = + expandDirtyNodesForCombinators( + root = root, + snapshot = snapshot, + rawDirty = rawDirty, + ).filter { it.isDescendantOfOrSame(root) } + .sortedBy { it.depth() } if (dirty.isEmpty()) { // Dirty markers may reference detached/template nodes after reconcile. // Fall back to full tree style application to keep frame output deterministic. @@ -439,7 +548,7 @@ object StyleEngine { rootFontSizePx = DEFAULT_ROOT_FONT_SIZE_PX, passMode = StylePassMode.Full, scope = StyleApplicationScope.Application, - allowInspectorOverrides = true + allowInspectorOverrides = true, ) } @@ -450,23 +559,20 @@ object StyleEngine { } } - var flags = NodeApplyFlags(layoutDirty = false, visualDirty = false) + var flags = 0 effectiveRoots.forEach { subtreeRoot -> - val subtreeFlags = applyStylesRecursively( - root = subtreeRoot, - snapshot = snapshot, - variables = variables, - metrics = metrics, - parentComputed = subtreeRoot.parent?.appliedComputedStyleSnapshot(), - rootFontSizePx = rootFontSizeFor(subtreeRoot), - passMode = StylePassMode.Targeted, - scope = StyleApplicationScope.Application, - allowInspectorOverrides = true - ) - flags = NodeApplyFlags( - layoutDirty = flags.layoutDirty || subtreeFlags.layoutDirty, - visualDirty = flags.visualDirty || subtreeFlags.visualDirty - ) + flags = flags or + applyStylesRecursively( + root = subtreeRoot, + snapshot = snapshot, + variables = variables, + metrics = metrics, + parentComputed = subtreeRoot.parent?.appliedComputedStyleSnapshot(), + rootFontSizePx = rootFontSizeFor(subtreeRoot), + passMode = StylePassMode.Targeted, + scope = StyleApplicationScope.Application, + allowInspectorOverrides = true, + ) } return flags } @@ -480,47 +586,48 @@ object StyleEngine { rootFontSizePx: Int, passMode: StylePassMode, scope: StyleApplicationScope, - allowInspectorOverrides: Boolean - ): NodeApplyFlags { - val nodeResult = applyStyleToNode( - node = root, - snapshot = snapshot, - variables = variables, - metrics = metrics, - parentComputed = parentComputed, - rootFontSizePx = rootFontSizePx, - scope = scope, - allowInspectorOverrides = allowInspectorOverrides - ) - var flags = nodeResult.flags - val canSkipSubtree = passMode == StylePassMode.Targeted && - nodeResult.cacheHit && - !snapshot.index.hasAncestorDependentSelectors - if (canSkipSubtree) { - return flags - } - val nodeComputed = root.appliedComputedStyleSnapshot() - val nextRootFontSizePx = if (root.parent == null) { - nodeComputed?.fontSize ?: rootFontSizePx - } else { - rootFontSizePx - } - root.children.forEach { child -> - val childFlags = applyStylesRecursively( - root = child, + allowInspectorOverrides: Boolean, + ): Int { + val nodeResult = + applyStyleToNode( + node = root, snapshot = snapshot, variables = variables, metrics = metrics, - parentComputed = nodeComputed, - rootFontSizePx = nextRootFontSizePx, - passMode = passMode, + parentComputed = parentComputed, + rootFontSizePx = rootFontSizePx, scope = scope, - allowInspectorOverrides = allowInspectorOverrides - ) - flags = NodeApplyFlags( - layoutDirty = flags.layoutDirty || childFlags.layoutDirty, - visualDirty = flags.visualDirty || childFlags.visualDirty + allowInspectorOverrides = allowInspectorOverrides, ) + var flags = nodeResult and (FLAG_LAYOUT_DIRTY or FLAG_VISUAL_DIRTY) + val canSkipSubtree = + passMode == StylePassMode.Targeted && + (nodeResult and FLAG_CACHE_HIT) != 0 && + !snapshot.index.hasAncestorDependentSelectors + if (canSkipSubtree) { + return flags + } + val nodeComputed = root.appliedComputedStyleSnapshot() + val nextRootFontSizePx = + if (root.parent == null) { + nodeComputed?.fontSize ?: rootFontSizePx + } else { + rootFontSizePx + } + val children = root.children + for (index in children.indices) { + flags = flags or + applyStylesRecursively( + root = children[index], + snapshot = snapshot, + variables = variables, + metrics = metrics, + parentComputed = nodeComputed, + rootFontSizePx = nextRootFontSizePx, + passMode = passMode, + scope = scope, + allowInspectorOverrides = allowInspectorOverrides, + ) } return flags } @@ -533,71 +640,69 @@ object StyleEngine { parentComputed: ComputedStyle?, rootFontSizePx: Int, scope: StyleApplicationScope, - allowInspectorOverrides: Boolean - ): NodeApplyResult { + allowInspectorOverrides: Boolean, + ): Int { metrics.visitedNodes += 1 val defaults = node.captureStyleDefaults() - val key = CacheKey( - typeName = node.styleType, - nodeId = selectorNodeId(node), - classesHash = node.styleClasses.hashCode(), - inlineHash = node.inlineStyleDeclarations.toStableHash(), - inspectorHash = if (allowInspectorOverrides) inspectorOverrideHash(node) else 0, - hovered = node.styleHovered, - active = node.styleActive, - focused = node.styleFocused, - disabled = node.styleDisabled, - open = node.styleOpen, - stylesheetVersion = snapshot.version, - themeVersion = if (scope == StyleApplicationScope.Application) themeVersion else 0L, - defaultsHash = defaults.hashCode(), - parentInheritedHash = inheritedHash(parentComputed), - ancestorSelectorHash = if (snapshot.index.hasAncestorDependentSelectors) ancestorSelectorHash(node) else 0, - siblingSelectorHash = if ( - snapshot.index.hasAdjacentSiblingCombinators || snapshot.index.hasGeneralSiblingCombinators - ) { + val probe = cacheKeyProbe + probe.typeName = node.styleType + probe.nodeId = selectorNodeId(node) + probe.classesHash = node.styleClasses.hashCode() + probe.inlineHash = node.inlineStyleDeclarations.toStableHash() + probe.inspectorHash = if (allowInspectorOverrides) inspectorOverrideHash(node) else 0 + probe.hovered = node.styleHovered + probe.active = node.styleActive + probe.focused = node.styleFocused + probe.disabled = node.styleDisabled + probe.open = node.styleOpen + probe.stylesheetVersion = snapshot.version + probe.themeVersion = if (scope == StyleApplicationScope.Application) themeVersion else 0L + probe.defaultsHash = defaults.hashCode() + probe.parentInheritedHash = inheritedHash(parentComputed) + probe.ancestorSelectorHash = + if (snapshot.index.hasAncestorDependentSelectors) { + ancestorSelectorHash(node) + } else { + 0 + } + probe.siblingSelectorHash = + if (snapshot.index.hasAdjacentSiblingCombinators || snapshot.index.hasGeneralSiblingCombinators) { previousSiblingSelectorHash(node) } else { 0 - }, - rootFontSizePx = rootFontSizePx, - viewportWidthPx = viewportWidthPx, - viewportHeightPx = viewportHeightPx, - scope = scope - ) + } + probe.rootFontSizePx = rootFontSizePx + probe.viewportWidthPx = viewportWidthPx + probe.viewportHeightPx = viewportHeightPx + probe.scope = scope val cached = cache[node] - if (cached != null && cached.key == key) { + if (cached != null && probe.matches(cached.key)) { metrics.cacheHits += 1 val result = node.applyComputedStyle(cached.style) - return NodeApplyResult( - flags = NodeApplyFlags( - layoutDirty = result.layoutDirty, - visualDirty = result.visualDirty - ), - cacheHit = true - ) + var flags = FLAG_CACHE_HIT + if (result.layoutDirty) flags = flags or FLAG_LAYOUT_DIRTY + if (result.visualDirty) flags = flags or FLAG_VISUAL_DIRTY + return flags } - val computed = computeStyle( - node = node, - defaults = defaults, - snapshot = snapshot, - variables = variables, - parentComputed = parentComputed, - rootFontSizePx = rootFontSizePx, - allowInspectorOverrides = allowInspectorOverrides - ) - cache[node] = CachedStyle(key = key, style = computed) + val computed = + computeStyle( + node = node, + defaults = defaults, + snapshot = snapshot, + variables = variables, + parentComputed = parentComputed, + rootFontSizePx = rootFontSizePx, + allowInspectorOverrides = allowInspectorOverrides, + ) + cache[node] = CachedStyle(key = probe.toKey(), style = computed) metrics.recomputedNodes += 1 val result = node.applyComputedStyle(computed) - return NodeApplyResult( - flags = NodeApplyFlags( - layoutDirty = result.layoutDirty, - visualDirty = result.visualDirty - ), - cacheHit = false - ) + var flags = 0 + if (result.layoutDirty) flags = flags or FLAG_LAYOUT_DIRTY + if (result.visualDirty) flags = flags or FLAG_VISUAL_DIRTY + return flags } private fun computeStyle( @@ -607,31 +712,32 @@ object StyleEngine { variables: Map, parentComputed: ComputedStyle?, rootFontSizePx: Int, - allowInspectorOverrides: Boolean + allowInspectorOverrides: Boolean, ): ComputedStyle { val candidates = matchingCandidates(node, snapshot.index) - val winners = resolveCascadeWinners( - node = node, - candidates = candidates, - inline = node.inlineStyleDeclarations, - inspector = if (allowInspectorOverrides) inspectorOverrides[inspectorOverrideTarget(node)] else null - ) + val winners = + resolveCascadeWinners( + candidates = candidates, + inline = node.inlineStyleDeclarations, + inspector = if (allowInspectorOverrides) inspectorOverrides[inspectorOverrideTarget(node)] else null, + ) var result = defaults.toComputedStyle() for (property in StyleProperty.entries) { val winner = winners[property] if (winner != null) { - val applied = runCatching { - applyProperty( - current = result, - parentComputed = parentComputed, - property = property, - expression = winner.expression, - variables = variables, - rootFontSizePx = rootFontSizePx - ) - }.onFailure { error -> - println("[DSGL-Style] Failed to apply '${property.key}': ${error.message}") - }.getOrNull() + val applied = + runCatching { + applyProperty( + current = result, + parentComputed = parentComputed, + property = property, + expression = winner.expression, + variables = variables, + rootFontSizePx = rootFontSizePx, + ) + }.onFailure { error -> + println("[DSGL-Style] Failed to apply '${property.key}': ${error.message}") + }.getOrNull() if (applied != null) { result = applied } @@ -643,30 +749,25 @@ object StyleEngine { } private fun resolveCascadeWinners( - node: DOMNode, candidates: List, inline: StyleDeclarations, - inspector: StyleDeclarations? + inspector: StyleDeclarations?, ): EnumMap { val winners = EnumMap(StyleProperty::class.java) candidates.forEach { rule -> rule.declarations.values.forEach { (property, expression) -> val important = rule.declarations.isImportant(property) - val candidate = CascadeWinner( - expression = expression, - important = important, - specificity = rule.selector.specificity, - sourceOrder = rule.sourceOrder, - origin = StyleOrigin.Stylesheet, - sourceKind = StyleSourceKind.Selector, - sourceLabel = buildString { - append(selectorLabel(rule.selector)) - append(" @ ") - append(rule.fileName) - if (important) append(" !important") - } - ) + val candidate = + CascadeWinner( + expression = expression, + important = important, + specificity = rule.selector.specificity, + sourceOrder = rule.sourceOrder, + origin = StyleOrigin.Stylesheet, + sourceKind = StyleSourceKind.Selector, + sourceRule = rule, + ) if (shouldReplace(winners[property], candidate)) { winners[property] = candidate } @@ -675,15 +776,15 @@ object StyleEngine { inline.values.forEach { (property, expression) -> val important = inline.isImportant(property) - val candidate = CascadeWinner( - expression = expression, - important = important, - specificity = StyleSpecificity(idCount = 1, classLikeCount = 0, typeCount = 0), - sourceOrder = Int.MAX_VALUE - 1, - origin = StyleOrigin.Inline, - sourceKind = StyleSourceKind.Inline, - sourceLabel = if (important) "inline !important" else "inline" - ) + val candidate = + CascadeWinner( + expression = expression, + important = important, + specificity = StyleSpecificity(idCount = 1, classLikeCount = 0, typeCount = 0), + sourceOrder = Int.MAX_VALUE - 1, + origin = StyleOrigin.Inline, + sourceKind = StyleSourceKind.Inline, + ) if (shouldReplace(winners[property], candidate)) { winners[property] = candidate } @@ -691,15 +792,15 @@ object StyleEngine { inspector?.values?.forEach { (property, expression) -> val important = inspector.isImportant(property) - val candidate = CascadeWinner( - expression = expression, - important = important, - specificity = StyleSpecificity(idCount = 1, classLikeCount = 0, typeCount = 0), - sourceOrder = Int.MAX_VALUE, - origin = StyleOrigin.Inspector, - sourceKind = StyleSourceKind.InspectorOverride, - sourceLabel = if (important) "inspector !important" else "inspector" - ) + val candidate = + CascadeWinner( + expression = expression, + important = important, + specificity = StyleSpecificity(idCount = 1, classLikeCount = 0, typeCount = 0), + sourceOrder = Int.MAX_VALUE, + origin = StyleOrigin.Inspector, + sourceKind = StyleSourceKind.InspectorOverride, + ) if (shouldReplace(winners[property], candidate)) { winners[property] = candidate } @@ -726,47 +827,68 @@ object StyleEngine { return true } - private fun snapshotForScope(scope: StyleApplicationScope): StylesheetSnapshot { - return when (scope) { + private fun snapshotForScope(scope: StyleApplicationScope): StylesheetSnapshot = + when (scope) { StyleApplicationScope.Application -> StylesheetManager.snapshot() - StyleApplicationScope.SystemOverlay -> StylesheetSnapshot( - version = Long.MIN_VALUE, - index = RuleIndex.EMPTY, - rootVariables = emptyMap() - ) + StyleApplicationScope.System, + StyleApplicationScope.Debug, + -> + StylesheetSnapshot( + version = Long.MIN_VALUE, + index = RuleIndex.EMPTY, + rootVariables = emptyMap(), + ) } - } private fun resolvedVariables(snapshot: StylesheetSnapshot, scope: StyleApplicationScope): Map { - if (scope == StyleApplicationScope.SystemOverlay) { + if (scope != StyleApplicationScope.Application) { return emptyMap() } - val variables = linkedMapOf() - variables.putAll(snapshot.rootVariables) - variables.putAll(themeVariables) - return variables + if (snapshot.version != cachedResolvedVariablesSnapshotVersion || + themeVersion != cachedResolvedVariablesThemeVersion + ) { + val variables = linkedMapOf() + variables.putAll(snapshot.rootVariables) + variables.putAll(themeVariables) + cachedResolvedVariables = variables + cachedResolvedVariablesSnapshotVersion = snapshot.version + cachedResolvedVariablesThemeVersion = themeVersion + } + return cachedResolvedVariables } - private fun matchingCandidates(node: DOMNode, index: RuleIndex): List { - return gatherCandidates(node, index) - .filter { selectorMatches(node, it.selector) } - .sortedBy { it.sourceOrder } - } + private val sourceOrderComparator: Comparator = compareBy { it.sourceOrder } - private fun gatherCandidates(node: DOMNode, index: RuleIndex): List { - val out = linkedSetOf() - out.addAll(index.universalIndex) + private fun matchingCandidates(node: DOMNode, index: RuleIndex): List { + val out = ArrayList() + val seen = HashSet() + addMatchingCandidates(index.universalIndex, node, seen, out) selectorNodeId(node)?.let { id -> - index.idIndex[id]?.let { out.addAll(it) } + index.idIndex[id]?.let { addMatchingCandidates(it, node, seen, out) } } - index.typeIndex[node.styleType]?.let { out.addAll(it) } + index.typeIndex[node.styleType]?.let { addMatchingCandidates(it, node, seen, out) } if (node.parent == null) { - index.typeIndex[ROOT_SELECTOR_INTERNAL]?.let { out.addAll(it) } + index.typeIndex[ROOT_SELECTOR_INTERNAL]?.let { addMatchingCandidates(it, node, seen, out) } } node.styleClasses.forEach { className -> - index.classIndex[className]?.let { out.addAll(it) } + index.classIndex[className]?.let { addMatchingCandidates(it, node, seen, out) } + } + out.sortWith(sourceOrderComparator) + return out + } + + private fun addMatchingCandidates( + rules: List, + node: DOMNode, + seen: MutableSet, + out: MutableList, + ) { + for (index in rules.indices) { + val rule = rules[index] + if (seen.add(rule) && selectorMatches(node, rule.selector)) { + out += rule + } } - return out.toList() } private fun selectorMatches(node: DOMNode, selector: StyleSelector): Boolean { @@ -777,18 +899,21 @@ object StyleEngine { val targetNode = current ?: return false if (!selectorPartMatches(targetNode, step.part)) return false if (index == 0) return true - current = when (step.combinatorToLeft ?: StyleCombinator.Descendant) { - StyleCombinator.Child -> targetNode.parent - StyleCombinator.Descendant -> findAncestorMatching( - from = targetNode.parent, - part = steps[index - 1].part - ) - StyleCombinator.AdjacentSibling -> targetNode.previousSiblingOrNull() - StyleCombinator.GeneralSibling -> findPreviousSiblingMatching( - from = targetNode.previousSiblingOrNull(), - part = steps[index - 1].part - ) - } + current = + when (step.combinatorToLeft ?: StyleCombinator.Descendant) { + StyleCombinator.Child -> targetNode.parent + StyleCombinator.Descendant -> + findAncestorMatching( + from = targetNode.parent, + part = steps[index - 1].part, + ) + StyleCombinator.AdjacentSibling -> targetNode.previousSiblingOrNull() + StyleCombinator.GeneralSibling -> + findPreviousSiblingMatching( + from = targetNode.previousSiblingOrNull(), + part = steps[index - 1].part, + ) + } if (current == null) return false } return true @@ -819,10 +944,11 @@ object StyleEngine { private fun selectorPartMatches(node: DOMNode, part: StyleSelectorPart): Boolean { if (part.id != null && part.id != selectorNodeId(node)) return false part.typeName?.let { typeName -> - val typeMatches = when (typeName) { - ROOT_SELECTOR_INTERNAL -> node.parent == null - else -> typeName == node.styleType - } + val typeMatches = + when (typeName) { + ROOT_SELECTOR_INTERNAL -> node.parent == null + else -> typeName == node.styleType + } if (!typeMatches) return false } if (part.classes.isNotEmpty() && !node.styleClasses.containsAll(part.classes)) return false @@ -836,38 +962,40 @@ object StyleEngine { } } - private fun selectorLabel(selector: StyleSelector): String { - return selector.steps.joinToString(separator = " ") { step -> - val part = buildString { - when { - step.part.universal -> append("*") - step.part.typeName != null -> append(step.part.typeName) - } - step.part.id?.let { append('#').append(it) } - step.part.classes.forEach { className -> append('.').append(className) } - when (step.part.pseudoState) { - StylePseudoState.HOVER -> append(":hover") - StylePseudoState.ACTIVE -> append(":active") - StylePseudoState.FOCUS -> append(":focus") - StylePseudoState.DISABLED -> append(":disabled") - StylePseudoState.OPEN -> append(":open") - null -> Unit - } - } - val prefix = when (step.combinatorToLeft) { - StyleCombinator.Child -> ">" - StyleCombinator.Descendant -> "" - StyleCombinator.AdjacentSibling -> "+" - StyleCombinator.GeneralSibling -> "~" - null -> "" - } - if (prefix.isEmpty()) part else "$prefix $part" - }.trim() - } + private fun selectorLabel(selector: StyleSelector): String = + selector.steps + .joinToString(separator = " ") { step -> + val part = + buildString { + when { + step.part.universal -> append("*") + step.part.typeName != null -> append(step.part.typeName) + } + step.part.id + ?.let { append('#').append(it) } + step.part.classes + .forEach { className -> append('.').append(className) } + when (step.part.pseudoState) { + StylePseudoState.HOVER -> append(":hover") + StylePseudoState.ACTIVE -> append(":active") + StylePseudoState.FOCUS -> append(":focus") + StylePseudoState.DISABLED -> append(":disabled") + StylePseudoState.OPEN -> append(":open") + null -> Unit + } + } + val prefix = + when (step.combinatorToLeft) { + StyleCombinator.Child -> ">" + StyleCombinator.Descendant -> "" + StyleCombinator.AdjacentSibling -> "+" + StyleCombinator.GeneralSibling -> "~" + null -> "" + } + if (prefix.isEmpty()) part else "$prefix $part" + }.trim() - private fun selectorNodeId(node: DOMNode): String? { - return node.styleId ?: (node.key as? String) - } + private fun selectorNodeId(node: DOMNode): String? = node.styleId ?: (node.key as? String) private fun ancestorSelectorHash(node: DOMNode): Int { var current = node.parent @@ -912,8 +1040,12 @@ object StyleEngine { ?: DEFAULT_ROOT_FONT_SIZE_PX } - private fun inheritProperty(current: ComputedStyle, parent: ComputedStyle, property: StyleProperty): ComputedStyle { - return when (property) { + private fun inheritProperty( + current: ComputedStyle, + parent: ComputedStyle, + property: StyleProperty, + ): ComputedStyle = + when (property) { StyleProperty.FOREGROUND_COLOR -> current.copy(foregroundColor = parent.foregroundColor) StyleProperty.FONT_ID -> current.copy(fontId = parent.fontId) StyleProperty.FONT_SIZE -> current.copy(fontSize = parent.fontSize, fontSizeValue = parent.fontSizeValue) @@ -922,7 +1054,6 @@ object StyleEngine { StyleProperty.FONT_STYLE -> current.copy(fontStyle = parent.fontStyle) else -> current } - } private const val ROOT_SELECTOR_INTERNAL = "dsgl-root" @@ -944,70 +1075,84 @@ object StyleEngine { property: StyleProperty, expression: StyleExpression, variables: Map, - rootFontSizePx: Int + rootFontSizePx: Int, ): ComputedStyle { val literal = resolveExpressionToLiteral(expression, variables) return when (property) { - StyleProperty.MARGIN -> current.copy( - margin = parseSpacingLengthShorthand( - raw = literal, - allowNegative = true + StyleProperty.MARGIN -> + current.copy( + margin = + parseSpacingLengthShorthand( + raw = literal, + allowNegative = true, + ), ) - ) - StyleProperty.PADDING -> current.copy( - padding = parseSpacingLengthShorthand( - raw = literal, - allowNegative = false + StyleProperty.PADDING -> + current.copy( + padding = + parseSpacingLengthShorthand( + raw = literal, + allowNegative = false, + ), ) - ) StyleProperty.BACKGROUND_COLOR -> current.copy(backgroundColor = parseColor(literal)) StyleProperty.BACKGROUND_IMAGE -> current.copy(backgroundImage = parseStringLiteral(literal)) StyleProperty.BORDER_COLOR -> current.copy(borderColor = parseColor(literal)) - StyleProperty.BORDER_WIDTH -> current.copy( - borderWidth = parseLengthLiteral(literal, allowNegative = false) - ) - StyleProperty.BORDER_RADIUS -> current.copy( - borderRadius = parseLengthLiteral(literal, allowNegative = false) - ) + StyleProperty.BORDER_WIDTH -> + current.copy( + borderWidth = parseLengthLiteral(literal, allowNegative = false), + ) + StyleProperty.BORDER_RADIUS -> + current.copy( + borderRadius = parseLengthLiteral(literal, allowNegative = false), + ) StyleProperty.FOREGROUND_COLOR -> current.copy(foregroundColor = parseColor(literal)) StyleProperty.FONT_ID -> current.copy(fontId = parseStringLiteral(literal)) StyleProperty.FONT_SIZE -> { val fontSizeValue = parseLengthLiteral(literal, allowNegative = false) current.copy( - fontSize = resolveFontSizePx( - fontSizeValue = fontSizeValue, - current = current, - parentComputed = parentComputed, - rootFontSizePx = rootFontSizePx - ).coerceAtLeast(1), - fontSizeValue = fontSizeValue + fontSize = + resolveFontSizePx( + fontSizeValue = fontSizeValue, + current = current, + parentComputed = parentComputed, + rootFontSizePx = rootFontSizePx, + ).coerceAtLeast(1), + fontSizeValue = fontSizeValue, ) } - StyleProperty.LINE_HEIGHT -> current.copy( - lineHeight = StylePropertyRegistry.parseLineHeightLiteral(property, literal) - ) + StyleProperty.LINE_HEIGHT -> + current.copy( + lineHeight = StylePropertyRegistry.parseLineHeightLiteral(property, literal), + ) StyleProperty.FONT_WEIGHT -> current.copy(fontWeight = parseFontWeight(literal)) StyleProperty.FONT_STYLE -> current.copy(fontStyle = parseFontStyle(literal)) StyleProperty.TEXT_DECORATION -> current.copy(textDecoration = parseTextDecoration(literal)) StyleProperty.OBFUSCATED -> current.copy(obfuscated = parseBooleanLike(literal)) - StyleProperty.WIDTH -> current.copy( - width = parseLengthLiteral(literal, allowNegative = false) - ) - StyleProperty.HEIGHT -> current.copy( - height = parseLengthLiteral(literal, allowNegative = false) - ) - StyleProperty.MIN_WIDTH -> current.copy( - minWidth = parseOptionalConstraintLengthLiteral(literal) - ) - StyleProperty.MIN_HEIGHT -> current.copy( - minHeight = parseOptionalConstraintLengthLiteral(literal) - ) - StyleProperty.MAX_WIDTH -> current.copy( - maxWidth = parseOptionalConstraintLengthLiteral(literal) - ) - StyleProperty.MAX_HEIGHT -> current.copy( - maxHeight = parseOptionalConstraintLengthLiteral(literal) - ) + StyleProperty.WIDTH -> + current.copy( + width = parseLengthLiteral(literal, allowNegative = false), + ) + StyleProperty.HEIGHT -> + current.copy( + height = parseLengthLiteral(literal, allowNegative = false), + ) + StyleProperty.MIN_WIDTH -> + current.copy( + minWidth = parseOptionalConstraintLengthLiteral(literal), + ) + StyleProperty.MIN_HEIGHT -> + current.copy( + minHeight = parseOptionalConstraintLengthLiteral(literal), + ) + StyleProperty.MAX_WIDTH -> + current.copy( + maxWidth = parseOptionalConstraintLengthLiteral(literal), + ) + StyleProperty.MAX_HEIGHT -> + current.copy( + maxHeight = parseOptionalConstraintLengthLiteral(literal), + ) StyleProperty.ALIGN -> current.copy(align = parseAlign(literal)) StyleProperty.DISPLAY -> current.copy(display = parseDisplay(literal)) StyleProperty.POSITION -> current.copy(position = parsePosition(literal)) @@ -1019,13 +1164,14 @@ object StyleEngine { StyleProperty.OVERFLOW -> { val overflowAxes = parseOverflowShorthand(literal) current.copy( - overflow = if (overflowAxes.overflowX == overflowAxes.overflowY) { - overflowAxes.overflowX - } else { - Overflow.Visible - }, + overflow = + if (overflowAxes.overflowX == overflowAxes.overflowY) { + overflowAxes.overflowX + } else { + Overflow.Visible + }, overflowX = overflowAxes.overflowX, - overflowY = overflowAxes.overflowY + overflowY = overflowAxes.overflowY, ) } StyleProperty.OVERFLOW_X -> current.copy(overflowX = parseOverflow(literal)) @@ -1034,18 +1180,21 @@ object StyleEngine { StyleProperty.JUSTIFY_CONTENT -> current.copy(justifyContent = parseJustifyContent(literal)) StyleProperty.ALIGN_ITEMS -> current.copy(alignItems = parseAlignItems(literal)) StyleProperty.JUSTIFY_ITEMS -> current.copy(justifyItems = parseJustifyItems(literal)) - StyleProperty.GAP -> current.copy( - gap = parseLengthLiteral(literal, allowNegative = false) - ) + StyleProperty.GAP -> + current.copy( + gap = parseLengthLiteral(literal, allowNegative = false), + ) StyleProperty.FLEX_GROW -> current.copy(flexGrow = parseFloatLike(literal).coerceAtLeast(0f)) StyleProperty.FLEX_SHRINK -> current.copy(flexShrink = parseFloatLike(literal).coerceAtLeast(0f)) - StyleProperty.FLEX_BASIS -> current.copy( - flexBasis = parseOptionalCssLength(literal)?.also { length -> - if (length.value < 0f) { - error("Negative length is not allowed: '$literal'.") - } - } - ) + StyleProperty.FLEX_BASIS -> + current.copy( + flexBasis = + parseOptionalCssLength(literal)?.also { length -> + if (length.value < 0f) { + error("Negative length is not allowed: '$literal'.") + } + }, + ) StyleProperty.GRID_COLUMNS -> current.copy(gridColumns = parseIntLike(literal).coerceAtLeast(1)) StyleProperty.GRID_ROWS -> current.copy(gridRows = parseOptionalInt(literal)?.coerceAtLeast(1)) StyleProperty.GRID_AUTO_FLOW -> current.copy(gridAutoFlow = parseGridAutoFlow(literal)) @@ -1079,38 +1228,43 @@ object StyleEngine { fontSizeValue: CssLength, current: ComputedStyle, parentComputed: ComputedStyle?, - rootFontSizePx: Int + rootFontSizePx: Int, ): Int { - val inheritedFontPx = ( - parentComputed?.fontSize - ?: current.fontSize - ?: 16 + val inheritedFontPx = + ( + parentComputed?.fontSize + ?: current.fontSize + ?: 16 ).toFloat() - val context = LengthResolveContext( - viewportWidthPx = viewportWidthPx.toFloat(), - viewportHeightPx = viewportHeightPx.toFloat(), - containingBlockWidthPx = viewportWidthPx.toFloat(), - containingBlockHeightPx = viewportHeightPx.toFloat(), - rootFontSizePx = rootFontSizePx.toFloat(), - currentFontSizePx = inheritedFontPx, - inheritedFontSizePx = inheritedFontPx - ) + val context = + LengthResolveContext( + viewportWidthPx = viewportWidthPx.toFloat(), + viewportHeightPx = viewportHeightPx.toFloat(), + containingBlockWidthPx = viewportWidthPx.toFloat(), + containingBlockHeightPx = viewportHeightPx.toFloat(), + rootFontSizePx = rootFontSizePx.toFloat(), + currentFontSizePx = inheritedFontPx, + inheritedFontSizePx = inheritedFontPx, + ) return fontSizeValue .resolvePx(context, LengthPercentBase.InheritedFontSize) .roundToInt() .coerceAtLeast(1) } - private fun inspectorOverrideHash(node: DOMNode): Int { - return inspectorOverrideVersions[inspectorOverrideTarget(node)] ?: 0 - } + private fun inspectorOverrideHash(node: DOMNode): Int = + inspectorOverrideVersions[inspectorOverrideTarget(node)] ?: 0 private fun anonymousInspectorPath(node: DOMNode): String { val parts = ArrayList(8) var current: DOMNode? = node while (current != null) { val parent = current.parent - val index = parent?.children?.indexOf(current)?.coerceAtLeast(0) ?: 0 + val index = + parent + ?.children + ?.indexOf(current) + ?.coerceAtLeast(0) ?: 0 parts += "${current.styleType}[$index]" current = parent } @@ -1161,7 +1315,7 @@ object StyleEngine { private fun expandDirtyNodesForCombinators( root: DOMNode, snapshot: StylesheetSnapshot, - rawDirty: Collection + rawDirty: Collection, ): Set { if (rawDirty.isEmpty()) return emptySet() if (!snapshot.index.hasAdjacentSiblingCombinators && !snapshot.index.hasGeneralSiblingCombinators) { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleInspection.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleInspection.kt index 2fde298..841b469 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleInspection.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleInspection.kt @@ -5,17 +5,17 @@ enum class StyleSourceKind { Inherited, Selector, Inline, - InspectorOverride + InspectorOverride, } data class StylePropertySource( val property: StyleProperty, val kind: StyleSourceKind, - val source: String + val source: String, ) data class StyleInspection( val computed: ComputedStyle, val propertySources: Map, - val matchedRules: List + val matchedRules: List, ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleModel.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleModel.kt index f4eaa19..8495a87 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleModel.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleModel.kt @@ -8,13 +8,13 @@ enum class StylePseudoState { ACTIVE, FOCUS, DISABLED, - OPEN + OPEN, } enum class StyleAlign { START, CENTER, - END + END, } enum class Display { @@ -22,7 +22,7 @@ enum class Display { Inline, None, Flex, - Grid + Grid, } enum class PositionMode { @@ -30,19 +30,19 @@ enum class PositionMode { Relative, Absolute, Fixed, - Sticky + Sticky, } enum class Overflow { Visible, Hidden, Scroll, - Auto + Auto, } enum class FlexDirection { Row, - Column + Column, } enum class JustifyContent { @@ -51,58 +51,61 @@ enum class JustifyContent { End, SpaceBetween, SpaceAround, - SpaceEvenly + SpaceEvenly, } enum class AlignItems { Start, Center, End, - Stretch + Stretch, } enum class JustifyItems { Start, Center, End, - Stretch + Stretch, } enum class GridAutoFlow { Row, - Column + Column, } enum class TextWrap { Wrap, - NoWrap + NoWrap, } enum class TextFormatting { None, - Minecraft + Minecraft, } enum class FontWeight { Normal, - Bold + Bold, } enum class FontStyle { Normal, - Italic + Italic, } enum class TextDecoration { None, Underline, Strikethrough, - UnderlineStrikethrough + UnderlineStrikethrough, } sealed class LineHeightValue { object Normal : LineHeightValue() - data class Length(val value: CssLength) : LineHeightValue() + + data class Length( + val value: CssLength, + ) : LineHeightValue() } data class UiTransform( @@ -110,19 +113,20 @@ data class UiTransform( val translateY: Float = 0f, val scaleX: Float = 1f, val scaleY: Float = 1f, - val rotateDeg: Float = 0f + val rotateDeg: Float = 0f, ) { fun translated(x: Float, y: Float): UiTransform = copy(translateX = translateX + x, translateY = translateY + y) + fun scaled(x: Float, y: Float = x): UiTransform = copy(scaleX = scaleX * x, scaleY = scaleY * y) + fun rotated(deg: Float): UiTransform = copy(rotateDeg = rotateDeg + deg) - fun isIdentity(): Boolean { - return translateX == 0f && - translateY == 0f && - scaleX == 1f && - scaleY == 1f && - rotateDeg == 0f - } + fun isIdentity(): Boolean = + translateX == 0f && + translateY == 0f && + scaleX == 1f && + scaleY == 1f && + rotateDeg == 0f companion object { val IDENTITY: UiTransform = UiTransform() @@ -131,7 +135,7 @@ data class UiTransform( data class TransformOrigin( val originX: Float = 0.5f, - val originY: Float = 0.5f + val originY: Float = 0.5f, ) { init { require(originX in 0f..1f) { "transform originX must be in [0..1]" } @@ -143,7 +147,9 @@ data class TransformOrigin( } } -enum class StyleProperty(val key: String) { +enum class StyleProperty( + val key: String, +) { MARGIN("margin"), PADDING("padding"), BACKGROUND_COLOR("background-color"), @@ -193,10 +199,12 @@ enum class StyleProperty(val key: String) { TEXT_FORMATTING("text-formatting"), TRANSFORM("transform"), TRANSFORM_ORIGIN("transform-origin"), - OPACITY("opacity"); + OPACITY("opacity"), + ; companion object { - private val byName: Map = entries.associateBy { it.key.lowercase() } + + private val byName: Map = + entries.associateBy { it.key.lowercase() } + mapOf( "backgroundcolor" to BACKGROUND_COLOR, "background-color" to BACKGROUND_COLOR, @@ -220,77 +228,83 @@ enum class StyleProperty(val key: String) { "fontweight" to FONT_WEIGHT, "font-weight" to FONT_WEIGHT, "fontstyle" to FONT_STYLE, - "font-style" to FONT_STYLE - ) + mapOf( - "position" to POSITION, - "left" to LEFT, - "top" to TOP, - "right" to RIGHT, - "bottom" to BOTTOM, - "zindex" to Z_INDEX, - "z-index" to Z_INDEX, - "flexdirection" to FLEX_DIRECTION, - "flex-direction" to FLEX_DIRECTION, - "justifycontent" to JUSTIFY_CONTENT, - "justify-content" to JUSTIFY_CONTENT, - "alignitems" to ALIGN_ITEMS, - "align-items" to ALIGN_ITEMS, - "justifyitems" to JUSTIFY_ITEMS, - "justify-items" to JUSTIFY_ITEMS, - "flexgrow" to FLEX_GROW, - "flex-grow" to FLEX_GROW, - "flexshrink" to FLEX_SHRINK, - "flex-shrink" to FLEX_SHRINK, - "flexbasis" to FLEX_BASIS, - "flex-basis" to FLEX_BASIS, - "gridcolumns" to GRID_COLUMNS, - "grid-columns" to GRID_COLUMNS, - "gridrows" to GRID_ROWS, - "grid-rows" to GRID_ROWS, - "gridautoflow" to GRID_AUTO_FLOW, - "grid-auto-flow" to GRID_AUTO_FLOW, - "gridcolumnspan" to GRID_COLUMN_SPAN, - "grid-column-span" to GRID_COLUMN_SPAN, - "gridrowspan" to GRID_ROW_SPAN, - "grid-row-span" to GRID_ROW_SPAN, - "overflow" to OVERFLOW, - "overflowx" to OVERFLOW_X, - "overflow-x" to OVERFLOW_X, - "overflowy" to OVERFLOW_Y, - "overflow-y" to OVERFLOW_Y, - "minwidth" to MIN_WIDTH, - "min-width" to MIN_WIDTH, - "minheight" to MIN_HEIGHT, - "min-height" to MIN_HEIGHT, - "maxwidth" to MAX_WIDTH, - "max-width" to MAX_WIDTH, - "maxheight" to MAX_HEIGHT, - "max-height" to MAX_HEIGHT, - "textwrap" to TEXT_WRAP, - "text-wrap" to TEXT_WRAP, - "textformatting" to TEXT_FORMATTING, - "text-formatting" to TEXT_FORMATTING, - "textdecoration" to TEXT_DECORATION, - "text-decoration" to TEXT_DECORATION, - "obfuscated" to OBFUSCATED, - "transform" to TRANSFORM, - "transformorigin" to TRANSFORM_ORIGIN, - "transform-origin" to TRANSFORM_ORIGIN, - "opacity" to OPACITY - ) + "font-style" to FONT_STYLE, + ) + + mapOf( + "position" to POSITION, + "left" to LEFT, + "top" to TOP, + "right" to RIGHT, + "bottom" to BOTTOM, + "zindex" to Z_INDEX, + "z-index" to Z_INDEX, + "flexdirection" to FLEX_DIRECTION, + "flex-direction" to FLEX_DIRECTION, + "justifycontent" to JUSTIFY_CONTENT, + "justify-content" to JUSTIFY_CONTENT, + "alignitems" to ALIGN_ITEMS, + "align-items" to ALIGN_ITEMS, + "justifyitems" to JUSTIFY_ITEMS, + "justify-items" to JUSTIFY_ITEMS, + "flexgrow" to FLEX_GROW, + "flex-grow" to FLEX_GROW, + "flexshrink" to FLEX_SHRINK, + "flex-shrink" to FLEX_SHRINK, + "flexbasis" to FLEX_BASIS, + "flex-basis" to FLEX_BASIS, + "gridcolumns" to GRID_COLUMNS, + "grid-columns" to GRID_COLUMNS, + "gridrows" to GRID_ROWS, + "grid-rows" to GRID_ROWS, + "gridautoflow" to GRID_AUTO_FLOW, + "grid-auto-flow" to GRID_AUTO_FLOW, + "gridcolumnspan" to GRID_COLUMN_SPAN, + "grid-column-span" to GRID_COLUMN_SPAN, + "gridrowspan" to GRID_ROW_SPAN, + "grid-row-span" to GRID_ROW_SPAN, + "overflow" to OVERFLOW, + "overflowx" to OVERFLOW_X, + "overflow-x" to OVERFLOW_X, + "overflowy" to OVERFLOW_Y, + "overflow-y" to OVERFLOW_Y, + "minwidth" to MIN_WIDTH, + "min-width" to MIN_WIDTH, + "minheight" to MIN_HEIGHT, + "min-height" to MIN_HEIGHT, + "maxwidth" to MAX_WIDTH, + "max-width" to MAX_WIDTH, + "maxheight" to MAX_HEIGHT, + "max-height" to MAX_HEIGHT, + "textwrap" to TEXT_WRAP, + "text-wrap" to TEXT_WRAP, + "textformatting" to TEXT_FORMATTING, + "text-formatting" to TEXT_FORMATTING, + "textdecoration" to TEXT_DECORATION, + "text-decoration" to TEXT_DECORATION, + "obfuscated" to OBFUSCATED, + "transform" to TRANSFORM, + "transformorigin" to TRANSFORM_ORIGIN, + "transform-origin" to TRANSFORM_ORIGIN, + "opacity" to OPACITY, + ) fun fromKeyOrNull(name: String): StyleProperty? = byName[name.trim().lowercase()] } } sealed class StyleExpression { - data class Literal(val value: String) : StyleExpression() - data class VariableRef(val name: String) : StyleExpression() + data class Literal( + val value: String, + ) : StyleExpression() + + data class VariableRef( + val name: String, + ) : StyleExpression() } data class StyleDeclarations( val values: MutableMap = linkedMapOf(), - val importantProperties: MutableSet = linkedSetOf() + val importantProperties: MutableSet = linkedSetOf(), ) { fun set(property: StyleProperty, value: StyleExpression, important: Boolean = false) { values[property] = value @@ -384,7 +398,7 @@ data class ComputedStyle( val textFormatting: TextFormatting, val transform: UiTransform, val transformOrigin: TransformOrigin, - val opacity: Float + val opacity: Float, ) data class ComputedStyleDefaults( @@ -438,10 +452,10 @@ data class ComputedStyleDefaults( val textFormatting: TextFormatting = TextFormatting.None, val transform: UiTransform = UiTransform.IDENTITY, val transformOrigin: TransformOrigin = TransformOrigin.CENTER, - val opacity: Float = 1f + val opacity: Float = 1f, ) { - fun toComputedStyle(): ComputedStyle { - return ComputedStyle( + fun toComputedStyle(): ComputedStyle = + ComputedStyle( margin = margin, padding = padding, backgroundColor = backgroundColor, @@ -492,7 +506,6 @@ data class ComputedStyleDefaults( textFormatting = textFormatting, transform = transform, transformOrigin = transformOrigin, - opacity = opacity + opacity = opacity, ) - } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistry.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistry.kt index 09dba1e..a7e8849 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistry.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistry.kt @@ -11,7 +11,7 @@ enum class StyleEditorValueType { Spacing, SpacingLengthPx, ColorHex, - StringPreset + StringPreset, } enum class StyleValueGrammarKind { @@ -19,14 +19,14 @@ enum class StyleValueGrammarKind { UnitlessInt, LengthLike, LineHeight, - Other + Other, } enum class StyleInspectorEditorKind { EnumSelect, FontSelect, NumericInput, - StringInput + StringInput, } data class StylePropertyDescriptor( @@ -38,147 +38,246 @@ data class StylePropertyDescriptor( val minFloat: Float = 0f, val isInherited: Boolean = false, val grammarKind: StyleValueGrammarKind = defaultGrammarKind(valueType), - val inspectorEditorKind: StyleInspectorEditorKind = defaultInspectorEditorKind(property, valueType) + val inspectorEditorKind: StyleInspectorEditorKind = defaultInspectorEditorKind(property, valueType), ) object StylePropertyRegistry { - val all: List = listOf( - StylePropertyDescriptor(StyleProperty.DISPLAY, StyleEditorValueType.EnumChoice, enumOptions = listOf("block", "inline", "none", "flex", "grid")), - StylePropertyDescriptor( - property = StyleProperty.POSITION, - valueType = StyleEditorValueType.EnumChoice, - enumOptions = listOf("static", "relative", "absolute", "fixed", "sticky"), - grammarKind = StyleValueGrammarKind.Enum, - inspectorEditorKind = StyleInspectorEditorKind.EnumSelect - ), - StylePropertyDescriptor( - property = StyleProperty.LEFT, - valueType = StyleEditorValueType.OptionalLengthPx, - numericStep = 1f, - grammarKind = StyleValueGrammarKind.LengthLike, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor( - property = StyleProperty.TOP, - valueType = StyleEditorValueType.OptionalLengthPx, - numericStep = 1f, - grammarKind = StyleValueGrammarKind.LengthLike, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor( - property = StyleProperty.RIGHT, - valueType = StyleEditorValueType.OptionalLengthPx, - numericStep = 1f, - grammarKind = StyleValueGrammarKind.LengthLike, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor( - property = StyleProperty.BOTTOM, - valueType = StyleEditorValueType.OptionalLengthPx, - numericStep = 1f, - grammarKind = StyleValueGrammarKind.LengthLike, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor( - property = StyleProperty.Z_INDEX, - valueType = StyleEditorValueType.IntNumber, - numericStep = 1f, - minInt = Int.MIN_VALUE, - grammarKind = StyleValueGrammarKind.UnitlessInt, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor(StyleProperty.WIDTH, StyleEditorValueType.LengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.HEIGHT, StyleEditorValueType.LengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.MIN_WIDTH, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.MIN_HEIGHT, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.MAX_WIDTH, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.MAX_HEIGHT, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), - StylePropertyDescriptor( - StyleProperty.OVERFLOW, - StyleEditorValueType.EnumChoice, - enumOptions = listOf("visible", "hidden", "scroll", "auto") - ), - StylePropertyDescriptor( - StyleProperty.OVERFLOW_X, - StyleEditorValueType.EnumChoice, - enumOptions = listOf("visible", "hidden", "scroll", "auto") - ), - StylePropertyDescriptor( - StyleProperty.OVERFLOW_Y, - StyleEditorValueType.EnumChoice, - enumOptions = listOf("visible", "hidden", "scroll", "auto") - ), - StylePropertyDescriptor(StyleProperty.MARGIN, StyleEditorValueType.SpacingLengthPx, numericStep = 1f), - StylePropertyDescriptor(StyleProperty.PADDING, StyleEditorValueType.SpacingLengthPx, numericStep = 1f), - StylePropertyDescriptor(StyleProperty.BACKGROUND_COLOR, StyleEditorValueType.ColorHex, enumOptions = colorPalette()), - StylePropertyDescriptor( - StyleProperty.BACKGROUND_IMAGE, - StyleEditorValueType.StringPreset, - enumOptions = listOf( - "textures/gui/options_background.png", - "minecraft:textures/blocks/stone.png" - ) - ), - StylePropertyDescriptor(StyleProperty.BORDER_COLOR, StyleEditorValueType.ColorHex, enumOptions = colorPalette()), - StylePropertyDescriptor(StyleProperty.BORDER_WIDTH, StyleEditorValueType.LengthPx, numericStep = 1f), - StylePropertyDescriptor(StyleProperty.BORDER_RADIUS, StyleEditorValueType.LengthPx, numericStep = 1f), - StylePropertyDescriptor(StyleProperty.FOREGROUND_COLOR, StyleEditorValueType.ColorHex, enumOptions = colorPalette(), isInherited = true), - StylePropertyDescriptor( - StyleProperty.FONT_ID, - StyleEditorValueType.StringPreset, - enumOptions = listOf("minecraft", "ubuntu", "JetBrains Mono"), - isInherited = true, - inspectorEditorKind = StyleInspectorEditorKind.FontSelect - ), - StylePropertyDescriptor(StyleProperty.FONT_SIZE, StyleEditorValueType.LengthPx, numericStep = 1f, minInt = 1, isInherited = true), - StylePropertyDescriptor( - property = StyleProperty.LINE_HEIGHT, - valueType = StyleEditorValueType.LineHeight, - enumOptions = listOf("normal"), - numericStep = 1f, - minInt = 0, - isInherited = true, - grammarKind = StyleValueGrammarKind.LineHeight, - inspectorEditorKind = StyleInspectorEditorKind.NumericInput - ), - StylePropertyDescriptor(StyleProperty.FONT_WEIGHT, StyleEditorValueType.EnumChoice, enumOptions = listOf("normal", "bold"), isInherited = true), - StylePropertyDescriptor(StyleProperty.FONT_STYLE, StyleEditorValueType.EnumChoice, enumOptions = listOf("normal", "italic"), isInherited = true), - StylePropertyDescriptor( - StyleProperty.TEXT_DECORATION, - StyleEditorValueType.EnumChoice, - enumOptions = listOf("none", "underline", "strikethrough", "underline-strikethrough") - ), - StylePropertyDescriptor(StyleProperty.OBFUSCATED, StyleEditorValueType.EnumChoice, enumOptions = listOf("false", "true")), - StylePropertyDescriptor(StyleProperty.ALIGN, StyleEditorValueType.EnumChoice, enumOptions = listOf("start", "center", "end")), - StylePropertyDescriptor(StyleProperty.FLEX_DIRECTION, StyleEditorValueType.EnumChoice, enumOptions = listOf("row", "column")), - StylePropertyDescriptor( - StyleProperty.JUSTIFY_CONTENT, - StyleEditorValueType.EnumChoice, - enumOptions = listOf("start", "center", "end", "space-between", "space-around", "space-evenly") - ), - StylePropertyDescriptor(StyleProperty.ALIGN_ITEMS, StyleEditorValueType.EnumChoice, enumOptions = listOf("start", "center", "end", "stretch")), - StylePropertyDescriptor(StyleProperty.JUSTIFY_ITEMS, StyleEditorValueType.EnumChoice, enumOptions = listOf("start", "center", "end", "stretch")), - StylePropertyDescriptor(StyleProperty.GAP, StyleEditorValueType.LengthPx, numericStep = 1f), - StylePropertyDescriptor(StyleProperty.FLEX_GROW, StyleEditorValueType.FloatNumber, numericStep = 0.25f), - StylePropertyDescriptor(StyleProperty.FLEX_SHRINK, StyleEditorValueType.FloatNumber, numericStep = 0.25f), - StylePropertyDescriptor(StyleProperty.FLEX_BASIS, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), - StylePropertyDescriptor(StyleProperty.GRID_COLUMNS, StyleEditorValueType.IntNumber, numericStep = 1f, minInt = 1), - StylePropertyDescriptor(StyleProperty.GRID_ROWS, StyleEditorValueType.OptionalIntNumber, numericStep = 1f, minInt = 1), - StylePropertyDescriptor(StyleProperty.GRID_AUTO_FLOW, StyleEditorValueType.EnumChoice, enumOptions = listOf("row", "column")), - StylePropertyDescriptor(StyleProperty.GRID_COLUMN_SPAN, StyleEditorValueType.IntNumber, numericStep = 1f, minInt = 1), - StylePropertyDescriptor(StyleProperty.GRID_ROW_SPAN, StyleEditorValueType.IntNumber, numericStep = 1f, minInt = 1), - StylePropertyDescriptor(StyleProperty.TEXT_WRAP, StyleEditorValueType.EnumChoice, enumOptions = listOf("wrap", "nowrap")), - StylePropertyDescriptor(StyleProperty.TEXT_FORMATTING, StyleEditorValueType.EnumChoice, enumOptions = listOf("none", "minecraft")), - StylePropertyDescriptor(StyleProperty.TRANSFORM, StyleEditorValueType.StringPreset, enumOptions = listOf("none", "translate(12, 0)", "scale(1.2)", "rotate(15deg)")), - StylePropertyDescriptor(StyleProperty.TRANSFORM_ORIGIN, StyleEditorValueType.StringPreset, enumOptions = listOf("0 0", "0.5 0.5", "1 1", "50% 50%")), - StylePropertyDescriptor(StyleProperty.OPACITY, StyleEditorValueType.FloatNumber, numericStep = 0.05f, minFloat = 0f) - ) + val all: List = + listOf( + StylePropertyDescriptor( + StyleProperty.DISPLAY, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("block", "inline", "none", "flex", "grid"), + ), + StylePropertyDescriptor( + property = StyleProperty.POSITION, + valueType = StyleEditorValueType.EnumChoice, + enumOptions = listOf("static", "relative", "absolute", "fixed", "sticky"), + grammarKind = StyleValueGrammarKind.Enum, + inspectorEditorKind = StyleInspectorEditorKind.EnumSelect, + ), + StylePropertyDescriptor( + property = StyleProperty.LEFT, + valueType = StyleEditorValueType.OptionalLengthPx, + numericStep = 1f, + grammarKind = StyleValueGrammarKind.LengthLike, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor( + property = StyleProperty.TOP, + valueType = StyleEditorValueType.OptionalLengthPx, + numericStep = 1f, + grammarKind = StyleValueGrammarKind.LengthLike, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor( + property = StyleProperty.RIGHT, + valueType = StyleEditorValueType.OptionalLengthPx, + numericStep = 1f, + grammarKind = StyleValueGrammarKind.LengthLike, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor( + property = StyleProperty.BOTTOM, + valueType = StyleEditorValueType.OptionalLengthPx, + numericStep = 1f, + grammarKind = StyleValueGrammarKind.LengthLike, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor( + property = StyleProperty.Z_INDEX, + valueType = StyleEditorValueType.IntNumber, + numericStep = 1f, + minInt = Int.MIN_VALUE, + grammarKind = StyleValueGrammarKind.UnitlessInt, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor(StyleProperty.WIDTH, StyleEditorValueType.LengthPx, numericStep = 4f), + StylePropertyDescriptor(StyleProperty.HEIGHT, StyleEditorValueType.LengthPx, numericStep = 4f), + StylePropertyDescriptor(StyleProperty.MIN_WIDTH, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), + StylePropertyDescriptor(StyleProperty.MIN_HEIGHT, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), + StylePropertyDescriptor(StyleProperty.MAX_WIDTH, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), + StylePropertyDescriptor(StyleProperty.MAX_HEIGHT, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), + StylePropertyDescriptor( + StyleProperty.OVERFLOW, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("visible", "hidden", "scroll", "auto"), + ), + StylePropertyDescriptor( + StyleProperty.OVERFLOW_X, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("visible", "hidden", "scroll", "auto"), + ), + StylePropertyDescriptor( + StyleProperty.OVERFLOW_Y, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("visible", "hidden", "scroll", "auto"), + ), + StylePropertyDescriptor(StyleProperty.MARGIN, StyleEditorValueType.SpacingLengthPx, numericStep = 1f), + StylePropertyDescriptor(StyleProperty.PADDING, StyleEditorValueType.SpacingLengthPx, numericStep = 1f), + StylePropertyDescriptor( + StyleProperty.BACKGROUND_COLOR, + StyleEditorValueType.ColorHex, + enumOptions = colorPalette(), + ), + StylePropertyDescriptor( + StyleProperty.BACKGROUND_IMAGE, + StyleEditorValueType.StringPreset, + enumOptions = + listOf( + "textures/gui/options_background.png", + "minecraft:textures/blocks/stone.png", + ), + ), + StylePropertyDescriptor( + StyleProperty.BORDER_COLOR, + StyleEditorValueType.ColorHex, + enumOptions = colorPalette(), + ), + StylePropertyDescriptor(StyleProperty.BORDER_WIDTH, StyleEditorValueType.LengthPx, numericStep = 1f), + StylePropertyDescriptor(StyleProperty.BORDER_RADIUS, StyleEditorValueType.LengthPx, numericStep = 1f), + StylePropertyDescriptor( + StyleProperty.FOREGROUND_COLOR, + StyleEditorValueType.ColorHex, + enumOptions = colorPalette(), + isInherited = true, + ), + StylePropertyDescriptor( + StyleProperty.FONT_ID, + StyleEditorValueType.StringPreset, + enumOptions = listOf("minecraft", "ubuntu", "JetBrains Mono"), + isInherited = true, + inspectorEditorKind = StyleInspectorEditorKind.FontSelect, + ), + StylePropertyDescriptor( + StyleProperty.FONT_SIZE, + StyleEditorValueType.LengthPx, + numericStep = 1f, + minInt = 1, + isInherited = true, + ), + StylePropertyDescriptor( + property = StyleProperty.LINE_HEIGHT, + valueType = StyleEditorValueType.LineHeight, + enumOptions = listOf("normal"), + numericStep = 1f, + minInt = 0, + isInherited = true, + grammarKind = StyleValueGrammarKind.LineHeight, + inspectorEditorKind = StyleInspectorEditorKind.NumericInput, + ), + StylePropertyDescriptor( + StyleProperty.FONT_WEIGHT, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("normal", "bold"), + isInherited = true, + ), + StylePropertyDescriptor( + StyleProperty.FONT_STYLE, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("normal", "italic"), + isInherited = true, + ), + StylePropertyDescriptor( + StyleProperty.TEXT_DECORATION, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("none", "underline", "strikethrough", "underline-strikethrough"), + ), + StylePropertyDescriptor( + StyleProperty.OBFUSCATED, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("false", "true"), + ), + StylePropertyDescriptor( + StyleProperty.ALIGN, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("start", "center", "end"), + ), + StylePropertyDescriptor( + StyleProperty.FLEX_DIRECTION, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("row", "column"), + ), + StylePropertyDescriptor( + StyleProperty.JUSTIFY_CONTENT, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("start", "center", "end", "space-between", "space-around", "space-evenly"), + ), + StylePropertyDescriptor( + StyleProperty.ALIGN_ITEMS, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("start", "center", "end", "stretch"), + ), + StylePropertyDescriptor( + StyleProperty.JUSTIFY_ITEMS, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("start", "center", "end", "stretch"), + ), + StylePropertyDescriptor(StyleProperty.GAP, StyleEditorValueType.LengthPx, numericStep = 1f), + StylePropertyDescriptor(StyleProperty.FLEX_GROW, StyleEditorValueType.FloatNumber, numericStep = 0.25f), + StylePropertyDescriptor(StyleProperty.FLEX_SHRINK, StyleEditorValueType.FloatNumber, numericStep = 0.25f), + StylePropertyDescriptor(StyleProperty.FLEX_BASIS, StyleEditorValueType.OptionalLengthPx, numericStep = 4f), + StylePropertyDescriptor( + StyleProperty.GRID_COLUMNS, + StyleEditorValueType.IntNumber, + numericStep = 1f, + minInt = 1, + ), + StylePropertyDescriptor( + StyleProperty.GRID_ROWS, + StyleEditorValueType.OptionalIntNumber, + numericStep = 1f, + minInt = 1, + ), + StylePropertyDescriptor( + StyleProperty.GRID_AUTO_FLOW, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("row", "column"), + ), + StylePropertyDescriptor( + StyleProperty.GRID_COLUMN_SPAN, + StyleEditorValueType.IntNumber, + numericStep = 1f, + minInt = 1, + ), + StylePropertyDescriptor( + StyleProperty.GRID_ROW_SPAN, + StyleEditorValueType.IntNumber, + numericStep = 1f, + minInt = 1, + ), + StylePropertyDescriptor( + StyleProperty.TEXT_WRAP, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("wrap", "nowrap"), + ), + StylePropertyDescriptor( + StyleProperty.TEXT_FORMATTING, + StyleEditorValueType.EnumChoice, + enumOptions = listOf("none", "minecraft"), + ), + StylePropertyDescriptor( + StyleProperty.TRANSFORM, + StyleEditorValueType.StringPreset, + enumOptions = listOf("none", "translate(12, 0)", "scale(1.2)", "rotate(15deg)"), + ), + StylePropertyDescriptor( + StyleProperty.TRANSFORM_ORIGIN, + StyleEditorValueType.StringPreset, + enumOptions = listOf("0 0", "0.5 0.5", "1 1", "50% 50%"), + ), + StylePropertyDescriptor( + StyleProperty.OPACITY, + StyleEditorValueType.FloatNumber, + numericStep = 0.05f, + minFloat = 0f, + ), + ) private val byProperty: Map = all.associateBy { it.property } - fun descriptor(property: StyleProperty): StylePropertyDescriptor { - return byProperty[property] ?: error("Missing style descriptor for '${property.key}'.") - } + fun descriptor(property: StyleProperty): StylePropertyDescriptor = + byProperty[property] ?: error("Missing style descriptor for '${property.key}'.") fun isInherited(property: StyleProperty): Boolean = descriptor(property).isInherited @@ -188,8 +287,9 @@ object StylePropertyRegistry { "Property '${property.key}' does not use enum grammar." } val normalized = literal.trim().lowercase() - val matched = descriptor.enumOptions.firstOrNull { it.equals(normalized, ignoreCase = true) } - ?: error("Unsupported value '$literal' for '${property.key}'.") + val matched = + descriptor.enumOptions.firstOrNull { it.equals(normalized, ignoreCase = true) } + ?: error("Unsupported value '$literal' for '${property.key}'.") return matched } @@ -209,8 +309,8 @@ object StylePropertyRegistry { return parseLineHeightValue(literal) } - private fun colorPalette(): List { - return listOf( + private fun colorPalette(): List = + listOf( "#FF1B1F24", "#FF2D3748", "#FF4A5568", @@ -222,26 +322,25 @@ object StylePropertyRegistry { "#FFD69E2E", "#FF38A169", "#FF3182CE", - "#FF805AD5" + "#FF805AD5", ) - } } -private fun defaultGrammarKind(valueType: StyleEditorValueType): StyleValueGrammarKind { - return when (valueType) { +private fun defaultGrammarKind(valueType: StyleEditorValueType): StyleValueGrammarKind = + when (valueType) { StyleEditorValueType.EnumChoice -> StyleValueGrammarKind.Enum StyleEditorValueType.IntNumber -> StyleValueGrammarKind.UnitlessInt StyleEditorValueType.LineHeight -> StyleValueGrammarKind.LineHeight StyleEditorValueType.LengthPx, StyleEditorValueType.OptionalLengthPx, - StyleEditorValueType.SpacingLengthPx -> StyleValueGrammarKind.LengthLike + StyleEditorValueType.SpacingLengthPx, + -> StyleValueGrammarKind.LengthLike else -> StyleValueGrammarKind.Other } -} private fun defaultInspectorEditorKind( property: StyleProperty, - valueType: StyleEditorValueType + valueType: StyleEditorValueType, ): StyleInspectorEditorKind { if (property == StyleProperty.FONT_ID) { return StyleInspectorEditorKind.FontSelect @@ -255,7 +354,8 @@ private fun defaultInspectorEditorKind( StyleEditorValueType.LengthPx, StyleEditorValueType.OptionalLengthPx, StyleEditorValueType.Spacing, - StyleEditorValueType.SpacingLengthPx -> StyleInspectorEditorKind.NumericInput + StyleEditorValueType.SpacingLengthPx, + -> StyleInspectorEditorKind.NumericInput else -> StyleInspectorEditorKind.StringInput } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleSelector.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleSelector.kt index e9d9464..06baa5b 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleSelector.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleSelector.kt @@ -4,13 +4,13 @@ enum class StyleCombinator { Descendant, Child, AdjacentSibling, - GeneralSibling + GeneralSibling, } data class StyleSpecificity( val idCount: Int = 0, val classLikeCount: Int = 0, - val typeCount: Int = 0 + val typeCount: Int = 0, ) : Comparable { override fun compareTo(other: StyleSpecificity): Int { if (idCount != other.idCount) return idCount.compareTo(other.idCount) @@ -18,13 +18,12 @@ data class StyleSpecificity( return typeCount.compareTo(other.typeCount) } - operator fun plus(other: StyleSpecificity): StyleSpecificity { - return StyleSpecificity( + operator fun plus(other: StyleSpecificity): StyleSpecificity = + StyleSpecificity( idCount = idCount + other.idCount, classLikeCount = classLikeCount + other.classLikeCount, - typeCount = typeCount + other.typeCount + typeCount = typeCount + other.typeCount, ) - } } data class StyleSelectorPart( @@ -32,30 +31,29 @@ data class StyleSelectorPart( val classes: Set = emptySet(), val id: String? = null, val pseudoState: StylePseudoState? = null, - val universal: Boolean = false + val universal: Boolean = false, ) { init { require( - universal || typeName != null || id != null || classes.isNotEmpty() || pseudoState != null + universal || typeName != null || id != null || classes.isNotEmpty() || pseudoState != null, ) { "Selector part must not be empty." } } - fun specificity(): StyleSpecificity { - return StyleSpecificity( + fun specificity(): StyleSpecificity = + StyleSpecificity( idCount = if (id != null) 1 else 0, classLikeCount = classes.size + if (pseudoState != null) 1 else 0, - typeCount = if (typeName != null) 1 else 0 + typeCount = if (typeName != null) 1 else 0, ) - } } data class StyleSelectorStep( val part: StyleSelectorPart, - val combinatorToLeft: StyleCombinator? = null + val combinatorToLeft: StyleCombinator? = null, ) data class StyleSelector( - val steps: List + val steps: List, ) { init { require(steps.isNotEmpty()) { "Selector must contain at least one step." } @@ -67,17 +65,41 @@ data class StyleSelector( val hasCombinators: Boolean = steps.size > 1 val typeName: String? - get() = if (steps.size == 1) steps.first().part.typeName else null + get() = + if (steps.size == 1) { + steps + .first() + .part.typeName + } else { + null + } val className: String? get() { if (steps.size != 1) return null - val classes = steps.first().part.classes + val classes = + steps + .first() + .part.classes return if (classes.size == 1) classes.first() else null } val id: String? - get() = if (steps.size == 1) steps.first().part.id else null + get() = + if (steps.size == 1) { + steps + .first() + .part.id + } else { + null + } val pseudoState: StylePseudoState? - get() = if (steps.size == 1) steps.first().part.pseudoState else null + get() = + if (steps.size == 1) { + steps + .first() + .part.pseudoState + } else { + null + } fun rightMostPart(): StyleSelectorPart = steps.last().part @@ -86,6 +108,7 @@ data class StyleSelector( private val classRegex = Regex("^[a-zA-Z0-9_-]+$") private val idRegex = Regex("^[a-zA-Z0-9_-]+$") + @Suppress("LoopWithTooManyJumpStatements") fun parse(rawSelector: String): StyleSelector { val trimmed = rawSelector.trim() require(trimmed.isNotEmpty()) { "Selector cannot be empty." } @@ -108,12 +131,13 @@ data class StyleSelector( if (trimmed[index] == '>' || trimmed[index] == '+' || trimmed[index] == '~') { require(steps.isNotEmpty()) { "Selector cannot start with a combinator." } - pendingCombinator = when (trimmed[index]) { - '>' -> StyleCombinator.Child - '+' -> StyleCombinator.AdjacentSibling - '~' -> StyleCombinator.GeneralSibling - else -> error("Unsupported combinator") - } + pendingCombinator = + when (trimmed[index]) { + '>' -> StyleCombinator.Child + '+' -> StyleCombinator.AdjacentSibling + '~' -> StyleCombinator.GeneralSibling + else -> error("Unsupported combinator") + } index++ continue } @@ -129,10 +153,17 @@ data class StyleSelector( index++ } val token = trimmed.substring(tokenStart, index) - val step = StyleSelectorStep( - part = parsePartToken(token, rawSelector), - combinatorToLeft = if (steps.isEmpty()) null else pendingCombinator ?: StyleCombinator.Descendant - ) + val step = + StyleSelectorStep( + part = parsePartToken(token, rawSelector), + combinatorToLeft = + if (steps.isEmpty()) { + null + } else { + pendingCombinator + ?: StyleCombinator.Descendant + }, + ) steps += step pendingCombinator = null } @@ -155,7 +186,9 @@ data class StyleSelector( index++ } else if (token[index].isLetter()) { val typeStart = index - while (index < token.length && (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-')) { + while (index < token.length && + (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-') + ) { index++ } val typeCandidate = token.substring(typeStart, index) @@ -168,7 +201,9 @@ data class StyleSelector( '.' -> { index++ val classStart = index - while (index < token.length && (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-')) { + while (index < token.length && + (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-') + ) { index++ } val classValue = token.substring(classStart, index) @@ -179,7 +214,9 @@ data class StyleSelector( '#' -> { index++ val idStart = index - while (index < token.length && (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-')) { + while (index < token.length && + (token[index].isLetterOrDigit() || token[index] == '_' || token[index] == '-') + ) { index++ } val idValue = token.substring(idStart, index) @@ -193,14 +230,17 @@ data class StyleSelector( while (index < token.length && token[index].isLetter()) { index++ } - val parsedPseudo = when (val pseudoValue = token.substring(pseudoStart, index).lowercase()) { - "hover" -> StylePseudoState.HOVER - "active" -> StylePseudoState.ACTIVE - "focus" -> StylePseudoState.FOCUS - "disabled" -> StylePseudoState.DISABLED - "open" -> StylePseudoState.OPEN - else -> throw IllegalArgumentException("Unsupported pseudo-state '$pseudoValue' in '$rawSelector'.") - } + val parsedPseudo = + when (val pseudoValue = token.substring(pseudoStart, index).lowercase()) { + "hover" -> StylePseudoState.HOVER + "active" -> StylePseudoState.ACTIVE + "focus" -> StylePseudoState.FOCUS + "disabled" -> StylePseudoState.DISABLED + "open" -> StylePseudoState.OPEN + else -> throw IllegalArgumentException( + "Unsupported pseudo-state '$pseudoValue' in '$rawSelector'.", + ) + } pseudoState = parsedPseudo } @@ -213,7 +253,7 @@ data class StyleSelector( classes = classes, id = id, pseudoState = pseudoState, - universal = universal + universal = universal, ) } } @@ -223,12 +263,12 @@ data class StyleRule( val selector: StyleSelector, val declarations: StyleDeclarations, val sourceOrder: Int, - val fileName: String + val fileName: String, ) data class StylesheetData( val rules: List, val rootVariables: Map, val source: String, - val warnings: List = emptyList() + val warnings: List = emptyList(), ) diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsing.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsing.kt index b3a8f0d..58342fb 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsing.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsing.kt @@ -17,32 +17,34 @@ fun parseExpression(rawValue: String): StyleExpression { } } -fun parseSpacingShorthand(raw: String): Insets { - return parseSpacingLengthShorthand( +fun parseSpacingShorthand(raw: String): Insets = + parseSpacingLengthShorthand( raw = raw, - allowNegative = true + allowNegative = true, ).resolveToInsets(LengthResolveContext()) -} +@Suppress("UnusedParameter") fun parseSpacingLengthShorthand( raw: String, allowNegative: Boolean, allowUnitlessZero: Boolean = true, warningReporter: StyleWarningReporter? = null, - warningKey: String = "deprecated.unitless-length" + warningKey: String = "deprecated.unitless-length", ): LengthInsets { val parts = splitLengthTokens(raw) require(parts.isNotEmpty()) { "Spacing value cannot be empty." } - val values = parts.map { - val length = parseCssLength( - raw = it, - allowUnitlessZero = allowUnitlessZero - ) - if (!allowNegative && length.value < 0f) { - error("Negative length is not allowed: '$it'.") + val values = + parts.map { + val length = + parseCssLength( + raw = it, + allowUnitlessZero = allowUnitlessZero, + ) + if (!allowNegative && length.value < 0f) { + error("Negative length is not allowed: '$it'.") + } + length } - length - } return when (values.size) { 1 -> LengthInsets.all(values[0]) 2 -> LengthInsets(values[0], values[1], values[0], values[1]) @@ -57,16 +59,15 @@ fun parseSpacingShorthand( allowNegative: Boolean, allowUnitlessPx: Boolean = true, warningReporter: StyleWarningReporter? = null, - warningKey: String = "deprecated.unitless-length" -): Insets { - return parseSpacingLengthShorthand( + warningKey: String = "deprecated.unitless-length", +): Insets = + parseSpacingLengthShorthand( raw = raw, allowNegative = allowNegative, allowUnitlessZero = allowUnitlessPx, warningReporter = warningReporter, - warningKey = warningKey + warningKey = warningKey, ).resolveToInsets(LengthResolveContext()) -} fun parseColor(raw: String): Int { val value = raw.trim() @@ -90,17 +91,16 @@ fun parseColor(raw: String): Int { } } -fun parseAlign(raw: String): StyleAlign { - return when (raw.trim().lowercase()) { +fun parseAlign(raw: String): StyleAlign = + when (raw.trim().lowercase()) { "start", "left", "top" -> StyleAlign.START "center", "middle" -> StyleAlign.CENTER "end", "right", "bottom" -> StyleAlign.END else -> error("Unsupported align value '$raw'.") } -} -fun parseDisplay(raw: String): Display { - return when (raw.trim().lowercase()) { +fun parseDisplay(raw: String): Display = + when (raw.trim().lowercase()) { "block" -> Display.Block "inline" -> Display.Inline "none" -> Display.None @@ -108,10 +108,9 @@ fun parseDisplay(raw: String): Display { "grid" -> Display.Grid else -> error("Unsupported display value '$raw'.") } -} -fun parsePosition(raw: String): PositionMode { - return when (raw.trim().lowercase()) { +fun parsePosition(raw: String): PositionMode = + when (raw.trim().lowercase()) { "static" -> PositionMode.Static "relative" -> PositionMode.Relative "absolute" -> PositionMode.Absolute @@ -119,7 +118,6 @@ fun parsePosition(raw: String): PositionMode { "sticky" -> PositionMode.Sticky else -> error("Unsupported position value '$raw'.") } -} fun parseLineHeightValue(raw: String): LineHeightValue { val normalized = raw.trim().lowercase() @@ -133,41 +131,43 @@ fun parseLineHeightValue(raw: String): LineHeightValue { data class OverflowAxes( val overflowX: Overflow, - val overflowY: Overflow + val overflowY: Overflow, ) -fun parseOverflow(raw: String): Overflow { - return when (raw.trim().lowercase()) { +fun parseOverflow(raw: String): Overflow = + when (raw.trim().lowercase()) { "visible" -> Overflow.Visible "hidden" -> Overflow.Hidden "scroll" -> Overflow.Scroll "auto" -> Overflow.Auto else -> error("Unsupported overflow value '$raw'.") } -} fun parseOverflowShorthand(raw: String): OverflowAxes { - val parts = raw.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + val parts = + raw + .trim() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } require(parts.isNotEmpty()) { "overflow value cannot be empty." } require(parts.size <= 2) { "overflow supports one or two values." } val overflowX = parseOverflow(parts[0]) val overflowY = if (parts.size == 2) parseOverflow(parts[1]) else overflowX return OverflowAxes( overflowX = overflowX, - overflowY = overflowY + overflowY = overflowY, ) } -fun parseFlexDirection(raw: String): FlexDirection { - return when (raw.trim().lowercase()) { +fun parseFlexDirection(raw: String): FlexDirection = + when (raw.trim().lowercase()) { "row" -> FlexDirection.Row "column" -> FlexDirection.Column else -> error("Unsupported flex-direction value '$raw'.") } -} -fun parseJustifyContent(raw: String): JustifyContent { - return when (raw.trim().lowercase()) { +fun parseJustifyContent(raw: String): JustifyContent = + when (raw.trim().lowercase()) { "start", "flex-start", "left", "top" -> JustifyContent.Start "center" -> JustifyContent.Center "end", "flex-end", "right", "bottom" -> JustifyContent.End @@ -176,70 +176,62 @@ fun parseJustifyContent(raw: String): JustifyContent { "space-evenly" -> JustifyContent.SpaceEvenly else -> error("Unsupported justify-content value '$raw'.") } -} -fun parseAlignItems(raw: String): AlignItems { - return when (raw.trim().lowercase()) { +fun parseAlignItems(raw: String): AlignItems = + when (raw.trim().lowercase()) { "start", "flex-start", "top", "left" -> AlignItems.Start "center" -> AlignItems.Center "end", "flex-end", "bottom", "right" -> AlignItems.End "stretch" -> AlignItems.Stretch else -> error("Unsupported align-items value '$raw'.") } -} -fun parseJustifyItems(raw: String): JustifyItems { - return when (raw.trim().lowercase()) { +fun parseJustifyItems(raw: String): JustifyItems = + when (raw.trim().lowercase()) { "start", "left", "top" -> JustifyItems.Start "center" -> JustifyItems.Center "end", "right", "bottom" -> JustifyItems.End "stretch" -> JustifyItems.Stretch else -> error("Unsupported justify-items value '$raw'.") } -} -fun parseGridAutoFlow(raw: String): GridAutoFlow { - return when (raw.trim().lowercase()) { +fun parseGridAutoFlow(raw: String): GridAutoFlow = + when (raw.trim().lowercase()) { "row" -> GridAutoFlow.Row "column" -> GridAutoFlow.Column else -> error("Unsupported grid-auto-flow value '$raw'.") } -} -fun parseTextWrap(raw: String): TextWrap { - return when (raw.trim().lowercase()) { +fun parseTextWrap(raw: String): TextWrap = + when (raw.trim().lowercase()) { "wrap" -> TextWrap.Wrap "nowrap" -> TextWrap.NoWrap else -> error("Unsupported text-wrap value '$raw'.") } -} -fun parseTextFormatting(raw: String): TextFormatting { - return when (raw.trim().lowercase()) { +fun parseTextFormatting(raw: String): TextFormatting = + when (raw.trim().lowercase()) { "none" -> TextFormatting.None "minecraft" -> TextFormatting.Minecraft else -> error("Unsupported text-formatting value '$raw'.") } -} -fun parseFontWeight(raw: String): FontWeight { - return when (raw.trim().lowercase()) { +fun parseFontWeight(raw: String): FontWeight = + when (raw.trim().lowercase()) { "normal" -> FontWeight.Normal "bold" -> FontWeight.Bold else -> error("Unsupported font-weight value '$raw'.") } -} -fun parseFontStyle(raw: String): FontStyle { - return when (raw.trim().lowercase()) { +fun parseFontStyle(raw: String): FontStyle = + when (raw.trim().lowercase()) { "normal" -> FontStyle.Normal "italic" -> FontStyle.Italic else -> error("Unsupported font-style value '$raw'.") } -} -fun parseTextDecoration(raw: String): TextDecoration { - return when (raw.trim().lowercase()) { +fun parseTextDecoration(raw: String): TextDecoration = + when (raw.trim().lowercase()) { "none" -> TextDecoration.None "underline" -> TextDecoration.Underline "strikethrough", "line-through" -> TextDecoration.Strikethrough @@ -249,15 +241,13 @@ fun parseTextDecoration(raw: String): TextDecoration { else -> error("Unsupported text-decoration value '$raw'.") } -} -fun parseBooleanLike(raw: String): Boolean { - return when (raw.trim().lowercase()) { +fun parseBooleanLike(raw: String): Boolean = + when (raw.trim().lowercase()) { "true", "1", "yes", "on" -> true "false", "0", "no", "off" -> false else -> error("Expected boolean but got '$raw'.") } -} fun parseTransform(raw: String): UiTransform { val input = raw.trim() @@ -270,40 +260,49 @@ fun parseTransform(raw: String): UiTransform { require(matches.isNotEmpty()) { "Expected transform functions, got '$raw'." } matches.forEach { match -> - val fn = match.groupValues[1].trim().lowercase() - val args = match.groupValues[2] - .split(Regex("[,\\s]+")) - .map { it.trim() } - .filter { it.isNotBlank() } - transform = when (fn) { - "translate" -> { - require(args.size in 1..2) { "translate expects 1 or 2 arguments." } - val tx = parseFloatLike(args[0]) - val ty = if (args.size >= 2) parseFloatLike(args[1]) else 0f - transform.translated(tx, ty) + val fn = + match.groupValues[1] + .trim() + .lowercase() + val args = + match.groupValues[2] + .split(Regex("[,\\s]+")) + .map { it.trim() } + .filter { it.isNotBlank() } + transform = + when (fn) { + "translate" -> { + require(args.size in 1..2) { "translate expects 1 or 2 arguments." } + val tx = parseFloatLike(args[0]) + val ty = if (args.size >= 2) parseFloatLike(args[1]) else 0f + transform.translated(tx, ty) + } + + "scale" -> { + require(args.size in 1..2) { "scale expects 1 or 2 arguments." } + val sx = parseFloatLike(args[0]) + val sy = if (args.size >= 2) parseFloatLike(args[1]) else sx + transform.scaled(sx, sy) + } + + "rotate" -> { + require(args.size == 1) { "rotate expects 1 argument." } + val normalized = args[0].removeSuffix("deg").trim() + transform.rotated(parseFloatLike(normalized)) + } + + else -> error("Unsupported transform function '$fn'.") } - - "scale" -> { - require(args.size in 1..2) { "scale expects 1 or 2 arguments." } - val sx = parseFloatLike(args[0]) - val sy = if (args.size >= 2) parseFloatLike(args[1]) else sx - transform.scaled(sx, sy) - } - - "rotate" -> { - require(args.size == 1) { "rotate expects 1 argument." } - val normalized = args[0].removeSuffix("deg").trim() - transform.rotated(parseFloatLike(normalized)) - } - - else -> error("Unsupported transform function '$fn'.") - } } return transform } fun parseTransformOrigin(raw: String): TransformOrigin { - val parts = raw.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + val parts = + raw + .trim() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } require(parts.size == 2) { "transform-origin expects exactly two values." } val ox = parseOriginComponent(parts[0]) val oy = parseOriginComponent(parts[1]) @@ -320,9 +319,7 @@ private fun parseOriginComponent(raw: String): Float { } } -fun parseOpacity(raw: String): Float { - return parseFloatLike(raw).coerceIn(0f, 1f) -} +fun parseOpacity(raw: String): Float = parseFloatLike(raw).coerceIn(0f, 1f) fun parseIntLike(raw: String): Int { val trimmed = raw.trim() @@ -341,9 +338,11 @@ fun parseOptionalInt(raw: String): Int? { return if (normalized == "auto") null else parseIntLike(raw) } -private fun splitLengthTokens(raw: String): List { - return raw.trim().split(Regex("\\s+")).filter { it.isNotBlank() } -} +private fun splitLengthTokens(raw: String): List = + raw + .trim() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } fun parseStringLiteral(raw: String): String { val trimmed = raw.trim() @@ -360,44 +359,50 @@ fun validateLiteralForProperty( property: StyleProperty, literal: String, warningReporter: StyleWarningReporter? = null, - deprecatedLengthWarningKey: String = "deprecated.unitless-length" + deprecatedLengthWarningKey: String = "deprecated.unitless-length", ) { when (property) { - StyleProperty.MARGIN -> parseSpacingLengthShorthand( - raw = literal, - allowNegative = true, - allowUnitlessZero = true, - warningReporter = warningReporter, - warningKey = deprecatedLengthWarningKey - ) - StyleProperty.PADDING -> parseSpacingLengthShorthand( - raw = literal, - allowNegative = false, - allowUnitlessZero = true, - warningReporter = warningReporter, - warningKey = deprecatedLengthWarningKey - ) + StyleProperty.MARGIN -> + parseSpacingLengthShorthand( + raw = literal, + allowNegative = true, + allowUnitlessZero = true, + warningReporter = warningReporter, + warningKey = deprecatedLengthWarningKey, + ) + StyleProperty.PADDING -> + parseSpacingLengthShorthand( + raw = literal, + allowNegative = false, + allowUnitlessZero = true, + warningReporter = warningReporter, + warningKey = deprecatedLengthWarningKey, + ) StyleProperty.BACKGROUND_COLOR, StyleProperty.BORDER_COLOR, - StyleProperty.FOREGROUND_COLOR -> parseColor(literal) + StyleProperty.FOREGROUND_COLOR, + -> parseColor(literal) StyleProperty.BACKGROUND_IMAGE -> parseStringLiteral(literal) StyleProperty.FONT_ID -> parseStringLiteral(literal) - StyleProperty.BORDER_WIDTH -> validateLengthLiteral( - literal = literal, - allowNegative = false - ) - StyleProperty.BORDER_RADIUS -> validateLengthLiteral( - literal = literal, - allowNegative = false - ) - StyleProperty.FONT_SIZE -> { - val parsed = parseCssLength( - raw = literal, - allowUnitlessZero = true + StyleProperty.BORDER_WIDTH -> + validateLengthLiteral( + literal = literal, + allowNegative = false, ) + StyleProperty.BORDER_RADIUS -> + validateLengthLiteral( + literal = literal, + allowNegative = false, + ) + StyleProperty.FONT_SIZE -> { + val parsed = + parseCssLength( + raw = literal, + allowUnitlessZero = true, + ) require(parsed.value >= 0f) { "font-size must be non-negative." } } StyleProperty.LINE_HEIGHT -> StylePropertyRegistry.parseLineHeightLiteral(property, literal) @@ -406,7 +411,8 @@ fun validateLiteralForProperty( StyleProperty.MIN_WIDTH, StyleProperty.MIN_HEIGHT, StyleProperty.MAX_WIDTH, - StyleProperty.MAX_HEIGHT -> { + StyleProperty.MAX_HEIGHT, + -> { val parsed = parseOptionalCssLength(literal) if (parsed != null && parsed.value < 0f) { error("Negative length is not allowed: '$literal'.") @@ -419,11 +425,13 @@ fun validateLiteralForProperty( StyleProperty.LEFT, StyleProperty.TOP, StyleProperty.RIGHT, - StyleProperty.BOTTOM -> parseOptionalCssLength(literal) + StyleProperty.BOTTOM, + -> parseOptionalCssLength(literal) StyleProperty.Z_INDEX -> StylePropertyRegistry.parseUnitlessIntLiteral(property, literal) StyleProperty.OVERFLOW -> parseOverflowShorthand(literal) StyleProperty.OVERFLOW_X, - StyleProperty.OVERFLOW_Y -> parseOverflow(literal) + StyleProperty.OVERFLOW_Y, + -> parseOverflow(literal) StyleProperty.FLEX_DIRECTION -> parseFlexDirection(literal) StyleProperty.JUSTIFY_CONTENT -> parseJustifyContent(literal) StyleProperty.ALIGN_ITEMS -> parseAlignItems(literal) @@ -454,10 +462,7 @@ fun validateLiteralForProperty( } } -private fun validateLengthLiteral( - literal: String, - allowNegative: Boolean -) { +private fun validateLengthLiteral(literal: String, allowNegative: Boolean) { val parsed = parseCssLength(literal) if (!allowNegative && parsed.value < 0f) { error("Negative length is not allowed: '$literal'.") @@ -467,9 +472,9 @@ private fun validateLengthLiteral( fun resolveExpressionToLiteral( expression: StyleExpression, variables: Map, - resolving: MutableSet = linkedSetOf() -): String { - return when (expression) { + resolving: MutableSet = linkedSetOf(), +): String = + when (expression) { is StyleExpression.Literal -> expression.value is StyleExpression.VariableRef -> { val varName = expression.name @@ -483,4 +488,3 @@ fun resolveExpressionToLiteral( resolved } } -} diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylesheetManager.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylesheetManager.kt index 080e6b7..4c8c21c 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylesheetManager.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylesheetManager.kt @@ -6,7 +6,7 @@ import java.util.concurrent.ConcurrentHashMap data class StylesheetSnapshot( val version: Long, val index: RuleIndex, - val rootVariables: Map + val rootVariables: Map, ) data class RuleIndex( @@ -16,18 +16,19 @@ data class RuleIndex( val universalIndex: List, val hasAncestorDependentSelectors: Boolean, val hasAdjacentSiblingCombinators: Boolean, - val hasGeneralSiblingCombinators: Boolean + val hasGeneralSiblingCombinators: Boolean, ) { companion object { - val EMPTY = RuleIndex( - typeIndex = emptyMap(), - classIndex = emptyMap(), - idIndex = emptyMap(), - universalIndex = emptyList(), - hasAncestorDependentSelectors = false, - hasAdjacentSiblingCombinators = false, - hasGeneralSiblingCombinators = false - ) + val EMPTY = + RuleIndex( + typeIndex = emptyMap(), + classIndex = emptyMap(), + idIndex = emptyMap(), + universalIndex = emptyList(), + hasAncestorDependentSelectors = false, + hasAdjacentSiblingCombinators = false, + hasGeneralSiblingCombinators = false, + ) } } @@ -35,7 +36,7 @@ object StylesheetManager { private data class LoadedSheet( val file: File, val lastModified: Long, - val data: StylesheetData + val data: StylesheetData, ) private var stylesDir: File? = null @@ -85,10 +86,16 @@ object StylesheetManager { lastDirectoryScanMillis = now scannedOnce = true - val currentFiles = dir.walkTopDown() - .filter { it.isFile && it.extension.equals("dss", ignoreCase = true) } - .sortedBy { it.relativeTo(dir).path.replace('\\', '/') } - .toList() + val currentFiles = + dir + .walkTopDown() + .filter { it.isFile && it.extension.equals("dss", ignoreCase = true) } + .sortedBy { + it + .relativeTo(dir) + .path + .replace('\\', '/') + }.toList() val currentPaths = currentFiles.map { it.absolutePath }.toSet() val removedPaths = loadedSheets.keys.filter { it !in currentPaths } @@ -100,11 +107,11 @@ object StylesheetManager { val lastModified = file.lastModified() val loaded = loadedSheets[key] if (force || loaded == null || loaded.lastModified != lastModified) { - val parsed = runCatching { DssParser.parse(file) } - .onFailure { error -> - println("[DSGL-Style] Parse error in ${file.path}: ${error.message}") - } - .getOrNull() + val parsed = + runCatching { DssParser.parse(file) } + .onFailure { error -> + println("[DSGL-Style] Parse error in ${file.path}: ${error.message}") + }.getOrNull() if (parsed != null) { loadedSheets[key] = LoadedSheet(file, lastModified, parsed) @@ -123,14 +130,18 @@ object StylesheetManager { } @Synchronized - fun snapshot(): StylesheetSnapshot { - return snapshot - } + fun snapshot(): StylesheetSnapshot = snapshot @Synchronized private fun rebuildSnapshot(baseDir: File) { - val orderedSheets = loadedSheets.values - .sortedBy { it.file.relativeTo(baseDir).path.replace('\\', '/') } + val orderedSheets = + loadedSheets.values + .sortedBy { + it.file + .relativeTo(baseDir) + .path + .replace('\\', '/') + } var sourceOrder = 0 val rules = mutableListOf() @@ -141,18 +152,20 @@ object StylesheetManager { rootVars[name] = value } loaded.data.rules.forEach { rule -> - rules += rule.copy( - sourceOrder = sourceOrder++, - fileName = loaded.file.path - ) + rules += + rule.copy( + sourceOrder = sourceOrder++, + fileName = loaded.file.path, + ) } } - snapshot = StylesheetSnapshot( - version = ++versionCounter, - index = buildIndex(rules), - rootVariables = rootVars.toMap() - ) + snapshot = + StylesheetSnapshot( + version = ++versionCounter, + index = buildIndex(rules), + rootVariables = rootVars.toMap(), + ) } private fun buildIndex(rules: List): RuleIndex { @@ -205,7 +218,7 @@ object StylesheetManager { universalIndex = universalIndex.toList(), hasAncestorDependentSelectors = hasAncestorDependentSelectors, hasAdjacentSiblingCombinators = hasAdjacentSiblingCombinators, - hasGeneralSiblingCombinators = hasGeneralSiblingCombinators + hasGeneralSiblingCombinators = hasGeneralSiblingCombinators, ) } } diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParser.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParser.kt index 7d107b9..8bd4d78 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParser.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParser.kt @@ -7,10 +7,10 @@ data class TextFormattingFlags( val bold: Boolean = false, val strikethrough: Boolean = false, val underline: Boolean = false, - val italic: Boolean = false + val italic: Boolean = false, ) { - fun withLegacyCode(code: Char): TextFormattingFlags { - return when (code.lowercaseChar()) { + fun withLegacyCode(code: Char): TextFormattingFlags = + when (code.lowercaseChar()) { 'k' -> copy(obfuscated = true) 'l' -> copy(bold = true) 'm' -> copy(strikethrough = true) @@ -18,7 +18,6 @@ data class TextFormattingFlags( 'o' -> copy(italic = true) else -> this } - } companion object { val NONE: TextFormattingFlags = TextFormattingFlags() @@ -29,18 +28,18 @@ data class ParsedTextSpan( val start: Int, val end: Int, val colorRgb: Int?, - val flags: TextFormattingFlags + val flags: TextFormattingFlags, ) data class ParsedText( val plainText: String, - val spans: List + val spans: List, ) data class ResolvedTextColorSpan( val start: Int, val end: Int, - val color: Int + val color: Int, ) data class TextStyleFlags( @@ -48,7 +47,7 @@ data class TextStyleFlags( val italic: Boolean = false, val underline: Boolean = false, val strikethrough: Boolean = false, - val obfuscated: Boolean = false + val obfuscated: Boolean = false, ) { companion object { val NONE: TextStyleFlags = TextStyleFlags() @@ -59,28 +58,29 @@ data class ResolvedTextStyleSpan( val start: Int, val end: Int, val color: Int, - val flags: TextStyleFlags + val flags: TextStyleFlags, ) object MinecraftLegacyColors { - private val colorsByCode: Map = mapOf( - '0' to 0x000000, - '1' to 0x0000AA, - '2' to 0x00AA00, - '3' to 0x00AAAA, - '4' to 0xAA0000, - '5' to 0xAA00AA, - '6' to 0xFFAA00, - '7' to 0xAAAAAA, - '8' to 0x555555, - '9' to 0x5555FF, - 'a' to 0x55FF55, - 'b' to 0x55FFFF, - 'c' to 0xFF5555, - 'd' to 0xFF55FF, - 'e' to 0xFFFF55, - 'f' to 0xFFFFFF - ) + private val colorsByCode: Map = + mapOf( + '0' to 0x000000, + '1' to 0x0000AA, + '2' to 0x00AA00, + '3' to 0x00AAAA, + '4' to 0xAA0000, + '5' to 0xAA00AA, + '6' to 0xFFAA00, + '7' to 0xAAAAAA, + '8' to 0x555555, + '9' to 0x5555FF, + 'a' to 0x55FF55, + 'b' to 0x55FFFF, + 'c' to 0xFF5555, + 'd' to 0xFF55FF, + 'e' to 0xFFFF55, + 'f' to 0xFFFFFF, + ) fun rgb(code: Char): Int? = colorsByCode[code.lowercaseChar()] } @@ -88,21 +88,20 @@ object MinecraftLegacyColors { object MinecraftFormattingParser { private data class CacheKey( val text: String, - val mode: TextFormatting + val mode: TextFormatting, ) private data class SpanStyle( val colorRgb: Int? = null, - val flags: TextFormattingFlags = TextFormattingFlags.NONE + val flags: TextFormattingFlags = TextFormattingFlags.NONE, ) private const val PREFIX: Char = '\u00A7' private const val MAX_CACHE_SIZE: Int = 512 private val cache: MutableMap = object : LinkedHashMap(128, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MAX_CACHE_SIZE - } + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > MAX_CACHE_SIZE } fun clearCache() { @@ -121,10 +120,11 @@ object MinecraftFormattingParser { cache[key]?.let { return it } } - val parsed = when (mode) { - TextFormatting.None -> ParsedText(plainText = text, spans = emptyList()) - TextFormatting.Minecraft -> parseMinecraft(text) - } + val parsed = + when (mode) { + TextFormatting.None -> ParsedText(plainText = text, spans = emptyList()) + TextFormatting.Minecraft -> parseMinecraft(text) + } synchronized(cache) { cache[key] = parsed @@ -136,7 +136,7 @@ object MinecraftFormattingParser { parsed: ParsedText, baseColor: Int, rangeStart: Int = 0, - rangeEnd: Int = parsed.plainText.length + rangeEnd: Int = parsed.plainText.length, ): List { if (rangeEnd <= rangeStart || parsed.spans.isEmpty()) return emptyList() val safeStart = rangeStart.coerceIn(0, parsed.plainText.length) @@ -151,11 +151,12 @@ object MinecraftFormattingParser { val rgb = span.colorRgb ?: return@forEach val alpha = (baseColor ushr 24) and 0xFF val argb = (alpha shl 24) or (rgb and 0x00FF_FFFF) - out += ResolvedTextColorSpan( - start = start - safeStart, - end = end - safeStart, - color = argb - ) + out += + ResolvedTextColorSpan( + start = start - safeStart, + end = end - safeStart, + color = argb, + ) } return out } @@ -165,7 +166,7 @@ object MinecraftFormattingParser { baseColor: Int, baseFlags: TextStyleFlags, rangeStart: Int = 0, - rangeEnd: Int = parsed.plainText.length + rangeEnd: Int = parsed.plainText.length, ): List { if (rangeEnd <= rangeStart) return emptyList() if (parsed.spans.isEmpty()) return emptyList() @@ -178,27 +179,31 @@ object MinecraftFormattingParser { val start = maxOf(span.start, safeStart) val end = minOf(span.end, safeEnd) if (end <= start) return@forEach - val color = span.colorRgb?.let { rgb -> - val alpha = (baseColor ushr 24) and 0xFF - (alpha shl 24) or (rgb and 0x00FF_FFFF) - } ?: baseColor - val flags = TextStyleFlags( - bold = baseFlags.bold || span.flags.bold, - italic = baseFlags.italic || span.flags.italic, - underline = baseFlags.underline || span.flags.underline, - strikethrough = baseFlags.strikethrough || span.flags.strikethrough, - obfuscated = baseFlags.obfuscated || span.flags.obfuscated - ) - out += ResolvedTextStyleSpan( - start = start - safeStart, - end = end - safeStart, - color = color, - flags = flags - ) + val color = + span.colorRgb?.let { rgb -> + val alpha = (baseColor ushr 24) and 0xFF + (alpha shl 24) or (rgb and 0x00FF_FFFF) + } ?: baseColor + val flags = + TextStyleFlags( + bold = baseFlags.bold || span.flags.bold, + italic = baseFlags.italic || span.flags.italic, + underline = baseFlags.underline || span.flags.underline, + strikethrough = baseFlags.strikethrough || span.flags.strikethrough, + obfuscated = baseFlags.obfuscated || span.flags.obfuscated, + ) + out += + ResolvedTextStyleSpan( + start = start - safeStart, + end = end - safeStart, + color = color, + flags = flags, + ) } return out } + @Suppress("LoopWithTooManyJumpStatements") private fun parseMinecraft(text: String): ParsedText { val plain = StringBuilder(text.length) val spans = ArrayList(8) @@ -209,12 +214,13 @@ object MinecraftFormattingParser { fun flush() { val end = plain.length if (end <= segmentStart) return - spans += ParsedTextSpan( - start = segmentStart, - end = end, - colorRgb = style.colorRgb, - flags = style.flags - ) + spans += + ParsedTextSpan( + start = segmentStart, + end = end, + colorRgb = style.colorRgb, + flags = style.flags, + ) segmentStart = end } @@ -261,10 +267,11 @@ object MinecraftFormattingParser { MinecraftLegacyColors.rgb(lower) != null -> { flush() - style = SpanStyle( - colorRgb = MinecraftLegacyColors.rgb(lower), - flags = TextFormattingFlags.NONE - ) + style = + SpanStyle( + colorRgb = MinecraftLegacyColors.rgb(lower), + flags = TextFormattingFlags.NONE, + ) index += 2 segmentStart = plain.length } @@ -302,7 +309,8 @@ object MinecraftFormattingParser { var current = spans.first() for (index in 1 until spans.size) { val next = spans[index] - val mergeable = current.end == next.start && + val mergeable = + current.end == next.start && current.colorRgb == next.colorRgb && current.flags == next.flags if (mergeable) { diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/ObfuscationTextSelector.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/ObfuscationTextSelector.kt index 7dde08f..af192ad 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/ObfuscationTextSelector.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/ObfuscationTextSelector.kt @@ -1,9 +1,7 @@ package org.dreamfinity.dsgl.core.text object ObfuscationTextSelector { - fun shouldObfuscateCodepoint(codepoint: Int): Boolean { - return !TextStyleMetrics.isWhitespaceCodepoint(codepoint) - } + fun shouldObfuscateCodepoint(codepoint: Int): Boolean = !TextStyleMetrics.isWhitespaceCodepoint(codepoint) fun selectCandidateIndex( sourceKey: String, @@ -11,7 +9,7 @@ object ObfuscationTextSelector { glyphIndexInLine: Int, timeSlice: Long, originalCodepoint: Int, - candidateCount: Int + candidateCount: Int, ): Int { if (candidateCount <= 0) return 0 var seed = 17 diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayout.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayout.kt index 14de4e3..6ac496e 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayout.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayout.kt @@ -4,7 +4,7 @@ import kotlin.math.roundToInt enum class DecorationType { Underline, - Strikethrough + Strikethrough, } data class TextVisualLine( @@ -13,7 +13,7 @@ data class TextVisualLine( val baselineY: Float, val lineHeightPx: Float, val glyphStartIndex: Int, - val glyphEndIndexExclusive: Int + val glyphEndIndexExclusive: Int, ) data class GlyphDecorationSample( @@ -23,7 +23,7 @@ data class GlyphDecorationSample( val xEnd: Float, val color: Int, val underline: Boolean, - val strikethrough: Boolean + val strikethrough: Boolean, ) data class DecorationFontMetrics( @@ -32,14 +32,14 @@ data class DecorationFontMetrics( val ascenderEm: Float, val descenderEm: Float, val underlineYEm: Float?, - val underlineThicknessEm: Float? + val underlineThicknessEm: Float?, ) data class ResolvedLineDecorationMetrics( val underlineY: Float, val underlineThickness: Float, val strikethroughY: Float, - val strikethroughThickness: Float + val strikethroughThickness: Float, ) data class DecorationQuad( @@ -49,7 +49,7 @@ data class DecorationQuad( val xEnd: Float, val y: Float, val thickness: Float, - val color: Int + val color: Int, ) object TextDecorationLayout { @@ -58,38 +58,38 @@ object TextDecorationLayout { return fontPx.coerceAtLeast(1) / safeEm } - fun baselineY(lineTopY: Float, ascenderEm: Float, scalePx: Float): Float { - return lineTopY + ascenderEm * scalePx - } + fun baselineY(lineTopY: Float, ascenderEm: Float, scalePx: Float): Float = lineTopY + ascenderEm * scalePx - fun screenYFromYUpMetric(baselineY: Float, metricYUpEm: Float, scalePx: Float): Float { - return baselineY - metricYUpEm * scalePx - } + fun screenYFromYUpMetric(baselineY: Float, metricYUpEm: Float, scalePx: Float): Float = + baselineY - metricYUpEm * scalePx fun resolveLineMetrics( line: TextVisualLine, fontMetrics: DecorationFontMetrics, - fontPx: Int + fontPx: Int, ): ResolvedLineDecorationMetrics { val lineHeight = line.lineHeightPx.coerceAtLeast(1f) val scale = scalePx(fontPx, fontMetrics.emSize) val rawUnderlineThickness = (fontMetrics.underlineThicknessEm ?: 0f) * scale val fallbackThickness = maxOf(1f, (0.06f * lineHeight).roundToInt().toFloat()) - val underlineThickness = when { - rawUnderlineThickness < 0.5f -> fallbackThickness - else -> maxOf(1f, rawUnderlineThickness.roundToInt().toFloat()) - } + val underlineThickness = + when { + rawUnderlineThickness < 0.5f -> fallbackThickness + else -> maxOf(1f, rawUnderlineThickness.roundToInt().toFloat()) + } val fallbackUnderlineY = line.baselineY + 0.08f * lineHeight - val metaUnderlineY = fontMetrics.underlineYEm?.let { metricY -> - screenYFromYUpMetric(line.baselineY, metricY, scale) - } - val underlineY = when { - metaUnderlineY == null -> fallbackUnderlineY - metaUnderlineY <= line.baselineY + 0.5f -> fallbackUnderlineY - else -> metaUnderlineY - }.roundToInt().toFloat() + val metaUnderlineY = + fontMetrics.underlineYEm?.let { metricY -> + screenYFromYUpMetric(line.baselineY, metricY, scale) + } + val underlineY = + when { + metaUnderlineY == null -> fallbackUnderlineY + metaUnderlineY <= line.baselineY + 0.5f -> fallbackUnderlineY + else -> metaUnderlineY + }.roundToInt().toFloat() val strikeY = (line.baselineY - 0.30f * lineHeight).roundToInt().toFloat() val strikeThickness = maxOf(1f, (0.06f * lineHeight).roundToInt().toFloat()) @@ -102,7 +102,7 @@ object TextDecorationLayout { underlineY = underlineY.coerceIn(line.lineTopY, underlineMaxY), underlineThickness = underlineThickness, strikethroughY = strikeY.coerceIn(line.lineTopY, strikeMaxY), - strikethroughThickness = strikeThickness + strikethroughThickness = strikeThickness, ) } @@ -110,17 +110,19 @@ object TextDecorationLayout { lines: List, glyphs: List, fontMetrics: DecorationFontMetrics, - fontPx: Int + fontPx: Int, ): List { if (lines.isEmpty() || glyphs.isEmpty()) return emptyList() val lineByIndex = lines.associateBy { it.lineIndex } - val lineMetricsByIndex = lines.associate { line -> - line.lineIndex to resolveLineMetrics( - line = line, - fontMetrics = fontMetrics, - fontPx = fontPx - ) - } + val lineMetricsByIndex = + lines.associate { line -> + line.lineIndex to + resolveLineMetrics( + line = line, + fontMetrics = fontMetrics, + fontPx = fontPx, + ) + } val out = ArrayList(glyphs.size) glyphs.sortedWith(compareBy { it.lineIndex }.thenBy { it.glyphIndex }).forEach { glyph -> if (glyph.xEnd <= glyph.xStart) return@forEach @@ -132,29 +134,31 @@ object TextDecorationLayout { if (glyph.underline) { appendMerged( out = out, - quad = DecorationQuad( - type = DecorationType.Underline, - lineIndex = glyph.lineIndex, - xStart = glyph.xStart, - xEnd = glyph.xEnd, - y = metrics.underlineY, - thickness = metrics.underlineThickness, - color = glyph.color - ) + quad = + DecorationQuad( + type = DecorationType.Underline, + lineIndex = glyph.lineIndex, + xStart = glyph.xStart, + xEnd = glyph.xEnd, + y = metrics.underlineY, + thickness = metrics.underlineThickness, + color = glyph.color, + ), ) } if (glyph.strikethrough) { appendMerged( out = out, - quad = DecorationQuad( - type = DecorationType.Strikethrough, - lineIndex = glyph.lineIndex, - xStart = glyph.xStart, - xEnd = glyph.xEnd, - y = metrics.strikethroughY, - thickness = metrics.strikethroughThickness, - color = glyph.color - ) + quad = + DecorationQuad( + type = DecorationType.Strikethrough, + lineIndex = glyph.lineIndex, + xStart = glyph.xStart, + xEnd = glyph.xEnd, + y = metrics.strikethroughY, + thickness = metrics.strikethroughThickness, + color = glyph.color, + ), ) } } @@ -177,4 +181,3 @@ object TextDecorationLayout { out += quad } } - diff --git a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextStyleMetrics.kt b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextStyleMetrics.kt index b363604..0fe46a2 100644 --- a/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextStyleMetrics.kt +++ b/core/src/main/kotlin/org/dreamfinity/dsgl/core/text/TextStyleMetrics.kt @@ -8,23 +8,22 @@ object TextStyleMetrics { spans: List, baseFlags: TextStyleFlags, rangeStart: Int = 0, - rangeEnd: Int = plainText.length - ): Int { - return boldExtraPxForRangeInText( + rangeEnd: Int = plainText.length, + ): Int = + boldExtraPxForRangeInText( plainText = plainText, spans = spans, baseFlags = baseFlags, rangeStart = rangeStart, - rangeEnd = rangeEnd + rangeEnd = rangeEnd, ) - } fun boldExtraPxForRangeInText( plainText: String, spans: List, baseFlags: TextStyleFlags, rangeStart: Int = 0, - rangeEnd: Int = plainText.length + rangeEnd: Int = plainText.length, ): Int { if (plainText.isEmpty()) return 0 val safeStart = rangeStart.coerceIn(0, plainText.length) @@ -39,12 +38,13 @@ object TextStyleMetrics { while (spanIndex < spans.size && absoluteIndex >= spans[spanIndex].end) { spanIndex += 1 } - val spanBold = if (spanIndex < spans.size) { - val span = spans[spanIndex] - absoluteIndex >= span.start && absoluteIndex < span.end && span.flags.bold - } else { - false - } + val spanBold = + if (spanIndex < spans.size) { + val span = spans[spanIndex] + absoluteIndex >= span.start && absoluteIndex < span.end && span.flags.bold + } else { + false + } val bold = baseFlags.bold || spanBold if (bold && !isWhitespaceCodepoint(codepoint)) { extra += BOLD_ADVANCE_EXTRA_PX @@ -54,12 +54,11 @@ object TextStyleMetrics { return extra } - fun isWhitespaceCodepoint(codepoint: Int): Boolean { - return codepoint == ' '.code || - codepoint == '\n'.code || - codepoint == '\r'.code || - codepoint == '\t'.code || - codepoint == 0x00A0 || - Character.isWhitespace(codepoint) - } + fun isWhitespaceCodepoint(codepoint: Int): Boolean = + codepoint == ' '.code || + codepoint == '\n'.code || + codepoint == '\r'.code || + codepoint == '\t'.code || + codepoint == 0x00A0 || + Character.isWhitespace(codepoint) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/ContainerDslTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/ContainerDslTests.kt new file mode 100644 index 0000000..09e6ec1 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/ContainerDslTests.kt @@ -0,0 +1,34 @@ +package org.dreamfinity.dsgl.core + +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.ui +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ContainerDslTests { + @Test + fun `div overlapChildren opts into overlapping child layout`() { + val tree = + ui { + div({ + key = "plain" + }) + div({ + key = "overlap" + overlapChildren = true + }) + } + + val plain = + tree.root.children + .first { node -> node.key == "plain" } as ContainerNode + val overlap = + tree.root.children + .first { node -> node.key == "overlap" } as ContainerNode + + assertFalse(plain.stackLayout) + assertTrue(overlap.stackLayout) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/DomTreeCachingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/DomTreeCachingTests.kt index 14fb08a..82115f8 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/DomTreeCachingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/DomTreeCachingTests.kt @@ -1,8 +1,8 @@ package org.dreamfinity.dsgl.core -import org.dreamfinity.dsgl.core.components.modal.internal.ModalHostNode -import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalAnchorNode import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.elements.TextNode import org.dreamfinity.dsgl.core.dom.elements.TextSource @@ -15,14 +15,14 @@ import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.UiTransform import kotlin.test.AfterTest import kotlin.test.Test -import kotlin.test.assertSame import kotlin.test.assertEquals +import kotlin.test.assertSame import kotlin.test.assertTrue class DomTreeCachingTests { private class CountingRectNode( private var color: Int, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { var buildCount: Int = 0 private set @@ -45,7 +45,7 @@ class DomTreeCachingTests { private class TestRectNode( private val color: Int, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { var throwOnBuild: Boolean = false override val styleType: String = "test-rect" @@ -62,7 +62,7 @@ class DomTreeCachingTests { private class LegacyManualChildrenNode( private val color: Int, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { override val styleType: String = "legacy-manual" @@ -76,13 +76,18 @@ class DomTreeCachingTests { } } - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -175,17 +180,19 @@ class DomTreeCachingTests { node.display = Display.None val afterHide = tree.paint(ctx) - assertTrue(afterHide.none { command -> - command is RenderCommand.DrawRect && - command.color == 0xFF3399CC.toInt() - }) + assertTrue( + afterHide.none { command -> + command is RenderCommand.DrawRect && + command.color == 0xFF3399CC.toInt() + }, + ) } @Test - fun `modal host does not duplicate child commands in chunk assembly`() { - val host = ModalHostNode(key = "modal-host") + fun `modal portal does not duplicate child commands in chunk assembly`() { + val host = ModalPortalAnchorNode(key = "modal-host") CountingRectNode(color = 0xFFAA3300.toInt(), key = "content").applyParent(host) - CountingRectNode(color = 0xFF0033AA.toInt(), key = "overlay").applyParent(host) + CountingRectNode(color = 0xFF0033AA.toInt(), key = "floating-layer").applyParent(host) val tree = DomTree(host) tree.render(ctx, 320, 180) @@ -214,9 +221,10 @@ class DomTreeCachingTests { @Test fun `render command chunk does not hold dom node references`() { - val hasNodeField = RenderCommandChunk::class.java.declaredFields.any { field -> - DOMNode::class.java.isAssignableFrom(field.type) - } + val hasNodeField = + RenderCommandChunk::class.java.declaredFields.any { field -> + DOMNode::class.java.isAssignableFrom(field.type) + } assertTrue(!hasNodeField) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/GlyphsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/GlyphsTests.kt index d3ed69a..1e8839e 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/GlyphsTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/GlyphsTests.kt @@ -3,11 +3,9 @@ package org.dreamfinity.dsgl.core import sun.font.StandardGlyphVector import java.awt.Font import java.awt.font.FontRenderContext -import java.awt.font.TextLayout import java.io.File import kotlin.test.Test - class GlyphsTests { @Test fun `test glyphs`() { @@ -21,6 +19,5 @@ class GlyphsTests { val glyph = gv.getGlyphCode(i) usedGlyphs.add(glyph) } - val x = 1 } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/LayoutStrictModeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/LayoutStrictModeTests.kt index 1bf0697..262725e 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/LayoutStrictModeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/LayoutStrictModeTests.kt @@ -1,8 +1,5 @@ package org.dreamfinity.dsgl.core -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.debug.LayoutDebug @@ -13,6 +10,9 @@ import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue class LayoutStrictModeTests { @Test @@ -27,10 +27,11 @@ class LayoutStrictModeTests { LayoutDebug.drawBounds = false LayoutDebug.logViolations = false - val root = ContainerNode(key = "root").apply { - width = 20 - height = 10 - } + val root = + ContainerNode(key = "root").apply { + width = 20 + height = 10 + } RogueNode(key = "rogue").applyParent(root) val tree = DomTree(root) val ctx = testMeasureContext() @@ -42,7 +43,7 @@ class LayoutStrictModeTests { assertTrue(commands.isEmpty(), "Strict invalid layout should suppress normal paint commands.") assertFalse( tree.dispatchClick(MouseClickEvent(5, 5, MouseButton.LEFT)), - "Strict invalid layout should suppress hit-testing." + "Strict invalid layout should suppress hit-testing.", ) } finally { LayoutDebug.validateLayouts = prevValidate @@ -52,19 +53,28 @@ class LayoutStrictModeTests { } } - private class RogueNode(key: Any?) : DOMNode(key) { + private class RogueNode( + key: Any?, + ) : DOMNode(key) { override fun measure(ctx: UiMeasureContext): Size = Size(6, 4) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x + 128, y + 64, width, height) } } - private fun testMeasureContext(): UiMeasureContext { - return object : UiMeasureContext { + private fun testMeasureContext(): UiMeasureContext = + object : UiMeasureContext { override val fontHeight: Int = 8 + override fun measureText(text: String): Int = text.length * 6 + override fun paint(commands: List) = Unit } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UiScopeHookApiTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UiScopeHookApiTests.kt index f85788a..2d77b47 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UiScopeHookApiTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UiScopeHookApiTests.kt @@ -2,13 +2,13 @@ package org.dreamfinity.dsgl.core import org.dreamfinity.dsgl.core.hooks.createContext import org.dreamfinity.dsgl.core.hooks.provideContext +import org.dreamfinity.dsgl.core.hooks.ref.useRef import org.dreamfinity.dsgl.core.hooks.useCallback import org.dreamfinity.dsgl.core.hooks.useContext import org.dreamfinity.dsgl.core.hooks.useEffect import org.dreamfinity.dsgl.core.hooks.useMemo import org.dreamfinity.dsgl.core.hooks.useReducer import org.dreamfinity.dsgl.core.hooks.useState -import org.dreamfinity.dsgl.core.hooks.ref.useRef import kotlin.test.Test import kotlin.test.assertEquals @@ -69,8 +69,8 @@ class UiScopeHookApiTests { var contextSeen: String? = null val effectEvents: MutableList = arrayListOf() - override fun render(): DomTree { - return ui { + override fun render(): DomTree = + ui { var count by useState(initial) val valueRef by useRef() val memoValue by useMemo(count) { count * 2 } @@ -78,10 +78,11 @@ class UiScopeHookApiTests { val captured = count { captured } } - val (reducerValue, _) = useReducer( - initialState = 10, - reducer = { old: Int, action: Int -> old + action } - ) + val (reducerValue, _) = + useReducer( + initialState = 10, + reducer = { old: Int, action: Int -> old + action }, + ) useEffect(count) { val captured = count effectEvents += "run:$captured" @@ -98,6 +99,5 @@ class UiScopeHookApiTests { contextSeen = useContext(ApiContext) } } - } } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseContextTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseContextTests.kt index b5b8f12..8eac3e3 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseContextTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseContextTests.kt @@ -74,11 +74,10 @@ class UseContextTests { private class DefaultOnlyContextWindow : DsglWindow() { var lastSeen: String? = null - override fun render(): DomTree { - return ui { + override fun render(): DomTree = + ui { lastSeen = useContext(ThemeContext) } - } } private class NestedProviderWindow : DsglWindow() { @@ -86,8 +85,8 @@ class UseContextTests { var innerSeen: String? = null var afterNestedSeen: String? = null - override fun render(): DomTree { - return ui { + override fun render(): DomTree = + ui { provideContext(ThemeContext, "Outer") { outerSeen = useContext(ThemeContext) provideContext(ThemeContext, "Inner") { @@ -96,15 +95,14 @@ class UseContextTests { afterNestedSeen = useContext(ThemeContext) } } - } } private class ProviderValueChangeWindow : DsglWindow() { var pendingProviderValue: String? = null var lastSeen: String? = null - override fun render(): DomTree { - return ui { + override fun render(): DomTree = + ui { var providerValue by useState("Light") pendingProviderValue?.let { next -> providerValue = next @@ -114,15 +112,14 @@ class UseContextTests { lastSeen = useContext(ThemeContext) } } - } } private class ConditionalProviderWindow : DsglWindow() { var showProvider: Boolean = true var lastSeen: String? = null - override fun render(): DomTree { - return ui { + override fun render(): DomTree = + ui { if (showProvider) { provideContext(ThemeContext, "Provided") { lastSeen = useContext(ThemeContext) @@ -131,6 +128,5 @@ class UseContextTests { lastSeen = useContext(ThemeContext) } } - } } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseEffectHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseEffectHookRuntimeTests.kt index 39f7870..e6f3506 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseEffectHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseEffectHookRuntimeTests.kt @@ -75,9 +75,10 @@ class UseEffectHookRuntimeTests { fun `render failure attempt does not execute effect`() { val window = FailingEffectWindow() - val error = assertFailsWith { - renderWithHookSession(window, commit = false) - } + val error = + assertFailsWith { + renderWithHookSession(window, commit = false) + } assertTrue(error.message?.contains("forced render failure") == true) assertEquals(emptyList(), window.events) } @@ -90,9 +91,10 @@ class UseEffectHookRuntimeTests { renderWithHookSession(window, commit = true) window.everyCommitBranch = true - val error = assertFailsWith { - renderWithHookSession(window, commit = true) - } + val error = + assertFailsWith { + renderWithHookSession(window, commit = true) + } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("useEffect#0") == true) @@ -122,11 +124,7 @@ class UseEffectHookRuntimeTests { assertEquals(listOf("run:1", "cleanup:1"), window.events) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal, - commit: Boolean - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal, commit: Boolean): DomTree { window.beginRenderBuild(mode) var renderSucceeded = false return try { @@ -221,7 +219,7 @@ class UseEffectHookRuntimeTests { events += "run" onDispose { events += "cleanup" } } - throw IllegalStateException("forced render failure") + error("forced render failure") } } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseMemoHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseMemoHookRuntimeTests.kt index 6d98165..bcb6d03 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseMemoHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseMemoHookRuntimeTests.kt @@ -75,9 +75,10 @@ class UseMemoHookRuntimeTests { renderWithHookSession(window) window.useStringBranch = true - val error = assertFailsWith { - renderWithHookSession(window) - } + val error = + assertFailsWith { + renderWithHookSession(window) + } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("memoValue") == true) @@ -155,9 +156,10 @@ class UseMemoHookRuntimeTests { window.beginRenderBuild() window.render() - val error = assertFailsWith { - window.endRenderBuild() - } + val error = + assertFailsWith { + window.endRenderBuild() + } assertTrue(error.message?.contains("Storage-backed hook 'useMemo'") == true) assertTrue(error.message?.contains("delegated property syntax") == true) @@ -169,18 +171,16 @@ class UseMemoHookRuntimeTests { window.beginRenderBuild() window.render() - val error = assertFailsWith { - window.endRenderBuild() - } + val error = + assertFailsWith { + window.endRenderBuild() + } assertTrue(error.message?.contains("Storage-backed hook 'useCallback'") == true) assertTrue(error.message?.contains("delegated property syntax") == true) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal): DomTree { window.beginRenderBuild(mode) return try { window.render() diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseReducerHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseReducerHookRuntimeTests.kt index 23c9c01..198b38d 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseReducerHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseReducerHookRuntimeTests.kt @@ -7,11 +7,7 @@ import org.dreamfinity.dsgl.core.hooks.HookUsageException import org.dreamfinity.dsgl.core.hooks.useReducer import org.dreamfinity.dsgl.core.host.DsglWindowHost import org.dreamfinity.dsgl.core.host.Viewport -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue -import kotlin.test.fail +import kotlin.test.* class UseReducerHookRuntimeTests { @Test @@ -80,9 +76,10 @@ class UseReducerHookRuntimeTests { renderWithHookSession(window) window.useStringBranch = true - val error = assertFailsWith { - renderWithHookSession(window) - } + val error = + assertFailsWith { + renderWithHookSession(window) + } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("useReducer#0") == true) @@ -122,19 +119,17 @@ class UseReducerHookRuntimeTests { fun `useReducer outside active render fails loudly`() { val window = ReducerProbeWindow() - val error = assertFailsWith { - window.useReducer(0) { old: Int, action: Int -> - old + action + val error = + assertFailsWith { + window.useReducer(0) { old: Int, action: Int -> + old + action + } } - } assertTrue(error.message?.contains("outside active component render") == true) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal): DomTree { window.beginRenderBuild(mode) return try { window.render() @@ -162,7 +157,11 @@ class UseReducerHookRuntimeTests { private class ReducerProbeWindow : DsglWindow() { sealed interface CounterAction { data object Increment : CounterAction - data class Add(val delta: Int) : CounterAction + + data class Add( + val delta: Int, + ) : CounterAction + data object Noop : CounterAction } @@ -182,13 +181,12 @@ class UseReducerHookRuntimeTests { return DomTree(ContainerNode(key = "reducer.probe.root")) } - private fun reduceCounter(old: Int, action: CounterAction): Int { - return when (action) { + private fun reduceCounter(old: Int, action: CounterAction): Int = + when (action) { CounterAction.Increment -> old + 1 is CounterAction.Add -> old + action.delta CounterAction.Noop -> old } - } } private class ConditionalReducerTypeWindow : DsglWindow() { @@ -198,18 +196,20 @@ class UseReducerHookRuntimeTests { override fun render(): DomTree { if (useStringBranch) { - val (state, dispatch) = useReducer("fresh") { old: String, action: String -> - old + action - } + val (state, dispatch) = + useReducer("fresh") { old: String, action: String -> + old + action + } lastSeen = state lastIntDispatch = null if (dispatch.hashCode() == Int.MIN_VALUE) { error("unreachable") } } else { - val (state, dispatch) = useReducer(0) { old: Int, action: Int -> - old + action - } + val (state, dispatch) = + useReducer(0) { old: Int, action: Int -> + old + action + } lastSeen = state lastIntDispatch = dispatch } @@ -218,7 +218,7 @@ class UseReducerHookRuntimeTests { } private class RecordingHost( - override val window: DsglWindow + override val window: DsglWindow, ) : DsglWindowHost { var rebuildRequests: Int = 0 @@ -226,11 +226,10 @@ class UseReducerHookRuntimeTests { rebuildRequests += 1 } + @Suppress("EmptyFunctionBlock") override fun requestRedraw() { } - override fun getViewport(): Viewport { - return Viewport(width = 0, height = 0) - } + override fun getViewport(): Viewport = Viewport(width = 0, height = 0) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseStateHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseStateHookRuntimeTests.kt index 6b2192b..30240aa 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseStateHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/UseStateHookRuntimeTests.kt @@ -1,18 +1,14 @@ package org.dreamfinity.dsgl.core import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dsl.UiScope import org.dreamfinity.dsgl.core.hooks.HookHotReloadRemountException import org.dreamfinity.dsgl.core.hooks.HookRenderSessionMode import org.dreamfinity.dsgl.core.hooks.HookUsageException import org.dreamfinity.dsgl.core.hooks.useState -import org.dreamfinity.dsgl.core.dsl.UiScope import org.dreamfinity.dsgl.core.host.DsglWindowHost import org.dreamfinity.dsgl.core.host.Viewport -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue -import kotlin.test.fail +import kotlin.test.* class UseStateHookRuntimeTests { @Test @@ -53,14 +49,14 @@ class UseStateHookRuntimeTests { renderWithHookSession(window) assertEquals( linkedMapOf("Left panel" to 1, "Right panel" to 0), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) window.enqueueIncrement("Right panel") renderWithHookSession(window) assertEquals( linkedMapOf("Left panel" to 1, "Right panel" to 1), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) } @@ -73,13 +69,13 @@ class UseStateHookRuntimeTests { renderWithHookSession(window) assertEquals( linkedMapOf("A" to 0, "B" to 1, "C" to 0), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) renderWithHookSession(window) assertEquals( linkedMapOf("A" to 0, "B" to 1, "C" to 0), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) } @@ -92,14 +88,14 @@ class UseStateHookRuntimeTests { renderWithHookSession(window) assertEquals( linkedMapOf("A" to 0, "B" to 1, "C" to 0), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) window.order = listOf("B", "A", "C") renderWithHookSession(window) assertEquals( linkedMapOf("B" to 0, "A" to 1, "C" to 0), - window.observedCountsSnapshot() + window.observedCountsSnapshot(), ) } @@ -133,9 +129,10 @@ class UseStateHookRuntimeTests { renderWithHookSession(window) window.useStringBranch = true - val error = assertFailsWith { - renderWithHookSession(window) - } + val error = + assertFailsWith { + renderWithHookSession(window) + } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("counter") == true) @@ -163,9 +160,10 @@ class UseStateHookRuntimeTests { window.beginRenderBuild() window.render() - val error = assertFailsWith { - window.endRenderBuild() - } + val error = + assertFailsWith { + window.endRenderBuild() + } assertEquals(error.message?.contains("Storage-backed hook 'useState'"), true) assertEquals(error.message?.contains("delegated property syntax"), true) @@ -175,17 +173,15 @@ class UseStateHookRuntimeTests { fun `useState outside active render fails loudly`() { val window = StateProbeWindow() - val error = assertFailsWith { - window.useState(0) - } + val error = + assertFailsWith { + window.useState(0) + } assertEquals(error.message?.contains("outside active component render"), true) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal): DomTree { window.beginRenderBuild(mode) return try { window.render() @@ -213,7 +209,10 @@ class UseStateHookRuntimeTests { private class StateProbeWindow : DsglWindow() { sealed interface Mutation { data object None : Mutation - data class Assign(val value: Int) : Mutation + + data class Assign( + val value: Int, + ) : Mutation } var showState: Boolean = true @@ -278,9 +277,7 @@ class UseStateHookRuntimeTests { pendingIncrements += label } - fun observedCountsSnapshot(): LinkedHashMap { - return LinkedHashMap(observedCounts) - } + fun observedCountsSnapshot(): LinkedHashMap = LinkedHashMap(observedCounts) override fun render(): DomTree { observedCounts.clear() @@ -301,7 +298,7 @@ class UseStateHookRuntimeTests { } private class RecordingHost( - override val window: DsglWindow + override val window: DsglWindow, ) : DsglWindowHost { var rebuildRequests: Int = 0 @@ -309,11 +306,10 @@ class UseStateHookRuntimeTests { rebuildRequests += 1 } + @Suppress("EmptyFunctionBlock") override fun requestRedraw() { } - override fun getViewport(): Viewport { - return Viewport(width = 0, height = 0) - } + override fun getViewport(): Viewport = Viewport(width = 0, height = 0) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/AnimationEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/AnimationEngineTests.kt index 061977c..6e907ee 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/AnimationEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/AnimationEngineTests.kt @@ -32,15 +32,16 @@ class AnimationEngineTests { fun `transition retargeting starts from current interpolated value`() { val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.transitionSpec = TransitionSpec( - listOf( - TransitionPropertySpec( - property = AnimatedStyleProperty.Opacity, - durationMs = 1000, - easing = Easings.LINEAR - ) + node.transitionSpec = + TransitionSpec( + listOf( + TransitionPropertySpec( + property = AnimatedStyleProperty.Opacity, + durationMs = 1000, + easing = Easings.LINEAR, + ), + ), ) - ) node.applyComputedStyle(style(opacity = 0f)) node.applyComputedStyle(style(opacity = 1f)) @@ -58,16 +59,17 @@ class AnimationEngineTests { fun `transition delay is respected`() { val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.transitionSpec = TransitionSpec( - listOf( - TransitionPropertySpec( - property = AnimatedStyleProperty.Opacity, - durationMs = 500, - delayMs = 300, - easing = Easings.LINEAR - ) + node.transitionSpec = + TransitionSpec( + listOf( + TransitionPropertySpec( + property = AnimatedStyleProperty.Opacity, + durationMs = 500, + delayMs = 300, + easing = Easings.LINEAR, + ), + ), ) - ) node.applyComputedStyle(style(opacity = 0f)) node.applyComputedStyle(style(opacity = 1f)) @@ -85,16 +87,17 @@ class AnimationEngineTests { } val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.animationSpecs = listOf( - AnimationSpec( - name = "pulse", - durationMs = 1000, - delayMs = 200, - easing = Easings.LINEAR, - direction = AnimationDirection.Reverse, - fillMode = AnimationFillMode.Both + node.animationSpecs = + listOf( + AnimationSpec( + name = "pulse", + durationMs = 1000, + delayMs = 200, + easing = Easings.LINEAR, + direction = AnimationDirection.Reverse, + fillMode = AnimationFillMode.Both, + ), ) - ) node.applyComputedStyle(style(opacity = 1f)) StyleAnimationEngine.tickAndApply(root, 0.1, null) @@ -115,13 +118,14 @@ class AnimationEngineTests { } val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.animationSpecs = listOf( - AnimationSpec( - name = "fadeOnly", - durationMs = 1000, - easing = Easings.LINEAR + node.animationSpecs = + listOf( + AnimationSpec( + name = "fadeOnly", + durationMs = 1000, + easing = Easings.LINEAR, + ), ) - ) node.applyComputedStyle(style(opacity = 1f, color = 0xFF123456.toInt())) StyleAnimationEngine.tickAndApply(root, 0.5, null) @@ -137,16 +141,17 @@ class AnimationEngineTests { } val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.animationSpecs = listOf( - AnimationSpec( - name = "altDir", - durationMs = 1000, - easing = Easings.LINEAR, - iterationCount = IterationCount.Count(2), - direction = AnimationDirection.Alternate, - fillMode = AnimationFillMode.Forwards + node.animationSpecs = + listOf( + AnimationSpec( + name = "altDir", + durationMs = 1000, + easing = Easings.LINEAR, + iterationCount = IterationCount.Count(2), + direction = AnimationDirection.Alternate, + fillMode = AnimationFillMode.Forwards, + ), ) - ) node.applyComputedStyle(style(opacity = 1f)) StyleAnimationEngine.tickAndApply(root, 1.25, null) assertTrue(node.effectiveOpacity() in 0.70f..0.80f) @@ -162,15 +167,16 @@ class AnimationEngineTests { } val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.animationSpecs = listOf( - AnimationSpec( - name = "delayBackwards", - durationMs = 1000, - delayMs = 400, - easing = Easings.LINEAR, - fillMode = AnimationFillMode.Backwards + node.animationSpecs = + listOf( + AnimationSpec( + name = "delayBackwards", + durationMs = 1000, + delayMs = 400, + easing = Easings.LINEAR, + fillMode = AnimationFillMode.Backwards, + ), ) - ) node.applyComputedStyle(style(opacity = 1f)) StyleAnimationEngine.tickAndApply(root, 0.2, null) assertEquals(0f, node.effectiveOpacity()) @@ -180,20 +186,21 @@ class AnimationEngineTests { fun `transition animates transform opacity and color together`() { val root = ContainerNode(key = "root") val node = TextNode(TextSource.Static("x"), key = "animated").applyParent(root) - node.transitionSpec = TransitionSpec( - listOf( - TransitionPropertySpec(AnimatedStyleProperty.Transform, 1000, easing = Easings.LINEAR), - TransitionPropertySpec(AnimatedStyleProperty.Opacity, 1000, easing = Easings.LINEAR), - TransitionPropertySpec(AnimatedStyleProperty.Color, 1000, easing = Easings.LINEAR) + node.transitionSpec = + TransitionSpec( + listOf( + TransitionPropertySpec(AnimatedStyleProperty.Transform, 1000, easing = Easings.LINEAR), + TransitionPropertySpec(AnimatedStyleProperty.Opacity, 1000, easing = Easings.LINEAR), + TransitionPropertySpec(AnimatedStyleProperty.Color, 1000, easing = Easings.LINEAR), + ), ) - ) node.applyComputedStyle(style(opacity = 1f, color = 0xFFFFFFFF.toInt(), transform = UiTransform.IDENTITY)) node.applyComputedStyle( style( opacity = 0.5f, color = 0xFF000000.toInt(), - transform = UiTransform(translateX = 10f, translateY = 0f, scaleX = 1f, scaleY = 1f, rotateDeg = 0f) - ) + transform = UiTransform(translateX = 10f, translateY = 0f, scaleX = 1f, scaleY = 1f, rotateDeg = 0f), + ), ) StyleAnimationEngine.tickAndApply(root, 0.5, null) @@ -205,15 +212,10 @@ class AnimationEngineTests { assertTrue(red in 120..136) } - private fun style( - transform: UiTransform = UiTransform.IDENTITY, - opacity: Float = 1f, - color: Int = 0xFFFFFFFF.toInt() - ): ComputedStyle { - return ComputedStyleDefaults( + private fun style(transform: UiTransform = UiTransform.IDENTITY, opacity: Float = 1f, color: Int = 0xFFFFFFFF.toInt()): ComputedStyle = + ComputedStyleDefaults( foregroundColor = color, opacity = opacity, - transform = transform + transform = transform, ).toComputedStyle() - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/EasingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/EasingTests.kt index 149243d..09555ab 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/EasingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/animation/EasingTests.kt @@ -39,4 +39,3 @@ class EasingTests { assertTrue(easeInOutMid in 0.3f..0.7f) } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerControllerTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerControllerTests.kt index e07744b..6485d2f 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerControllerTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerControllerTests.kt @@ -11,18 +11,26 @@ class ColorPickerControllerTests { @Test fun `mode switch keeps selected color`() { val initial = RgbaColor(0.25f, 0.5f, 0.75f, 0.6f) - val controller = ColorPickerController( - initial = ColorPickerState( - color = initial, - previous = initial, - mode = ColorFormatMode.RGB, - alphaEnabled = true + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = initial, + previous = initial, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + ), ) - ) val firstLayout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) - controller.handleMouseDown(firstLayout.modeSelectRect.x + 2, firstLayout.modeSelectRect.y + 2, MouseButton.LEFT, firstLayout) + controller.handleMouseDown( + firstLayout.modeSelectRect.x + 2, + firstLayout.modeSelectRect.y + 2, + MouseButton.LEFT, + firstLayout, + ) val dropdownLayout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) - val hslOption = dropdownLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") + val hslOption = + dropdownLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") controller.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT, dropdownLayout) val after = controller.snapshot() @@ -38,11 +46,12 @@ class ColorPickerControllerTests { val sampled = 0xCC112233.toInt() val history = ColorRecentHistory() var committed: RgbaColor? = null - val controller = ColorPickerController( - initial = ColorPickerState(RgbaColor.WHITE, closeOnSelect = false), - recentHistory = history, - screenSampler = ScreenColorSampler { _, _ -> sampled } - ) + val controller = + ColorPickerController( + initial = ColorPickerState(RgbaColor.WHITE, closeOnSelect = false), + recentHistory = history, + screenSampler = ScreenColorSampler { _, _ -> sampled }, + ) controller.onCommit = { committed = it } controller.beginEyedropper() val layout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) @@ -60,32 +69,41 @@ class ColorPickerControllerTests { @Test fun `commit stores color in recents`() { val history = ColorRecentHistory() - val controller = ColorPickerController( - initial = ColorPickerState(color = RgbaColor.WHITE), - recentHistory = history - ) + val controller = + ColorPickerController( + initial = ColorPickerState(color = RgbaColor.WHITE), + recentHistory = history, + ) val next = RgbaColor(0.1f, 0.2f, 0.3f, 0.4f) controller.setState( ColorPickerState( color = next, previous = RgbaColor.WHITE, mode = ColorFormatMode.HEX, - alphaEnabled = true - ) + alphaEnabled = true, + ), ) controller.commitCurrentColor() - assertEquals(next.toArgbInt(), history.snapshot().first().toArgbInt()) + assertEquals( + next.toArgbInt(), + history + .snapshot() + .first() + .toArgbInt(), + ) } @Test fun `input slot layout separates label and input rectangles`() { - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = true + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + ), ) - ) val layout = controller.buildLayout(Rect(0, 0, 360, controller.preferredHeight(true))) assertTrue(layout.inputSlots.isNotEmpty()) layout.inputSlots.forEach { slot -> @@ -97,14 +115,16 @@ class ColorPickerControllerTests { @Test fun `rgb channel order switch updates active mode and input order`() { - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor(0.3f, 0.4f, 0.5f, 0.6f), - mode = ColorFormatMode.RGB, - rgbOrder = RgbChannelOrder.RGBA, - alphaEnabled = true + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor(0.3f, 0.4f, 0.5f, 0.6f), + mode = ColorFormatMode.RGB, + rgbOrder = RgbChannelOrder.RGBA, + alphaEnabled = true, + ), ) - ) val firstLayout = controller.buildLayout(Rect(0, 0, 360, controller.preferredHeight(true))) val argbRect = firstLayout.argbOrderRect ?: error("ARGB switch not rendered") controller.handleMouseDown(argbRect.x + 2, argbRect.y + 2, MouseButton.LEFT, firstLayout) @@ -118,55 +138,61 @@ class ColorPickerControllerTests { } @Test - fun `eyedropper overlay shows mode and formatted value tooltip`() { + fun `eyedropper preview shows mode and formatted value tooltip`() { val sampled = 0xFF336699.toInt() - val sampler = object : ScreenColorSampler { - override fun sampleColorAt(x: Int, y: Int): Int? = sampled - } - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - rgbOrder = RgbChannelOrder.ARGB, - alphaEnabled = true - ), - screenSampler = sampler - ) + val sampler = + object : ScreenColorSampler { + override fun sampleColorAt(x: Int, y: Int): Int? = sampled + } + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + rgbOrder = RgbChannelOrder.ARGB, + alphaEnabled = true, + ), + screenSampler = sampler, + ) controller.beginEyedropper() val layout = controller.buildLayout(Rect(20, 20, 340, controller.preferredHeight(true))) controller.handleMouseMove(120, 160, layout) controller.sampleEyedropperAtHover() val out = ArrayList() - controller.appendEyedropperOverlay(800, 600, out) + controller.appendEyedropperPreview(800, 600, out) val textCommands = out.filterIsInstance() assertTrue(textCommands.any { it.text.contains("Mode: RGB (ARGB)") }) - val expected = ColorTextCodec.format( - RgbaColor.fromArgbInt(sampled).normalized(), - ColorFormatMode.RGB, - true, - RgbChannelOrder.ARGB - ) + val expected = + ColorTextCodec.format( + RgbaColor.fromArgbInt(sampled).normalized(), + ColorFormatMode.RGB, + true, + RgbChannelOrder.ARGB, + ) assertTrue(textCommands.any { it.text == expected }) } @Test - fun `eyedropper overlay preview path does not use area sampling`() { + fun `eyedropper preview preview path does not use area sampling`() { val sampler = RecordingSampler() - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.HEX, - alphaEnabled = true - ), - screenSampler = sampler - ) + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.HEX, + alphaEnabled = true, + ), + screenSampler = sampler, + ) controller.beginEyedropper() val layout = controller.buildLayout(Rect(0, 0, 360, controller.preferredHeight(true))) controller.handleMouseMove(80, 90, layout) controller.sampleEyedropperAtHover() val out = ArrayList() - controller.appendEyedropperOverlay(640, 480, out) + controller.appendEyedropperPreview(640, 480, out) assertEquals(0, sampler.areaCalls) assertTrue(sampler.colorCalls > 0) @@ -175,47 +201,55 @@ class ColorPickerControllerTests { } @Test - fun `eyedropper overlay emits capture and textured magnifier commands instead of per-cell rectangles`() { - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.HEX, - alphaEnabled = true + fun `eyedropper preview emits capture and textured magnifier commands instead of per-cell rectangles`() { + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.HEX, + alphaEnabled = true, + ), ) - ) controller.beginEyedropper() val layout = controller.buildLayout(Rect(0, 0, 360, controller.preferredHeight(true))) controller.handleMouseMove(80, 90, layout) val out = ArrayList() - controller.appendEyedropperOverlay(640, 480, out) + controller.appendEyedropperPreview(640, 480, out) assertTrue(out.any { it is RenderCommand.CaptureScreenRegion }) assertTrue(out.any { it is RenderCommand.DrawCapturedScreenRegion }) - assertTrue(out.none { command -> - command is RenderCommand.DrawRect && command.width == 8 && command.height == 8 - }) + assertTrue( + out.none { command -> + command is RenderCommand.DrawRect && command.width == 8 && command.height == 8 + }, + ) } + @Test - fun `eyedropper overlay draws aligned light grid over captured magnifier`() { + fun `eyedropper preview draws aligned light grid over captured magnifier`() { val gridColor = 0x7F57C2FF - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.HEX, - alphaEnabled = true - ), - style = ColorPickerStyle( - eyedropperGridSize = 5, - eyedropperCellSize = 4, - eyedropperGridOverlayEnabled = true, - eyedropperGridOverlayColor = gridColor + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.HEX, + alphaEnabled = true, + ), + style = + ColorPickerStyle( + eyedropperGridSize = 5, + eyedropperCellSize = 4, + eyedropperGridEnabled = true, + eyedropperGridColor = gridColor, + ), ) - ) controller.beginEyedropper() val layout = controller.buildLayout(Rect(0, 0, 360, controller.preferredHeight(true))) controller.handleMouseMove(80, 90, layout) val out = ArrayList() - controller.appendEyedropperOverlay(640, 480, out) + controller.appendEyedropperPreview(640, 480, out) val magnifier = out.filterIsInstance().single() val gridLines = out.filterIsInstance().filter { it.color == gridColor } @@ -227,14 +261,16 @@ class ColorPickerControllerTests { @Test fun `eyedropper keeps existing alpha while sampling rgb`() { val sampled = 0x00336699 - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor(0.1f, 0.2f, 0.3f, 0.4f), - mode = ColorFormatMode.HEX, - alphaEnabled = true - ), - screenSampler = ScreenColorSampler { _, _ -> sampled } - ) + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor(0.1f, 0.2f, 0.3f, 0.4f), + mode = ColorFormatMode.HEX, + alphaEnabled = true, + ), + screenSampler = ScreenColorSampler { _, _ -> sampled }, + ) controller.beginEyedropper() val layout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) controller.handleMouseMove(20, 24, layout) @@ -249,13 +285,15 @@ class ColorPickerControllerTests { @Test fun `picker draws gradient bars with dedicated render commands`() { - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = true + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + ), ) - ) val layout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) val out = ArrayList() controller.appendCommands(layout, out) @@ -269,26 +307,32 @@ class ColorPickerControllerTests { fun `picker checker backgrounds use dedicated checkerboard command`() { val checkerLight = 0x7FA0D010 val checkerDark = 0x7F104090 - val controller = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = true - ), - style = ColorPickerStyle( - checkerLightColor = checkerLight, - checkerDarkColor = checkerDark + val controller = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + ), + style = + ColorPickerStyle( + checkerLightColor = checkerLight, + checkerDarkColor = checkerDark, + ), ) - ) val layout = controller.buildLayout(Rect(0, 0, 320, controller.preferredHeight(true))) val out = ArrayList() controller.appendCommands(layout, out) assertTrue(out.any { it is RenderCommand.DrawCheckerboard }) - assertTrue(out.none { command -> - command is RenderCommand.DrawRect && (command.color == checkerLight || command.color == checkerDark) - }) + assertTrue( + out.none { command -> + command is RenderCommand.DrawRect && (command.color == checkerLight || command.color == checkerDark) + }, + ) } + private class RecordingSampler : ScreenColorSampler { var colorCalls: Int = 0 var areaCalls: Int = 0 @@ -298,7 +342,13 @@ class ColorPickerControllerTests { return 0xFF112233.toInt() } - override fun sampleArea(x: Int, y: Int, width: Int, height: Int, outArgb: IntArray): Boolean { + override fun sampleArea( + x: Int, + y: Int, + width: Int, + height: Int, + outArgb: IntArray, + ): Boolean { areaCalls += 1 var index = 0 while (index < width * height && index < outArgb.size) { @@ -309,8 +359,5 @@ class ColorPickerControllerTests { } } - private fun closeEnough(a: Float, b: Float): Boolean { - return kotlin.math.abs(a - b) <= 0.01f - } + private fun closeEnough(a: Float, b: Float): Boolean = kotlin.math.abs(a - b) <= 0.01f } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineNodeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineNodeTests.kt index 7b50a81..10c2e09 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineNodeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineNodeTests.kt @@ -7,53 +7,62 @@ import org.dreamfinity.dsgl.core.event.EventBus import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseDownEvent import org.dreamfinity.dsgl.core.event.MouseDragEvent +import org.dreamfinity.dsgl.core.event.MouseLeaveEvent import org.dreamfinity.dsgl.core.event.MouseMoveEvent +import org.dreamfinity.dsgl.core.event.MouseOverEvent import org.dreamfinity.dsgl.core.event.MouseUpEvent import org.dreamfinity.dsgl.core.render.RenderCommand import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class ColorPickerInlineNodeTests { - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @Test fun `controlled inline picker drag survives controlled rerender`() { var controlledValue = RgbaColor(1f, 0f, 0f, 1f) - fun createPicker(): ColorPickerInlineNode { - return ColorPickerInlineNode( + fun createPicker(): ColorPickerInlineNode = + ColorPickerInlineNode( controlled = true, value = controlledValue, mode = ColorFormatMode.HSB, alphaEnabled = true, - key = "picker" + key = "picker", ).apply { closeOnSelect = false onPreviewColor = { controlledValue = it } onChangeColor = { controlledValue = it } onCommitColor = { controlledValue = it } } - } val retained = createPicker() retained.render(ctx, 0, 0, 350, 392) - val layoutProbe = ColorPickerController( - initial = ColorPickerState( - color = RgbaColor(1f, 0f, 0f, 1f), - previous = RgbaColor(1f, 0f, 0f, 1f), - mode = ColorFormatMode.HSB, - alphaEnabled = true, - closeOnSelect = false - ) - ).buildLayout(Rect(0, 0, 350, 392)) + val layoutProbe = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor(1f, 0f, 0f, 1f), + previous = RgbaColor(1f, 0f, 0f, 1f), + mode = ColorFormatMode.HSB, + alphaEnabled = true, + closeOnSelect = false, + ), + ).buildLayout(Rect(0, 0, 350, 392)) val startX = layoutProbe.colorFieldRect.x + 4 val startY = layoutProbe.colorFieldRect.y + layoutProbe.colorFieldRect.height - 4 @@ -72,8 +81,8 @@ class ColorPickerInlineNodeTests { lastMouseY = startY, dx = endX - startX, dy = endY - startY, - mouseButton = MouseButton.LEFT - ).also { it.target = retained } + mouseButton = MouseButton.LEFT, + ).also { it.target = retained }, ) assertTrue(afterPress.toArgbInt() != controlledValue.toArgbInt()) @@ -81,15 +90,16 @@ class ColorPickerInlineNodeTests { @Test fun `inline picker draws mode options after clicking mode selector`() { - val picker = ColorPickerInlineNode( - controlled = true, - value = RgbaColor.WHITE, - mode = ColorFormatMode.HEX, - alphaEnabled = true, - key = "picker" - ).apply { - closeOnSelect = false - } + val picker = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.HEX, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + } picker.render(ctx, 0, 0, 350, 392) val probeLayout = layoutProbe(mode = ColorFormatMode.HEX, alphaEnabled = true) @@ -98,29 +108,97 @@ class ColorPickerInlineNodeTests { MouseDownEvent( probeLayout.modeSelectRect.x + 4, probeLayout.modeSelectRect.y + 4, - MouseButton.LEFT - ).also { it.target = picker } + MouseButton.LEFT, + ).also { it.target = picker }, ) val commands = buildCommands(picker) - val modeTexts = commands.filterIsInstance() - .map { it.text } - .filter { text -> ColorFormatMode.entries.any { it.name == text } } + val modeTexts = + commands + .filterIsInstance() + .map { it.text } + .filter { text -> ColorFormatMode.entries.any { it.name == text } } assertTrue(modeTexts.size >= 5) } @Test - fun `inline picker draws eyedropper overlay after clicking pipette`() { - val picker = ColorPickerInlineNode( - controlled = true, - value = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = true, - key = "picker" - ).apply { - closeOnSelect = false - } + fun `inline picker clears custom hover state on mouse leave`() { + val picker = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + } + picker.render(ctx, 0, 0, 350, 392) + val probeLayout = layoutProbe(mode = ColorFormatMode.RGB, alphaEnabled = true) + val style = ColorPickerStyle() + val copyX = probeLayout.copyRect.x + 2 + val copyY = probeLayout.copyRect.y + 2 + + EventBus.post(MouseMoveEvent(copyX, copyY, copyX - 1, copyY - 1).also { it.target = picker }) + + assertEquals(style.buttonHoverColor, fillForRect(buildCommands(picker), probeLayout.copyRect)) + + EventBus.post(MouseLeaveEvent(copyX, copyY).also { it.target = picker }) + + assertEquals(style.buttonBackgroundColor, fillForRect(buildCommands(picker), probeLayout.copyRect)) + } + + @Test + fun `inline picker ignores targetless hover events`() { + val picker = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + } + picker.render(ctx, 0, 0, 350, 392) + val probeLayout = layoutProbe(mode = ColorFormatMode.RGB, alphaEnabled = true) + val style = ColorPickerStyle() + val inputX = + probeLayout.inputSlots + .first() + .inputRect.x + 2 + val inputY = + probeLayout.inputSlots + .first() + .inputRect.y + 2 + + EventBus.post(MouseMoveEvent(inputX, inputY, inputX - 1, inputY - 1)) + EventBus.post(MouseOverEvent(inputX, inputY)) + + assertEquals( + style.inputBorderColor, + borderColorForRect( + buildCommands(picker), + probeLayout.inputSlots + .first() + .inputRect, + ), + ) + } + + @Test + fun `inline picker draws eyedropper preview after clicking pipette`() { + val picker = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + } picker.render(ctx, 0, 0, 350, 392) val probeLayout = layoutProbe(mode = ColorFormatMode.RGB, alphaEnabled = true) @@ -129,8 +207,8 @@ class ColorPickerInlineNodeTests { MouseDownEvent( probeLayout.pipetteRect.x + 4, probeLayout.pipetteRect.y + 4, - MouseButton.LEFT - ).also { it.target = picker } + MouseButton.LEFT, + ).also { it.target = picker }, ) val commands = buildGlobalEyedropperCommands(picker) @@ -141,21 +219,25 @@ class ColorPickerInlineNodeTests { @Test fun `inline eyedropper samples color on mouse move even outside picker bounds`() { - val sampledArgb = 0xFF25AEEF.toInt() - ScreenColorSamplerBridge.install(ScreenColorSampler { _, _ -> sampledArgb }) + ScreenColorSamplerBridge.install( + ScreenColorSampler { x, y -> + (0xFF shl 24) or ((x and 0xFF) shl 16) or ((y and 0xFF) shl 8) or 0x44 + }, + ) try { var current = RgbaColor.WHITE - val picker = ColorPickerInlineNode( - controlled = true, - value = current, - mode = ColorFormatMode.RGB, - alphaEnabled = true, - key = "picker" - ).apply { - closeOnSelect = false - onPreviewColor = { current = it } - onChangeColor = { current = it } - } + val picker = + ColorPickerInlineNode( + controlled = true, + value = current, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + onPreviewColor = { current = it } + onChangeColor = { current = it } + } picker.render(ctx, 0, 0, 350, 392) val probeLayout = layoutProbe(mode = ColorFormatMode.RGB, alphaEnabled = true) @@ -163,8 +245,8 @@ class ColorPickerInlineNodeTests { MouseDownEvent( probeLayout.pipetteRect.x + 4, probeLayout.pipetteRect.y + 4, - MouseButton.LEFT - ).also { it.target = picker } + MouseButton.LEFT, + ).also { it.target = picker }, ) EventBus.post( @@ -172,12 +254,26 @@ class ColorPickerInlineNodeTests { mouseX = 1200, mouseY = 900, prevX = 300, - prevY = 250 - ).also { it.target = picker } + prevY = 250, + ).also { it.target = picker }, ) picker.captureEyedropperSample() - assertEquals(sampledArgb, current.toArgbInt()) + val first = current.toArgbInt() + assertEquals((0xFF shl 24) or (0xB0 shl 16) or (0x84 shl 8) or 0x44, first) + + EventBus.post( + MouseMoveEvent( + mouseX = 1234, + mouseY = 912, + prevX = 1200, + prevY = 900, + ).also { it.target = picker }, + ) + picker.captureEyedropperSample() + + assertEquals((0xFF shl 24) or (0xD2 shl 16) or (0x90 shl 8) or 0x44, current.toArgbInt()) + assertNotEquals(first, current.toArgbInt()) } finally { ScreenColorSamplerBridge.install(null) } @@ -189,19 +285,19 @@ class ColorPickerInlineNodeTests { ScreenColorSamplerBridge.install(ScreenColorSampler { _, _ -> sampledArgb }) try { var current = RgbaColor.WHITE - fun createPicker(value: RgbaColor): ColorPickerInlineNode { - return ColorPickerInlineNode( + + fun createPicker(value: RgbaColor): ColorPickerInlineNode = + ColorPickerInlineNode( controlled = true, value = value, mode = ColorFormatMode.RGB, alphaEnabled = true, - key = "picker" + key = "picker", ).apply { closeOnSelect = false onPreviewColor = { current = it } onChangeColor = { current = it } } - } val retained = createPicker(current) retained.render(ctx, 0, 0, 350, 392) @@ -210,8 +306,8 @@ class ColorPickerInlineNodeTests { MouseDownEvent( probeLayout.pipetteRect.x + 4, probeLayout.pipetteRect.y + 4, - MouseButton.LEFT - ).also { it.target = retained } + MouseButton.LEFT, + ).also { it.target = retained }, ) val template = createPicker(current) @@ -223,8 +319,8 @@ class ColorPickerInlineNodeTests { mouseX = 1440, mouseY = 820, prevX = 300, - prevY = 250 - ).also { it.target = retained } + prevY = 250, + ).also { it.target = retained }, ) retained.captureEyedropperSample() @@ -237,15 +333,16 @@ class ColorPickerInlineNodeTests { @Test fun `external mode and alpha changes apply after non drag click`() { - val retained = ColorPickerInlineNode( - controlled = true, - value = RgbaColor.WHITE, - mode = ColorFormatMode.HEX, - alphaEnabled = true, - key = "picker" - ).apply { - closeOnSelect = false - } + val retained = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.HEX, + alphaEnabled = true, + key = "picker", + ).apply { + closeOnSelect = false + } retained.render(ctx, 0, 0, 350, 392) val firstLayout = layoutProbe(mode = ColorFormatMode.HEX, alphaEnabled = true) @@ -253,26 +350,27 @@ class ColorPickerInlineNodeTests { MouseDownEvent( firstLayout.modeSelectRect.x + 4, firstLayout.modeSelectRect.y + 4, - MouseButton.LEFT - ).also { it.target = retained } + MouseButton.LEFT, + ).also { it.target = retained }, ) EventBus.post( MouseUpEvent( firstLayout.modeSelectRect.x + 4, firstLayout.modeSelectRect.y + 4, - MouseButton.LEFT - ).also { it.target = retained } + MouseButton.LEFT, + ).also { it.target = retained }, ) - val template = ColorPickerInlineNode( - controlled = true, - value = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = false, - key = "picker" - ).apply { - closeOnSelect = false - } + val template = + ColorPickerInlineNode( + controlled = true, + value = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = false, + key = "picker", + ).apply { + closeOnSelect = false + } retained.syncFrom(template) retained.render(ctx, 0, 0, 350, 392) @@ -292,23 +390,43 @@ class ColorPickerInlineNodeTests { private fun buildGlobalEyedropperCommands(picker: ColorPickerInlineNode): List { val out = ArrayList() - picker.appendEyedropperOverlayCommands( + picker.appendEyedropperPortalCommands( viewportWidth = 1920, viewportHeight = 1080, - out = out + out = out, ) return out } - private fun layoutProbe(mode: ColorFormatMode, alphaEnabled: Boolean): ColorPickerLayout { - return ColorPickerController( - initial = ColorPickerState( - color = RgbaColor.WHITE, - previous = RgbaColor.WHITE, - mode = mode, - alphaEnabled = alphaEnabled, - closeOnSelect = false - ) + private fun fillForRect(commands: List, rect: Rect): Int = + commands + .filterIsInstance() + .first { command -> + command.x == rect.x && + command.y == rect.y && + command.width == rect.width && + command.height == rect.height + }.color + + private fun borderColorForRect(commands: List, rect: Rect): Int = + commands + .filterIsInstance() + .first { command -> + command.x == rect.x && + command.y == rect.y && + command.width == rect.width && + command.height == 1 + }.color + + private fun layoutProbe(mode: ColorFormatMode, alphaEnabled: Boolean): ColorPickerLayout = + ColorPickerController( + initial = + ColorPickerState( + color = RgbaColor.WHITE, + previous = RgbaColor.WHITE, + mode = mode, + alphaEnabled = alphaEnabled, + closeOnSelect = false, + ), ).buildLayout(Rect(0, 0, 350, 392)) - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineStylingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineStylingTests.kt index 9de1080..8ebc11b 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineStylingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerInlineStylingTests.kt @@ -22,16 +22,17 @@ class ColorPickerInlineStylingTests { @Test fun `inline color picker is stylable in application scope`() { - val stylesDir = createTempStylesDir( - """ - color-picker { - border-width: 3px; - border-color: #224466; - padding: 7px; - width: 280px; - } - """.trimIndent() - ) + val stylesDir = + createTempStylesDir( + """ + color-picker { + border-width: 3px; + border-color: #224466; + padding: 7px; + width: 280px; + } + """.trimIndent(), + ) StyleEngine.setStylesDirectory(stylesDir) StyleEngine.forceReloadStylesheets() diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngineTests.kt index 4a11070..282619c 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupEngineTests.kt @@ -3,9 +3,9 @@ package org.dreamfinity.dsgl.core.colorpicker import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.OverlayLayerContracts -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope -import org.dreamfinity.dsgl.core.overlay.UiLayerId +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.render.RenderCommand import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -13,7 +13,6 @@ import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.render.RenderCommand class ColorPickerPopupEngineTests { @Test @@ -24,8 +23,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = "owner", anchorRect = Rect(120, 80, 40, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) assertTrue(engine.isOpen()) @@ -42,8 +41,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = "owner", anchorRect = Rect(40, 40, 20, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) assertFalse(engine.handleKeyDown(KeyCodes.ESCAPE)) @@ -59,8 +58,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(100, 80, 32, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val panel = engine.debugPanelRect(owner) ?: error("panel missing") val closeX = panel.x + panel.width - 14 @@ -74,11 +73,12 @@ class ColorPickerPopupEngineTests { val engine = ColorPickerPopupEngine() val owner = "owner" engine.onFrame(640, 360) - val request = ColorPickerPopupRequest( - owner = owner, - anchorRect = Rect(100, 80, 32, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + val request = + ColorPickerPopupRequest( + owner = owner, + anchorRect = Rect(100, 80, 32, 20), + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ) engine.open(request) assertNotNull(engine.debugPanelRect(owner)) @@ -100,8 +100,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(100, 80, 32, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val firstController = engine.debugController(owner) ?: error("controller missing") @@ -109,8 +109,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 90, 32, 20), - state = ColorPickerState(color = RgbaColor(0f, 0f, 0f, 1f), closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor(0f, 0f, 0f, 1f), closeOnSelect = false), + ), ) val secondController = engine.debugController(owner) ?: error("controller missing") @@ -127,8 +127,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(180, 120, 32, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val panelBeforeFieldDrag = engine.debugPanelRect(owner) ?: error("panel missing") val layout = engine.debugBodyLayout(owner) ?: error("layout missing") @@ -160,8 +160,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(180, 120, 32, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val x = layout.colorFieldRect.x + 12 @@ -185,8 +185,8 @@ class ColorPickerPopupEngineTests { anchorRect = Rect(180, 120, 32, 20), state = ColorPickerState(color = RgbaColor(1f, 0f, 0f, 1f), closeOnSelect = false), onPreview = { previews += it }, - onCommit = { committed = it } - ) + onCommit = { committed = it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val startX = layout.colorFieldRect.x + 4 @@ -202,7 +202,12 @@ class ColorPickerPopupEngineTests { assertTrue(engine.handleMouseUp(endX, endY, MouseButton.LEFT)) assertTrue(previews.size >= 2) - assertTrue(previews.map { it.toArgbInt() }.distinct().size >= 2) + assertTrue( + previews + .map { it.toArgbInt() } + .distinct() + .size >= 2, + ) assertEquals(previews.last().toArgbInt(), committed?.toArgbInt()) } @@ -217,8 +222,8 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(180, 120, 32, 20), state = ColorPickerState(color = RgbaColor(1f, 0f, 0f, 1f), closeOnSelect = false), - onCommit = { committed = it } - ) + onCommit = { committed = it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val startX = layout.hueRect.x + 4 @@ -228,9 +233,17 @@ class ColorPickerPopupEngineTests { assertTrue(engine.handleMouseDown(startX, y, MouseButton.LEFT)) assertTrue(engine.handleMouseMove(midX, y)) - val midColor = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val midColor = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") assertTrue(engine.handleMouseMove(endX, y)) - val endColor = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val endColor = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") assertTrue(engine.handleMouseUp(endX, y, MouseButton.LEFT)) assertTrue(midColor.toArgbInt() != endColor.toArgbInt()) @@ -248,14 +261,15 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(180, 120, 32, 20), - state = ColorPickerState( - color = RgbaColor(0.5f, 0.4f, 0.3f, 1f), - closeOnSelect = false, - alphaEnabled = true - ), + state = + ColorPickerState( + color = RgbaColor(0.5f, 0.4f, 0.3f, 1f), + closeOnSelect = false, + alphaEnabled = true, + ), onPreview = { previews += it }, - onCommit = { committed = it } - ) + onCommit = { committed = it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val alphaRect = layout.alphaRect ?: error("alpha rect missing") @@ -283,8 +297,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 100, 20, 20), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val header = engine.debugHeaderRect(owner) ?: error("header missing") val startX = header.x + 6 @@ -299,8 +313,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(20, 20, 16, 16), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val reopened = engine.debugPanelRect(owner) ?: error("panel missing") assertEquals(moved.x, reopened.x) @@ -320,8 +334,8 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(120, 80, 18, 18), state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), - onCommit = { commits++ } - ) + onCommit = { commits++ }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) @@ -344,12 +358,14 @@ class ColorPickerPopupEngineTests { @Test fun `pipette samples from capture pass before commit`() { - ScreenColorSamplerBridge.install(ScreenColorSampler { x, y -> - val r = (x and 0xFF) - val g = (y and 0xFF) - val b = 0x44 - (0xFF shl 24) or (r shl 16) or (g shl 8) or b - }) + ScreenColorSamplerBridge.install( + ScreenColorSampler { x, y -> + val r = (x and 0xFF) + val g = (y and 0xFF) + val b = 0x44 + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + }, + ) try { val engine = ColorPickerPopupEngine() val owner = "owner" @@ -360,8 +376,8 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(120, 80, 18, 18), state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), - onCommit = { committed = it } - ) + onCommit = { committed = it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) @@ -379,12 +395,14 @@ class ColorPickerPopupEngineTests { @Test fun `pipette capture updates preview continuously while moving`() { - ScreenColorSamplerBridge.install(ScreenColorSampler { x, y -> - val r = (x and 0xFF) - val g = (y and 0xFF) - val b = 0x55 - (0xFF shl 24) or (r shl 16) or (g shl 8) or b - }) + ScreenColorSamplerBridge.install( + ScreenColorSampler { x, y -> + val r = (x and 0xFF) + val g = (y and 0xFF) + val b = 0x55 + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + }, + ) try { val engine = ColorPickerPopupEngine() val owner = "owner" @@ -395,19 +413,27 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(120, 80, 18, 18), state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), - onPreview = { previews += it } - ) + onPreview = { previews += it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) engine.handleMouseMove(40, 72) engine.captureEyedropperSample() - val first = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val first = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") engine.handleMouseMove(96, 24) engine.captureEyedropperSample() - val second = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val second = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") assertNotEquals(first.toArgbInt(), second.toArgbInt()) assertTrue(previews.isNotEmpty()) @@ -418,53 +444,59 @@ class ColorPickerPopupEngineTests { } @Test - fun `app-owned pipette emits transient overlay commands in application layer contract`() { + fun `app-owned pipette emits transient portal commands in application portal contract`() { val engine = ColorPickerPopupEngine() val owner = "owner-app" engine.onFrame(900, 700) engine.open( ColorPickerPopupRequest( owner = owner, - ownerScope = OverlayOwnerScope.Application, + ownerDomain = ScreenDomainId.Application, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) assertTrue(engine.handleMouseMove(layout.pipetteRect.x + 24, layout.pipetteRect.y + 24)) - val overlay = mutableListOf() - engine.appendEyedropperOverlayCommands(900, 700, overlay) + val portalCommands = mutableListOf() + engine.appendEyedropperPortalCommands(900, 700, portalCommands) - assertTrue(overlay.isNotEmpty()) - assertEquals(OverlayOwnerScope.Application, engine.debugActiveOwnerScope()) - assertEquals(UiLayerId.ApplicationOverlay, OverlayLayerContracts.resolveTransientLayer(engine.debugActiveOwnerScope()!!)) + assertTrue(portalCommands.isNotEmpty()) + assertEquals(ScreenDomainId.Application, engine.debugActiveOwnerDomain()) + assertEquals( + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.portalSurfaceForDomain(engine.debugActiveOwnerDomain()!!), + ) } @Test - fun `system-owned pipette emits transient overlay commands in system layer contract`() { + fun `system-owned pipette emits transient portal commands in system portal contract`() { val engine = ColorPickerPopupEngine() val owner = "owner-system" engine.onFrame(900, 700) engine.open( ColorPickerPopupRequest( owner = owner, - ownerScope = OverlayOwnerScope.System, + ownerDomain = ScreenDomainId.System, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) assertTrue(engine.handleMouseMove(layout.pipetteRect.x + 24, layout.pipetteRect.y + 24)) - val overlay = mutableListOf() - engine.appendEyedropperOverlayCommands(900, 700, overlay) + val portalCommands = mutableListOf() + engine.appendEyedropperPortalCommands(900, 700, portalCommands) - assertTrue(overlay.isNotEmpty()) - assertEquals(OverlayOwnerScope.System, engine.debugActiveOwnerScope()) - assertEquals(UiLayerId.SystemOverlay, OverlayLayerContracts.resolveTransientLayer(engine.debugActiveOwnerScope()!!)) + assertTrue(portalCommands.isNotEmpty()) + assertEquals(ScreenDomainId.System, engine.debugActiveOwnerDomain()) + assertEquals( + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.portalSurfaceForDomain(engine.debugActiveOwnerDomain()!!), + ) } @Test @@ -476,8 +508,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val initialLayout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(initialLayout.modeOptionsRect == null) @@ -485,8 +517,8 @@ class ColorPickerPopupEngineTests { engine.handleMouseDown( initialLayout.modeSelectRect.x + 2, initialLayout.modeSelectRect.y + 2, - MouseButton.LEFT - ) + MouseButton.LEFT, + ), ) val openedLayout = engine.debugBodyLayout(owner) ?: error("layout missing") assertNotNull(openedLayout.modeOptionsRect) @@ -504,15 +536,16 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState( - color = RgbaColor.WHITE, - mode = ColorFormatMode.RGB, - alphaEnabled = true, - closeOnSelect = false - ), + state = + ColorPickerState( + color = RgbaColor.WHITE, + mode = ColorFormatMode.RGB, + alphaEnabled = true, + closeOnSelect = false, + ), onPreview = { previews += it }, - onCommit = { committed = it } - ) + onCommit = { committed = it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val redInput = layout.inputSlots.firstOrNull { it.key == "r" } ?: error("red input missing") @@ -540,7 +573,7 @@ class ColorPickerPopupEngineTests { } @Test - fun `opened popup appends overlay commands after frame sync`() { + fun `opened popup appends portal commands after frame sync`() { val engine = ColorPickerPopupEngine() val owner = "owner" engine.open( @@ -548,13 +581,13 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(120, 80, 18, 18), title = "Popup Test", - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) engine.onFrame(900, 700) val out = ArrayList() - engine.appendOverlayCommands(out) + engine.appendPortalCommands(out) assertTrue(out.isNotEmpty()) assertTrue(out.any { it is RenderCommand.DrawText && it.text == "Popup Test" }) @@ -571,8 +604,8 @@ class ColorPickerPopupEngineTests { owner = owner, anchorRect = Rect(180, 120, 32, 20), state = ColorPickerState(color = RgbaColor(1f, 0f, 0f, 1f), closeOnSelect = false), - onPreview = { previews += it } - ) + onPreview = { previews += it }, + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") val startX = layout.colorFieldRect.x + 4 @@ -584,19 +617,27 @@ class ColorPickerPopupEngineTests { assertTrue(engine.handleMouseDown(startX, startY, MouseButton.LEFT)) assertTrue(engine.handleMouseMove(midX, midY)) - val midColor = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val midColor = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") engine.sync( ColorPickerPopupRequest( owner = owner, anchorRect = Rect(180, 120, 32, 20), - state = ColorPickerState(color = RgbaColor(0f, 1f, 0f, 1f), closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor(0f, 1f, 0f, 1f), closeOnSelect = false), + ), ) assertTrue(engine.handleMouseMove(endX, endY)) assertTrue(engine.handleMouseUp(endX, endY, MouseButton.LEFT)) - val finalColor = engine.debugController(owner)?.snapshot()?.color ?: error("controller missing") + val finalColor = + engine + .debugController(owner) + ?.snapshot() + ?.color ?: error("controller missing") assertNotEquals(midColor.toArgbInt(), finalColor.toArgbInt()) assertTrue(previews.size >= 2) } @@ -610,8 +651,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val layout = engine.debugBodyLayout(owner) ?: error("layout missing") assertTrue(engine.handleMouseDown(layout.pipetteRect.x + 2, layout.pipetteRect.y + 2, MouseButton.LEFT)) @@ -621,8 +662,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor(0.2f, 0.3f, 0.4f, 1f), closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor(0.2f, 0.3f, 0.4f, 1f), closeOnSelect = false), + ), ) assertTrue(engine.debugController(owner)?.isEyedropperActive() == true) @@ -637,8 +678,8 @@ class ColorPickerPopupEngineTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(120, 80, 18, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) assertFalse(engine.hasActiveEyedropper()) @@ -647,52 +688,53 @@ class ColorPickerPopupEngineTests { assertTrue(engine.hasActiveEyedropper()) } + @Test fun `manager reuses same owner token`() { - val fakeHost = FakeColorPickerHost() - val manager = ColorPickerPopupManager(host = fakeHost) + val fakeService = FakeColorPickerPortalService() + val manager = ColorPickerPopupManager(portalService = fakeService) manager.open( anchorRect = Rect(10, 10, 10, 10), title = "A", - state = ColorPickerState(RgbaColor.WHITE) + state = ColorPickerState(RgbaColor.WHITE), ) manager.open( anchorRect = Rect(20, 20, 10, 10), title = "B", - state = ColorPickerState(RgbaColor(0f, 0f, 0f, 1f)) + state = ColorPickerState(RgbaColor(0f, 0f, 0f, 1f)), ) - assertEquals(2, fakeHost.opened.size) - val first = fakeHost.opened[0] - val second = fakeHost.opened[1] + assertEquals(2, fakeService.opened.size) + val first = fakeService.opened[0] + val second = fakeService.opened[1] assertTrue(first.owner === second.owner) manager.close() - assertNotNull(fakeHost.lastClosedOwner) - assertTrue(fakeHost.lastClosedOwner === first.owner) + assertNotNull(fakeService.lastClosedOwner) + assertTrue(fakeService.lastClosedOwner === first.owner) } @Test fun `manager popup owner scope defaults to application and supports explicit system owner scope`() { - val fakeHost = FakeColorPickerHost() - val manager = ColorPickerPopupManager(host = fakeHost) + val fakeService = FakeColorPickerPortalService() + val manager = ColorPickerPopupManager(portalService = fakeService) manager.open( anchorRect = Rect(10, 10, 10, 10), title = "App", - state = ColorPickerState(RgbaColor.WHITE) + state = ColorPickerState(RgbaColor.WHITE), ) manager.open( - ownerScope = OverlayOwnerScope.System, + ownerDomain = ScreenDomainId.System, anchorRect = Rect(20, 20, 10, 10), title = "System", - state = ColorPickerState(RgbaColor.WHITE) + state = ColorPickerState(RgbaColor.WHITE), ) - assertEquals(2, fakeHost.opened.size) - assertEquals(OverlayOwnerScope.Application, fakeHost.opened[0].ownerScope) - assertEquals(OverlayOwnerScope.System, fakeHost.opened[1].ownerScope) + assertEquals(2, fakeService.opened.size) + assertEquals(ScreenDomainId.Application, fakeService.opened[0].ownerDomain) + assertEquals(ScreenDomainId.System, fakeService.opened[1].ownerDomain) } - private class FakeColorPickerHost : ColorPickerPopupHost { + private class FakeColorPickerPortalService : ColorPickerPopupPortalService { val opened: MutableList = ArrayList() var lastClosedOwner: Any? = null @@ -713,5 +755,3 @@ class ColorPickerPopupEngineTests { override fun isOpen(): Boolean = false } } - - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometryTests.kt index 3a40f23..893467b 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometryTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerPopupGeometryTests.kt @@ -8,11 +8,12 @@ import kotlin.test.assertTrue class ColorPickerPopupGeometryTests { @Test fun `buildFrame derives header body and close rectangles from panel`() { - val frame = ColorPickerPopupGeometry.buildFrame( - panelRect = Rect(100, 80, 320, 260), - headerHeight = 26, - panelPadding = 6 - ) + val frame = + ColorPickerPopupGeometry.buildFrame( + panelRect = Rect(100, 80, 320, 260), + headerHeight = 26, + panelPadding = 6, + ) assertEquals(Rect(100, 80, 320, 260), frame.panelRect) assertEquals(Rect(100, 80, 320, 26), frame.headerRect) @@ -22,11 +23,12 @@ class ColorPickerPopupGeometryTests { @Test fun `clampPanel keeps popup inside viewport with margins`() { - val clamped = ColorPickerPopupGeometry.clampPanel( - rect = Rect(-50, 999, 300, 220), - viewportWidth = 640, - viewportHeight = 360 - ) + val clamped = + ColorPickerPopupGeometry.clampPanel( + rect = Rect(-50, 999, 300, 220), + viewportWidth = 640, + viewportHeight = 360, + ) assertEquals(2, clamped.x) assertEquals(138, clamped.y) @@ -37,20 +39,22 @@ class ColorPickerPopupGeometryTests { @Test fun `resolvePanelRect uses remembered position when available`() { val owner = "owner" - val store = ColorPickerPopupPositionStore().apply { - remember(owner, Rect(140, 120, 300, 200)) - } - val resolved = ColorPickerPopupGeometry.resolvePanelRect( - owner = owner, - anchorRect = Rect(20, 20, 10, 10), - width = 320, - height = 260, - viewportWidth = 800, - viewportHeight = 600, - keepPosition = false, - currentRect = null, - store = store - ) + val store = + ColorPickerPopupPositionStore().apply { + remember(owner, Rect(140, 120, 300, 200)) + } + val resolved = + ColorPickerPopupGeometry.resolvePanelRect( + owner = owner, + anchorRect = Rect(20, 20, 10, 10), + width = 320, + height = 260, + viewportWidth = 800, + viewportHeight = 600, + keepPosition = false, + currentRect = null, + store = store, + ) assertEquals(140, resolved.x) assertEquals(120, resolved.y) @@ -60,17 +64,18 @@ class ColorPickerPopupGeometryTests { @Test fun `resolvePanelRect honors keepPosition using current rect`() { - val resolved = ColorPickerPopupGeometry.resolvePanelRect( - owner = "owner", - anchorRect = Rect(20, 20, 10, 10), - width = 300, - height = 240, - viewportWidth = 800, - viewportHeight = 600, - keepPosition = true, - currentRect = Rect(200, 150, 120, 80), - store = ColorPickerPopupPositionStore() - ) + val resolved = + ColorPickerPopupGeometry.resolvePanelRect( + owner = "owner", + anchorRect = Rect(20, 20, 10, 10), + width = 300, + height = 240, + viewportWidth = 800, + viewportHeight = 600, + keepPosition = true, + currentRect = Rect(200, 150, 120, 80), + store = ColorPickerPopupPositionStore(), + ) assertEquals(Rect(200, 150, 300, 240), resolved) assertTrue(resolved.x >= 2 && resolved.y >= 2) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStateTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStateTests.kt index 017dbb4..e68eedf 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStateTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorPickerStateTests.kt @@ -6,10 +6,11 @@ import kotlin.test.assertEquals class ColorPickerStateTests { @Test fun `disables alpha by forcing opaque color`() { - val state = ColorPickerState( - color = RgbaColor(0.2f, 0.4f, 0.6f, 0.3f), - alphaEnabled = false - ) + val state = + ColorPickerState( + color = RgbaColor(0.2f, 0.4f, 0.6f, 0.3f), + alphaEnabled = false, + ) val updated = state.withColor(RgbaColor(0.1f, 0.2f, 0.3f, 0.1f)) assertEquals(1f, updated.color.a) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodecTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodecTests.kt index c3a62ba..54e077c 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodecTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/colorpicker/ColorTextCodecTests.kt @@ -54,7 +54,13 @@ class ColorTextCodecTests { fun `rgb hsl and hsb formatting includes mode-specific prefixes`() { val source = RgbaColor(0.1f, 0.5f, 0.9f, 0.7f) val rgb = ColorTextCodec.format(source, ColorFormatMode.RGB, includeAlpha = true) - val argb = ColorTextCodec.format(source, ColorFormatMode.RGB, includeAlpha = true, rgbOrder = RgbChannelOrder.ARGB) + val argb = + ColorTextCodec.format( + source, + ColorFormatMode.RGB, + includeAlpha = true, + rgbOrder = RgbChannelOrder.ARGB, + ) val hsl = ColorTextCodec.format(source, ColorFormatMode.HSL, includeAlpha = true) val hsb = ColorTextCodec.format(source, ColorFormatMode.HSB, includeAlpha = true) @@ -73,7 +79,5 @@ class ColorTextCodecTests { assertEquals(234f, hsl.hueDeg) } - private fun closeEnough(a: Float, b: Float): Boolean { - return abs(a - b) <= 0.01f - } + private fun closeEnough(a: Float, b: Float): Boolean = abs(a - b) <= 0.01f } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostKeyboardRegressionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostKeyboardRegressionTests.kt deleted file mode 100644 index 57c573f..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostKeyboardRegressionTests.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.dreamfinity.dsgl.core.components.modal - -import org.dreamfinity.dsgl.core.DomTree -import org.dreamfinity.dsgl.core.event.EventBus -import org.dreamfinity.dsgl.core.event.FocusManager -import org.dreamfinity.dsgl.core.event.KeyCodes -import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.div -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.dsl.ui -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull - -class ModalHostKeyboardRegressionTests { - private val trees: MutableList = ArrayList() - private val measureContext = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } - - @AfterTest - fun cleanup() { - FocusManager.clearFocus() - EventBus.run { - trees.forEach { tree -> - tree.root.clearListenersDeep() - } - } - trees.clear() - } - - @Test - fun `escape is not cancelled after static modal closes`() { - val hostKey = "tests.modal.host.keyboard.regression" - val current = buildTree(hostKey, emptyList()) - trees += current - - val withStatic = buildTree(hostKey, listOf(staticModal())) - trees += withStatic - current.reconcileWith(withStatic) - - val closed = buildTree(hostKey, emptyList()) - trees += closed - current.reconcileWith(closed) - - FocusManager.clearFocus() - val event = KeyboardKeyDownEvent('\u0000', KeyCodes.ESCAPE) - EventBus.post(event) - - assertFalse(event.cancelled) - } - - @Test - fun `modal host fills root viewport bounds`() { - val tree = buildTree("tests.modal.host.layout.viewport", emptyList()) - trees += tree - - tree.render(measureContext, 1920, 1080) - - val host = tree.root.children.firstOrNull() - assertNotNull(host) - assertEquals(0, host.bounds.x) - assertEquals(0, host.bounds.y) - assertEquals(1920, host.bounds.width) - assertEquals(1080, host.bounds.height) - - val content = host.children.firstOrNull() - assertNotNull(content) - assertEquals(1920, content.bounds.width) - assertEquals(1080, content.bounds.height) - } - - private fun buildTree(hostKey: String, modals: List): DomTree { - return ui { - modalHost(modals = modals, modalKey = hostKey) { - div({ key = "$hostKey.content" }) - } - } - } - - private fun staticModal(): ModalSpec { - return ModalSpec( - key = "modal.static", - backdrop = BackdropMode.Static, - keyboard = false - ) { _ -> } - } -} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostHookOwnerPropagationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalHookOwnerPropagationTests.kt similarity index 70% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostHookOwnerPropagationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalHookOwnerPropagationTests.kt index a87ba1a..9c5f8ff 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalHostHookOwnerPropagationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalHookOwnerPropagationTests.kt @@ -7,10 +7,10 @@ import org.dreamfinity.dsgl.core.hooks.useState import kotlin.test.Test import kotlin.test.assertEquals -class ModalHostHookOwnerPropagationTests { +class ModalPortalHookOwnerPropagationTests { @Test - fun `hooks inside modalHost content keep owner-bound UiScope`() { - val window = ModalHostStateWindow() + fun `hooks inside modalPortal content keep owner-bound UiScope`() { + val window = ModalPortalStateWindow() renderWithHookSession(window) assertEquals(0, window.lastSeenCount) @@ -23,10 +23,7 @@ class ModalHostHookOwnerPropagationTests { assertEquals(6, window.lastSeenCount) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal): DomTree { window.beginRenderBuild(mode) return try { window.render() @@ -36,15 +33,15 @@ class ModalHostHookOwnerPropagationTests { } } - private class ModalHostStateWindow : DsglWindow() { + private class ModalPortalStateWindow : DsglWindow() { var pendingMutation: Int? = null var lastSeenCount: Int = -1 - override fun render(): DomTree { - return ui { - modalHost( + override fun render(): DomTree = + ui { + modalPortal( modals = emptyList(), - modalKey = "test.modal.host" + key = "test.modal.portal", ) { var count by useState(0) pendingMutation?.let { mutation -> @@ -54,6 +51,5 @@ class ModalHostHookOwnerPropagationTests { lastSeenCount = count } } - } } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalKeyboardRegressionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalKeyboardRegressionTests.kt new file mode 100644 index 0000000..4ea0ac0 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalKeyboardRegressionTests.kt @@ -0,0 +1,767 @@ +package org.dreamfinity.dsgl.core.components.modal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalSessionStore +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.input +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.KeyCodes +import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseClickEvent +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.host.DsglWindowHost +import org.dreamfinity.dsgl.core.host.Viewport +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.portal.PortalPointerRegion +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.portal.handlePortalKeyDownBeforeDom +import org.dreamfinity.dsgl.core.portal.isFloatingWindowDemoOpen +import org.dreamfinity.dsgl.core.portal.toggleFloatingWindowDemo +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModalPortalKeyboardRegressionTests { + private val trees: MutableList = ArrayList() + private val portalHosts: MutableList = ArrayList() + private val hostKeys: MutableSet = LinkedHashSet() + private val measureContext = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + FocusManager.clearFocus() + EventBus.run { + trees.forEach { tree -> + tree.clearRefs() + tree.root.clearListenersDeep() + } + } + portalHosts.forEach { portalHost -> portalHost.clearRefs() } + hostKeys.forEach(ModalPortalSessionStore::forgetPortal) + trees.clear() + portalHosts.clear() + hostKeys.clear() + } + + @Test + fun `escape is not cancelled after static modal closes`() { + val hostKey = "tests.modal.portal.keyboard.regression" + val current = buildTree(hostKey, emptyList()) + trees += current + + val withStatic = buildTree(hostKey, listOf(staticModal())) + trees += withStatic + current.reconcileWith(withStatic) + + val closed = buildTree(hostKey, emptyList()) + trees += closed + current.reconcileWith(closed) + + FocusManager.clearFocus() + val event = KeyboardKeyDownEvent('\u0000', KeyCodes.ESCAPE) + EventBus.post(event) + + assertFalse(event.cancelled) + } + + @Test + fun `modal portal fills root viewport bounds`() { + val tree = buildTree("tests.modal.portal.layout.viewport", emptyList()) + trees += tree + + tree.render(measureContext, 1920, 1080) + + val host = + tree.root.children + .firstOrNull() + assertNotNull(host) + assertEquals(0, host.bounds.x) + assertEquals(0, host.bounds.y) + assertEquals(1920, host.bounds.width) + assertEquals(1080, host.bounds.height) + + val content = host.children.firstOrNull() + assertNotNull(content) + assertEquals(1920, content.bounds.width) + assertEquals(1080, content.bounds.height) + } + + @Test + fun `modal layers mount through application portalHost portal`() { + val hostKey = "tests.modal.portal.portal" + val tree = buildTree(hostKey, listOf(basicModal())) + trees += tree + tree.render(measureContext, 320, 180) + + val modalPortal = + tree.root.children + .firstOrNull() + assertNotNull(modalPortal) + assertEquals(listOf("$hostKey.content"), modalPortal.children.map { it.key }) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + assertEquals(listOf("application.modal.$hostKey"), portalHost.modalPortal.debugActivePortalEntryIds()) + } + + @Test + fun `modal activation detaches stale application floating window before paint`() { + val hostKey = "tests.modal.portal.floating.stale" + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + + portalHost.onInputFrame(320, 180) + portalHost.toggleFloatingWindowDemo(anchorX = 24, anchorY = 24) + portalHost.render(measureContext, 320, 180) + val floatingNode = portalHost.floatingWindowPortal.debugNode() + assertTrue(floatingNode.parent === portalHost.rootNode) + + val tree = buildTree(hostKey, listOf(basicModal())) + trees += tree + tree.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + + assertFalse(portalHost.isFloatingWindowDemoOpen()) + assertTrue(floatingNode.parent == null) + assertEquals(listOf("application.modal.$hostKey"), portalHost.modalPortal.debugActivePortalEntryIds()) + } + + @Test + fun `modal portal blocks application root click through`() { + val hostKey = "tests.modal.portal.portal.input" + val tree = buildTree(hostKey, listOf(basicModal())) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + assertTrue(portalHost.handleMouseDown(4, 4, MouseButton.LEFT)) + } + + @Test + fun `modal portal escape is handled before focused application root`() { + val hostKey = "tests.modal.portal.escape.pre.dom" + var hideCount = 0 + val tree = buildTreeWithContentInput(hostKey, listOf(dismissibleBodyModal { hideCount += 1 })) + trees += tree + tree.render(measureContext, 320, 180) + val focusedContentInput = requireNodeByKey(tree.root, "$hostKey.content.input") + FocusManager.requestFocus(focusedContentInput) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + assertTrue(portalHost.handlePortalKeyDownBeforeDom(KeyCodes.ESCAPE, 0.toChar())) + assertEquals(1, hideCount) + } + + @Test + fun `modal portal generic policy classifies dialog as inside and backdrop as outside`() { + val hostKey = "tests.modal.portal.portal.policy" + val tree = buildTree(hostKey, listOf(dismissibleBodyModal {})) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.dismissible")) + assertNotNull(dialog) + val inside = + portalHost.modalPortal.debugEvaluatePointerDownPolicy( + mouseX = dialog.bounds.x + dialog.bounds.width / 2, + mouseY = dialog.bounds.y + dialog.bounds.height / 2, + ) + val outside = portalHost.modalPortal.debugEvaluatePointerDownPolicy(mouseX = 2, mouseY = 2) + + assertNotNull(inside) + assertEquals(PortalPointerRegion.InsideEntry, inside.region) + assertTrue(inside.consumed) + assertFalse(inside.shouldClose) + assertNotNull(outside) + assertEquals(PortalPointerRegion.OutsideEntry, outside.region) + assertTrue(outside.consumed) + assertTrue(outside.shouldClose) + } + + @Test + fun `modal portal policy blocks application root fallthrough for non interactive dialog body`() { + val hostKey = "tests.modal.portal.portal.policy.inside" + val tree = buildTree(hostKey, listOf(dismissibleBodyModal {})) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.dismissible")) + assertNotNull(dialog) + var applicationRootReceived = false + val consumedBy = + dispatchApplicationPortalPointer( + portalHost = portalHost, + mouseX = dialog.bounds.x + dialog.bounds.width / 2, + mouseY = dialog.bounds.y + dialog.bounds.height / 2, + pressed = true, + ) { + applicationRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(applicationRootReceived) + } + + @Test + fun `static modal backdrop consumes without dismissing or falling through`() { + val hostKey = "tests.modal.portal.portal.policy.static" + var hideCount = 0 + val tree = + buildTree( + hostKey, + listOf( + ModalSpec( + key = "modal.static.backdrop", + backdrop = BackdropMode.Static, + onHide = { hideCount += 1 }, + ) { _ -> }, + ), + ) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + var applicationRootReceived = false + val consumedBy = + dispatchApplicationPortalPointer( + portalHost = portalHost, + mouseX = 2, + mouseY = 2, + pressed = true, + ) { + applicationRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(applicationRootReceived) + assertEquals(0, hideCount) + } + + @Test + fun `modal backdrop dismiss consumes full pointer sequence and does not click through`() { + val hostKey = "tests.modal.portal.portal.policy.full.click" + var modals: List = emptyList() + modals = listOf(dismissibleBodyModal { modals = emptyList() }) + var tree = buildTree(hostKey, modals) + trees += tree + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + renderTreeAndPortal(tree, portalHost) + + var applicationRootReceivedDown = false + val consumedDown = + dispatchApplicationPortalPointer( + portalHost = portalHost, + mouseX = 2, + mouseY = 2, + pressed = true, + ) { + applicationRootReceivedDown = true + true + } + + assertEquals(listOf("modal.dismissible"), modals.map { it.key }) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedDown) + assertFalse(applicationRootReceivedDown) + + var applicationRootReceivedUp = false + val consumedUp = + dispatchApplicationPortalPointer( + portalHost = portalHost, + mouseX = 2, + mouseY = 2, + pressed = false, + ) { + applicationRootReceivedUp = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedUp) + assertFalse(applicationRootReceivedUp) + assertEquals(emptyList(), modals) + + tree = reconcileTree(tree, buildTree(hostKey, modals)) + renderTreeAndPortal(tree, portalHost) + } + + @Test + fun `modal portal does not dismiss non static modal when clicking inside dialog body`() { + val hostKey = "tests.modal.portal.portal.inside.dismiss" + var hideCount = 0 + val tree = buildTree(hostKey, listOf(dismissibleBodyModal { hideCount += 1 })) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.dismissible")) + assertNotNull(dialog) + val clickX = dialog.bounds.x + dialog.bounds.width / 2 + val clickY = dialog.bounds.y + dialog.bounds.height / 2 + + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + assertEquals(0, hideCount) + } + + @Test + fun `modal portal dismisses non static modal when clicking backdrop`() { + val hostKey = "tests.modal.portal.portal.backdrop.dismiss" + var hideCount = 0 + val tree = buildTree(hostKey, listOf(dismissibleBodyModal { hideCount += 1 })) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + assertTrue(portalHost.handleMouseDown(2, 2, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(2, 2, MouseButton.LEFT)) + assertEquals(1, hideCount) + } + + @Test + fun `modal portal keeps topmost focus request on portalHost commit`() { + val hostKey = "tests.modal.portal.portal.focus" + val current = buildTreeWithContentInput(hostKey, emptyList()) + trees += current + current.render(measureContext, 320, 180) + FocusManager.requestFocus(requireNodeByKey(current.root, "$hostKey.content.input")) + + val withModal = buildTreeWithContentInput(hostKey, listOf(inputModal())) + trees += withModal + current.reconcileWith(withModal) + current.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + assertEquals("modal.input", FocusManager.focusedNode()?.key) + } + + @Test + fun `modal portal keeps underlying modal pointer interactive after top modal closes`() { + val hostKey = "tests.modal.portal.portal.stack.pointer" + var stepOneClicks = 0 + val stepOne = clickableModal("step.one", "step.one.button") { stepOneClicks += 1 } + val stepTwo = clickableModal("step.two", "step.two.button") {} + val current = buildTree(hostKey, listOf(stepOne)) + trees += current + current.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val stacked = buildTree(hostKey, listOf(stepOne, stepTwo)) + trees += stacked + current.reconcileWith(stacked) + current.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + + val popped = buildTree(hostKey, listOf(stepOne)) + trees += popped + current.reconcileWith(popped) + current.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + + val stepOneButton = portalHost.modalPortal.debugFindNodeByKey("step.one.button") + assertNotNull(stepOneButton) + val clickX = stepOneButton.bounds.x + stepOneButton.bounds.width / 2 + val clickY = stepOneButton.bounds.y + stepOneButton.bounds.height / 2 + + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + assertEquals(1, stepOneClicks) + } + + @Test + fun `modal portal restores showcase flow modal pointer interaction after closing step two`() { + val hostKey = "tests.modal.portal.portal.showcase.flow" + var modals: List = emptyList() + + fun removeModal(key: String) { + modals = modals.filterNot { modal -> modal.key == key } + } + + fun pushModal(modal: ModalSpec) { + modals += modal + } + pushModal(showcaseFlowStepOne(::pushModal, ::removeModal)) + + var tree = buildTree(hostKey, modals) + trees += tree + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + assertEquals(listOf("modal.flow.1", "modal.flow.2"), modals.map { it.key }) + tree = reconcileTree(tree, buildTree(hostKey, modals)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Back to Step 1") + assertEquals(listOf("modal.flow.1"), modals.map { it.key }) + tree = reconcileTree(tree, buildTree(hostKey, modals)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + assertEquals(listOf("modal.flow.1", "modal.flow.2"), modals.map { it.key }) + } + + @Test + fun `modal portal restores showcase flow pointer interaction after closing step two header button`() { + val hostKey = "tests.modal.portal.portal.showcase.flow.header" + var modals: List = emptyList() + + fun removeModal(key: String) { + modals = modals.filterNot { modal -> modal.key == key } + } + + fun pushModal(modal: ModalSpec) { + modals += modal + } + pushModal(showcaseFlowStepOne(::pushModal, ::removeModal)) + + var tree = buildTree(hostKey, modals) + trees += tree + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + tree = reconcileTree(tree, buildTree(hostKey, modals)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButtonInDialog( + portalHost = portalHost, + text = "x", + dialogKey = ModalPortalSessionStore.dialogKey(hostKey, "modal.flow.2"), + ) + assertEquals(listOf("modal.flow.1"), modals.map { it.key }) + tree = reconcileTree(tree, buildTree(hostKey, modals)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + assertEquals(listOf("modal.flow.1", "modal.flow.2"), modals.map { it.key }) + } + + @Test + fun `modal portal restores hook state showcase flow pointer interaction after closing step two`() { + val window = ShowcaseFlowWindow() + val host = RecordingHost(window) + window.attachHost(host) + var tree = renderWithHookSession(window) + trees += tree + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + renderTreeAndPortal(tree, portalHost) + + clickTreeNode(tree, "open.flow") + assertTrue(host.rebuildRequests > 0) + tree = reconcileTree(tree, renderWithHookSession(window)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + tree = reconcileTree(tree, renderWithHookSession(window)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Back to Step 1") + tree = reconcileTree(tree, renderWithHookSession(window)) + renderTreeAndPortal(tree, portalHost) + + clickPortalButton(portalHost, "Next") + tree = reconcileTree(tree, renderWithHookSession(window)) + renderTreeAndPortal(tree, portalHost) + + assertEquals(listOf("modal.flow.1", "modal.flow.2"), window.lastRenderedModalKeys) + } + + private fun buildTree(hostKey: String, modals: List): DomTree { + hostKeys += hostKey + return ui { + modalPortal(modals = modals, key = hostKey) { + div({ key = "$hostKey.content" }) + } + } + } + + private fun buildTreeWithContentInput(hostKey: String, modals: List): DomTree { + hostKeys += hostKey + return ui { + modalPortal(modals = modals, key = hostKey) { + input(InputType.Text(value = ""), { + key = "$hostKey.content.input" + }) + } + } + } + + private fun staticModal(): ModalSpec = + ModalSpec( + key = "modal.static", + backdrop = BackdropMode.Static, + keyboard = false, + ) { _ -> } + + private fun basicModal(): ModalSpec = + ModalSpec( + key = "modal.basic", + ) { _ -> } + + private fun inputModal(): ModalSpec = + ModalSpec( + key = "modal.input", + ) { _ -> + input(InputType.Text(value = ""), { + key = "modal.input" + }) + } + + private fun dismissibleBodyModal(onHide: () -> Unit): ModalSpec = + ModalSpec( + key = "modal.dismissible", + centered = true, + onHide = onHide, + ) { _ -> + modalBody { + text("Clicking this non-interactive body area must not dismiss the modal.") + } + } + + private fun clickableModal(modalKey: String, buttonKey: String, onClick: () -> Unit): ModalSpec = + ModalSpec(key = modalKey) { _ -> + modalBody { + button("Click", { + key = buttonKey + onMouseClick = { onClick() } + }) + } + } + + private fun showcaseFlowStepOne(onPushModal: (ModalSpec) -> Unit, onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( + key = "modal.flow.1", + onHide = { onRemoveModal("modal.flow.1") }, + ) { scope -> + modalHeader(closeButton = true, onHide = scope.dismiss) { + modalTitle("Flow Step 1") + } + modalBody { + text("Step 1 remains visible but inert when Step 2 is pushed.") + } + modalFooter { + button("Close", { + onMouseClick = { scope.dismiss?.invoke() } + }) + button("Next", { + onMouseClick = { + onPushModal(showcaseFlowStepTwo(onRemoveModal)) + } + }) + } + } + + private fun showcaseFlowStepTwo(onRemoveModal: (String) -> Unit): ModalSpec = + ModalSpec( + key = "modal.flow.2", + centered = true, + onHide = { onRemoveModal("modal.flow.2") }, + ) { scope -> + modalHeader(closeButton = true, onHide = scope.dismiss) { + modalTitle("Flow Step 2") + } + modalBody { + text("Topmost modal only. Closing returns interaction to Step 1.") + } + modalFooter { + button("Back to Step 1", { + onMouseClick = { scope.dismiss?.invoke() } + }) + } + } + + private fun renderTreeAndPortal(tree: DomTree, portalHost: ApplicationPortalHost) { + tree.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + } + + private fun reconcileTree(current: DomTree, next: DomTree): DomTree { + trees += next + current.reconcileWith(next) + return current + } + + private fun clickPortalButton(portalHost: ApplicationPortalHost, text: String) { + val button = + portalHost.modalPortal.debugFindNode { node -> + node is ButtonNode && node.text == text + } + assertNotNull(button) + val clickX = button.bounds.x + button.bounds.width / 2 + val clickY = button.bounds.y + button.bounds.height / 2 + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + } + + private fun clickPortalButtonInDialog(portalHost: ApplicationPortalHost, text: String, dialogKey: String) { + val button = + portalHost.modalPortal.debugFindNode { node -> + node is ButtonNode && node.text == text && hasAncestorWithKey(node, dialogKey) + } + assertNotNull(button) + val clickX = button.bounds.x + button.bounds.width / 2 + val clickY = button.bounds.y + button.bounds.height / 2 + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + } + + private fun hasAncestorWithKey(node: DOMNode, key: Any?): Boolean { + var current: DOMNode? = node + while (current != null) { + if (current.key == key) return true + current = current.parent + } + return false + } + + private fun renderWithHookSession(window: DsglWindow): DomTree { + window.beginRenderBuild() + return try { + window.render() + } finally { + window.endRenderBuild() + window.commitRenderBuild() + } + } + + private inner class ShowcaseFlowWindow : DsglWindow() { + var lastRenderedModalKeys: List = emptyList() + + override fun render(): DomTree = + ui { + var modals by useState(emptyList()) + lastRenderedModalKeys = modals.map { it.key } + + fun removeModal(key: String) { + modals = modals.filterNot { modal -> modal.key == key } + } + + fun pushModal(modal: ModalSpec) { + modals += modal + } + modalPortal(modals = modals, key = "tests.modal.portal.portal.hook.showcase") { + button("Open flow step 1", { + key = "open.flow" + onMouseClick = { pushModal(showcaseFlowStepOne(::pushModal, ::removeModal)) } + }) + } + } + } + + private class RecordingHost( + override val window: DsglWindow, + ) : DsglWindowHost { + var rebuildRequests: Int = 0 + + override fun requestRebuild(reason: String?) { + rebuildRequests += 1 + } + + override fun requestRedraw() = Unit + + override fun getViewport(): Viewport = Viewport(width = 320, height = 180) + } + + private fun clickTreeNode(tree: DomTree, key: Any) { + val node = requireNodeByKey(tree.root, key) + val clickX = node.bounds.x + node.bounds.width / 2 + val clickY = node.bounds.y + node.bounds.height / 2 + EventBus.post( + MouseClickEvent(clickX, clickY, MouseButton.LEFT).also { event -> + event.target = node + }, + ) + } + + private fun dispatchApplicationPortalPointer( + portalHost: ApplicationPortalHost, + mouseX: Int, + mouseY: Int, + pressed: Boolean, + applicationRootHandler: () -> Boolean, + ) = ScreenDomainSurfaces.firstInputConsumer( + canConsume = { surface -> + when (surface) { + ScreenDomainSurfaces.ApplicationPortal -> + if (pressed) { + portalHost.handleMouseDown(mouseX, mouseY, MouseButton.LEFT) + } else { + portalHost.handleMouseUp(mouseX, mouseY, MouseButton.LEFT) + } + + ScreenDomainSurfaces.ApplicationRoot -> applicationRootHandler() + else -> false + } + }, + ) + + private fun requireNodeByKey(root: DOMNode, key: Any): DOMNode { + if (root.key == key) return root + root.children.forEach { child -> + val found = runCatching { requireNodeByKey(child, key) }.getOrNull() + if (found != null) return found + } + error("Missing node key=$key") + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalLayoutRegressionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalLayoutRegressionTests.kt new file mode 100644 index 0000000..83801f8 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalLayoutRegressionTests.kt @@ -0,0 +1,118 @@ +package org.dreamfinity.dsgl.core.components.modal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalSessionStore +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModalPortalLayoutRegressionTests { + private val trees: MutableList = ArrayList() + private val portalHosts: MutableList = ArrayList() + private val hostKeys: MutableSet = LinkedHashSet() + private val measureContext = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + EventBus.run { + trees.forEach { tree -> + tree.clearRefs() + tree.root.clearListenersDeep() + } + } + portalHosts.forEach { portalHost -> portalHost.clearRefs() } + hostKeys.forEach(ModalPortalSessionStore::forgetPortal) + trees.clear() + portalHosts.clear() + hostKeys.clear() + } + + @Test + fun `modal portal resolves layer style before first portalHost layout`() { + val hostKey = "tests.modal.portal.layout.first.frame" + val tree = buildTree(hostKey, listOf(basicModal())) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val layer = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.modal.basic.layer") + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.basic")) + assertNotNull(layer) + assertNotNull(dialog) + + assertEquals(10, layer.padding.top) + assertEquals(10, layer.padding.right) + assertEquals(10, layer.padding.bottom) + assertEquals(10, layer.padding.left) + assertEquals(16, dialog.bounds.y) + } + + @Test + fun `centered modal keeps stable bounds across passive portalHost frames`() { + val hostKey = "tests.modal.portal.layout.centered.stable" + val tree = + buildTree( + hostKey, + listOf( + ModalSpec( + key = "modal.centered", + centered = true, + ) { _ -> }, + ), + ) + trees += tree + tree.render(measureContext, 320, 180) + + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + + val dialogKey = ModalPortalSessionStore.dialogKey(hostKey, "modal.centered") + val firstDialog = portalHost.modalPortal.debugFindNodeByKey(dialogKey) + assertNotNull(firstDialog) + val firstBounds = firstDialog.bounds + + assertTrue(portalHost.handleMouseMove(firstBounds.x + firstBounds.width / 2, firstBounds.y + firstBounds.height / 2)) + portalHost.render(measureContext, 320, 180) + + val secondDialog = portalHost.modalPortal.debugFindNodeByKey(dialogKey) + assertNotNull(secondDialog) + assertEquals(firstBounds, secondDialog.bounds) + } + + private fun buildTree(hostKey: String, modals: List): DomTree { + hostKeys += hostKey + return ui { + modalPortal(modals = modals, key = hostKey) { + div({ key = "$hostKey.content" }) + } + } + } + + private fun basicModal(): ModalSpec = + ModalSpec( + key = "modal.basic", + ) { _ -> } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalPointerRegressionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalPointerRegressionTests.kt new file mode 100644 index 0000000..fef61e7 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalPointerRegressionTests.kt @@ -0,0 +1,259 @@ +package org.dreamfinity.dsgl.core.components.modal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalSessionStore +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.dsl.ui +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.portal.hasActiveModalPortal +import org.dreamfinity.dsgl.core.portal.isFloatingWindowDemoOpen +import org.dreamfinity.dsgl.core.portal.toggleFloatingWindowDemo +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModalPortalPointerRegressionTests { + private val trees: MutableList = ArrayList() + private val portalHosts: MutableList = ArrayList() + private val hostKeys: MutableSet = LinkedHashSet() + private val measureContext = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + FocusManager.clearFocus() + EventBus.run { + trees.forEach { tree -> + tree.clearRefs() + tree.root.clearListenersDeep() + } + } + portalHosts.forEach { portalHost -> portalHost.clearRefs() } + hostKeys.forEach(ModalPortalSessionStore::forgetPortal) + trees.clear() + portalHosts.clear() + hostKeys.clear() + } + + @Test + fun `active modal prevents application floating window from opening`() { + val hostKey = "tests.modal.portal.floating.blocked" + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + val tree = buildTree(hostKey, listOf(staticModal())) + trees += tree + + tree.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + assertTrue(portalHost.hasActiveModalPortal()) + + portalHost.toggleFloatingWindowDemo(anchorX = 24, anchorY = 24) + portalHost.render(measureContext, 320, 180) + + assertFalse(portalHost.isFloatingWindowDemoOpen()) + assertTrue( + portalHost.floatingWindowPortal + .debugNode() + .parent == null, + ) + } + + @Test + fun `static modal backdrop pointer press does not activate modal layer`() { + val hostKey = "tests.modal.portal.static.backdrop.active" + val portalHost = renderStaticModalPortal(hostKey) + val layer = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.modal.static.layer") + assertNotNull(layer) + + assertTrue(portalHost.handleMouseDown(4, 4, MouseButton.LEFT)) + assertFalse(layer.styleActive) + assertTrue(portalHost.handleMouseUp(4, 4, MouseButton.LEFT)) + assertFalse(layer.styleActive) + } + + @Test + fun `static modal dialog pointer press does not activate modal layer`() { + val hostKey = "tests.modal.portal.static.dialog.active" + val portalHost = renderStaticModalPortal(hostKey) + val layer = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.modal.static.layer") + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.static")) + assertNotNull(layer) + assertNotNull(dialog) + val clickX = dialog.bounds.x + dialog.bounds.width / 2 + val clickY = dialog.bounds.y + dialog.bounds.height / 2 + + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertFalse(layer.styleActive) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + assertFalse(layer.styleActive) + } + + @Test + fun `static modal dialog control receives pointer through modal owned routing`() { + val hostKey = "tests.modal.portal.static.dialog.button" + var clicks = 0 + val tree = + buildTree( + hostKey = hostKey, + modals = + listOf( + staticModal { + button("OK", { + key = "$hostKey.modal.button" + onMouseClick = { clicks += 1 } + }) + }, + ), + ) + trees += tree + tree.render(measureContext, 320, 180) + val portalHost = + ApplicationPortalHost().also { portalHost -> + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + } + val button = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.button") + assertNotNull(button) + val clickX = button.bounds.x + button.bounds.width / 2 + val clickY = button.bounds.y + button.bounds.height / 2 + + assertTrue(portalHost.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(portalHost.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + + assertTrue(clicks == 1) + } + + @Test + fun `static modal clears dialog hover when pointer moves to backdrop`() { + val hostKey = "tests.modal.portal.static.dialog.hover.clear" + val portalHost = renderStaticModalWithButton(hostKey) + val button = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.button") + assertNotNull(button) + val hoverX = button.bounds.x + button.bounds.width / 2 + val hoverY = button.bounds.y + button.bounds.height / 2 + + assertTrue(portalHost.handleMouseMove(hoverX, hoverY)) + assertTrue(button.styleHovered) + + assertTrue(portalHost.handleMouseMove(4, 4)) + assertFalse(button.styleHovered) + assertFalse(button.styleActive) + } + + @Test + fun `static modal backdrop move does not activate modal nodes`() { + val hostKey = "tests.modal.portal.static.backdrop.move" + val portalHost = renderStaticModalWithButton(hostKey) + val layer = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.modal.static.layer") + val dialog = portalHost.modalPortal.debugFindNodeByKey(ModalPortalSessionStore.dialogKey(hostKey, "modal.static")) + val button = portalHost.modalPortal.debugFindNodeByKey("$hostKey.modal.button") + assertNotNull(layer) + assertNotNull(dialog) + assertNotNull(button) + + assertTrue(portalHost.handleMouseMove(4, 4)) + + assertFalse(layer.styleActive) + assertFalse(dialog.styleActive) + assertFalse(button.styleActive) + assertFalse(button.styleHovered) + } + + @Test + fun `active modal mouse move clears stale application portal hover`() { + val hostKey = "tests.modal.portal.static.lower.hover.clear" + val portalHost = ApplicationPortalHost() + portalHosts += portalHost + portalHost.rootNode.setViewportBounds(320, 180) + val lowerButton = + ButtonNode("Lower", key = "$hostKey.lower.button").apply { + bounds = Rect(0, 0, 80, 24) + } + lowerButton.applyParent(portalHost.rootNode) + + assertTrue(portalHost.handleMouseMove(12, 12)) + assertTrue(lowerButton.styleHovered) + + val tree = buildTree(hostKey, listOf(staticModal())) + trees += tree + tree.render(measureContext, 320, 180) + portalHost.render(measureContext, 320, 180) + + assertTrue(portalHost.handleMouseMove(4, 4)) + assertFalse(lowerButton.styleHovered) + } + + private fun renderStaticModalPortal(hostKey: String): ApplicationPortalHost { + val tree = buildTree(hostKey, listOf(staticModal())) + trees += tree + tree.render(measureContext, 320, 180) + return ApplicationPortalHost().also { portalHost -> + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + } + } + + private fun renderStaticModalWithButton(hostKey: String): ApplicationPortalHost { + val tree = + buildTree( + hostKey = hostKey, + modals = + listOf( + staticModal { + button("OK", { + key = "$hostKey.modal.button" + onMouseClick = {} + }) + }, + ), + ) + trees += tree + tree.render(measureContext, 320, 180) + return ApplicationPortalHost().also { portalHost -> + portalHosts += portalHost + portalHost.render(measureContext, 320, 180) + } + } + + private fun buildTree(hostKey: String, modals: List): DomTree { + hostKeys += hostKey + return ui { + modalPortal(modals = modals, key = hostKey) { + div({ key = "$hostKey.content" }) + } + } + } + + private fun staticModal(content: UiScope.() -> Unit = {}): ModalSpec = + ModalSpec( + key = "modal.static", + backdrop = BackdropMode.Static, + keyboard = false, + ) { _ -> + text("Static") + content() + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalSessionStoreTests.kt similarity index 72% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalRuntimeTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalSessionStoreTests.kt index 73a3c92..d7e2327 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/components/modal/ModalPortalSessionStoreTests.kt @@ -1,6 +1,6 @@ package org.dreamfinity.dsgl.core.components.modal -import org.dreamfinity.dsgl.core.components.modal.internal.ModalRuntime +import org.dreamfinity.dsgl.core.components.modal.internal.ModalPortalSessionStore import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -9,7 +9,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals -class ModalRuntimeTests { +class ModalPortalSessionStoreTests { @AfterTest fun cleanup() { FocusManager.clearFocus() @@ -17,47 +17,47 @@ class ModalRuntimeTests { @Test fun restoresPreviousFocusWhenTopModalCloses() { - val hostKey = "tests.modal.host.restore" + val hostKey = "tests.modal.portal.restore" val contentRoot = buildRoot(hostKey, includeM1 = false, includeM2 = false) FocusManager.requestFocus(requireNodeByKey(contentRoot, "content.input")) FocusManager.retainFocus(contentRoot) val modal1 = ModalSpec(key = "m1") { _ -> } - ModalRuntime.onBuild(hostKey, listOf(modal1)) + ModalPortalSessionStore.onBuild(hostKey, listOf(modal1)) val withModal = buildRoot(hostKey, includeM1 = true, includeM2 = false) FocusManager.retainFocus(withModal) - ModalRuntime.onCommit(hostKey, listOf(modal1)) + ModalPortalSessionStore.onCommit(hostKey, listOf(modal1)) assertEquals("m1.input", FocusManager.focusedNode()?.key) - ModalRuntime.onBuild(hostKey, emptyList()) + ModalPortalSessionStore.onBuild(hostKey, emptyList()) val withoutModal = buildRoot(hostKey, includeM1 = false, includeM2 = false) FocusManager.retainFocus(withoutModal) - ModalRuntime.onCommit(hostKey, emptyList()) + ModalPortalSessionStore.onCommit(hostKey, emptyList()) assertEquals("content.input", FocusManager.focusedNode()?.key) } @Test fun focusesNewestTopmostAndRestoresUnderlyingModalFocusOnPop() { - val hostKey = "tests.modal.host.stack" + val hostKey = "tests.modal.portal.stack" val modal1 = ModalSpec(key = "m1") { _ -> } val modal2 = ModalSpec(key = "m2") { _ -> } val withM1 = buildRoot(hostKey, includeM1 = true, includeM2 = false) FocusManager.retainFocus(withM1) - ModalRuntime.onBuild(hostKey, listOf(modal1)) - ModalRuntime.onCommit(hostKey, listOf(modal1)) + ModalPortalSessionStore.onBuild(hostKey, listOf(modal1)) + ModalPortalSessionStore.onCommit(hostKey, listOf(modal1)) assertEquals("m1.input", FocusManager.focusedNode()?.key) - ModalRuntime.onBuild(hostKey, listOf(modal1, modal2)) + ModalPortalSessionStore.onBuild(hostKey, listOf(modal1, modal2)) val withM1M2 = buildRoot(hostKey, includeM1 = true, includeM2 = true) FocusManager.retainFocus(withM1M2) - ModalRuntime.onCommit(hostKey, listOf(modal1, modal2)) + ModalPortalSessionStore.onCommit(hostKey, listOf(modal1, modal2)) assertEquals("m2.input", FocusManager.focusedNode()?.key) - ModalRuntime.onBuild(hostKey, listOf(modal1)) + ModalPortalSessionStore.onBuild(hostKey, listOf(modal1)) val backToM1 = buildRoot(hostKey, includeM1 = true, includeM2 = false) FocusManager.retainFocus(backToM1) - ModalRuntime.onCommit(hostKey, listOf(modal1)) + ModalPortalSessionStore.onCommit(hostKey, listOf(modal1)) assertEquals("m1.input", FocusManager.focusedNode()?.key) } @@ -67,12 +67,12 @@ class ModalRuntimeTests { FocusableNode(key = "content.input").applyParent(this) } if (includeM1) { - ContainerNode(key = ModalRuntime.dialogKey(hostKey, "m1")).applyParent(root).apply { + ContainerNode(key = ModalPortalSessionStore.dialogKey(hostKey, "m1")).applyParent(root).apply { FocusableNode(key = "m1.input").applyParent(this) } } if (includeM2) { - ContainerNode(key = ModalRuntime.dialogKey(hostKey, "m2")).applyParent(root).apply { + ContainerNode(key = ModalPortalSessionStore.dialogKey(hostKey, "m2")).applyParent(root).apply { FocusableNode(key = "m2.input").applyParent(this) } } @@ -90,7 +90,9 @@ class ModalRuntimeTests { error("Missing node key=$key") } - private class FocusableNode(key: Any) : DOMNode(key) { + private class FocusableNode( + key: Any, + ) : DOMNode(key) { override val focusable: Boolean = true } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngineTests.kt index b1299ef..77a587d 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuEngineTests.kt @@ -13,41 +13,49 @@ import kotlin.test.assertTrue class ContextMenuEngineTests { private class FakeClock( - var now: Long = 0L + var now: Long = 0L, ) : ContextMenuClock { override fun nowMs(): Long = now + fun advance(ms: Long) { now += ms } } - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @Test fun `stack supports open submenu switch esc pop and outside close`() { val clock = FakeClock() - val style = ContextMenuStyle( - hoverOpenDelayMs = 80L, - submenuCloseDelayMs = 120L - ) + val style = + ContextMenuStyle( + hoverOpenDelayMs = 80L, + submenuCloseDelayMs = 120L, + ) val engine = ContextMenuEngine(clock = clock) engine.setStyle(style) - val model = contextMenu(id = "state.machine") { - submenu("More", id = "more") { - item("A") - item("B") - } - submenu("Tools", id = "tools") { - item("Hammer") + val model = + contextMenu(id = "state.machine") { + submenu("More", id = "more") { + item("A") + item("B") + } + submenu("Tools", id = "tools") { + item("Hammer") + } + item("Leaf", id = "leaf") } - item("Leaf", id = "leaf") - } engine.openAtCursor(model, 20, 20) engine.onFrame(ctx, 320, 180, 1f) @@ -64,7 +72,13 @@ class ContextMenuEngineTests { clock.advance(style.hoverOpenDelayMs + 1L) engine.onFrame(ctx, 320, 180, 1f) assertEquals(2, engine.snapshot().levelCount) - assertEquals(1, engine.snapshot().hoveredIndices.first()) + assertEquals( + 1, + engine + .snapshot() + .hoveredIndices + .first(), + ) assertTrue(engine.handleKeyDown(KeyCodes.ESCAPE)) assertEquals(1, engine.snapshot().levelCount) @@ -79,13 +93,14 @@ class ContextMenuEngineTests { } @Test - fun `overlay consumes pointer before base dispatch when menu is open`() { + fun `portal consumes pointer before base dispatch when menu is open`() { val clock = FakeClock() val engine = ContextMenuEngine(clock = clock) - val model = contextMenu(id = "overlay.order") { - item("Run") - item("Build") - } + val model = + contextMenu(id = "portal.order") { + item("Run") + item("Build") + } engine.openAtCursor(model, 24, 24) engine.onFrame(ctx, 320, 180, 1f) @@ -105,13 +120,14 @@ class ContextMenuEngineTests { val clock = FakeClock() val engine = ContextMenuEngine(clock = clock) var actionHits = 0 - val model = contextMenu(id = "keyboard") { - submenu("RootSub") { - item("Leaf") { - onClick { actionHits += 1 } + val model = + contextMenu(id = "keyboard") { + submenu("RootSub") { + item("Leaf") { + onClick { actionHits += 1 } + } } } - } engine.openAtCursor(model, 20, 20) engine.onFrame(ctx, 320, 180, 1f) @@ -128,10 +144,11 @@ class ContextMenuEngineTests { @Test fun `open at cursor keeps root panel under click point`() { val engine = ContextMenuEngine() - val model = contextMenu(id = "placement.cursor") { - item("Open") - item("Rename") - } + val model = + contextMenu(id = "placement.cursor") { + item("Open") + item("Rename") + } val openX = 84 val openY = 62 @@ -146,10 +163,11 @@ class ContextMenuEngineTests { @Test fun `open anchored positions root panel below anchor`() { val engine = ContextMenuEngine() - val model = contextMenu(id = "placement.anchor") { - item("Open") - item("Rename") - } + val model = + contextMenu(id = "placement.anchor") { + item("Open") + item("Rename") + } val anchor = Rect(48, 34, 96, 18) engine.openAnchored(model, anchor) @@ -164,14 +182,15 @@ class ContextMenuEngineTests { @Test fun `menu model font size is used for rendered text`() { val engine = ContextMenuEngine() - val model = contextMenu(id = "font.size", fontSize = 24) { - item("Open") - } + val model = + contextMenu(id = "font.size", fontSize = 24) { + item("Open") + } engine.openAtCursor(model, 20, 20) engine.onFrame(ctx, 320, 180, 1f) val commands = mutableListOf() - engine.appendOverlayCommands(ctx, 320, 180, commands) + engine.appendPortalCommands(ctx, 320, 180, commands) val textCommand = commands.filterIsInstance().firstOrNull() assertNotNull(textCommand) @@ -185,5 +204,6 @@ class ContextMenuEngineTests { } private fun centerX(rect: Rect): Int = rect.x + rect.width / 2 + private fun centerY(rect: Rect): Int = rect.y + rect.height / 2 } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCacheTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCacheTests.kt index 3c45ca9..e0de9eb 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCacheTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/ContextMenuMeasurementCacheTests.kt @@ -8,21 +8,27 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class ContextMenuMeasurementCacheTests { - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @Test fun `measurement reuses cached value for unchanged inputs`() { val cache = ContextMenuMeasurementCache() - val model = contextMenu(id = "cache.same") { - item("Copy") - item("Paste") - } + val model = + contextMenu(id = "cache.same") { + item("Copy") + item("Paste") + } val first = cache.measure(model.token, model.entries, ContextMenuStyle(), null, null, ctx, 1f) val second = cache.measure(model.token, model.entries, ContextMenuStyle(), null, null, ctx, 1f) @@ -34,9 +40,10 @@ class ContextMenuMeasurementCacheTests { fun `measurement recomputes when resolved content changes`() { val cache = ContextMenuMeasurementCache() var dynamicLabel = "Open" - val model = contextMenu(id = "cache.dynamic") { - item({ dynamicLabel }) - } + val model = + contextMenu(id = "cache.dynamic") { + item({ dynamicLabel }) + } val first = cache.measure(model.token, model.entries, ContextMenuStyle(), null, null, ctx, 1f) dynamicLabel = "Open in new window" @@ -49,10 +56,11 @@ class ContextMenuMeasurementCacheTests { @Test fun `measurement recomputes when style hash changes`() { val cache = ContextMenuMeasurementCache() - val model = contextMenu(id = "cache.style") { - item("Run") - item("Rename") - } + val model = + contextMenu(id = "cache.style") { + item("Run") + item("Rename") + } cache.measure(model.token, model.entries, ContextMenuStyle(), null, null, ctx, 1f) cache.measure(model.token, model.entries, ContextMenuStyle(minPanelWidth = 220), null, null, ctx, 1f) @@ -63,14 +71,16 @@ class ContextMenuMeasurementCacheTests { @Test fun `measurement expands panel when icon column grows`() { val cache = ContextMenuMeasurementCache() - val noIcons = contextMenu(id = "cache.icons.none") { - item("Rename") - } - val withIcons = contextMenu(id = "cache.icons.present") { - item("Rename") { - icon("FILE-LONG") + val noIcons = + contextMenu(id = "cache.icons.none") { + item("Rename") + } + val withIcons = + contextMenu(id = "cache.icons.present") { + item("Rename") { + icon("FILE-LONG") + } } - } val style = ContextMenuStyle(minPanelWidth = 1) val noIconMeasurement = cache.measure(noIcons.token, noIcons.entries, style, null, null, ctx, 1f) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacementTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacementTests.kt index 8f0f8a8..b472eb4 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacementTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/contextmenu/PopupPlacementTests.kt @@ -9,15 +9,16 @@ import kotlin.test.assertTrue class PopupPlacementTests { @Test fun `submenu flips left when overflowing right edge`() { - val result = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = Rect(290, 40, 120, 100), - popupSize = Size(120, 100), - viewport = Rect(0, 0, 320, 180), - padding = 6, - horizontalFlipX = 150 + val result = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = Rect(290, 40, 120, 100), + popupSize = Size(120, 100), + viewport = Rect(0, 0, 320, 180), + padding = 6, + horizontalFlipX = 150, + ), ) - ) assertTrue(result.flippedHorizontally) assertEquals(150, result.rect.x) @@ -25,14 +26,15 @@ class PopupPlacementTests { @Test fun `placement clamps vertically into viewport`() { - val result = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = Rect(40, 170, 120, 80), - popupSize = Size(120, 80), - viewport = Rect(0, 0, 320, 180), - padding = 6 + val result = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = Rect(40, 170, 120, 80), + popupSize = Size(120, 80), + viewport = Rect(0, 0, 320, 180), + padding = 6, + ), ) - ) assertTrue(result.clampedVertically) assertEquals(94, result.rect.y) @@ -40,14 +42,15 @@ class PopupPlacementTests { @Test fun `oversized popup is constrained to viewport bounds`() { - val result = PopupPlacement.resolve( - PopupPlacementRequest( - preferredRect = Rect(0, 0, 600, 500), - popupSize = Size(600, 500), - viewport = Rect(0, 0, 320, 180), - padding = 8 + val result = + PopupPlacement.resolve( + PopupPlacementRequest( + preferredRect = Rect(0, 0, 600, 500), + popupSize = Size(600, 500), + viewport = Rect(0, 0, 320, 180), + padding = 8, + ), ) - ) assertEquals(304, result.rect.width) assertEquals(164, result.rect.height) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHostsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHostsTests.kt new file mode 100644 index 0000000..d3a6a4a --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/DebugDomainHostsTests.kt @@ -0,0 +1,315 @@ +package org.dreamfinity.dsgl.core.debug + +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.PortalEntry +import org.dreamfinity.dsgl.core.portal.PortalEntryBounds +import org.dreamfinity.dsgl.core.portal.PortalEntryId +import org.dreamfinity.dsgl.core.portal.PortalEntryOrder +import org.dreamfinity.dsgl.core.portal.PortalEntryPlacement +import org.dreamfinity.dsgl.core.portal.PortalEntryState +import org.dreamfinity.dsgl.core.portal.PortalFrameContext +import org.dreamfinity.dsgl.core.portal.ScreenDomainSurfaces +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.StyleApplicationScope +import java.util.Locale +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DebugDomainHostsTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + DomainSurfaceDebugState.resetAll() + DomainSurfaceDebugState.setControlsEnabledTestOverride(null) + } + + @Test + fun `debug panel remains available when app and system portal surfaces are disabled`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + DomainSurfaceDebugState.applicationPortalRenderEnabled = false + DomainSurfaceDebugState.applicationPortalInputEnabled = false + DomainSurfaceDebugState.systemPortalRenderEnabled = false + DomainSurfaceDebugState.systemPortalInputEnabled = false + val host = DebugDomainRootHost() + + host.render(ctx, 960, 540) + val layout = host.debugLayout() + val commands = host.paint(ctx) + + assertNotNull(layout) + assertTrue(commands.any { it is RenderCommand.DrawText && it.text == "Debug Domain" }) + assertTrue(host.handleMouseDown(layout.panelRect.x + 3, layout.panelRect.y + 3, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(layout.panelRect.x + 3, layout.panelRect.y + 3, MouseButton.LEFT)) + } + + @Test + fun `debug control host uses explicit debug style scope`() { + val host = DebugDomainRootHost() + + assertEquals(StyleApplicationScope.Debug, host.debugStyleScope) + } + + @Test + fun `reset all restores domain-surface render and input toggles`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + DomainSurfaceDebugState.applicationPortalRenderEnabled = false + DomainSurfaceDebugState.applicationPortalInputEnabled = false + DomainSurfaceDebugState.systemPortalRenderEnabled = false + DomainSurfaceDebugState.systemPortalInputEnabled = false + val host = DebugDomainRootHost() + + host.render(ctx, 960, 540) + val layout = host.debugLayout() ?: error("layout missing") + host.paint(ctx) + assertTrue(host.handleMouseDown(layout.resetRect.x + 2, layout.resetRect.y + 2, MouseButton.LEFT)) + + assertTrue(DomainSurfaceDebugState.applicationPortalRenderEnabled) + assertTrue(DomainSurfaceDebugState.applicationPortalInputEnabled) + assertTrue(DomainSurfaceDebugState.systemPortalRenderEnabled) + assertTrue(DomainSurfaceDebugState.systemPortalInputEnabled) + } + + @Test + fun `debug panel toggles mutate independent app and system portal state`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + DomainSurfaceDebugState.resetAll() + val host = DebugDomainRootHost() + + host.render(ctx, 960, 540) + val layout = host.debugLayout() ?: error("layout missing") + host.paint(ctx) + + assertTrue( + host.handleMouseDown( + layout.appPortalRenderRect.x + 2, + layout.appPortalRenderRect.y + 2, + MouseButton.LEFT, + ), + ) + assertFalse(DomainSurfaceDebugState.applicationPortalRenderEnabled) + assertTrue(DomainSurfaceDebugState.applicationPortalInputEnabled) + assertTrue(DomainSurfaceDebugState.systemPortalRenderEnabled) + assertTrue(DomainSurfaceDebugState.systemPortalInputEnabled) + + assertTrue( + host.handleMouseDown( + layout.systemPortalInputRect.x + 2, + layout.systemPortalInputRect.y + 2, + MouseButton.LEFT, + ), + ) + assertFalse(DomainSurfaceDebugState.systemPortalInputEnabled) + assertFalse(DomainSurfaceDebugState.applicationPortalRenderEnabled) + } + + @Test + fun `debug panel status shows fps and frame time`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + DomainSurfaceDebugState.updateFrameTiming(0.025) + val host = DebugDomainRootHost() + + host.render(ctx, 960, 540) + host.paint(ctx) + val commands = host.paint(ctx) + val drawTexts = + commands + .filterIsInstance() + val statusTexts = + drawTexts + .filter { it.sourceKey == "dsgl-debug-domain-status" } + .map { it.text } + val statusTextValue = + assertNotNull( + statusTexts.lastOrNull { it.isNotBlank() } ?: statusTexts.lastOrNull(), + "draw texts: ${drawTexts.joinToString { "${it.sourceKey}:${it.text}" }}", + ) + val expectedFps = DomainSurfaceDebugState.frameFps + val expectedFrameMs = String.format(Locale.US, "%.1f", DomainSurfaceDebugState.frameTimeMs) + val expectedWindowFps = DomainSurfaceDebugState.frameFpsWindow + val expectedWindowFrameMs = String.format(Locale.US, "%.1f", DomainSurfaceDebugState.frameTimeWindowMs) + assertTrue(statusTextValue.contains("FPS:$expectedFps"), "statusText='$statusTextValue'") + assertTrue(statusTextValue.contains("(${expectedFrameMs}ms)"), "statusText='$statusTextValue'") + assertTrue(statusTextValue.contains("AvgFPS:$expectedWindowFps"), "statusText='$statusTextValue'") + assertTrue(statusTextValue.contains("(${expectedWindowFrameMs}ms)"), "statusText='$statusTextValue'") + } + + @Test + fun `toggle button label updates immediately after state change`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + DomainSurfaceDebugState.resetAll() + val host = DebugDomainRootHost() + + host.render(ctx, 960, 540) + val layout = host.debugLayout() ?: error("layout missing") + val initialText = + host + .paint(ctx) + .filterIsInstance() + .lastOrNull { it.sourceKey == "dsgl-debug-domain-toggle-app-render" } + ?.text + assertEquals("ON", initialText) + + assertTrue( + host.handleMouseDown( + layout.appPortalRenderRect.x + 2, + layout.appPortalRenderRect.y + 2, + MouseButton.LEFT, + ), + ) + + host.render(ctx, 960, 540) + val updatedText = + host + .paint(ctx) + .filterIsInstance() + .lastOrNull { it.sourceKey == "dsgl-debug-domain-toggle-app-render" } + ?.text + assertEquals("OFF", updatedText) + } + + @Test + fun `sliding window fps smooths immediate fps`() { + DomainSurfaceDebugState.updateFrameTiming(0.010) + DomainSurfaceDebugState.updateFrameTiming(0.030) + val snapshot = DomainSurfaceDebugState.snapshot() + + assertEquals(33, snapshot.frameFps) + assertEquals(30.0f, snapshot.frameTimeMs) + assertEquals(50, snapshot.frameFpsWindow) + assertEquals(20.0f, snapshot.frameTimeWindowMs) + } + + @Test + fun `controls visibility obeys debug-only toggle`() { + DomainSurfaceDebugState.setControlsEnabledTestOverride(false) + val host = DebugDomainRootHost() + host.render(ctx, 960, 540) + assertTrue(host.paint(ctx).isEmpty()) + + DomainSurfaceDebugState.setControlsEnabledTestOverride(true) + host.render(ctx, 960, 540) + assertTrue(host.paint(ctx).isNotEmpty()) + } + + @Test + fun `debug portal host starts empty but participates as debug portal surface`() { + val host = DebugDomainPortalHost() + + host.onInputFrame(960, 540) + host.render(ctx, 960, 540) + + assertEquals(ScreenDomainSurfaces.DebugPortal, host.surface) + assertTrue(host.paint(ctx).isEmpty()) + assertFalse(host.handleMouseDown(12, 12, MouseButton.LEFT)) + assertTrue(host.debugActivePortalEntryIds.isEmpty()) + } + + @Test + fun `debug portal host dispatches registered portal entries`() { + val host = DebugDomainPortalHost() + val entry = FakeDebugPortalEntry() + host.debugRegisterPortalEntryForTests(entry) + entry.activate() + + host.onInputFrame(960, 540) + host.render(ctx, 960, 540) + val consumed = host.handleMouseDown(14, 14, MouseButton.LEFT) + val commands = host.paint(ctx) + + assertTrue(consumed) + assertEquals(1, entry.inputFrameCalls) + assertEquals(1, entry.renderCalls) + assertEquals(1, entry.mouseDownCalls) + assertEquals(listOf("debug.test"), host.debugActivePortalEntryIds) + assertTrue(commands.any { it is RenderCommand.DrawRect && it.color == 0xFF336699.toInt() }) + } + + @Test + fun `debug domain surfaces remain enabled in state even when app and system portals are disabled`() { + DomainSurfaceDebugState.applicationPortalTintEnabled = false + DomainSurfaceDebugState.applicationPortalRenderEnabled = false + DomainSurfaceDebugState.applicationPortalInputEnabled = false + DomainSurfaceDebugState.systemPortalRenderEnabled = false + DomainSurfaceDebugState.systemPortalTintEnabled = false + DomainSurfaceDebugState.systemPortalInputEnabled = false + + assertTrue(DomainSurfaceDebugState.isRenderEnabled(ScreenDomainSurfaces.DebugRoot)) + assertTrue(DomainSurfaceDebugState.isInputEnabled(ScreenDomainSurfaces.DebugRoot)) + assertTrue(DomainSurfaceDebugState.isRenderEnabled(ScreenDomainSurfaces.DebugPortal)) + assertTrue(DomainSurfaceDebugState.isInputEnabled(ScreenDomainSurfaces.DebugPortal)) + assertEquals( + DomainSurfaceDebugSnapshot( + applicationPortalRenderEnabled = false, + applicationPortalTintEnabled = false, + applicationPortalInputEnabled = false, + systemPortalRenderEnabled = false, + systemPortalTintEnabled = false, + systemPortalInputEnabled = false, + frameFps = 0, + frameTimeMs = 0f, + frameFpsWindow = 0, + frameTimeWindowMs = 0f, + ), + DomainSurfaceDebugState.snapshot(), + ) + } + + private class FakeDebugPortalEntry : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId("debug.test"), + ownerToken = this, + surface = ScreenDomainSurfaces.DebugPortal, + order = PortalEntryOrder(zIndex = 0), + ) + override val node = null + var inputFrameCalls: Int = 0 + private set + var renderCalls: Int = 0 + private set + var mouseDownCalls: Int = 0 + private set + + fun activate() { + state.activate( + PortalEntryPlacement( + anchorBounds = Rect(10, 10, 20, 20), + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, 960, 540), + entryBounds = Rect(12, 12, 100, 80), + ), + ), + ) + } + + override fun onInputFrame(context: PortalFrameContext) { + inputFrameCalls += 1 + } + + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + renderCalls += 1 + } + + override fun paint(ctx: UiMeasureContext): List = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF336699.toInt())) + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + mouseDownCalls += 1 + return true + } + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHostTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHostTests.kt deleted file mode 100644 index 7c675a3..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/debug/OverlayDebugControlHostTests.kt +++ /dev/null @@ -1,188 +0,0 @@ -package org.dreamfinity.dsgl.core.debug - -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.overlay.UiLayerId -import java.util.Locale - -class OverlayDebugControlHostTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - @AfterTest - fun cleanup() { - OverlayLayerDebugState.resetAll() - OverlayLayerDebugState.setControlsEnabledTestOverride(null) - } - - @Test - fun `debug panel remains available when app and system overlays are disabled`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - OverlayLayerDebugState.applicationOverlayRenderEnabled = false - OverlayLayerDebugState.applicationOverlayInputEnabled = false - OverlayLayerDebugState.systemOverlayRenderEnabled = false - OverlayLayerDebugState.systemOverlayInputEnabled = false - val host = OverlayDebugControlHost() - - host.render(960, 540) - val layout = host.debugLayout() - val commands = host.paint(ctx) - - assertNotNull(layout) - assertTrue(commands.any { it is RenderCommand.DrawText && it.text == "Overlay Debug" }) - assertTrue(host.handleMouseDown(layout.panelRect.x + 3, layout.panelRect.y + 3, MouseButton.LEFT)) - assertTrue(host.handleMouseUp(layout.panelRect.x + 3, layout.panelRect.y + 3, MouseButton.LEFT)) - } - - @Test - fun `reset all restores overlay render and input toggles`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - OverlayLayerDebugState.applicationOverlayRenderEnabled = false - OverlayLayerDebugState.applicationOverlayInputEnabled = false - OverlayLayerDebugState.systemOverlayRenderEnabled = false - OverlayLayerDebugState.systemOverlayInputEnabled = false - val host = OverlayDebugControlHost() - - host.render(960, 540) - val layout = host.debugLayout() ?: error("layout missing") - assertTrue(host.handleMouseDown(layout.resetRect.x + 2, layout.resetRect.y + 2, MouseButton.LEFT)) - - assertTrue(OverlayLayerDebugState.applicationOverlayRenderEnabled) - assertTrue(OverlayLayerDebugState.applicationOverlayInputEnabled) - assertTrue(OverlayLayerDebugState.systemOverlayRenderEnabled) - assertTrue(OverlayLayerDebugState.systemOverlayInputEnabled) - } - - @Test - fun `debug panel toggles mutate independent app and system overlay state`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - OverlayLayerDebugState.resetAll() - val host = OverlayDebugControlHost() - - host.render(960, 540) - val layout = host.debugLayout() ?: error("layout missing") - - assertTrue(host.handleMouseDown(layout.appOverlayRenderRect.x + 2, layout.appOverlayRenderRect.y + 2, MouseButton.LEFT)) - assertFalse(OverlayLayerDebugState.applicationOverlayRenderEnabled) - assertTrue(OverlayLayerDebugState.applicationOverlayInputEnabled) - assertTrue(OverlayLayerDebugState.systemOverlayRenderEnabled) - assertTrue(OverlayLayerDebugState.systemOverlayInputEnabled) - - assertTrue(host.handleMouseDown(layout.systemOverlayInputRect.x + 2, layout.systemOverlayInputRect.y + 2, MouseButton.LEFT)) - assertFalse(OverlayLayerDebugState.systemOverlayInputEnabled) - assertFalse(OverlayLayerDebugState.applicationOverlayRenderEnabled) - } - - @Test - fun `debug panel status shows fps and frame time`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - OverlayLayerDebugState.updateFrameTiming(0.025) - val host = OverlayDebugControlHost() - - host.render(960, 540) - host.paint(ctx) - val commands = host.paint(ctx) - val drawTexts = commands - .filterIsInstance() - val statusTexts = drawTexts - .filter { it.sourceKey == "dsgl-overlay-debug-status" } - .map { it.text } - val statusTextValue = assertNotNull( - statusTexts.lastOrNull { it.isNotBlank() } ?: statusTexts.lastOrNull(), - "draw texts: ${drawTexts.joinToString { "${it.sourceKey}:${it.text}" }}" - ) - val expectedFps = OverlayLayerDebugState.frameFps - val expectedFrameMs = String.format(Locale.US, "%.1f", OverlayLayerDebugState.frameTimeMs) - val expectedWindowFps = OverlayLayerDebugState.frameFpsWindow - val expectedWindowFrameMs = String.format(Locale.US, "%.1f", OverlayLayerDebugState.frameTimeWindowMs) - assertTrue(statusTextValue.contains("FPS:$expectedFps"), "statusText='$statusTextValue'") - assertTrue(statusTextValue.contains("(${expectedFrameMs}ms)"), "statusText='$statusTextValue'") - assertTrue(statusTextValue.contains("AvgFPS:$expectedWindowFps"), "statusText='$statusTextValue'") - assertTrue(statusTextValue.contains("(${expectedWindowFrameMs}ms)"), "statusText='$statusTextValue'") - } - - @Test - fun `toggle button label updates immediately after state change`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - OverlayLayerDebugState.resetAll() - val host = OverlayDebugControlHost() - - host.render(960, 540) - val layout = host.debugLayout() ?: error("layout missing") - val initialText = host.paint(ctx) - .filterIsInstance() - .lastOrNull { it.sourceKey == "dsgl-overlay-debug-toggle-app-render" } - ?.text - assertEquals("ON", initialText) - - assertTrue(host.handleMouseDown(layout.appOverlayRenderRect.x + 2, layout.appOverlayRenderRect.y + 2, MouseButton.LEFT)) - - host.render(960, 540) - val updatedText = host.paint(ctx) - .filterIsInstance() - .lastOrNull { it.sourceKey == "dsgl-overlay-debug-toggle-app-render" } - ?.text - assertEquals("OFF", updatedText) - } - - @Test - fun `sliding window fps smooths immediate fps`() { - OverlayLayerDebugState.updateFrameTiming(0.010) - OverlayLayerDebugState.updateFrameTiming(0.030) - val snapshot = OverlayLayerDebugState.snapshot() - - assertEquals(33, snapshot.frameFps) - assertEquals(30.0f, snapshot.frameTimeMs) - assertEquals(50, snapshot.frameFpsWindow) - assertEquals(20.0f, snapshot.frameTimeWindowMs) - } - - @Test - fun `controls visibility obeys debug-only toggle`() { - OverlayLayerDebugState.setControlsEnabledTestOverride(false) - val host = OverlayDebugControlHost() - host.render(960, 540) - assertTrue(host.paint(ctx).isEmpty()) - - OverlayLayerDebugState.setControlsEnabledTestOverride(true) - host.render(960, 540) - assertTrue(host.paint(ctx).isNotEmpty()) - } - - @Test - fun `debug layer remains enabled in state even when app and system layers are disabled`() { - OverlayLayerDebugState.applicationOverlayTintEnabled = false - OverlayLayerDebugState.applicationOverlayRenderEnabled = false - OverlayLayerDebugState.applicationOverlayInputEnabled = false - OverlayLayerDebugState.systemOverlayRenderEnabled = false - OverlayLayerDebugState.systemOverlayTintEnabled = false - OverlayLayerDebugState.systemOverlayInputEnabled = false - - assertTrue(OverlayLayerDebugState.isRenderEnabled(UiLayerId.Debug)) - assertTrue(OverlayLayerDebugState.isInputEnabled(UiLayerId.Debug)) - assertEquals( - OverlayLayerDebugSnapshot( - applicationOverlayRenderEnabled = false, - applicationOverlayTintEnabled = false, - applicationOverlayInputEnabled = false, - systemOverlayRenderEnabled = false, - systemOverlayTintEnabled = false, - systemOverlayInputEnabled = false, - frameFps = 0, - frameTimeMs = 0f, - frameFpsWindow = 0, - frameTimeWindowMs = 0f - ), - OverlayLayerDebugState.snapshot() - ) - } -} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooksRuntimeIntegrationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooksRuntimeIntegrationTests.kt index f12caa3..e419e17 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooksRuntimeIntegrationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/DndHooksRuntimeIntegrationTests.kt @@ -2,34 +2,34 @@ package org.dreamfinity.dsgl.core.dnd import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.DsglWindow -import org.dreamfinity.dsgl.core.hooks.HookUsageException import org.dreamfinity.dsgl.core.dnd.internal.DefaultDndEngine +import org.dreamfinity.dsgl.core.hooks.HookUsageException +import java.util.WeakHashMap import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNotNull -import java.util.WeakHashMap class DndHooksRuntimeIntegrationTests { @Test fun `dnd hooks are callable from UiScope without window qualification`() { val baselineListeners = monitorListenersSnapshot().keys - val window = object : DsglWindow() { - override fun render(): DomTree { - return ui { - useDraggable(id = "card-a", nodeKey = "card-a") - useDroppable(id = "lane-a", nodeKey = "lane-a") - useSortable( - id = "card-b", - nodeKey = "card-b", - containerId = "lane-a", - items = listOf("card-b") - ) - useDragDropMonitor(DragDropMonitorCallbacks()) - } + val window = + object : DsglWindow() { + override fun render(): DomTree = + ui { + useDraggable(id = "card-a", nodeKey = "card-a") + useDroppable(id = "lane-a", nodeKey = "lane-a") + useSortable( + id = "card-b", + nodeKey = "card-b", + containerId = "lane-a", + items = listOf("card-b"), + ) + useDragDropMonitor(DragDropMonitorCallbacks()) + } } - } try { renderWithHookSession(window, commit = true) @@ -42,19 +42,20 @@ class DndHooksRuntimeIntegrationTests { @Test fun `duplicate hook-component identity in same render fails loudly`() { - val window = object : DsglWindow() { - override fun render(): DomTree { - return ui { - useDraggable(id = "card-a", nodeKey = "same") - useDraggable(id = "card-b", nodeKey = "same") - } + val window = + object : DsglWindow() { + override fun render(): DomTree = + ui { + useDraggable(id = "card-a", nodeKey = "same") + useDraggable(id = "card-b", nodeKey = "same") + } } - } window.beginRenderBuild() - val error = assertFailsWith { - window.render() - } + val error = + assertFailsWith { + window.render() + } assertEquals(error.message?.contains("Duplicate component identity"), true) window.endRenderBuild() } @@ -63,19 +64,19 @@ class DndHooksRuntimeIntegrationTests { fun `drag-drop monitor keeps one subscription and refreshes callbacks`() { val baselineListeners = monitorListenersSnapshot() val callbackCalls: MutableList = arrayListOf() - val window = object : DsglWindow() { - var callbackVersion: String = "v1" - - override fun render(): DomTree { - return ui { - useDragDropMonitor( - DragDropMonitorCallbacks( - onDragCancel = { callbackCalls += callbackVersion } + val window = + object : DsglWindow() { + var callbackVersion: String = "v1" + + override fun render(): DomTree = + ui { + useDragDropMonitor( + DragDropMonitorCallbacks( + onDragCancel = { callbackCalls += callbackVersion }, + ), ) - ) - } + } } - } try { renderWithHookSession(window, commit = true) @@ -102,17 +103,17 @@ class DndHooksRuntimeIntegrationTests { @Test fun `drag-drop monitor cleans up on disappearance and re-subscribes on reappearance`() { val baselineCount = monitorListenersSnapshot().size - val window = object : DsglWindow() { - var showMonitor: Boolean = true - - override fun render(): DomTree { - return ui { - if (showMonitor) { - useDragDropMonitor(DragDropMonitorCallbacks()) + val window = + object : DsglWindow() { + var showMonitor: Boolean = true + + override fun render(): DomTree = + ui { + if (showMonitor) { + useDragDropMonitor(DragDropMonitorCallbacks()) + } } - } } - } try { renderWithHookSession(window, commit = true) @@ -136,22 +137,22 @@ class DndHooksRuntimeIntegrationTests { @Test fun `sortable container state persists while mounted and resets after disappearance`() { - val window = object : DsglWindow() { - var showSortable: Boolean = true - - override fun render(): DomTree { - return ui { - if (showSortable) { - useSortable( - id = "card-a", - nodeKey = "card-a", - containerId = "lane", - items = listOf("card-a") - ) + val window = + object : DsglWindow() { + var showSortable: Boolean = true + + override fun render(): DomTree = + ui { + if (showSortable) { + useSortable( + id = "card-a", + nodeKey = "card-a", + containerId = "lane", + items = listOf("card-a"), + ) + } } - } } - } try { renderWithHookSession(window, commit = true) @@ -220,8 +221,8 @@ class DndHooksRuntimeIntegrationTests { return System.identityHashCode(state) } - private fun sampleActiveDrag(): ActiveDrag { - return ActiveDrag( + private fun sampleActiveDrag(): ActiveDrag = + ActiveDrag( id = "sample", type = "sample", sourceKey = "source", @@ -231,7 +232,6 @@ class DndHooksRuntimeIntegrationTests { cursorY = 0, transform = Transform(0.0, 0.0), dropEffect = DropEffect.NONE, - dataTransfer = DataTransfer() + dataTransfer = DataTransfer(), ) - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngineTests.kt index e448256..665bee7 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dnd/internal/DefaultDndEngineTests.kt @@ -2,24 +2,39 @@ package org.dreamfinity.dsgl.core.dnd.internal import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode +import org.dreamfinity.dsgl.core.dom.elements.TextInputNode import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.event.MouseDragEvent import org.dreamfinity.dsgl.core.event.collectHoverChain import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class DefaultDndEngineTests { + @AfterTest + fun cleanup() { + DefaultDndEngine.cancelActiveDrag() + } + @Test fun `drop target selection prefers deepest candidate over previous ancestor`() { val list = ContainerNode(key = "list") val folder = ContainerNode(key = "folder") - val selected = DefaultDndEngine.selectDropTargetCandidate( - candidates = listOf(list, folder), - previousTarget = list - ) + val selected = + DefaultDndEngine.selectDropTargetCandidate( + candidates = listOf(list, folder), + previousTarget = list, + ) assertSame(folder, selected) } @@ -28,34 +43,37 @@ class DefaultDndEngineTests { fun `drop target selection keeps deepest candidate when already selected`() { val folder = ContainerNode(key = "folder") - val selected = DefaultDndEngine.selectDropTargetCandidate( - candidates = listOf(folder), - previousTarget = folder - ) + val selected = + DefaultDndEngine.selectDropTargetCandidate( + candidates = listOf(folder), + previousTarget = folder, + ) assertSame(folder, selected) } @Test fun `shiftCommand translates checkerboard command without changing checker offsets`() { - val shift = DefaultDndEngine::class.java.getDeclaredMethod( - "shiftCommand", - RenderCommand::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType - ) + val shift = + DefaultDndEngine::class.java.getDeclaredMethod( + "shiftCommand", + RenderCommand::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) shift.isAccessible = true - val command = RenderCommand.DrawCheckerboard( - x = 10, - y = 15, - width = 20, - height = 25, - cellSize = 4, - lightColor = 0xFFCCDDEE.toInt(), - darkColor = 0xFF445566.toInt(), - offsetX = 3, - offsetY = 7 - ) + val command = + RenderCommand.DrawCheckerboard( + x = 10, + y = 15, + width = 20, + height = 25, + cellSize = 4, + lightColor = 0xFFCCDDEE.toInt(), + darkColor = 0xFF445566.toInt(), + offsetX = 3, + offsetY = 7, + ) val shifted = shift.invoke(DefaultDndEngine, command, 8, -5) as RenderCommand.DrawCheckerboard @@ -67,38 +85,136 @@ class DefaultDndEngineTests { @Test fun `drop target selection returns null for empty candidates`() { - val selected = DefaultDndEngine.selectDropTargetCandidate( - candidates = emptyList(), - previousTarget = null - ) + val selected = + DefaultDndEngine.selectDropTargetCandidate( + candidates = emptyList(), + previousTarget = null, + ) assertNull(selected) } + @Test + fun `cancelled mouse down does not arm dnd`() { + val root = ContainerNode(key = "dnd-cancel-root") + val draggable = + ContainerNode(key = "dnd-cancel-source") + .apply { + draggable = true + width = 80 + height = 20 + }.applyParent(root) + root.render(testMeasureContext(), 0, 0, 200, 120) + val down = + MouseDownEvent(10, 10, MouseButton.LEFT).also { event -> + event.target = draggable + event.cancelled = true + } + + DefaultDndEngine.onMouseDown(root, draggable, down) + DefaultDndEngine.onMouseMove(root, 80, 10) + + assertFalse(DefaultDndEngine.isDragging) + } + + @Test + fun `range input consumes pointer sequence before draggable ancestor can arm dnd`() { + val root = ContainerNode(key = "dnd-range-root") + val draggableParent = + ContainerNode(key = "dnd-range-parent") + .apply { + draggable = true + width = 160 + height = 40 + }.applyParent(root) + val range = + RangeInputNode(value = 0L, min = 0L, max = 100L, key = "dnd-range") + .applyParent(draggableParent) + root.render(testMeasureContext(), 0, 0, 200, 120) + range.render(testMeasureContext(), 10, 10, 120, 12) + val down = + MouseDownEvent(10, 16, MouseButton.LEFT).also { event -> + event.target = range + } + val drag = + MouseDragEvent(10, 16, 110, 0, MouseButton.LEFT).also { event -> + event.target = range + } + + EventBus.post(down) + DefaultDndEngine.onMouseDown(root, range, down) + EventBus.post(drag) + DefaultDndEngine.onMouseMove(root, 120, 16) + + assertTrue(range.value > 0L) + assertTrue(drag.cancelled) + assertFalse(DefaultDndEngine.isDragging) + } + + @Test + fun `text input selection drag consumes before draggable ancestor can arm dnd`() { + val root = ContainerNode(key = "dnd-text-root") + val draggableParent = + ContainerNode(key = "dnd-text-parent") + .apply { + draggable = true + width = 180 + height = 40 + }.applyParent(root) + val input = + TextInputNode(text = "abcdef", key = "dnd-text-input") + .applyParent(draggableParent) + root.render(testMeasureContext(), 0, 0, 240, 120) + input.render(testMeasureContext(), 10, 10, 120, 20) + val down = + MouseDownEvent(12, 16, MouseButton.LEFT).also { event -> + event.target = input + } + val drag = + MouseDragEvent(12, 16, 90, 0, MouseButton.LEFT).also { event -> + event.target = input + } + + EventBus.post(down) + DefaultDndEngine.onMouseDown(root, input, down) + EventBus.post(drag) + DefaultDndEngine.onMouseMove(root, 102, 16) + + assertFalse(DefaultDndEngine.isDragging) + assertTrue(drag.cancelled) + } + @Test fun `hover chain remains coherent for dnd candidate selection after core hover migration`() { val root = ContainerNode(key = "dnd-root") - val parent = ContainerNode(key = "dnd-parent").apply { - width = 120 - height = 60 - droppable = true - }.applyParent(root) - val child = ContainerNode(key = "dnd-child").apply { - width = 34 - height = 16 - droppable = true - }.applyParent(parent) + val parent = + ContainerNode(key = "dnd-parent") + .apply { + width = 120 + height = 60 + droppable = true + }.applyParent(root) + val child = + ContainerNode(key = "dnd-child") + .apply { + width = 34 + height = 16 + droppable = true + }.applyParent(parent) root.render( - ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - }, + ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + }, x = 0, y = 0, width = 260, - height = 140 + height = 140, ) val chain = collectHoverChain(root, 10, 10) @@ -108,4 +224,15 @@ class DefaultDndEngineTests { assertSame(child, selected) assertEquals(listOf(root, parent, child), chain) } + + private fun testMeasureContext(): UiMeasureContext = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEventsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEventsTests.kt index aa4ed1b..4cb53c6 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEventsTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ContextMenuEventsTests.kt @@ -1,7 +1,7 @@ package org.dreamfinity.dsgl.core.dom -import org.dreamfinity.dsgl.core.contextmenu.ContextMenuHost import org.dreamfinity.dsgl.core.contextmenu.ContextMenuModel +import org.dreamfinity.dsgl.core.contextmenu.ContextMenuPortalService import org.dreamfinity.dsgl.core.contextmenu.contextMenu import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect @@ -13,7 +13,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class ContextMenuEventsTests { - private class RecordingHost : ContextMenuHost { + private class RecordingPortalService : ContextMenuPortalService { var openedModel: ContextMenuModel? = null var openedAtCursor: Pair? = null var openedAnchored: Rect? = null @@ -35,13 +35,14 @@ class ContextMenuEventsTests { @Test fun `openMenu in context handler uses mouse root coordinates`() { - val host = RecordingHost() + val host = RecordingPortalService() val node = ContainerNode() node.bounds = Rect(10, 15, 120, 30) - val model = contextMenu(id = "events.cursor") { - item("Open") - } - node.onContextMenu(host = host) { + val model = + contextMenu(id = "events.cursor") { + item("Open") + } + node.onContextMenu(portalService = host) { openMenu(model) } @@ -55,13 +56,14 @@ class ContextMenuEventsTests { @Test fun `openMenuAnchored in context handler uses target bounds`() { - val host = RecordingHost() + val host = RecordingPortalService() val node = ContainerNode() node.bounds = Rect(22, 41, 86, 19) - val model = contextMenu(id = "events.anchor") { - item("Open") - } - node.onContextMenu(host = host) { + val model = + contextMenu(id = "events.anchor") { + item("Open") + } + node.onContextMenu(portalService = host) { openMenuAnchored(model) } @@ -77,15 +79,16 @@ class ContextMenuEventsTests { @Test fun `context menu inherits font settings from triggering node when model omits them`() { - val host = RecordingHost() + val host = RecordingPortalService() val node = ContainerNode() node.bounds = Rect(22, 41, 86, 19) node.fontId = "test-font" node.fontSize = 24 - val model = contextMenu(id = "events.font") { - item("Open") - } - node.onContextMenu(host = host) { + val model = + contextMenu(id = "events.font") { + item("Open") + } + node.onContextMenu(portalService = host) { openMenu(model) } @@ -101,7 +104,7 @@ class ContextMenuEventsTests { @Test fun `context menu inherits font from clicked target on repeated opens`() { - val host = RecordingHost() + val host = RecordingPortalService() val parent = ContainerNode() val child = ContainerNode() child.parent = parent @@ -110,10 +113,11 @@ class ContextMenuEventsTests { child.bounds = Rect(10, 10, 60, 16) child.fontId = "child-font" child.fontSize = 28 - val model = contextMenu(id = "events.target.font") { - item("Open") - } - parent.onContextMenu(host = host) { + val model = + contextMenu(id = "events.target.font") { + item("Open") + } + parent.onContextMenu(portalService = host) { openMenu(model) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowClippingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowClippingTests.kt index 5e6429a..67016d4 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowClippingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowClippingTests.kt @@ -1,8 +1,5 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Insets @@ -11,23 +8,31 @@ import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class OverflowClippingTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @Test fun `overflow hidden container emits clip around child paint`() { val childColor = 0xFF0A8A3C.toInt() val root = ContainerNode(key = "root") - val clip = ContainerNode(backgroundColor = 0xFF1B2330.toInt(), key = "clip").apply { - width = 80 - height = 40 - overflow = Overflow.Hidden - }.applyParent(root) + val clip = + ContainerNode(backgroundColor = 0xFF1B2330.toInt(), key = "clip") + .apply { + width = 80 + height = 40 + overflow = Overflow.Hidden + }.applyParent(root) FixedPaintNode(color = childColor, nodeWidth = 120, nodeHeight = 20, key = "child") .applyParent(clip) @@ -35,23 +40,26 @@ class OverflowClippingTests { tree.render(ctx, 200, 120) val commands = tree.paint(ctx, applyStyles = false) - val clipPushIndex = commands.indexOfFirst { command -> - command is RenderCommand.PushClip && - command.x == clip.bounds.x && - command.y == clip.bounds.y && - command.width == clip.bounds.width && - command.height == clip.bounds.height - } + val clipPushIndex = + commands.indexOfFirst { command -> + command is RenderCommand.PushClip && + command.x == clip.bounds.x && + command.y == clip.bounds.y && + command.width == clip.bounds.width && + command.height == clip.bounds.height + } assertTrue(clipPushIndex >= 0) - val childDrawIndex = commands.indexOfFirst { command -> - command is RenderCommand.DrawRect && command.color == childColor - } + val childDrawIndex = + commands.indexOfFirst { command -> + command is RenderCommand.DrawRect && command.color == childColor + } assertTrue(childDrawIndex > clipPushIndex) - val clipPopIndex = commands.indices.firstOrNull { index -> - index > childDrawIndex && commands[index] is RenderCommand.PopClip - } ?: -1 + val clipPopIndex = + commands.indices.firstOrNull { index -> + index > childDrawIndex && commands[index] is RenderCommand.PopClip + } ?: -1 assertTrue(clipPopIndex > childDrawIndex) val pushCount = commands.count { it is RenderCommand.PushClip } @@ -63,13 +71,16 @@ class OverflowClippingTests { fun `overflow clipping preserves child geometry for partial visibility`() { val childColor = 0xFFB0522D.toInt() val root = ContainerNode(key = "root") - val clip = ContainerNode(backgroundColor = 0xFF18212C.toInt(), key = "clip").apply { - width = 80 - height = 40 - overflow = Overflow.Hidden - }.applyParent(root) - val child = FixedPaintNode(color = childColor, nodeWidth = 120, nodeHeight = 28, key = "child") - .applyParent(clip) + val clip = + ContainerNode(backgroundColor = 0xFF18212C.toInt(), key = "clip") + .apply { + width = 80 + height = 40 + overflow = Overflow.Hidden + }.applyParent(root) + val child = + FixedPaintNode(color = childColor, nodeWidth = 120, nodeHeight = 28, key = "child") + .applyParent(clip) val tree = DomTree(root) tree.render(ctx, 220, 120) @@ -78,10 +89,11 @@ class OverflowClippingTests { assertEquals(120, child.bounds.width) assertTrue(child.bounds.x + child.bounds.width > clip.bounds.x + clip.bounds.width) - val childDraw = commands - .filterIsInstance() - .firstOrNull { it.color == childColor } - ?: error("child draw command missing") + val childDraw = + commands + .filterIsInstance() + .firstOrNull { it.color == childColor } + ?: error("child draw command missing") assertEquals(120, childDraw.width) } @@ -89,17 +101,21 @@ class OverflowClippingTests { fun `nested overflow containers emit nested clip commands`() { val leafColor = 0xFF3F7FBF.toInt() val root = ContainerNode(key = "root") - val outer = ContainerNode(backgroundColor = 0xFF101820.toInt(), key = "outer").apply { - width = 100 - height = 80 - overflow = Overflow.Hidden - }.applyParent(root) - val inner = ContainerNode(backgroundColor = 0xFF182230.toInt(), key = "inner").apply { - width = 60 - height = 40 - margin = Insets(top = 10, right = 0, bottom = 0, left = 20) - overflow = Overflow.Hidden - }.applyParent(outer) + val outer = + ContainerNode(backgroundColor = 0xFF101820.toInt(), key = "outer") + .apply { + width = 100 + height = 80 + overflow = Overflow.Hidden + }.applyParent(root) + val inner = + ContainerNode(backgroundColor = 0xFF182230.toInt(), key = "inner") + .apply { + width = 60 + height = 40 + margin = Insets(top = 10, right = 0, bottom = 0, left = 20) + overflow = Overflow.Hidden + }.applyParent(outer) FixedPaintNode(color = leafColor, nodeWidth = 120, nodeHeight = 24, key = "leaf") .applyParent(inner) @@ -107,23 +123,26 @@ class OverflowClippingTests { tree.render(ctx, 240, 180) val commands = tree.paint(ctx, applyStyles = false) - val outerClipIndex = commands.indexOfFirst { command -> - command is RenderCommand.PushClip && - command.x == outer.bounds.x && - command.y == outer.bounds.y && - command.width == outer.bounds.width && - command.height == outer.bounds.height - } - val innerClipIndex = commands.indexOfFirst { command -> - command is RenderCommand.PushClip && - command.x == inner.bounds.x && - command.y == inner.bounds.y && - command.width == inner.bounds.width && - command.height == inner.bounds.height - } - val leafDrawIndex = commands.indexOfFirst { command -> - command is RenderCommand.DrawRect && command.color == leafColor - } + val outerClipIndex = + commands.indexOfFirst { command -> + command is RenderCommand.PushClip && + command.x == outer.bounds.x && + command.y == outer.bounds.y && + command.width == outer.bounds.width && + command.height == outer.bounds.height + } + val innerClipIndex = + commands.indexOfFirst { command -> + command is RenderCommand.PushClip && + command.x == inner.bounds.x && + command.y == inner.bounds.y && + command.width == inner.bounds.width && + command.height == inner.bounds.height + } + val leafDrawIndex = + commands.indexOfFirst { command -> + command is RenderCommand.DrawRect && command.color == leafColor + } assertTrue(outerClipIndex >= 0) assertTrue(innerClipIndex > outerClipIndex) @@ -133,21 +152,26 @@ class OverflowClippingTests { val popCount = commands.count { it is RenderCommand.PopClip } assertEquals(pushCount, popCount) } + @Test fun `nested overflow still emits child clip when child extends beyond parent`() { val leafColor = 0xFF46A0D8.toInt() val root = ContainerNode(key = "root") - val outer = ContainerNode(backgroundColor = 0xFF111820.toInt(), key = "outer").apply { - width = 100 - height = 50 - overflow = Overflow.Hidden - }.applyParent(root) - val inner = ContainerNode(backgroundColor = 0xFF182536.toInt(), key = "inner").apply { - width = 80 - height = 40 - margin = Insets(top = 8, right = 0, bottom = 0, left = 72) - overflow = Overflow.Hidden - }.applyParent(outer) + val outer = + ContainerNode(backgroundColor = 0xFF111820.toInt(), key = "outer") + .apply { + width = 100 + height = 50 + overflow = Overflow.Hidden + }.applyParent(root) + val inner = + ContainerNode(backgroundColor = 0xFF182536.toInt(), key = "inner") + .apply { + width = 80 + height = 40 + margin = Insets(top = 8, right = 0, bottom = 0, left = 72) + overflow = Overflow.Hidden + }.applyParent(outer) FixedPaintNode(color = leafColor, nodeWidth = 120, nodeHeight = 24, key = "leaf") .applyParent(inner) @@ -155,26 +179,29 @@ class OverflowClippingTests { tree.render(ctx, 240, 180) val commands = tree.paint(ctx, applyStyles = false) - val outerClip = commands - .filterIsInstance() - .firstOrNull { push -> - push.x == outer.bounds.x && - push.y == outer.bounds.y && - push.width == outer.bounds.width && - push.height == outer.bounds.height - } - ?: error("outer clip command missing") - - val expectedInner = Rect(inner.bounds.x, inner.bounds.y, inner.bounds.width, inner.bounds.height) - .intersection(Rect(outerClip.x, outerClip.y, outerClip.width, outerClip.height)) - ?: error("inner should intersect outer") + val outerClip = + commands + .filterIsInstance() + .firstOrNull { push -> + push.x == outer.bounds.x && + push.y == outer.bounds.y && + push.width == outer.bounds.width && + push.height == outer.bounds.height + } + ?: error("outer clip command missing") + + val expectedInner = + Rect(inner.bounds.x, inner.bounds.y, inner.bounds.width, inner.bounds.height) + .intersection(Rect(outerClip.x, outerClip.y, outerClip.width, outerClip.height)) + ?: error("inner should intersect outer") val pushClips = commands.filterIsInstance() - val outerClipIndex = pushClips.indexOfFirst { push -> - push.x == outerClip.x && - push.y == outerClip.y && - push.width == outerClip.width && - push.height == outerClip.height - } + val outerClipIndex = + pushClips.indexOfFirst { push -> + push.x == outerClip.x && + push.y == outerClip.y && + push.width == outerClip.width && + push.height == outerClip.height + } assertTrue(outerClipIndex >= 0) val innerClip = pushClips.getOrNull(outerClipIndex + 1) ?: error("inner clip command missing") assertEquals(inner.bounds.x, innerClip.x) @@ -183,11 +210,12 @@ class OverflowClippingTests { assertEquals(inner.bounds.height, innerClip.height) assertTrue(innerClip.x < expectedInner.x + expectedInner.width) } + private class FixedPaintNode( private val color: Int, private val nodeWidth: Int, private val nodeHeight: Int, - key: Any? = null + key: Any? = null, ) : DOMNode(key) { init { width = nodeWidth @@ -202,6 +230,3 @@ class OverflowClippingTests { } } } - - - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowInputClippingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowInputClippingTests.kt index 5797566..c694cb8 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowInputClippingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowInputClippingTests.kt @@ -1,41 +1,46 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class OverflowInputClippingTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @Test fun `generic clipped container rejects pointer input outside viewport`() { - listOf("app-dom", "app-overlay", "system-overlay").forEach { layer -> - val (root, router) = createLayerRouter(layer) + listOf("app-dom", "app-portal", "system-portal").forEach { layer -> + val (root, router) = createSurfaceRouter(layer) var clicks = 0 - val clippedViewport = ContainerNode(key = "$layer-clip").apply { - bounds = Rect(20, 20, 100, 40) - overflow = Overflow.Hidden - } + val clippedViewport = + ContainerNode(key = "$layer-clip").apply { + bounds = Rect(20, 20, 100, 40) + overflow = Overflow.Hidden + } clippedViewport.applyParent(root) - val child = ButtonNode("child", key = "$layer-child").apply { - bounds = Rect(24, 46, 80, 22) - onClick { clicks += 1 } - } + val child = + ButtonNode("child", key = "$layer-child").apply { + bounds = Rect(24, 46, 80, 22) + onClick { clicks += 1 } + } child.applyParent(clippedViewport) assertFalse(router.handleMouseDown(30, 65, MouseButton.LEFT)) @@ -46,19 +51,21 @@ class OverflowInputClippingTests { @Test fun `generic partial visibility limits pointer interaction to visible area`() { - val (root, router) = createLayerRouter("partial-visible") + val (root, router) = createSurfaceRouter("partial-visible") var clicks = 0 - val clippedViewport = ContainerNode(key = "partial-clip").apply { - bounds = Rect(20, 20, 100, 40) - overflow = Overflow.Hidden - } + val clippedViewport = + ContainerNode(key = "partial-clip").apply { + bounds = Rect(20, 20, 100, 40) + overflow = Overflow.Hidden + } clippedViewport.applyParent(root) - val child = ButtonNode("child", key = "partial-child").apply { - bounds = Rect(24, 46, 80, 22) - onClick { clicks += 1 } - } + val child = + ButtonNode("child", key = "partial-child").apply { + bounds = Rect(24, 46, 80, 22) + onClick { clicks += 1 } + } child.applyParent(clippedViewport) assertTrue(router.handleMouseDown(30, 58, MouseButton.LEFT)) @@ -72,25 +79,28 @@ class OverflowInputClippingTests { @Test fun `nested clipped containers intersect effective input clip`() { - val (root, router) = createLayerRouter("nested-clip") + val (root, router) = createSurfaceRouter("nested-clip") var clicks = 0 - val outer = ContainerNode(key = "outer").apply { - bounds = Rect(10, 10, 130, 90) - overflow = Overflow.Hidden - } + val outer = + ContainerNode(key = "outer").apply { + bounds = Rect(10, 10, 130, 90) + overflow = Overflow.Hidden + } outer.applyParent(root) - val inner = ContainerNode(key = "inner").apply { - bounds = Rect(20, 20, 90, 40) - overflow = Overflow.Hidden - } + val inner = + ContainerNode(key = "inner").apply { + bounds = Rect(20, 20, 90, 40) + overflow = Overflow.Hidden + } inner.applyParent(outer) - val child = ButtonNode("child", key = "nested-child").apply { - bounds = Rect(24, 52, 80, 24) - onClick { clicks += 1 } - } + val child = + ButtonNode("child", key = "nested-child").apply { + bounds = Rect(24, 52, 80, 24) + onClick { clicks += 1 } + } child.applyParent(inner) assertTrue(router.handleMouseDown(30, 58, MouseButton.LEFT)) @@ -102,28 +112,30 @@ class OverflowInputClippingTests { assertEquals(1, clicks) } - @Test fun `nested clipped containers clamp interaction to parent child intersection`() { - val (root, router) = createLayerRouter("nested-intersection") + val (root, router) = createSurfaceRouter("nested-intersection") var clicks = 0 - val outer = ContainerNode(key = "outer").apply { - bounds = Rect(10, 10, 100, 60) - overflow = Overflow.Hidden - } + val outer = + ContainerNode(key = "outer").apply { + bounds = Rect(10, 10, 100, 60) + overflow = Overflow.Hidden + } outer.applyParent(root) - val inner = ContainerNode(key = "inner").apply { - bounds = Rect(80, 20, 70, 40) - overflow = Overflow.Hidden - } + val inner = + ContainerNode(key = "inner").apply { + bounds = Rect(80, 20, 70, 40) + overflow = Overflow.Hidden + } inner.applyParent(outer) - val child = ButtonNode("child", key = "intersection-child").apply { - bounds = Rect(84, 24, 60, 24) - onClick { clicks += 1 } - } + val child = + ButtonNode("child", key = "intersection-child").apply { + bounds = Rect(84, 24, 60, 24) + onClick { clicks += 1 } + } child.applyParent(inner) // Visible point inside effective intersection. @@ -136,33 +148,38 @@ class OverflowInputClippingTests { assertFalse(router.handleMouseUp(118, 30, MouseButton.LEFT)) assertEquals(1, clicks) } + @Test fun `paint and pointer clipping stay consistent for clipped containers`() { - val (root, router) = createLayerRouter("paint-input-consistency") + val (root, router) = createSurfaceRouter("paint-input-consistency") var clicks = 0 - val clippedViewport = ContainerNode(backgroundColor = 0xFF112233.toInt(), key = "clip").apply { - bounds = Rect(20, 20, 100, 40) - overflow = Overflow.Hidden - } + val clippedViewport = + ContainerNode(backgroundColor = 0xFF112233.toInt(), key = "clip").apply { + bounds = Rect(20, 20, 100, 40) + overflow = Overflow.Hidden + } clippedViewport.applyParent(root) - val child = ButtonNode("child", key = "clip-child").apply { - bounds = Rect(24, 46, 80, 22) - onClick { clicks += 1 } - } + val child = + ButtonNode("child", key = "clip-child").apply { + bounds = Rect(24, 46, 80, 22) + onClick { clicks += 1 } + } child.applyParent(clippedViewport) val commands = ArrayList() root.appendRenderCommands(ctx, commands) - assertTrue(commands.any { command -> - command is RenderCommand.PushClip && - command.x == clippedViewport.bounds.x && - command.y == clippedViewport.bounds.y && - command.width == clippedViewport.bounds.width && - command.height == clippedViewport.bounds.height - }) + assertTrue( + commands.any { command -> + command is RenderCommand.PushClip && + command.x == clippedViewport.bounds.x && + command.y == clippedViewport.bounds.y && + command.width == clippedViewport.bounds.width && + command.height == clippedViewport.bounds.height + }, + ) assertTrue(router.handleMouseDown(30, 58, MouseButton.LEFT)) assertTrue(router.handleMouseUp(30, 58, MouseButton.LEFT)) @@ -173,10 +190,11 @@ class OverflowInputClippingTests { assertEquals(1, clicks) } - private fun createLayerRouter(key: String): Pair { - val root = ContainerNode(key = "$key-root").apply { - bounds = Rect(0, 0, 320, 200) - } - return root to LayerDomInputRouter { root } + private fun createSurfaceRouter(key: String): Pair { + val root = + ContainerNode(key = "$key-root").apply { + bounds = Rect(0, 0, 320, 200) + } + return root to SurfaceDomInputRouter { root } } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowPositionedClippingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowPositionedClippingTests.kt index 7364b37..afa1495 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowPositionedClippingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/OverflowPositionedClippingTests.kt @@ -1,10 +1,5 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -19,13 +14,21 @@ import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class OverflowPositionedClippingTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -36,15 +39,18 @@ class OverflowPositionedClippingTests { @Test fun `overflow auto keeps clipping for ordinary in-flow child`() { val root = ContainerNode(key = "root") - val clip = ContainerNode(key = "clip").apply { - width = 80 - height = 40 - overflow = Overflow.Auto - }.applyParent(root) - ButtonNode("child", key = "child").apply { - width = 120 - height = 16 - }.applyParent(clip) + val clip = + ContainerNode(key = "clip") + .apply { + width = 80 + height = 40 + overflow = Overflow.Auto + }.applyParent(root) + ButtonNode("child", key = "child") + .apply { + width = 120 + height = 16 + }.applyParent(clip) val tree = DomTree(root) tree.render(ctx, 220, 160) @@ -52,12 +58,13 @@ class OverflowPositionedClippingTests { val commands = tree.paint(ctx, applyStyles = false) assertTrue(state.axisX.clipsToViewport) - val clipPush = commands.filterIsInstance().firstOrNull { push -> - push.x == state.viewportRect.x && - push.y == state.viewportRect.y && - push.width == state.viewportRect.width && - push.height == state.viewportRect.height - } + val clipPush = + commands.filterIsInstance().firstOrNull { push -> + push.x == state.viewportRect.x && + push.y == state.viewportRect.y && + push.width == state.viewportRect.width && + push.height == state.viewportRect.height + } assertTrue(clipPush != null) } @@ -76,29 +83,37 @@ class OverflowPositionedClippingTests { @Test fun `nested mixed-axis overflow still clips positioned descendant at outer gutter edge`() { val root = ContainerNode(key = "root") - val outer = ContainerNode(key = "outer").apply { - width = 90 - height = 50 - overflowX = Overflow.Visible - overflowY = Overflow.Auto - }.applyParent(root) - val inner = ContainerNode(key = "inner").apply { - width = 86 - height = 40 - }.applyParent(outer) - ContainerNode(key = "filler").apply { - width = 20 - height = 220 - }.applyParent(outer) - val absolute = ButtonNode("abs", key = "nested-absolute").apply { - width = 24 - height = 12 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "74px", - StyleProperty.TOP to "0px" - ) - }.applyParent(inner) + val outer = + ContainerNode(key = "outer") + .apply { + width = 90 + height = 50 + overflowX = Overflow.Visible + overflowY = Overflow.Auto + }.applyParent(root) + val inner = + ContainerNode(key = "inner") + .apply { + width = 86 + height = 40 + }.applyParent(outer) + ContainerNode(key = "filler") + .apply { + width = 20 + height = 220 + }.applyParent(outer) + val absolute = + ButtonNode("abs", key = "nested-absolute") + .apply { + width = 24 + height = 12 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "74px", + StyleProperty.TOP to "0px", + ) + }.applyParent(inner) val tree = DomTree(root) tree.render(ctx, 240, 180) @@ -115,41 +130,48 @@ class OverflowPositionedClippingTests { private fun verifyPositionedChildGutterClipping(position: PositionMode, overflowY: Overflow) { val root = ContainerNode(key = "root-$position-$overflowY") - val scroll = ContainerNode(key = "scroll-$position-$overflowY").apply { - width = 80 - height = 40 - overflowX = Overflow.Visible - this.overflowY = overflowY - }.applyParent(root) + val scroll = + ContainerNode(key = "scroll-$position-$overflowY") + .apply { + width = 80 + height = 40 + overflowX = Overflow.Visible + this.overflowY = overflowY + }.applyParent(root) var clicks = 0 - val child = ButtonNode("child", key = "child-$position-$overflowY").apply { - width = 20 - height = 12 - val modeLiteral = when (position) { - PositionMode.Absolute -> "absolute" - PositionMode.Relative -> "relative" - PositionMode.Fixed -> "fixed" - PositionMode.Static -> "static" - PositionMode.Sticky -> "sticky" + val child = + ButtonNode("child", key = "child-$position-$overflowY").apply { + width = 20 + height = 12 + val modeLiteral = + when (position) { + PositionMode.Absolute -> "absolute" + PositionMode.Relative -> "relative" + PositionMode.Fixed -> "fixed" + PositionMode.Static -> "static" + PositionMode.Sticky -> "sticky" + } + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to modeLiteral, + StyleProperty.LEFT to "68px", + StyleProperty.TOP to "0px", + ) + onClick { clicks += 1 } } - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to modeLiteral, - StyleProperty.LEFT to "68px", - StyleProperty.TOP to "0px" - ) - onClick { clicks += 1 } - } if (position == PositionMode.Relative) { child.applyParent(scroll) - ContainerNode(key = "filler-$position-$overflowY").apply { - width = 16 - height = 220 - }.applyParent(scroll) + ContainerNode(key = "filler-$position-$overflowY") + .apply { + width = 16 + height = 220 + }.applyParent(scroll) } else { - ContainerNode(key = "filler-$position-$overflowY").apply { - width = 16 - height = 220 - }.applyParent(scroll) + ContainerNode(key = "filler-$position-$overflowY") + .apply { + width = 16 + height = 220 + }.applyParent(scroll) child.applyParent(scroll) } @@ -162,7 +184,7 @@ class OverflowPositionedClippingTests { assertTrue(state.verticalScrollbarGutter > 0) assertEquals( state.baseViewportRect.width - state.verticalScrollbarGutter, - state.viewportRect.width + state.viewportRect.width, ) val visibleX = state.viewportRect.x + state.viewportRect.width - 1 @@ -175,20 +197,20 @@ class OverflowPositionedClippingTests { assertFalse(dispatchClick(root, MouseClickEvent(gutterX, probeY, MouseButton.LEFT))) assertEquals(1, clicks) - val clipPush = commands.filterIsInstance().firstOrNull { push -> - push.x == state.viewportRect.x && - push.y == state.viewportRect.y && - push.width == state.viewportRect.width && - push.height == state.viewportRect.height - } + val clipPush = + commands.filterIsInstance().firstOrNull { push -> + push.x == state.viewportRect.x && + push.y == state.viewportRect.y && + push.width == state.viewportRect.width && + push.height == state.viewportRect.height + } assertTrue(clipPush != null) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedBehaviorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedBehaviorTests.kt index 95a5232..f5de131 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedBehaviorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedBehaviorTests.kt @@ -10,7 +10,6 @@ import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.event.dispatchClick import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow -import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression @@ -22,11 +21,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PositionedLayoutFixedBehaviorTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -37,19 +39,22 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `fixed element is removed from normal flow`() { val root = ContainerNode(key = "fixed-flow-root") - val fixed = ContainerNode(key = "fixed-flow-fixed").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "10px" - ) - } - val follower = ContainerNode(key = "fixed-flow-follower").apply { - width = 30 - height = 12 - } + val fixed = + ContainerNode(key = "fixed-flow-fixed").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "10px", + ) + } + val follower = + ContainerNode(key = "fixed-flow-follower").apply { + width = 30 + height = 12 + } fixed.applyParent(root) follower.applyParent(root) @@ -63,15 +68,17 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `fixed anchors to current root viewport`() { val root = ContainerNode(key = "fixed-root-anchor") - val child = ContainerNode(key = "fixed-child").apply { - width = 24 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "20px", - StyleProperty.TOP to "14px" - ) - } + val child = + ContainerNode(key = "fixed-child").apply { + width = 24 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "20px", + StyleProperty.TOP to "14px", + ) + } child.applyParent(root) renderTree(root, width = 260, height = 180) @@ -84,22 +91,26 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `fixed ignores ordinary content scroll`() { - val root = ContainerNode(key = "fixed-scroll-root").apply { - overflowY = Overflow.Scroll - } - val spacer = ContainerNode(key = "fixed-scroll-spacer").apply { - width = 100 - height = 280 - } - val fixed = ContainerNode(key = "fixed-scroll-fixed").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "6px", - StyleProperty.TOP to "5px" - ) - } + val root = + ContainerNode(key = "fixed-scroll-root").apply { + overflowY = Overflow.Scroll + } + val spacer = + ContainerNode(key = "fixed-scroll-spacer").apply { + width = 100 + height = 280 + } + val fixed = + ContainerNode(key = "fixed-scroll-fixed").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "6px", + StyleProperty.TOP to "5px", + ) + } spacer.applyParent(root) fixed.applyParent(root) @@ -118,23 +129,27 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `fixed is not anchored to nearest positioned ancestor`() { val root = ContainerNode(key = "fixed-ancestor-root") - val ancestor = ContainerNode(key = "fixed-ancestor").apply { - width = 120 - height = 80 - margin = Insets(top = 40, right = 0, bottom = 0, left = 60) - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative" - ) - } - val child = ContainerNode(key = "fixed-inside-relative").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "9px", - StyleProperty.TOP to "11px" - ) - } + val ancestor = + ContainerNode(key = "fixed-ancestor").apply { + width = 120 + height = 80 + margin = Insets(top = 40, right = 0, bottom = 0, left = 60) + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + ) + } + val child = + ContainerNode(key = "fixed-inside-relative").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "9px", + StyleProperty.TOP to "11px", + ) + } child.applyParent(ancestor) ancestor.applyParent(root) @@ -148,16 +163,18 @@ class PositionedLayoutFixedBehaviorTests { fun `fixed hit-testing uses final fixed rect`() { val root = ContainerNode(key = "fixed-hit-root") var clicks = 0 - val button = ButtonNode("fixed", key = "fixed-hit-button").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "30px", - StyleProperty.TOP to "18px" - ) - onClick { clicks += 1 } - } + val button = + ButtonNode("fixed", key = "fixed-hit-button").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "30px", + StyleProperty.TOP to "18px", + ) + onClick { clicks += 1 } + } button.applyParent(root) renderTree(root, width = 240, height = 160) @@ -173,28 +190,32 @@ class PositionedLayoutFixedBehaviorTests { var lowerClicks = 0 var upperClicks = 0 - val lower = ButtonNode("lower", key = "fixed-lower").apply { - width = 60 - height = 24 - zIndex = 1 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "10px", - StyleProperty.TOP to "8px" - ) - onClick { lowerClicks += 1 } - } - val upper = ButtonNode("upper", key = "fixed-upper").apply { - width = 60 - height = 24 - zIndex = 4 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "10px", - StyleProperty.TOP to "8px" - ) - onClick { upperClicks += 1 } - } + val lower = + ButtonNode("lower", key = "fixed-lower").apply { + width = 60 + height = 24 + zIndex = 1 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "10px", + StyleProperty.TOP to "8px", + ) + onClick { lowerClicks += 1 } + } + val upper = + ButtonNode("upper", key = "fixed-upper").apply { + width = 60 + height = 24 + zIndex = 4 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "10px", + StyleProperty.TOP to "8px", + ) + onClick { upperClicks += 1 } + } lower.applyParent(root) upper.applyParent(root) @@ -209,17 +230,19 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `fixed uses left over right and top over bottom`() { val root = ContainerNode(key = "fixed-precedence-root") - val child = ContainerNode(key = "fixed-precedence-child").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "25px", - StyleProperty.RIGHT to "7px", - StyleProperty.TOP to "12px", - StyleProperty.BOTTOM to "3px" - ) - } + val child = + ContainerNode(key = "fixed-precedence-child").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "25px", + StyleProperty.RIGHT to "7px", + StyleProperty.TOP to "12px", + StyleProperty.BOTTOM to "3px", + ) + } child.applyParent(root) renderTree(root, width = 240, height = 160) @@ -231,37 +254,44 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `static relative and absolute behavior remains intact`() { val root = ContainerNode(key = "fixed-nr-root") - val staticChild = ContainerNode(key = "fixed-nr-static").apply { - width = 30 - height = 12 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static", - StyleProperty.LEFT to "50px", - StyleProperty.TOP to "20px" - ) - } - val relativeChild = ContainerNode(key = "fixed-nr-relative").apply { - width = 30 - height = 12 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "18px", - StyleProperty.TOP to "7px" - ) - } - val absoluteChild = ContainerNode(key = "fixed-nr-absolute").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "40px", - StyleProperty.TOP to "22px" - ) - } - val follower = ContainerNode(key = "fixed-nr-follower").apply { - width = 22 - height = 10 - } + val staticChild = + ContainerNode(key = "fixed-nr-static").apply { + width = 30 + height = 12 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + StyleProperty.LEFT to "50px", + StyleProperty.TOP to "20px", + ) + } + val relativeChild = + ContainerNode(key = "fixed-nr-relative").apply { + width = 30 + height = 12 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "18px", + StyleProperty.TOP to "7px", + ) + } + val absoluteChild = + ContainerNode(key = "fixed-nr-absolute").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "40px", + StyleProperty.TOP to "22px", + ) + } + val follower = + ContainerNode(key = "fixed-nr-follower").apply { + width = 22 + height = 10 + } staticChild.applyParent(root) relativeChild.applyParent(root) absoluteChild.applyParent(root) @@ -280,17 +310,20 @@ class PositionedLayoutFixedBehaviorTests { @Test fun `inspector overrides keep static relative absolute and fixed behavior`() { - val root = ContainerNode(key = "fixed-inspector-root").apply { - overflowY = Overflow.Scroll - } - val spacer = ContainerNode(key = "fixed-inspector-spacer").apply { - width = 80 - height = 220 - } - val child = ContainerNode(key = "fixed-inspector-child").apply { - width = 20 - height = 10 - } + val root = + ContainerNode(key = "fixed-inspector-root").apply { + overflowY = Overflow.Scroll + } + val spacer = + ContainerNode(key = "fixed-inspector-spacer").apply { + width = 80 + height = 220 + } + val child = + ContainerNode(key = "fixed-inspector-child").apply { + width = 20 + height = 10 + } child.applyParent(root) spacer.applyParent(root) val tree = DomTree(root) @@ -326,11 +359,10 @@ class PositionedLayoutFixedBehaviorTests { DomTree(root).render(ctx, width, height) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedClippingBehaviorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedClippingBehaviorTests.kt index db001a4..1b2de50 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedClippingBehaviorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedClippingBehaviorTests.kt @@ -22,11 +22,14 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class PositionedLayoutFixedClippingBehaviorTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -38,30 +41,36 @@ class PositionedLayoutFixedClippingBehaviorTests { fun `promoted fixed escapes ancestor overflow clipping but keeps paint hit symmetry`() { val fixedColor = 0x00_33_99_CC val root = ContainerNode(key = "fixed-overflow-root", stackLayout = true) - val clippedAncestor = ContainerNode(key = "fixed-overflow-ancestor").apply { - width = 90 - height = 46 - margin = Insets(top = 8, right = 0, bottom = 0, left = 12) - overflowY = Overflow.Auto - stackLayout = true - }.applyParent(root) - ContainerNode(key = "fixed-overflow-filler").apply { - width = 80 - height = 220 - }.applyParent(clippedAncestor) + val clippedAncestor = + ContainerNode(key = "fixed-overflow-ancestor") + .apply { + width = 90 + height = 46 + margin = Insets(top = 8, right = 0, bottom = 0, left = 12) + overflowY = Overflow.Auto + stackLayout = true + }.applyParent(root) + ContainerNode(key = "fixed-overflow-filler") + .apply { + width = 80 + height = 220 + }.applyParent(clippedAncestor) var fixedClicks = 0 - val fixed = ButtonNode("fixed", key = "fixed-overflow-node", backgroundColor = fixedColor).apply { - width = 44 - height = 20 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "140px", - StyleProperty.TOP to "90px" - ) - onClick { fixedClicks += 1 } - }.applyParent(clippedAncestor) + val fixed = + ButtonNode("fixed", key = "fixed-overflow-node", backgroundColor = fixedColor) + .apply { + width = 44 + height = 20 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "140px", + StyleProperty.TOP to "90px", + ) + onClick { fixedClicks += 1 } + }.applyParent(clippedAncestor) val tree = DomTree(root) tree.render(ctx, 220, 160) @@ -71,12 +80,13 @@ class PositionedLayoutFixedClippingBehaviorTests { assertTrue(dispatchClick(root, MouseClickEvent(145, 95, MouseButton.LEFT))) assertEquals(1, fixedClicks) - val fixedDrawIndex = commands.indexOfFirst { command -> - command is RenderCommand.DrawRect && - command.color == fixedColor && - command.x == 140 && - command.y == 90 - } + val fixedDrawIndex = + commands.indexOfFirst { command -> + command is RenderCommand.DrawRect && + command.color == fixedColor && + command.x == 140 && + command.y == 90 + } assertTrue(fixedDrawIndex > 0, "Expected fixed draw rect in root-level paint stream") val fixedClip = commands[fixedDrawIndex - 1] as? RenderCommand.PushClip @@ -91,15 +101,18 @@ class PositionedLayoutFixedClippingBehaviorTests { @Test fun `promoted fixed still respects root viewport clipping`() { val root = ContainerNode(key = "fixed-root-clip-root") - val fixed = ButtonNode("fixed", key = "fixed-root-clip-node").apply { - width = 50 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "180px", - StyleProperty.TOP to "24px" - ) - }.applyParent(root) + val fixed = + ButtonNode("fixed", key = "fixed-root-clip-node") + .apply { + width = 50 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "180px", + StyleProperty.TOP to "24px", + ) + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 200, 120) @@ -108,12 +121,13 @@ class PositionedLayoutFixedClippingBehaviorTests { assertTrue(fixed.containsGlobalPoint(195, 30)) assertFalse( fixed.containsGlobalPoint(225, 30), - "Point inside fixed bounds but outside root viewport must be clipped" + "Point inside fixed bounds but outside root viewport must be clipped", ) - val drawIndex = commands.indexOfFirst { command -> - command is RenderCommand.DrawRect && command.x == 180 && command.y == 24 - } + val drawIndex = + commands.indexOfFirst { command -> + command is RenderCommand.DrawRect && command.x == 180 && command.y == 24 + } assertTrue(drawIndex > 0) val clipBeforeDraw = commands[drawIndex - 1] as? RenderCommand.PushClip assertNotNull(clipBeforeDraw) @@ -127,24 +141,29 @@ class PositionedLayoutFixedClippingBehaviorTests { @Test fun `non-fixed descendants still obey ancestor overflow clipping`() { val root = ContainerNode(key = "nonfixed-clip-root", stackLayout = true) - val clippedAncestor = ContainerNode(key = "nonfixed-clip-ancestor").apply { - width = 90 - height = 46 - margin = Insets(top = 8, right = 0, bottom = 0, left = 12) - overflowY = Overflow.Scroll - }.applyParent(root) + val clippedAncestor = + ContainerNode(key = "nonfixed-clip-ancestor") + .apply { + width = 90 + height = 46 + margin = Insets(top = 8, right = 0, bottom = 0, left = 12) + overflowY = Overflow.Scroll + }.applyParent(root) var clicks = 0 - val absolute = ButtonNode("absolute", key = "nonfixed-clip-node").apply { - width = 44 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "140px", - StyleProperty.TOP to "90px" - ) - onClick { clicks += 1 } - }.applyParent(clippedAncestor) + val absolute = + ButtonNode("absolute", key = "nonfixed-clip-node") + .apply { + width = 44 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "140px", + StyleProperty.TOP to "90px", + ) + onClick { clicks += 1 } + }.applyParent(clippedAncestor) val tree = DomTree(root) tree.render(ctx, 220, 160) @@ -156,11 +175,10 @@ class PositionedLayoutFixedClippingBehaviorTests { assertEquals(0, clicks) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedStackingCharacterizationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedStackingCharacterizationTests.kt index af44df1..3d9311b 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedStackingCharacterizationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutFixedStackingCharacterizationTests.kt @@ -9,10 +9,10 @@ import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.event.dispatchClick import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression -import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleProperty import kotlin.test.AfterTest import kotlin.test.Test @@ -22,11 +22,14 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class PositionedLayoutFixedStackingCharacterizationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -37,21 +40,24 @@ class PositionedLayoutFixedStackingCharacterizationTests { @Test fun `nested fixed geometry is still root anchored in current model`() { val root = ContainerNode(key = "fixed-geom-root", stackLayout = true) - val ancestor = ContainerNode(key = "fixed-geom-ancestor").apply { - width = 140 - height = 90 - margin = Insets(top = 40, right = 0, bottom = 0, left = 60) - } - val fixed = ContainerNode(key = "fixed-geom-node").apply { - width = 24 - height = 12 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "9px", - StyleProperty.TOP to "11px" - ) - } + val ancestor = + ContainerNode(key = "fixed-geom-ancestor").apply { + width = 140 + height = 90 + margin = Insets(top = 40, right = 0, bottom = 0, left = 60) + } + val fixed = + ContainerNode(key = "fixed-geom-node").apply { + width = 24 + height = 12 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "9px", + StyleProperty.TOP to "11px", + ) + } fixed.applyParent(ancestor) ancestor.applyParent(root) @@ -69,28 +75,33 @@ class PositionedLayoutFixedStackingCharacterizationTests { val laterColor = 0x00_AA_33_55 val root = ContainerNode(key = "fixed-paint-root", stackLayout = true) - val earlySubtree = ContainerNode(key = "fixed-paint-early").apply { - width = 120 - height = 60 - } - val laterSubtree = ContainerNode(key = "fixed-paint-later").apply { - width = 120 - height = 60 - } - val fixed = ButtonNode("fixed", backgroundColor = fixedColor, key = "fixed-paint-node").apply { - width = 72 - height = 24 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - } - val later = ButtonNode("later", backgroundColor = laterColor, key = "fixed-paint-later-node").apply { - width = 72 - height = 24 - } + val earlySubtree = + ContainerNode(key = "fixed-paint-early").apply { + width = 120 + height = 60 + } + val laterSubtree = + ContainerNode(key = "fixed-paint-later").apply { + width = 120 + height = 60 + } + val fixed = + ButtonNode("fixed", backgroundColor = fixedColor, key = "fixed-paint-node").apply { + width = 72 + height = 24 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + } + val later = + ButtonNode("later", backgroundColor = laterColor, key = "fixed-paint-later-node").apply { + width = 72 + height = 24 + } fixed.applyParent(earlySubtree) later.applyParent(laterSubtree) earlySubtree.applyParent(root) @@ -101,52 +112,59 @@ class PositionedLayoutFixedStackingCharacterizationTests { val commands = tree.paint(ctx) val drawRects = commands.filterIsInstance() - val fixedPaintIndex = drawRects.indexOfFirst { rect -> - rect.color == fixedColor && rect.x == 8 && rect.y == 8 - } - val laterPaintIndex = drawRects.indexOfFirst { rect -> - rect.color == laterColor && rect.x == 0 && rect.y == 0 - } + val fixedPaintIndex = + drawRects.indexOfFirst { rect -> + rect.color == fixedColor && rect.x == 8 && rect.y == 8 + } + val laterPaintIndex = + drawRects.indexOfFirst { rect -> + rect.color == laterColor && rect.x == 0 && rect.y == 0 + } assertTrue(fixedPaintIndex >= 0, "Expected fixed draw rect in paint command stream") assertTrue(laterPaintIndex >= 0, "Expected later sibling draw rect in paint command stream") assertTrue( fixedPaintIndex > laterPaintIndex, - "Fixed should participate in root paint ordering and paint above lower-priority later sibling content" + "Fixed should participate in root paint ordering and paint above lower-priority later sibling content", ) } @Test fun `nested fixed high z-index now wins hit over later root sibling subtree`() { val root = ContainerNode(key = "fixed-hit-root", stackLayout = true) - val earlySubtree = ContainerNode(key = "fixed-hit-early").apply { - width = 120 - height = 60 - } - val laterSubtree = ContainerNode(key = "fixed-hit-later").apply { - width = 120 - height = 60 - } + val earlySubtree = + ContainerNode(key = "fixed-hit-early").apply { + width = 120 + height = 60 + } + val laterSubtree = + ContainerNode(key = "fixed-hit-later").apply { + width = 120 + height = 60 + } var fixedClicks = 0 var laterClicks = 0 - val fixed = ButtonNode("fixed", key = "fixed-hit-node").apply { - width = 72 - height = 24 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - onClick { fixedClicks += 1 } - } - val later = ButtonNode("later", key = "fixed-hit-later-node").apply { - width = 72 - height = 24 - onClick { laterClicks += 1 } - } + val fixed = + ButtonNode("fixed", key = "fixed-hit-node").apply { + width = 72 + height = 24 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + onClick { fixedClicks += 1 } + } + val later = + ButtonNode("later", key = "fixed-hit-later-node").apply { + width = 72 + height = 24 + onClick { laterClicks += 1 } + } fixed.applyParent(earlySubtree) later.applyParent(laterSubtree) @@ -166,15 +184,18 @@ class PositionedLayoutFixedStackingCharacterizationTests { val root = ContainerNode(key = "fixed-local-root", stackLayout = true) val earlySubtree = ContainerNode(key = "fixed-local-early").applyParent(root) val laterSubtree = ContainerNode(key = "fixed-local-later").applyParent(root) - val nestedFixed = ContainerNode(key = "fixed-local-nested").apply { - position = PositionMode.Fixed - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "4px", - StyleProperty.TOP to "4px" - ) - }.applyParent(earlySubtree) + val nestedFixed = + ContainerNode(key = "fixed-local-nested") + .apply { + position = PositionMode.Fixed + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "4px", + StyleProperty.TOP to "4px", + ) + }.applyParent(earlySubtree) assertSame(earlySubtree, nestedFixed.parent) assertEquals(listOf(earlySubtree, laterSubtree, nestedFixed), root.orderedChildrenForPaintTraversal()) @@ -188,15 +209,18 @@ class PositionedLayoutFixedStackingCharacterizationTests { val root = ContainerNode(key = "fixed-sym-root", stackLayout = true) val earlySubtree = ContainerNode(key = "fixed-sym-early").applyParent(root) val laterSubtree = ContainerNode(key = "fixed-sym-later").applyParent(root) - val fixed = ContainerNode(key = "fixed-sym-nested").apply { - position = PositionMode.Fixed - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "6px", - StyleProperty.TOP to "6px" - ) - }.applyParent(earlySubtree) + val fixed = + ContainerNode(key = "fixed-sym-nested") + .apply { + position = PositionMode.Fixed + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "6px", + StyleProperty.TOP to "6px", + ) + }.applyParent(earlySubtree) val nonFixedNested = ContainerNode(key = "fixed-sym-nested-normal").applyParent(earlySubtree) val rootPaint = root.orderedChildrenForPaintTraversal() @@ -227,11 +251,10 @@ class PositionedLayoutFixedStackingCharacterizationTests { DomTree(root).render(ctx, width, height) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModelTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModelTests.kt index 2b31979..84beb21 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModelTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutModelTests.kt @@ -36,16 +36,18 @@ class PositionedLayoutModelTests { val staticChild = ContainerNode(key = "static") staticChild.applyParent(root) - val positionedLow = ContainerNode(key = "positioned-low").apply { - position = PositionMode.Relative - zIndex = -1 - } + val positionedLow = + ContainerNode(key = "positioned-low").apply { + position = PositionMode.Relative + zIndex = -1 + } positionedLow.applyParent(root) - val positionedHigh = ContainerNode(key = "positioned-high").apply { - position = PositionMode.Relative - zIndex = 5 - } + val positionedHigh = + ContainerNode(key = "positioned-high").apply { + position = PositionMode.Relative + zIndex = 5 + } positionedHigh.applyParent(root) val paintOrder = root.orderedChildrenForPaintTraversal() @@ -58,15 +60,18 @@ class PositionedLayoutModelTests { @Test fun `equal ordering priority uses dom order tie-breaker`() { val root = ContainerNode(key = "tie-root") - val first = ContainerNode(key = "first").apply { - position = PositionMode.Relative - } - val second = ContainerNode(key = "second").apply { - position = PositionMode.Relative - } - val third = ContainerNode(key = "third").apply { - position = PositionMode.Relative - } + val first = + ContainerNode(key = "first").apply { + position = PositionMode.Relative + } + val second = + ContainerNode(key = "second").apply { + position = PositionMode.Relative + } + val third = + ContainerNode(key = "third").apply { + position = PositionMode.Relative + } first.applyParent(root) second.applyParent(root) @@ -80,9 +85,11 @@ class PositionedLayoutModelTests { fun `absolute containing block primitive resolves nearest positioned ancestor or root`() { val root = ContainerNode(key = "cb-root") val staticAncestor = ContainerNode(key = "cb-static-ancestor").applyParent(root) - val positionedAncestor = ContainerNode(key = "cb-positioned-ancestor").apply { - position = PositionMode.Relative - }.applyParent(staticAncestor) + val positionedAncestor = + ContainerNode(key = "cb-positioned-ancestor") + .apply { + position = PositionMode.Relative + }.applyParent(staticAncestor) val nestedStatic = ContainerNode(key = "cb-nested-static").applyParent(positionedAncestor) val leaf = ContainerNode(key = "cb-leaf").applyParent(nestedStatic) @@ -96,13 +103,17 @@ class PositionedLayoutModelTests { @Test fun `sticky participates in ordering but not absolute containing-block semantics`() { val root = ContainerNode(key = "sticky-root") - val stickyAncestor = ContainerNode(key = "sticky-ancestor").apply { - position = PositionMode.Sticky - zIndex = 99 - }.applyParent(root) - val absoluteLeaf = ContainerNode(key = "sticky-absolute-leaf").apply { - position = PositionMode.Absolute - }.applyParent(stickyAncestor) + val stickyAncestor = + ContainerNode(key = "sticky-ancestor") + .apply { + position = PositionMode.Sticky + zIndex = 99 + }.applyParent(root) + val absoluteLeaf = + ContainerNode(key = "sticky-absolute-leaf") + .apply { + position = PositionMode.Absolute + }.applyParent(stickyAncestor) assertTrue(stickyAncestor.participatesInPositionedOrderingModel()) assertTrue(PositionedLayoutModel.matchesChildContextTrigger(stickyAncestor)) @@ -127,26 +138,31 @@ class PositionedLayoutModelTests { val rootA = ContainerNode(key = "z-root-a") val rootB = ContainerNode(key = "z-root-b") - val aLow = ContainerNode(key = "a-low").apply { - position = PositionMode.Relative - zIndex = -10 - }.applyParent(rootA) - val aHigh = ContainerNode(key = "a-high").apply { - position = PositionMode.Relative - zIndex = 10 - }.applyParent(rootA) - - val bOnly = ContainerNode(key = "b-only").apply { - position = PositionMode.Relative - zIndex = 999 - }.applyParent(rootB) + val aLow = + ContainerNode(key = "a-low") + .apply { + position = PositionMode.Relative + zIndex = -10 + }.applyParent(rootA) + val aHigh = + ContainerNode(key = "a-high") + .apply { + position = PositionMode.Relative + zIndex = 10 + }.applyParent(rootA) + + val bOnly = + ContainerNode(key = "b-only") + .apply { + position = PositionMode.Relative + zIndex = 999 + }.applyParent(rootB) assertEquals(listOf(aLow, aHigh), rootA.orderedChildrenForPaintTraversal()) assertEquals(listOf(bOnly), rootB.orderedChildrenForPaintTraversal()) assertFalse(aHigh.sharesRootStackingScopeForPositioning(bOnly)) } - @Test fun `offset precedence contract prefers left over right and top over bottom`() { val left = CssLength.px(12) @@ -170,26 +186,30 @@ class PositionedLayoutModelTests { assertEquals(StyleProperty.BOTTOM, verticalFallback.sourceProperty) assertEquals(bottom, verticalFallback.value) } + @Test fun `dispatch click follows reverse paint order`() { - val root = ContainerNode(key = "click-root").apply { - bounds = Rect(0, 0, 120, 80) - } + val root = + ContainerNode(key = "click-root").apply { + bounds = Rect(0, 0, 120, 80) + } var underClicks = 0 var overClicks = 0 - val under = ButtonNode("under", key = "under").apply { - bounds = Rect(10, 10, 80, 24) - onClick { underClicks += 1 } - } + val under = + ButtonNode("under", key = "under").apply { + bounds = Rect(10, 10, 80, 24) + onClick { underClicks += 1 } + } under.applyParent(root) - val over = ButtonNode("over", key = "over").apply { - bounds = Rect(10, 10, 80, 24) - position = PositionMode.Relative - zIndex = 2 - onClick { overClicks += 1 } - } + val over = + ButtonNode("over", key = "over").apply { + bounds = Rect(10, 10, 80, 24) + position = PositionMode.Relative + zIndex = 2 + onClick { overClicks += 1 } + } over.applyParent(root) val paintOrder = root.orderedChildrenForPaintTraversal() @@ -201,4 +221,3 @@ class PositionedLayoutModelTests { assertEquals(0, underClicks) } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutRelativeBehaviorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutRelativeBehaviorTests.kt index 8fca9c7..8782a8a 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutRelativeBehaviorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutRelativeBehaviorTests.kt @@ -19,11 +19,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PositionedLayoutRelativeBehaviorTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -34,15 +37,17 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `relative applies visible offset to final geometry`() { val root = ContainerNode(key = "root") - val child = ContainerNode(key = "relative-child").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "12px", - StyleProperty.TOP to "8px" - ) - } + val child = + ContainerNode(key = "relative-child").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "12px", + StyleProperty.TOP to "8px", + ) + } child.applyParent(root) renderTree(root) @@ -54,19 +59,22 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `relative preserves normal-flow layout slot`() { val root = ContainerNode(key = "flow-root") - val first = ContainerNode(key = "first-relative").apply { - width = 50 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "24px", - StyleProperty.TOP to "16px" - ) - } - val second = ContainerNode(key = "second-static").apply { - width = 30 - height = 12 - } + val first = + ContainerNode(key = "first-relative").apply { + width = 50 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "24px", + StyleProperty.TOP to "16px", + ) + } + val second = + ContainerNode(key = "second-static").apply { + width = 30 + height = 12 + } first.applyParent(root) second.applyParent(root) @@ -79,15 +87,17 @@ class PositionedLayoutRelativeBehaviorTests { fun `relative hit-testing tracks visible shifted rect`() { val root = ContainerNode(key = "hit-root") var clicks = 0 - val button = ButtonNode("relative", key = "relative-button").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "30px" - ) - onClick { clicks += 1 } - } + val button = + ButtonNode("relative", key = "relative-button").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "30px", + ) + onClick { clicks += 1 } + } button.applyParent(root) renderTree(root) @@ -102,15 +112,17 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `relative horizontal precedence prefers left over right`() { val root = ContainerNode(key = "horizontal-root") - val child = ContainerNode(key = "horizontal-relative").apply { - width = 30 - height = 16 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "20px", - StyleProperty.RIGHT to "5px" - ) - } + val child = + ContainerNode(key = "horizontal-relative").apply { + width = 30 + height = 16 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "20px", + StyleProperty.RIGHT to "5px", + ) + } child.applyParent(root) renderTree(root) @@ -122,15 +134,17 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `relative vertical precedence prefers top over bottom`() { val root = ContainerNode(key = "vertical-root") - val child = ContainerNode(key = "vertical-relative").apply { - width = 30 - height = 16 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.TOP to "15px", - StyleProperty.BOTTOM to "4px" - ) - } + val child = + ContainerNode(key = "vertical-relative").apply { + width = 30 + height = 16 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.TOP to "15px", + StyleProperty.BOTTOM to "4px", + ) + } child.applyParent(root) renderTree(root) @@ -142,15 +156,17 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `static continues to ignore offsets for visible behavior`() { val root = ContainerNode(key = "static-root") - val child = ContainerNode(key = "static-child").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static", - StyleProperty.LEFT to "50px", - StyleProperty.TOP to "30px" - ) - } + val child = + ContainerNode(key = "static-child").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + StyleProperty.LEFT to "50px", + StyleProperty.TOP to "30px", + ) + } child.applyParent(root) renderTree(root) @@ -162,10 +178,11 @@ class PositionedLayoutRelativeBehaviorTests { @Test fun `inspector overrides reposition for relative but not static`() { val root = ContainerNode(key = "inspector-root") - val child = ContainerNode(key = "inspector-child").apply { - width = 40 - height = 20 - } + val child = + ContainerNode(key = "inspector-child").apply { + width = 40 + height = 20 + } child.applyParent(root) val tree = DomTree(root) tree.render(ctx, 220, 140) @@ -189,11 +206,10 @@ class PositionedLayoutRelativeBehaviorTests { DomTree(root).render(ctx, 240, 160) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStaticBaselineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStaticBaselineTests.kt index 44dab32..a280ec4 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStaticBaselineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStaticBaselineTests.kt @@ -23,11 +23,14 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class PositionedLayoutStaticBaselineTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -38,10 +41,11 @@ class PositionedLayoutStaticBaselineTests { @Test fun `default position remains static`() { val root = ContainerNode(key = "root") - val child = ContainerNode(key = "child").apply { - width = 40 - height = 18 - } + val child = + ContainerNode(key = "child").apply { + width = 40 + height = 18 + } child.applyParent(root) renderTree(root) @@ -55,20 +59,23 @@ class PositionedLayoutStaticBaselineTests { @Test fun `explicit static matches default layout behavior`() { val defaultRoot = ContainerNode(key = "default-root") - val defaultChild = ContainerNode(key = "default-child").apply { - width = 40 - height = 18 - } + val defaultChild = + ContainerNode(key = "default-child").apply { + width = 40 + height = 18 + } defaultChild.applyParent(defaultRoot) val explicitRoot = ContainerNode(key = "explicit-root") - val explicitChild = ContainerNode(key = "explicit-child").apply { - width = 40 - height = 18 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static" - ) - } + val explicitChild = + ContainerNode(key = "explicit-child").apply { + width = 40 + height = 18 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + ) + } explicitChild.applyParent(explicitRoot) renderTree(defaultRoot) @@ -80,24 +87,27 @@ class PositionedLayoutStaticBaselineTests { @Test fun `static ignores offsets for visible and hit geometry`() { val baselineRoot = ContainerNode(key = "baseline-root") - val baselineChild = ContainerNode(key = "baseline-child").apply { - width = 40 - height = 18 - } + val baselineChild = + ContainerNode(key = "baseline-child").apply { + width = 40 + height = 18 + } baselineChild.applyParent(baselineRoot) val offsetRoot = ContainerNode(key = "offset-root") - val offsetChild = ContainerNode(key = "offset-child").apply { - width = 40 - height = 18 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static", - StyleProperty.LEFT to "40px", - StyleProperty.TOP to "30px", - StyleProperty.RIGHT to "8px", - StyleProperty.BOTTOM to "6px" - ) - } + val offsetChild = + ContainerNode(key = "offset-child").apply { + width = 40 + height = 18 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + StyleProperty.LEFT to "40px", + StyleProperty.TOP to "30px", + StyleProperty.RIGHT to "8px", + StyleProperty.BOTTOM to "6px", + ) + } offsetChild.applyParent(offsetRoot) renderTree(baselineRoot) @@ -119,19 +129,22 @@ class PositionedLayoutStaticBaselineTests { @Test fun `static node remains in normal flow even with offsets declared`() { val root = ContainerNode(key = "flow-root") - val first = ContainerNode(key = "first").apply { - width = 50 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static", - StyleProperty.LEFT to "60px", - StyleProperty.TOP to "40px" - ) - } - val second = ContainerNode(key = "second").apply { - width = 30 - height = 12 - } + val first = + ContainerNode(key = "first").apply { + width = 50 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + StyleProperty.LEFT to "60px", + StyleProperty.TOP to "40px", + ) + } + val second = + ContainerNode(key = "second").apply { + width = 30 + height = 12 + } first.applyParent(root) second.applyParent(root) @@ -142,24 +155,27 @@ class PositionedLayoutStaticBaselineTests { @Test fun `static z-index does not reorder static paint or hit order`() { - val root = ContainerNode(key = "click-root").apply { - bounds = Rect(0, 0, 120, 80) - } + val root = + ContainerNode(key = "click-root").apply { + bounds = Rect(0, 0, 120, 80) + } var underClicks = 0 var overClicks = 0 - val under = ButtonNode("under", key = "under").apply { - bounds = Rect(10, 10, 80, 24) - zIndex = 100 - onClick { underClicks += 1 } - } + val under = + ButtonNode("under", key = "under").apply { + bounds = Rect(10, 10, 80, 24) + zIndex = 100 + onClick { underClicks += 1 } + } under.applyParent(root) - val over = ButtonNode("over", key = "over").apply { - bounds = Rect(10, 10, 80, 24) - zIndex = -100 - onClick { overClicks += 1 } - } + val over = + ButtonNode("over", key = "over").apply { + bounds = Rect(10, 10, 80, 24) + zIndex = -100 + onClick { overClicks += 1 } + } over.applyParent(root) assertEquals(listOf(under, over), root.orderedChildrenForPaintTraversal()) @@ -172,10 +188,11 @@ class PositionedLayoutStaticBaselineTests { @Test fun `inspector static offset overrides keep static geometry unchanged`() { val root = ContainerNode(key = "inspector-root") - val child = ContainerNode(key = "inspector-child").apply { - width = 44 - height = 16 - } + val child = + ContainerNode(key = "inspector-child").apply { + width = 44 + height = 16 + } child.applyParent(root) val tree = DomTree(root) tree.render(ctx, 200, 120) @@ -193,20 +210,23 @@ class PositionedLayoutStaticBaselineTests { @Test fun `sticky style value remains in-flow but participates in ordering`() { val root = ContainerNode(key = "sticky-inactive-root") - val sticky = ContainerNode(key = "sticky-inactive-node").apply { - width = 50 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "60px", - StyleProperty.TOP to "30px" - ) - zIndex = 100 - } - val follower = ContainerNode(key = "sticky-inactive-follower").apply { - width = 30 - height = 12 - } + val sticky = + ContainerNode(key = "sticky-inactive-node").apply { + width = 50 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "60px", + StyleProperty.TOP to "30px", + ) + zIndex = 100 + } + val follower = + ContainerNode(key = "sticky-inactive-follower").apply { + width = 30 + height = 12 + } sticky.applyParent(root) follower.applyParent(root) @@ -223,11 +243,10 @@ class PositionedLayoutStaticBaselineTests { DomTree(root).render(ctx, 240, 160) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStickyBehaviorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStickyBehaviorTests.kt index b9eb80f..edc20b2 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStickyBehaviorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutStickyBehaviorTests.kt @@ -10,7 +10,7 @@ import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.event.collectHoverChain import org.dreamfinity.dsgl.core.event.dispatchClick import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.Overflow @@ -26,11 +26,14 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class PositionedLayoutStickyBehaviorTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -40,21 +43,25 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky top sticks visually and keeps normal flow slot`() { - val root = ContainerNode(key = "sticky-top-root").apply { - overflowY = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-top-node").apply { - width = 100 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val follower = ContainerNode(key = "sticky-top-follower").apply { - width = 100 - height = 280 - } + val root = + ContainerNode(key = "sticky-top-root").apply { + overflowY = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-top-node").apply { + width = 100 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val follower = + ContainerNode(key = "sticky-top-follower").apply { + width = 100 + height = 280 + } sticky.applyParent(root) follower.applyParent(root) @@ -72,25 +79,30 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky bottom-only mode is deterministic`() { - val root = ContainerNode(key = "sticky-bottom-root").apply { - overflowY = Overflow.Scroll - } - val topSpacer = ContainerNode(key = "sticky-bottom-spacer-top").apply { - width = 100 - height = 140 - } - val sticky = ContainerNode(key = "sticky-bottom-node").apply { - width = 100 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.BOTTOM to "0px" - ) - } - val bottomSpacer = ContainerNode(key = "sticky-bottom-spacer-bottom").apply { - width = 100 - height = 180 - } + val root = + ContainerNode(key = "sticky-bottom-root").apply { + overflowY = Overflow.Scroll + } + val topSpacer = + ContainerNode(key = "sticky-bottom-spacer-top").apply { + width = 100 + height = 140 + } + val sticky = + ContainerNode(key = "sticky-bottom-node").apply { + width = 100 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.BOTTOM to "0px", + ) + } + val bottomSpacer = + ContainerNode(key = "sticky-bottom-spacer-bottom").apply { + width = 100 + height = 180 + } topSpacer.applyParent(root) sticky.applyParent(root) bottomSpacer.applyParent(root) @@ -106,22 +118,26 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky both top and bottom uses top precedence`() { - val root = ContainerNode(key = "sticky-both-root").apply { - overflowY = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-both-node").apply { - width = 80 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "10px", - StyleProperty.BOTTOM to "0px" - ) - } - val follower = ContainerNode(key = "sticky-both-follower").apply { - width = 80 - height = 220 - } + val root = + ContainerNode(key = "sticky-both-root").apply { + overflowY = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-both-node").apply { + width = 80 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "10px", + StyleProperty.BOTTOM to "0px", + ) + } + val follower = + ContainerNode(key = "sticky-both-follower").apply { + width = 80 + height = 220 + } sticky.applyParent(root) follower.applyParent(root) @@ -133,22 +149,26 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky left sticks visually on horizontal axis and keeps normal flow slot`() { - val root = ContainerNode(key = "sticky-left-root").apply { - display = Display.Flex - overflowX = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-left-node").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "0px" - ) - } - val filler = ContainerNode(key = "sticky-left-filler").apply { - width = 260 - height = 20 - } + val root = + ContainerNode(key = "sticky-left-root").apply { + display = Display.Flex + overflowX = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-left-node").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-left-filler").apply { + width = 260 + height = 20 + } sticky.applyParent(root) filler.applyParent(root) @@ -166,26 +186,31 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky right-only mode is deterministic on horizontal axis`() { - val root = ContainerNode(key = "sticky-right-root").apply { - display = Display.Flex - overflowX = Overflow.Scroll - } - val spacer = ContainerNode(key = "sticky-right-spacer").apply { - width = 140 - height = 20 - } - val sticky = ContainerNode(key = "sticky-right-node").apply { - width = 20 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.RIGHT to "0px" - ) - } - val tail = ContainerNode(key = "sticky-right-tail").apply { - width = 120 - height = 20 - } + val root = + ContainerNode(key = "sticky-right-root").apply { + display = Display.Flex + overflowX = Overflow.Scroll + } + val spacer = + ContainerNode(key = "sticky-right-spacer").apply { + width = 140 + height = 20 + } + val sticky = + ContainerNode(key = "sticky-right-node").apply { + width = 20 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.RIGHT to "0px", + ) + } + val tail = + ContainerNode(key = "sticky-right-tail").apply { + width = 120 + height = 20 + } spacer.applyParent(root) sticky.applyParent(root) tail.applyParent(root) @@ -201,23 +226,27 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky both left and right uses left precedence`() { - val root = ContainerNode(key = "sticky-horizontal-both-root").apply { - display = Display.Flex - overflowX = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-horizontal-both-node").apply { - width = 20 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "7px", - StyleProperty.RIGHT to "0px" - ) - } - val filler = ContainerNode(key = "sticky-horizontal-both-filler").apply { - width = 200 - height = 20 - } + val root = + ContainerNode(key = "sticky-horizontal-both-root").apply { + display = Display.Flex + overflowX = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-horizontal-both-node").apply { + width = 20 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "7px", + StyleProperty.RIGHT to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-horizontal-both-filler").apply { + width = 200 + height = 20 + } sticky.applyParent(root) filler.applyParent(root) @@ -229,23 +258,27 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky combines horizontal and vertical offsets from independent axis rules`() { - val root = ContainerNode(key = "sticky-xy-root").apply { - overflowX = Overflow.Scroll - overflowY = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-xy-node").apply { - width = 80 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "0px", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-xy-filler").apply { - width = 400 - height = 400 - } + val root = + ContainerNode(key = "sticky-xy-root").apply { + overflowX = Overflow.Scroll + overflowY = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-xy-node").apply { + width = 80 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "0px", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-xy-filler").apply { + width = 400 + height = 400 + } sticky.applyParent(root) filler.applyParent(root) @@ -262,21 +295,25 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky with no horizontal inset stays inactive on horizontal axis`() { - val root = ContainerNode(key = "sticky-horizontal-inactive-root").apply { - display = Display.Flex - overflowX = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-horizontal-inactive-node").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky" - ) - } - val filler = ContainerNode(key = "sticky-horizontal-inactive-filler").apply { - width = 260 - height = 20 - } + val root = + ContainerNode(key = "sticky-horizontal-inactive-root").apply { + display = Display.Flex + overflowX = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-horizontal-inactive-node").apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + ) + } + val filler = + ContainerNode(key = "sticky-horizontal-inactive-filler").apply { + width = 260 + height = 20 + } sticky.applyParent(root) filler.applyParent(root) @@ -290,20 +327,24 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky with no inset stays inactive on vertical axis`() { - val root = ContainerNode(key = "sticky-inactive-root").apply { - overflowY = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-inactive-node").apply { - width = 90 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky" - ) - } - val filler = ContainerNode(key = "sticky-inactive-filler").apply { - width = 90 - height = 260 - } + val root = + ContainerNode(key = "sticky-inactive-root").apply { + overflowY = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-inactive-node").apply { + width = 90 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + ) + } + val filler = + ContainerNode(key = "sticky-inactive-filler").apply { + width = 90 + height = 260 + } sticky.applyParent(root) filler.applyParent(root) @@ -317,21 +358,25 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky used geometry resolves from shared path without render-owned refresh`() { - val root = ContainerNode(key = "sticky-refresh-root").apply { - overflowY = Overflow.Scroll - } - val sticky = ContainerNode(key = "sticky-refresh-node").apply { - width = 100 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-refresh-filler").apply { - width = 100 - height = 260 - } + val root = + ContainerNode(key = "sticky-refresh-root").apply { + overflowY = Overflow.Scroll + } + val sticky = + ContainerNode(key = "sticky-refresh-node").apply { + width = 100 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-refresh-filler").apply { + width = 100 + height = 260 + } sticky.applyParent(root) filler.applyParent(root) @@ -345,23 +390,27 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky render and interaction use same final geometry`() { - val root = ContainerNode(key = "sticky-click-root").apply { - overflowY = Overflow.Scroll - } + val root = + ContainerNode(key = "sticky-click-root").apply { + overflowY = Overflow.Scroll + } var clicks = 0 - val sticky = ButtonNode("sticky", key = "sticky-click-node").apply { - width = 100 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - onClick { clicks += 1 } - } - val filler = ContainerNode(key = "sticky-click-filler").apply { - width = 100 - height = 260 - } + val sticky = + ButtonNode("sticky", key = "sticky-click-node").apply { + width = 100 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + onClick { clicks += 1 } + } + val filler = + ContainerNode(key = "sticky-click-filler").apply { + width = 100 + height = 260 + } sticky.applyParent(root) filler.applyParent(root) @@ -379,32 +428,38 @@ class PositionedLayoutStickyBehaviorTests { fun `sticky with high z-index paints above later normal-flow overlap content`() { val stickyColor = 0xFF1B6BA8.toInt() val overlapColor = 0xFFE06262.toInt() - val root = ContainerNode(key = "sticky-z-paint-root").apply { - width = 120 - height = 80 - overflowY = Overflow.Auto - } - val sticky = ContainerNode(key = "sticky-z-paint-sticky", backgroundColor = stickyColor).apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px", - StyleProperty.Z_INDEX to "999" - ) - } - val filler = ContainerNode(key = "sticky-z-paint-filler").apply { - width = 120 - height = 80 - } - val overlap = ContainerNode(key = "sticky-z-paint-overlap", backgroundColor = overlapColor).apply { - width = 120 - height = 20 - } - val tail = ContainerNode(key = "sticky-z-paint-tail").apply { - width = 120 - height = 120 - } + val root = + ContainerNode(key = "sticky-z-paint-root").apply { + width = 120 + height = 80 + overflowY = Overflow.Auto + } + val sticky = + ContainerNode(key = "sticky-z-paint-sticky", backgroundColor = stickyColor).apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + StyleProperty.Z_INDEX to "999", + ) + } + val filler = + ContainerNode(key = "sticky-z-paint-filler").apply { + width = 120 + height = 80 + } + val overlap = + ContainerNode(key = "sticky-z-paint-overlap", backgroundColor = overlapColor).apply { + width = 120 + height = 20 + } + val tail = + ContainerNode(key = "sticky-z-paint-tail").apply { + width = 120 + height = 120 + } sticky.applyParent(root) filler.applyParent(root) overlap.applyParent(root) @@ -420,52 +475,61 @@ class PositionedLayoutStickyBehaviorTests { assertNotNull(stickyRect.intersection(overlapRect)) val commands = tree.paint(ctx) - val stickyDrawIndex = commands.indexOfFirst { - it is RenderCommand.DrawRect && it.color == stickyColor - } - val overlapDrawIndex = commands.indexOfFirst { - it is RenderCommand.DrawRect && it.color == overlapColor - } + val stickyDrawIndex = + commands.indexOfFirst { + it is RenderCommand.DrawRect && it.color == stickyColor + } + val overlapDrawIndex = + commands.indexOfFirst { + it is RenderCommand.DrawRect && it.color == overlapColor + } assertTrue(stickyDrawIndex >= 0, "Expected sticky draw command") assertTrue(overlapDrawIndex >= 0, "Expected overlap draw command") assertTrue( stickyDrawIndex > overlapDrawIndex, - "Expected sticky draw after overlap draw, but stickyDrawIndex=$stickyDrawIndex overlapDrawIndex=$overlapDrawIndex" + "Expected sticky draw after overlap draw, but " + + "stickyDrawIndex=$stickyDrawIndex overlapDrawIndex=$overlapDrawIndex", ) } @Test fun `sticky with high z-index wins hover and click over later overlap content`() { - val root = ContainerNode(key = "sticky-z-hit-root").apply { - width = 120 - height = 80 - overflowY = Overflow.Auto - } + val root = + ContainerNode(key = "sticky-z-hit-root").apply { + width = 120 + height = 80 + overflowY = Overflow.Auto + } var stickyClicks = 0 var overlapClicks = 0 - val sticky = ButtonNode("sticky-top", key = "sticky-z-hit-sticky").apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px", - StyleProperty.Z_INDEX to "999" - ) - onClick { stickyClicks += 1 } - } - val filler = ContainerNode(key = "sticky-z-hit-filler").apply { - width = 120 - height = 80 - } - val overlap = ButtonNode("later-overlap", key = "sticky-z-hit-overlap").apply { - width = 120 - height = 20 - onClick { overlapClicks += 1 } - } - val tail = ContainerNode(key = "sticky-z-hit-tail").apply { - width = 120 - height = 120 - } + val sticky = + ButtonNode("sticky-top", key = "sticky-z-hit-sticky").apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + StyleProperty.Z_INDEX to "999", + ) + onClick { stickyClicks += 1 } + } + val filler = + ContainerNode(key = "sticky-z-hit-filler").apply { + width = 120 + height = 80 + } + val overlap = + ButtonNode("later-overlap", key = "sticky-z-hit-overlap").apply { + width = 120 + height = 20 + onClick { overlapClicks += 1 } + } + val tail = + ContainerNode(key = "sticky-z-hit-tail").apply { + width = 120 + height = 120 + } sticky.applyParent(root) filler.applyParent(root) overlap.applyParent(root) @@ -493,27 +557,32 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky top remains correct during scrollbar thumb drag`() { - val root = ContainerNode(key = "sticky-drag-root").apply { - width = 120 - height = 90 - overflowY = Overflow.Auto - } - val topSpacer = ContainerNode(key = "sticky-drag-spacer").apply { - width = 120 - height = 40 - } - val sticky = ContainerNode(key = "sticky-drag-node").apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-drag-filler").apply { - width = 120 - height = 320 - } + val root = + ContainerNode(key = "sticky-drag-root").apply { + width = 120 + height = 90 + overflowY = Overflow.Auto + } + val topSpacer = + ContainerNode(key = "sticky-drag-spacer").apply { + width = 120 + height = 40 + } + val sticky = + ContainerNode(key = "sticky-drag-node").apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-drag-filler").apply { + width = 120 + height = 320 + } topSpacer.applyParent(root) sticky.applyParent(root) filler.applyParent(root) @@ -522,7 +591,7 @@ class PositionedLayoutStickyBehaviorTests { tree.render(ctx, 220, 120) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } val visual = root.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -546,27 +615,32 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky drag stays correct when transform is read before paint`() { - val root = ContainerNode(key = "sticky-drag-transform-read-root").apply { - width = 120 - height = 90 - overflowY = Overflow.Auto - } - val topSpacer = ContainerNode(key = "sticky-drag-transform-read-spacer").apply { - width = 120 - height = 40 - } - val sticky = ContainerNode(key = "sticky-drag-transform-read-node").apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-drag-transform-read-filler").apply { - width = 120 - height = 320 - } + val root = + ContainerNode(key = "sticky-drag-transform-read-root").apply { + width = 120 + height = 90 + overflowY = Overflow.Auto + } + val topSpacer = + ContainerNode(key = "sticky-drag-transform-read-spacer").apply { + width = 120 + height = 40 + } + val sticky = + ContainerNode(key = "sticky-drag-transform-read-node").apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-drag-transform-read-filler").apply { + width = 120 + height = 320 + } topSpacer.applyParent(root) sticky.applyParent(root) filler.applyParent(root) @@ -575,7 +649,7 @@ class PositionedLayoutStickyBehaviorTests { tree.render(ctx, 220, 120) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } val visual = root.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -600,29 +674,34 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky render interaction and inspector stay aligned for combined axis movement`() { - val root = ContainerNode(key = "sticky-xy-consistency-root").apply { - overflowX = Overflow.Scroll - overflowY = Overflow.Scroll - } + val root = + ContainerNode(key = "sticky-xy-consistency-root").apply { + overflowX = Overflow.Scroll + overflowY = Overflow.Scroll + } var clicks = 0 - val sticky = ButtonNode("sticky", key = "sticky-xy-consistency-node").apply { - width = 80 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "0px", - StyleProperty.TOP to "0px" - ) - onClick { clicks += 1 } - } - val spacer = ContainerNode(key = "sticky-xy-consistency-spacer").apply { - width = 20 - height = 120 - } - val filler = ContainerNode(key = "sticky-xy-consistency-filler").apply { - width = 420 - height = 420 - } + val sticky = + ButtonNode("sticky", key = "sticky-xy-consistency-node").apply { + width = 80 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "0px", + StyleProperty.TOP to "0px", + ) + onClick { clicks += 1 } + } + val spacer = + ContainerNode(key = "sticky-xy-consistency-spacer").apply { + width = 20 + height = 120 + } + val filler = + ContainerNode(key = "sticky-xy-consistency-filler").apply { + width = 420 + height = 420 + } sticky.applyParent(root) spacer.applyParent(root) filler.applyParent(root) @@ -643,28 +722,32 @@ class PositionedLayoutStickyBehaviorTests { inspector.buildDomSnapshot(800, 600) assertEquals(sticky.key?.toString(), inspector.hoveredKey) - val highlight = inspector.debugHoveredHighlight() + val highlight = inspector.portalHoveredHighlight() assertNotNull(highlight) assertEquals(rect, highlight.borderRect) } @Test fun `inspector picks and highlights sticky at final used geometry`() { - val root = ContainerNode(key = "sticky-inspector-root").apply { - overflowY = Overflow.Scroll - } - val sticky = ButtonNode("sticky", key = "sticky-inspector-node").apply { - width = 100 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-inspector-filler").apply { - width = 40 - height = 240 - } + val root = + ContainerNode(key = "sticky-inspector-root").apply { + overflowY = Overflow.Scroll + } + val sticky = + ButtonNode("sticky", key = "sticky-inspector-node").apply { + width = 100 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-inspector-filler").apply { + width = 40 + height = 240 + } sticky.applyParent(root) filler.applyParent(root) @@ -683,41 +766,48 @@ class PositionedLayoutStickyBehaviorTests { inspector.buildDomSnapshot(800, 600) assertEquals(sticky.key?.toString(), inspector.hoveredKey) - val highlight = inspector.debugHoveredHighlight() + val highlight = inspector.portalHoveredHighlight() assertNotNull(highlight) assertEquals(visibleRect, highlight.borderRect) } @Test fun `sticky horizontal movement is clamped by direct-parent containing block`() { - val root = ContainerNode(key = "sticky-clamp-x-root").apply { - display = Display.Flex - overflowX = Overflow.Scroll - } - val leftSpacer = ContainerNode(key = "sticky-clamp-x-left-spacer").apply { - width = 200 - height = 80 - } - val section = ContainerNode(key = "sticky-clamp-x-section").apply { - width = 60 - height = 80 - } - val sticky = ContainerNode(key = "sticky-clamp-x-node").apply { - width = 20 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.LEFT to "0px" - ) - } - val sectionFiller = ContainerNode(key = "sticky-clamp-x-section-filler").apply { - width = 120 - height = 40 - } - val rightSpacer = ContainerNode(key = "sticky-clamp-x-right-spacer").apply { - width = 220 - height = 80 - } + val root = + ContainerNode(key = "sticky-clamp-x-root").apply { + display = Display.Flex + overflowX = Overflow.Scroll + } + val leftSpacer = + ContainerNode(key = "sticky-clamp-x-left-spacer").apply { + width = 200 + height = 80 + } + val section = + ContainerNode(key = "sticky-clamp-x-section").apply { + width = 60 + height = 80 + } + val sticky = + ContainerNode(key = "sticky-clamp-x-node").apply { + width = 20 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.LEFT to "0px", + ) + } + val sectionFiller = + ContainerNode(key = "sticky-clamp-x-section-filler").apply { + width = 120 + height = 40 + } + val rightSpacer = + ContainerNode(key = "sticky-clamp-x-right-spacer").apply { + width = 220 + height = 80 + } leftSpacer.applyParent(root) section.applyParent(root) rightSpacer.applyParent(root) @@ -735,33 +825,40 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `sticky movement is clamped by direct-parent containing block`() { - val root = ContainerNode(key = "sticky-clamp-root").apply { - overflowY = Overflow.Scroll - } - val topSpacer = ContainerNode(key = "sticky-clamp-top-spacer").apply { - width = 120 - height = 60 - } - val section = ContainerNode(key = "sticky-clamp-section").apply { - width = 120 - height = 120 - } - val sticky = ContainerNode(key = "sticky-clamp-node").apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val sectionFiller = ContainerNode(key = "sticky-clamp-section-filler").apply { - width = 120 - height = 220 - } - val bottomSpacer = ContainerNode(key = "sticky-clamp-bottom-spacer").apply { - width = 120 - height = 260 - } + val root = + ContainerNode(key = "sticky-clamp-root").apply { + overflowY = Overflow.Scroll + } + val topSpacer = + ContainerNode(key = "sticky-clamp-top-spacer").apply { + width = 120 + height = 60 + } + val section = + ContainerNode(key = "sticky-clamp-section").apply { + width = 120 + height = 120 + } + val sticky = + ContainerNode(key = "sticky-clamp-node").apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val sectionFiller = + ContainerNode(key = "sticky-clamp-section-filler").apply { + width = 120 + height = 220 + } + val bottomSpacer = + ContainerNode(key = "sticky-clamp-bottom-spacer").apply { + width = 120 + height = 260 + } topSpacer.applyParent(root) section.applyParent(root) sticky.applyParent(section) @@ -780,40 +877,52 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `nested scroll containers preserve sticky correctness on inner local scroll updates`() { val root = ContainerNode(key = "sticky-nested-root") - val outer = ContainerNode(key = "sticky-nested-outer").apply { - width = 220 - height = 180 - overflowY = Overflow.Auto - }.applyParent(root) - val outerSpacer = ContainerNode(key = "sticky-nested-outer-spacer").apply { - width = 200 - height = 48 - }.applyParent(outer) - val inner = ContainerNode(key = "sticky-nested-inner").apply { - width = 200 - height = 110 - overflowY = Overflow.Auto - }.applyParent(outer) - val innerSpacer = ContainerNode(key = "sticky-nested-inner-spacer").apply { - width = 200 - height = 28 - }.applyParent(inner) - val sticky = ContainerNode(key = "sticky-nested-node").apply { - width = 200 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(inner) - val innerFiller = ContainerNode(key = "sticky-nested-inner-filler").apply { - width = 200 - height = 260 - }.applyParent(inner) - val outerFiller = ContainerNode(key = "sticky-nested-outer-filler").apply { - width = 200 - height = 220 - }.applyParent(outer) + val outer = + ContainerNode(key = "sticky-nested-outer") + .apply { + width = 220 + height = 180 + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "sticky-nested-outer-spacer") + .apply { + width = 200 + height = 48 + }.applyParent(outer) + val inner = + ContainerNode(key = "sticky-nested-inner") + .apply { + width = 200 + height = 110 + overflowY = Overflow.Auto + }.applyParent(outer) + val innerSpacer = + ContainerNode(key = "sticky-nested-inner-spacer") + .apply { + width = 200 + height = 28 + }.applyParent(inner) + val sticky = + ContainerNode(key = "sticky-nested-node") + .apply { + width = 200 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(inner) + ContainerNode(key = "sticky-nested-inner-filler") + .apply { + width = 200 + height = 260 + }.applyParent(inner) + ContainerNode(key = "sticky-nested-outer-filler") + .apply { + width = 200 + height = 220 + }.applyParent(outer) val tree = DomTree(root) tree.render(ctx, 320, 260) @@ -835,40 +944,47 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `fixed subtree and sticky remain correct during local scroll updates`() { - val root = ContainerNode(key = "sticky-fixed-root").apply { - width = 220 - height = 140 - overflowY = Overflow.Auto - } + val root = + ContainerNode(key = "sticky-fixed-root").apply { + width = 220 + height = 140 + overflowY = Overflow.Auto + } var stickyClicks = 0 var fixedClicks = 0 - val spacer = ContainerNode(key = "sticky-fixed-spacer").apply { - width = 200 - height = 36 - } - val sticky = ButtonNode("sticky", key = "sticky-fixed-sticky").apply { - width = 120 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - onClick { stickyClicks += 1 } - } - val fixed = ButtonNode("fixed", key = "sticky-fixed-fixed").apply { - width = 80 - height = 18 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "6px" - ) - onClick { fixedClicks += 1 } - } - val filler = ContainerNode(key = "sticky-fixed-filler").apply { - width = 200 - height = 300 - } + val spacer = + ContainerNode(key = "sticky-fixed-spacer").apply { + width = 200 + height = 36 + } + val sticky = + ButtonNode("sticky", key = "sticky-fixed-sticky").apply { + width = 120 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + onClick { stickyClicks += 1 } + } + val fixed = + ButtonNode("fixed", key = "sticky-fixed-fixed").apply { + width = 80 + height = 18 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "6px", + ) + onClick { fixedClicks += 1 } + } + val filler = + ContainerNode(key = "sticky-fixed-filler").apply { + width = 200 + height = 300 + } spacer.applyParent(root) sticky.applyParent(root) fixed.applyParent(root) @@ -899,57 +1015,69 @@ class PositionedLayoutStickyBehaviorTests { @Test fun `non-sticky positioned modes remain unchanged with sticky enabled`() { - val root = ContainerNode(key = "sticky-regression-root").apply { - overflowY = Overflow.Scroll - } - val staticNode = ContainerNode(key = "sticky-regression-static").apply { - width = 30 - height = 12 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "static", - StyleProperty.LEFT to "50px", - StyleProperty.TOP to "20px" - ) - } - val relativeNode = ContainerNode(key = "sticky-regression-relative").apply { - width = 30 - height = 12 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "18px", - StyleProperty.TOP to "7px" - ) - } - val absoluteNode = ContainerNode(key = "sticky-regression-absolute").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "40px", - StyleProperty.TOP to "22px" - ) - } - val fixedNode = ContainerNode(key = "sticky-regression-fixed").apply { - width = 20 - height = 10 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "15px", - StyleProperty.TOP to "9px" - ) - } - val stickyNode = ContainerNode(key = "sticky-regression-sticky").apply { - width = 80 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - } - val filler = ContainerNode(key = "sticky-regression-filler").apply { - width = 120 - height = 260 - } + val root = + ContainerNode(key = "sticky-regression-root").apply { + overflowY = Overflow.Scroll + } + val staticNode = + ContainerNode(key = "sticky-regression-static").apply { + width = 30 + height = 12 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "static", + StyleProperty.LEFT to "50px", + StyleProperty.TOP to "20px", + ) + } + val relativeNode = + ContainerNode(key = "sticky-regression-relative").apply { + width = 30 + height = 12 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "18px", + StyleProperty.TOP to "7px", + ) + } + val absoluteNode = + ContainerNode(key = "sticky-regression-absolute").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "40px", + StyleProperty.TOP to "22px", + ) + } + val fixedNode = + ContainerNode(key = "sticky-regression-fixed").apply { + width = 20 + height = 10 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "15px", + StyleProperty.TOP to "9px", + ) + } + val stickyNode = + ContainerNode(key = "sticky-regression-sticky").apply { + width = 80 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + } + val filler = + ContainerNode(key = "sticky-regression-filler").apply { + width = 120 + height = 260 + } staticNode.applyParent(root) relativeNode.applyParent(root) @@ -976,13 +1104,9 @@ class PositionedLayoutStickyBehaviorTests { assertEquals(fixedBeforeScroll, fixedNode.bounds) } - private fun usedRect(node: ContainerNode): Rect { - return UsedInteractionGeometryResolver.resolveNodeGeometry(node).usedBorderRect - } + private fun usedRect(node: ContainerNode): Rect = UsedInteractionGeometryResolver.resolveNodeGeometry(node).usedBorderRect - private fun usedRect(node: ButtonNode): Rect { - return UsedInteractionGeometryResolver.resolveNodeGeometry(node).usedBorderRect - } + private fun usedRect(node: ButtonNode): Rect = UsedInteractionGeometryResolver.resolveNodeGeometry(node).usedBorderRect private fun visibleRect(node: ContainerNode): Rect { val geometry = UsedInteractionGeometryResolver.resolveNodeGeometry(node) @@ -994,11 +1118,10 @@ class PositionedLayoutStickyBehaviorTests { return geometry.visibleBorderRect ?: geometry.usedBorderRect } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutZIndexOrderingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutZIndexOrderingTests.kt index 9f57055..bd3e02f 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutZIndexOrderingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/PositionedLayoutZIndexOrderingTests.kt @@ -18,11 +18,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PositionedLayoutZIndexOrderingTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -49,12 +52,14 @@ class PositionedLayoutZIndexOrderingTests { var lowClicks = 0 var highClicks = 0 - button("low", zIndex = 0, position = PositionMode.Relative).apply { - onClick { lowClicks += 1 } - }.applyParent(root) - button("high", zIndex = 6, position = PositionMode.Relative).apply { - onClick { highClicks += 1 } - }.applyParent(root) + button("low", zIndex = 0, position = PositionMode.Relative) + .apply { + onClick { lowClicks += 1 } + }.applyParent(root) + button("high", zIndex = 6, position = PositionMode.Relative) + .apply { + onClick { highClicks += 1 } + }.applyParent(root) renderTree(root, width = 220, height = 140) @@ -69,12 +74,16 @@ class PositionedLayoutZIndexOrderingTests { var firstClicks = 0 var secondClicks = 0 - val first = button("first", zIndex = 2, position = PositionMode.Relative).apply { - onClick { firstClicks += 1 } - }.applyParent(root) - val second = button("second", zIndex = 2, position = PositionMode.Relative).apply { - onClick { secondClicks += 1 } - }.applyParent(root) + val first = + button("first", zIndex = 2, position = PositionMode.Relative) + .apply { + onClick { firstClicks += 1 } + }.applyParent(root) + val second = + button("second", zIndex = 2, position = PositionMode.Relative) + .apply { + onClick { secondClicks += 1 } + }.applyParent(root) renderTree(root, width = 220, height = 140) @@ -90,12 +99,16 @@ class PositionedLayoutZIndexOrderingTests { var staticClicks = 0 var positionedClicks = 0 - val staticNode = button("static", zIndex = 999, position = PositionMode.Static).apply { - onClick { staticClicks += 1 } - }.applyParent(root) - val positionedNode = button("positioned", zIndex = -100, position = PositionMode.Relative).apply { - onClick { positionedClicks += 1 } - }.applyParent(root) + val staticNode = + button("static", zIndex = 999, position = PositionMode.Static) + .apply { + onClick { staticClicks += 1 } + }.applyParent(root) + val positionedNode = + button("positioned", zIndex = -100, position = PositionMode.Relative) + .apply { + onClick { positionedClicks += 1 } + }.applyParent(root) renderTree(root, width = 220, height = 140) @@ -129,12 +142,16 @@ class PositionedLayoutZIndexOrderingTests { var lowerClicks = 0 var upperClicks = 0 - val lower = button("lower", zIndex = 1, position = PositionMode.Relative).apply { - onClick { lowerClicks += 1 } - }.applyParent(root) - val upper = button("upper", zIndex = 3, position = PositionMode.Relative).apply { - onClick { upperClicks += 1 } - }.applyParent(root) + val lower = + button("lower", zIndex = 1, position = PositionMode.Relative) + .apply { + onClick { lowerClicks += 1 } + }.applyParent(root) + val upper = + button("upper", zIndex = 3, position = PositionMode.Relative) + .apply { + onClick { upperClicks += 1 } + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 220, 140) @@ -156,12 +173,11 @@ class PositionedLayoutZIndexOrderingTests { DomTree(root).render(ctx, width, height) } - private fun button(key: String, zIndex: Int, position: PositionMode): ButtonNode { - return ButtonNode(key, key = key).apply { + private fun button(key: String, zIndex: Int, position: PositionMode): ButtonNode = + ButtonNode(key, key = key).apply { width = 80 height = 24 this.zIndex = zIndex this.position = position } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/RangeInputScrollFastPathTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/RangeInputScrollFastPathTests.kt index f1f8fee..98b74c3 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/RangeInputScrollFastPathTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/RangeInputScrollFastPathTests.kt @@ -1,10 +1,5 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.debug.ScrollPerformanceCounters import org.dreamfinity.dsgl.core.dom.DOMNode @@ -13,20 +8,28 @@ import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow +import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty -import org.dreamfinity.dsgl.core.style.StyleDeclarations +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue class RangeInputScrollFastPathTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -61,7 +64,10 @@ class RangeInputScrollFastPathTests { @Test fun `range track follows bounds during scrollbar thumb drag fast path`() { val fixture = createFixture(includeSticky = false) - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -133,7 +139,10 @@ class RangeInputScrollFastPathTests { fun `sticky remains correct while thumb dragging with range input present`() { val fixture = createFixture(includeSticky = true) val sticky = assertNotNull(fixture.sticky) - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -154,18 +163,27 @@ class RangeInputScrollFastPathTests { @Test fun `high frequency tiny thumb drag deltas keep range track aligned`() { val fixture = createFixture(includeSticky = false) - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 assertTrue(fixture.router.handleMouseDown(dragX, startY, MouseButton.LEFT)) - var previousScroll = fixture.viewport.scrollContainerState().scrollY + var previousScroll = + fixture.viewport + .scrollContainerState() + .scrollY var previousTrackY = expectedTrackRect(fixture.range).y repeat(32) { step -> assertTrue(fixture.router.handleMouseMove(dragX, startY + step + 1)) val commands = fixture.tree.paint(ctx) - val scrollY = fixture.viewport.scrollContainerState().scrollY + val scrollY = + fixture.viewport + .scrollContainerState() + .scrollY val trackY = expectedTrackRect(fixture.range).y assertTrue(scrollY >= previousScroll) assertTrue(trackY <= previousTrackY) @@ -193,46 +211,57 @@ class RangeInputScrollFastPathTests { private fun createFixture(includeSticky: Boolean): Fixture { val root = ContainerNode(key = "range-fast-root") - val viewport = ContainerNode(key = "range-fast-viewport").apply { - width = 170 - height = 90 - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - }.applyParent(root) - - val sticky = if (includeSticky) { - ContainerNode(key = "range-fast-sticky").apply { - width = 170 - height = 18 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(viewport) - } else { - null - } + val viewport = + ContainerNode(key = "range-fast-viewport") + .apply { + width = 170 + height = 90 + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + }.applyParent(root) + + val sticky = + if (includeSticky) { + ContainerNode(key = "range-fast-sticky") + .apply { + width = 170 + height = 18 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(viewport) + } else { + null + } - val controls = ContainerNode(key = "range-fast-controls").apply { - width = 170 - }.applyParent(viewport) - ContainerNode(key = "range-fast-spacer").apply { - width = 170 - height = 42 - }.applyParent(controls) - val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range-fast-input").apply { - width = 150 - height = 16 - }.applyParent(controls) - ContainerNode(key = "range-fast-filler").apply { - width = 170 - height = 360 - }.applyParent(controls) + val controls = + ContainerNode(key = "range-fast-controls") + .apply { + width = 170 + }.applyParent(viewport) + ContainerNode(key = "range-fast-spacer") + .apply { + width = 170 + height = 42 + }.applyParent(controls) + val range = + RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range-fast-input") + .apply { + width = 150 + height = 16 + }.applyParent(controls) + ContainerNode(key = "range-fast-filler") + .apply { + width = 170 + height = 360 + }.applyParent(controls) val tree = DomTree(root) tree.render(ctx, 420, 260) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return Fixture( tree = tree, root = root, @@ -241,47 +270,57 @@ class RangeInputScrollFastPathTests { sticky = sticky, router = router, baseRangeY = range.bounds.y, - baseStickyY = sticky?.bounds?.y ?: 0 + baseStickyY = sticky?.bounds?.y ?: 0, ) } private fun createNestedFixture(): NestedFixture { val root = ContainerNode(key = "range-nested-root") - val outer = ContainerNode(key = "range-nested-outer").apply { - width = 200 - height = 120 - overflowY = Overflow.Auto - }.applyParent(root) - ContainerNode(key = "range-nested-outer-spacer").apply { - width = 200 - height = 26 - }.applyParent(outer) - val inner = ContainerNode(key = "range-nested-inner").apply { - width = 180 - height = 80 - overflowY = Overflow.Auto - }.applyParent(outer) - ContainerNode(key = "range-nested-inner-spacer").apply { - width = 180 - height = 20 - }.applyParent(inner) - val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range-nested-input").apply { - width = 150 - height = 16 - }.applyParent(inner) - ContainerNode(key = "range-nested-inner-filler").apply { - width = 180 - height = 280 - }.applyParent(inner) - ContainerNode(key = "range-nested-outer-filler").apply { - width = 200 - height = 260 - }.applyParent(outer) + val outer = + ContainerNode(key = "range-nested-outer") + .apply { + width = 200 + height = 120 + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "range-nested-outer-spacer") + .apply { + width = 200 + height = 26 + }.applyParent(outer) + val inner = + ContainerNode(key = "range-nested-inner") + .apply { + width = 180 + height = 80 + overflowY = Overflow.Auto + }.applyParent(outer) + ContainerNode(key = "range-nested-inner-spacer") + .apply { + width = 180 + height = 20 + }.applyParent(inner) + val range = + RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range-nested-input") + .apply { + width = 150 + height = 16 + }.applyParent(inner) + ContainerNode(key = "range-nested-inner-filler") + .apply { + width = 180 + height = 280 + }.applyParent(inner) + ContainerNode(key = "range-nested-outer-filler") + .apply { + width = 200 + height = 260 + }.applyParent(outer) val tree = DomTree(root) tree.render(ctx, 520, 320) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return NestedFixture( tree = tree, root = root, @@ -289,24 +328,25 @@ class RangeInputScrollFastPathTests { inner = inner, range = range, router = router, - baseRangeY = range.bounds.y + baseRangeY = range.bounds.y, ) } private fun assertTrackMatchesBounds(commands: List, range: RangeInputNode) { val expected = expectedTrackRect(range) - val match = commands - .filterIsInstance() - .firstOrNull { command -> - command.x == expected.x && - command.y == expected.y && - command.width == expected.width && - command.height == expected.height && - command.color == range.trackColor - } + val match = + commands + .filterIsInstance() + .firstOrNull { command -> + command.x == expected.x && + command.y == expected.y && + command.width == expected.width && + command.height == expected.height && + command.color == range.trackColor + } assertNotNull( match, - "Range track command did not match expected geometry: expected=$expected bounds=${range.bounds}" + "Range track command did not match expected geometry: expected=$expected bounds=${range.bounds}", ) } @@ -317,7 +357,7 @@ class RangeInputScrollFastPathTests { x = range.bounds.x, y = trackY, width = range.bounds.width, - height = trackHeight + height = trackHeight, ) } @@ -326,13 +366,12 @@ class RangeInputScrollFastPathTests { return geometry.visibleBorderRect ?: geometry.usedBorderRect } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } private data class Fixture( val tree: DomTree, @@ -340,9 +379,9 @@ class RangeInputScrollFastPathTests { val viewport: ContainerNode, val range: RangeInputNode, val sticky: ContainerNode?, - val router: LayerDomInputRouter, + val router: SurfaceDomInputRouter, val baseRangeY: Int, - val baseStickyY: Int + val baseStickyY: Int, ) private data class NestedFixture( @@ -351,7 +390,7 @@ class RangeInputScrollFastPathTests { val outer: ContainerNode, val inner: ContainerNode, val range: RangeInputNode, - val router: LayerDomInputRouter, - val baseRangeY: Int + val router: SurfaceDomInputRouter, + val baseRangeY: Int, ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollContainerStateTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollContainerStateTests.kt index 6402a32..37db737 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollContainerStateTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollContainerStateTests.kt @@ -1,26 +1,29 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.StyleEngine +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class ScrollContainerStateTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -32,15 +35,18 @@ class ScrollContainerStateTests { @Test fun `generic viewport rect is exposed for scroll-capable container`() { val root = ContainerNode(key = "root") - val container = ContainerNode(padding = 4, key = "viewport").apply { - width = 100 - height = 60 - overflow = Overflow.Hidden - }.applyParent(root) - ContainerNode(key = "child").apply { - width = 140 - height = 24 - }.applyParent(container) + val container = + ContainerNode(padding = 4, key = "viewport") + .apply { + width = 100 + height = 60 + overflow = Overflow.Hidden + }.applyParent(root) + ContainerNode(key = "child") + .apply { + width = 140 + height = 24 + }.applyParent(container) DomTree(root).render(ctx, 320, 180) val state = container.scrollContainerState() @@ -54,19 +60,23 @@ class ScrollContainerStateTests { @Test fun `generic content extent is distinct from viewport when content exceeds bounds`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "extent").apply { - width = 80 - height = 40 - overflow = Overflow.Hidden - }.applyParent(root) - ContainerNode(key = "child-a").apply { - width = 70 - height = 30 - }.applyParent(container) - ContainerNode(key = "child-b").apply { - width = 70 - height = 30 - }.applyParent(container) + val container = + ContainerNode(key = "extent") + .apply { + width = 80 + height = 40 + overflow = Overflow.Hidden + }.applyParent(root) + ContainerNode(key = "child-a") + .apply { + width = 70 + height = 30 + }.applyParent(container) + ContainerNode(key = "child-b") + .apply { + width = 70 + height = 30 + }.applyParent(container) DomTree(root).render(ctx, 320, 180) val state = container.scrollContainerState() @@ -77,10 +87,12 @@ class ScrollContainerStateTests { @Test fun `per-axis overflow modes resolve to expected scroll-container capability`() { val root = ContainerNode(key = "root") - val subject = ContainerNode(key = "axis").apply { - width = 80 - height = 40 - }.applyParent(root) + val subject = + ContainerNode(key = "axis") + .apply { + width = 80 + height = 40 + }.applyParent(root) DomTree(root).render(ctx, 320, 180) @@ -105,16 +117,19 @@ class ScrollContainerStateTests { @Test fun `generic scroll offsets are meaningful for scroll-capable axes`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "scroll-state").apply { - width = 90 - height = 40 - overflowX = Overflow.Visible - overflowY = Overflow.Auto - }.applyParent(root) - ContainerNode(key = "tall-child").apply { - width = 70 - height = 180 - }.applyParent(container) + val container = + ContainerNode(key = "scroll-state") + .apply { + width = 90 + height = 40 + overflowX = Overflow.Visible + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "tall-child") + .apply { + width = 70 + height = 180 + }.applyParent(container) container.setScrollOffsets(scrollX = 45, scrollY = 70) DomTree(root).render(ctx, 320, 220) @@ -135,16 +150,19 @@ class ScrollContainerStateTests { @Test fun `overflow scroll always marks axis scrollbar present and reserves gutter`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "scroll-axis").apply { - width = 100 - height = 60 - overflowX = Overflow.Scroll - overflowY = Overflow.Scroll - }.applyParent(root) - ContainerNode(key = "child").apply { - width = 40 - height = 20 - }.applyParent(container) + val container = + ContainerNode(key = "scroll-axis") + .apply { + width = 100 + height = 60 + overflowX = Overflow.Scroll + overflowY = Overflow.Scroll + }.applyParent(root) + ContainerNode(key = "child") + .apply { + width = 40 + height = 20 + }.applyParent(container) DomTree(root).render(ctx, 320, 180) val state = container.scrollContainerState() @@ -161,17 +179,21 @@ class ScrollContainerStateTests { @Test fun `overflow auto marks scrollbar presence only when axis content exceeds viewport`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 320, 180) - } - val container = ContainerNode(key = "auto-axis").apply { - bounds = Rect(10, 10, 100, 60) - overflowX = Overflow.Auto - overflowY = Overflow.Auto - }.applyParent(root) - val child = ContainerNode(key = "child").apply { - bounds = Rect(10, 10, 90, 50) - } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 320, 180) + } + val container = + ContainerNode(key = "auto-axis") + .apply { + bounds = Rect(10, 10, 100, 60) + overflowX = Overflow.Auto + overflowY = Overflow.Auto + }.applyParent(root) + val child = + ContainerNode(key = "child").apply { + bounds = Rect(10, 10, 90, 50) + } child.applyParent(container) var state = container.scrollContainerState() @@ -185,19 +207,23 @@ class ScrollContainerStateTests { assertTrue(state.horizontalScrollbarGutter > 0) assertTrue(state.verticalScrollbarGutter > 0) } + @Test fun `overflow x auto reserves horizontal gutter when measured content width exceeds viewport`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "auto-horizontal").apply { - width = 794 - height = 634 - overflowX = Overflow.Auto - overflowY = Overflow.Hidden - }.applyParent(root) - ContainerNode(key = "child").apply { - width = 826 - height = 620 - }.applyParent(container) + val container = + ContainerNode(key = "auto-horizontal") + .apply { + width = 794 + height = 634 + overflowX = Overflow.Auto + overflowY = Overflow.Hidden + }.applyParent(root) + ContainerNode(key = "child") + .apply { + width = 826 + height = 620 + }.applyParent(container) DomTree(root).render(ctx, 1400, 1000) val state = container.scrollContainerState() @@ -212,19 +238,23 @@ class ScrollContainerStateTests { assertEquals(0, state.verticalScrollbarGutter) assertEquals(state.baseViewportRect.height - state.horizontalScrollbarGutter, state.viewportRect.height) } + @Test fun `visible and hidden overflow never expose visible scrollbars or gutters`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "hidden-visible-axis").apply { - width = 100 - height = 60 - overflowX = Overflow.Visible - overflowY = Overflow.Hidden - }.applyParent(root) - ContainerNode(key = "child").apply { - width = 180 - height = 220 - }.applyParent(container) + val container = + ContainerNode(key = "hidden-visible-axis") + .apply { + width = 100 + height = 60 + overflowX = Overflow.Visible + overflowY = Overflow.Hidden + }.applyParent(root) + ContainerNode(key = "child") + .apply { + width = 180 + height = 220 + }.applyParent(container) DomTree(root).render(ctx, 320, 220) val state = container.scrollContainerState() @@ -239,27 +269,30 @@ class ScrollContainerStateTests { assertEquals(0, state.axisY.scrollbarGutter) } - @Test fun `mixed overflow axis combinations resolve deterministic state`() { - val cases = listOf( - Triple(Overflow.Hidden, Overflow.Auto, Pair(true, true)), - Triple(Overflow.Visible, Overflow.Scroll, Pair(false, true)), - Triple(Overflow.Scroll, Overflow.Visible, Pair(true, false)) - ) + val cases = + listOf( + Triple(Overflow.Hidden, Overflow.Auto, Pair(true, true)), + Triple(Overflow.Visible, Overflow.Scroll, Pair(false, true)), + Triple(Overflow.Scroll, Overflow.Visible, Pair(true, false)), + ) cases.forEachIndexed { index, (overflowX, overflowY, expectedScrollContainer) -> val root = ContainerNode(key = "root-$index") - val container = ContainerNode(key = "mixed-$index").apply { - width = 110 - height = 66 - this.overflowX = overflowX - this.overflowY = overflowY - }.applyParent(root) - ContainerNode(key = "child-$index").apply { - width = 220 - height = 190 - }.applyParent(container) + val container = + ContainerNode(key = "mixed-$index") + .apply { + width = 110 + height = 66 + this.overflowX = overflowX + this.overflowY = overflowY + }.applyParent(root) + ContainerNode(key = "child-$index") + .apply { + width = 220 + height = 190 + }.applyParent(container) DomTree(root).render(ctx, 360, 220) val state = container.scrollContainerState() @@ -267,27 +300,36 @@ class ScrollContainerStateTests { assertEquals(expectedScrollContainer.first, state.axisX.scrollContainer) assertEquals(expectedScrollContainer.second, state.axisY.scrollContainer) - val expectedVisibleX = overflowX == Overflow.Scroll || (overflowX == Overflow.Auto && state.contentExtent.width > state.viewportRect.width) - val expectedVisibleY = overflowY == Overflow.Scroll || (overflowY == Overflow.Auto && state.contentExtent.height > state.viewportRect.height) + val expectedVisibleX = + overflowX == Overflow.Scroll || + (overflowX == Overflow.Auto && state.contentExtent.width > state.viewportRect.width) + val expectedVisibleY = + overflowY == Overflow.Scroll || + (overflowY == Overflow.Auto && state.contentExtent.height > state.viewportRect.height) assertEquals(expectedVisibleX, state.axisX.scrollbarPresent) assertEquals(expectedVisibleY, state.axisY.scrollbarPresent) assertEquals(if (expectedVisibleX) state.horizontalScrollbarGutter else 0, state.axisX.scrollbarGutter) assertEquals(if (expectedVisibleY) state.verticalScrollbarGutter else 0, state.axisY.scrollbarGutter) } } + @Test fun `cross-axis gutter forcing enables dependent auto scrollbar deterministically`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 320, 220) - } - val container = ContainerNode(key = "cross-axis").apply { - bounds = Rect(10, 10, 100, 80) - overflowX = Overflow.Auto - overflowY = Overflow.Auto - }.applyParent(root) - ContainerNode(key = "child").apply { - bounds = Rect(10, 10, 96, 220) - }.applyParent(container) + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 320, 220) + } + val container = + ContainerNode(key = "cross-axis") + .apply { + bounds = Rect(10, 10, 100, 80) + overflowX = Overflow.Auto + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "child") + .apply { + bounds = Rect(10, 10, 96, 220) + }.applyParent(container) val state = container.scrollContainerState() @@ -302,16 +344,19 @@ class ScrollContainerStateTests { @Test fun `final viewport resolution remains stable after gutter resolution`() { val root = ContainerNode(key = "root") - val container = ContainerNode(key = "stable-viewport").apply { - width = 120 - height = 72 - overflowX = Overflow.Auto - overflowY = Overflow.Scroll - }.applyParent(root) - ContainerNode(key = "child").apply { - width = 118 - height = 190 - }.applyParent(container) + val container = + ContainerNode(key = "stable-viewport") + .apply { + width = 120 + height = 72 + overflowX = Overflow.Auto + overflowY = Overflow.Scroll + }.applyParent(root) + ContainerNode(key = "child") + .apply { + width = 118 + height = 190 + }.applyParent(container) DomTree(root).render(ctx, 320, 220) val first = container.scrollContainerState() @@ -326,28 +371,34 @@ class ScrollContainerStateTests { @Test fun `paint and input clipping remain consistent with overflow auto state`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 320, 200) - } - val clippedViewport = ContainerNode(key = "clip").apply { - bounds = Rect(20, 20, 100, 40) - overflowX = Overflow.Visible - overflowY = Overflow.Auto - }.applyParent(root) - val child = ButtonNode("child", key = "clip-child").apply { - bounds = Rect(24, 46, 80, 22) - } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 320, 200) + } + val clippedViewport = + ContainerNode(key = "clip") + .apply { + bounds = Rect(20, 20, 100, 40) + overflowX = Overflow.Visible + overflowY = Overflow.Auto + }.applyParent(root) + val child = + ButtonNode("child", key = "clip-child").apply { + bounds = Rect(24, 46, 80, 22) + } child.applyParent(clippedViewport) val commands = ArrayList() root.appendRenderCommands(ctx, commands) - assertTrue(commands.any { command -> - command is RenderCommand.PushClip && - command.y == clippedViewport.bounds.y && - command.height == clippedViewport.bounds.height - }) + assertTrue( + commands.any { command -> + command is RenderCommand.PushClip && + command.y == clippedViewport.bounds.y && + command.height == clippedViewport.bounds.height + }, + ) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } assertTrue(router.handleMouseDown(30, 58, MouseButton.LEFT)) assertTrue(router.handleMouseUp(30, 58, MouseButton.LEFT)) assertTrue(!router.handleMouseDown(30, 65, MouseButton.LEFT)) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollInvalidationSemanticsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollInvalidationSemanticsTests.kt index fffaa91..080ce0b 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollInvalidationSemanticsTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollInvalidationSemanticsTests.kt @@ -1,11 +1,11 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.style.Overflow +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue class ScrollInvalidationSemanticsTests { @Test @@ -28,11 +28,12 @@ class ScrollInvalidationSemanticsTests { fun `restore snapshot with visual-only delta marks visual interaction without layout`() { val viewport = createScrollableViewport() val baseline = viewport.captureScrollSessionSnapshot() - val visualOnly = baseline.copy( - targetY = baseline.targetY + 36, - displayedY = baseline.displayedY + 36.0, - resolvedY = baseline.resolvedY - ) + val visualOnly = + baseline.copy( + targetY = baseline.targetY + 36, + displayedY = baseline.displayedY + 36.0, + resolvedY = baseline.resolvedY, + ) viewport.restoreScrollSessionSnapshot(visualOnly) val invalidation = viewport.consumeScrollInvalidationRecursively() @@ -45,9 +46,10 @@ class ScrollInvalidationSemanticsTests { @Test fun `root consumption aggregates child scroll invalidation recursively`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 400, 280) - } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 400, 280) + } val viewport = createScrollableViewport().applyParent(root) viewport.setScrollOffsets(0, 32) @@ -63,11 +65,12 @@ class ScrollInvalidationSemanticsTests { fun `layout-dirty snapshot restore keeps legacy layout-dirty consumer compatible`() { val viewport = createScrollableViewport() val baseline = viewport.captureScrollSessionSnapshot() - val layoutDirty = baseline.copy( - targetY = baseline.targetY + 28, - displayedY = baseline.displayedY + 28.0, - resolvedY = baseline.resolvedY + 28 - ) + val layoutDirty = + baseline.copy( + targetY = baseline.targetY + 28, + displayedY = baseline.displayedY + 28.0, + resolvedY = baseline.resolvedY + 28, + ) viewport.restoreScrollSessionSnapshot(layoutDirty) assertTrue(viewport.consumeScrollLayoutDirtyRecursively()) @@ -77,14 +80,16 @@ class ScrollInvalidationSemanticsTests { } private fun createScrollableViewport(): ContainerNode { - val viewport = ContainerNode(key = "viewport").apply { - bounds = Rect(0, 0, 180, 90) - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - } - ContainerNode(key = "content").apply { - bounds = Rect(0, 0, 180, 420) - }.applyParent(viewport) + val viewport = + ContainerNode(key = "viewport").apply { + bounds = Rect(0, 0, 180, 90) + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + } + ContainerNode(key = "content") + .apply { + bounds = Rect(0, 0, 180, 420) + }.applyParent(viewport) return viewport } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollPerformanceCountersTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollPerformanceCountersTests.kt index 9a1bce3..3b02664 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollPerformanceCountersTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollPerformanceCountersTests.kt @@ -1,29 +1,32 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.debug.ScrollPerformanceCounters import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class ScrollPerformanceCountersTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -128,10 +131,11 @@ class ScrollPerformanceCountersTests { fun `guarded scroll visual fast path skips full rerender for visual-only scroll invalidation`() { val fixture = createScrollStickyFixture() val baseline = fixture.viewport.captureScrollSessionSnapshot() - val visualOnlySnapshot = baseline.copy( - displayedY = baseline.displayedY + 0.4, - resolvedY = baseline.resolvedY - ) + val visualOnlySnapshot = + baseline.copy( + displayedY = baseline.displayedY + 0.4, + resolvedY = baseline.resolvedY, + ) fixture.viewport.restoreScrollSessionSnapshot(visualOnlySnapshot) ScrollPerformanceCounters.resetForTests() @@ -156,8 +160,11 @@ class ScrollPerformanceCountersTests { @Test fun `thumb-drag frame keeps sticky correct and remains visual-path only`() { val fixture = createScrollStickyFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical - ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical + ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 val dragY = startY + 26 @@ -179,8 +186,9 @@ class ScrollPerformanceCountersTests { assertTrue(counters.chunkTraversalCalls > 0L) assertTrue(counters.chunkRebuildCalls > 0L) - val visibleStickyRect = UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).visibleBorderRect - ?: UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).usedBorderRect + val visibleStickyRect = + UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).visibleBorderRect + ?: UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).usedBorderRect assertEquals(0, visibleStickyRect.y) assertTrue(fixture.router.handleMouseUp(dragX, dragY, MouseButton.LEFT)) } @@ -204,8 +212,9 @@ class ScrollPerformanceCountersTests { val state = fixture.viewport.scrollContainerState() val expectedBaseY = fixture.stickyBaseTopY - state.scrollY val expectedVisibleY = maxOf(expectedBaseY, 0) - val visibleStickyRect = UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).visibleBorderRect - ?: UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).usedBorderRect + val visibleStickyRect = + UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).visibleBorderRect + ?: UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.sticky).usedBorderRect assertEquals(1L, counters.guardedScrollVisualFastPathRuns) assertEquals(0L, counters.fullRerenderLayoutRuns) @@ -221,42 +230,52 @@ class ScrollPerformanceCountersTests { @Test fun `local scroll invalidation refreshes only sticky nodes in affected subtree`() { val root = ContainerNode(key = "perf-narrow-root") - val leftViewport = ContainerNode(key = "perf-narrow-left").apply { - width = 160 - height = 90 - overflowY = Overflow.Auto - }.applyParent(root) - val rightViewport = ContainerNode(key = "perf-narrow-right").apply { - width = 160 - height = 90 - overflowY = Overflow.Auto - }.applyParent(root) - - ContainerNode(key = "perf-narrow-left-sticky").apply { - width = 140 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(leftViewport) - ContainerNode(key = "perf-narrow-left-filler").apply { - width = 140 - height = 320 - }.applyParent(leftViewport) - - ContainerNode(key = "perf-narrow-right-sticky").apply { - width = 140 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(rightViewport) - ContainerNode(key = "perf-narrow-right-filler").apply { - width = 140 - height = 320 - }.applyParent(rightViewport) + val leftViewport = + ContainerNode(key = "perf-narrow-left") + .apply { + width = 160 + height = 90 + overflowY = Overflow.Auto + }.applyParent(root) + val rightViewport = + ContainerNode(key = "perf-narrow-right") + .apply { + width = 160 + height = 90 + overflowY = Overflow.Auto + }.applyParent(root) + + ContainerNode(key = "perf-narrow-left-sticky") + .apply { + width = 140 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(leftViewport) + ContainerNode(key = "perf-narrow-left-filler") + .apply { + width = 140 + height = 320 + }.applyParent(leftViewport) + + ContainerNode(key = "perf-narrow-right-sticky") + .apply { + width = 140 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(rightViewport) + ContainerNode(key = "perf-narrow-right-filler") + .apply { + width = 140 + height = 320 + }.applyParent(rightViewport) val tree = DomTree(root) tree.render(ctx, 420, 220) @@ -284,11 +303,12 @@ class ScrollPerformanceCountersTests { fun `layout-dirty scroll invalidation still falls back to full rerender`() { val fixture = createScrollStickyFixture() val baseline = fixture.viewport.captureScrollSessionSnapshot() - val layoutDirtySnapshot = baseline.copy( - targetY = baseline.targetY + 48, - displayedY = baseline.displayedY + 48.0, - resolvedY = baseline.resolvedY + 48 - ) + val layoutDirtySnapshot = + baseline.copy( + targetY = baseline.targetY + 48, + displayedY = baseline.displayedY + 48.0, + resolvedY = baseline.resolvedY + 48, + ) fixture.viewport.restoreScrollSessionSnapshot(layoutDirtySnapshot) ScrollPerformanceCounters.resetForTests() @@ -304,58 +324,65 @@ class ScrollPerformanceCountersTests { private fun createScrollStickyFixture(): ScrollStickyFixture { val root = ContainerNode(key = "perf-root") - val viewport = ContainerNode(key = "perf-scroll-viewport").apply { - width = 180 - height = 100 - overflowY = Overflow.Auto - }.applyParent(root) - val topSpacer = ContainerNode(key = "perf-top-spacer").apply { - width = 160 - height = 32 - }.applyParent(viewport) - - val sticky = ContainerNode(key = "perf-sticky").apply { - width = 160 - height = 24 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(viewport) - - ContainerNode(key = "perf-filler").apply { - width = 160 - height = 420 - }.applyParent(viewport) + val viewport = + ContainerNode(key = "perf-scroll-viewport") + .apply { + width = 180 + height = 100 + overflowY = Overflow.Auto + }.applyParent(root) + val topSpacer = + ContainerNode(key = "perf-top-spacer") + .apply { + width = 160 + height = 32 + }.applyParent(viewport) + + val sticky = + ContainerNode(key = "perf-sticky") + .apply { + width = 160 + height = 24 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(viewport) + + ContainerNode(key = "perf-filler") + .apply { + width = 160 + height = 420 + }.applyParent(viewport) val tree = DomTree(root) tree.render(ctx, 320, 220) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return ScrollStickyFixture( tree = tree, root = root, viewport = viewport, sticky = sticky, router = router, - stickyBaseTopY = topSpacer.height ?: 0 + stickyBaseTopY = topSpacer.height ?: 0, ) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } private data class ScrollStickyFixture( val tree: DomTree, val root: ContainerNode, val viewport: ContainerNode, val sticky: ContainerNode, - val router: LayerDomInputRouter, - val stickyBaseTopY: Int + val router: SurfaceDomInputRouter, + val stickyBaseTopY: Int, ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollReactiveSmoothTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollReactiveSmoothTests.kt index bdca003..1d96e70 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollReactiveSmoothTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollReactiveSmoothTests.kt @@ -1,30 +1,32 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.math.roundToInt import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Insets -import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.StyleEngine +import kotlin.math.roundToInt +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class ScrollReactiveSmoothTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -71,23 +73,37 @@ class ScrollReactiveSmoothTests { assertTrue(fixture.router.handleMouseWheel(wheelX, wheelY, -240)) - var previousScroll = fixture.viewport.scrollContainerState().scrollY - var previousThumb = fixture.viewport.debugScrollbarVisualState().vertical?.thumbRect?.y - ?: error("Expected vertical scrollbar") + var previousScroll = + fixture.viewport + .scrollContainerState() + .scrollY + var previousThumb = + fixture.viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?.y + ?: error("Expected vertical scrollbar") var previousButtonY = fixture.button.bounds.y repeat(14) { fixture.tree.paint(ctx) val state = fixture.viewport.scrollContainerState() - val thumbY = fixture.viewport.debugScrollbarVisualState().vertical?.thumbRect?.y - ?: error("Expected vertical scrollbar") + val thumbY = + fixture.viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?.y + ?: error("Expected vertical scrollbar") assertTrue(state.scrollY >= previousScroll) assertTrue(thumbY >= previousThumb) assertTrue(fixture.button.bounds.y <= previousButtonY) val expectedButtonY = state.viewportRect.y - state.scrollY + fixture.button.margin.top assertTrue( kotlin.math.abs(expectedButtonY - fixture.button.bounds.y) <= 1, - "expectedButtonY=$expectedButtonY actualButtonY=${fixture.button.bounds.y} scrollY=${state.scrollY} viewportY=${state.viewportRect.y}" + "expectedButtonY=$expectedButtonY actualButtonY=${fixture.button.bounds.y} " + + "scrollY=${state.scrollY} viewportY=${state.viewportRect.y}", ) previousScroll = state.scrollY previousThumb = thumbY @@ -130,7 +146,10 @@ class ScrollReactiveSmoothTests { val history = ArrayList(160) repeat(160) { fixture.tree.paint(ctx) - history += fixture.viewport.debugScrollAnimationState().resolvedY + history += + fixture.viewport + .debugScrollAnimationState() + .resolvedY } val finalState = fixture.viewport.debugScrollAnimationState() assertEquals(finalState.targetY, finalState.resolvedY) @@ -141,22 +160,38 @@ class ScrollReactiveSmoothTests { repeat(10) { fixture.tree.paint(ctx) - assertEquals(finalState.targetY, fixture.viewport.debugScrollAnimationState().resolvedY) + assertEquals( + finalState.targetY, + fixture.viewport + .debugScrollAnimationState() + .resolvedY, + ) } } @Test fun `thumb drag updates content and thumb continuously in sync`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 assertTrue(fixture.router.handleMouseDown(dragX, startY, MouseButton.LEFT)) - var previousScroll = fixture.viewport.scrollContainerState().scrollY - var previousThumbY = fixture.viewport.debugScrollbarVisualState().vertical?.thumbRect?.y - ?: error("Expected vertical scrollbar") + var previousScroll = + fixture.viewport + .scrollContainerState() + .scrollY + var previousThumbY = + fixture.viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?.y + ?: error("Expected vertical scrollbar") var previousButtonY = fixture.button.bounds.y repeat(8) { step -> val nextY = startY + (step + 1) * 6 @@ -164,15 +199,21 @@ class ScrollReactiveSmoothTests { fixture.tree.paint(ctx) val state = fixture.viewport.scrollContainerState() val debug = fixture.viewport.debugScrollAnimationState() - val currentThumbY = fixture.viewport.debugScrollbarVisualState().vertical?.thumbRect?.y - ?: error("Expected vertical scrollbar") + val currentThumbY = + fixture.viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?.y + ?: error("Expected vertical scrollbar") assertTrue(state.scrollY >= previousScroll) assertTrue(currentThumbY >= previousThumbY) assertTrue(fixture.button.bounds.y <= previousButtonY) val expectedButtonY = state.viewportRect.y - state.scrollY + fixture.button.margin.top assertTrue( kotlin.math.abs(expectedButtonY - fixture.button.bounds.y) <= 1, - "expectedButtonY=$expectedButtonY actualButtonY=${fixture.button.bounds.y} scrollY=${state.scrollY} viewportY=${state.viewportRect.y}" + "expectedButtonY=$expectedButtonY actualButtonY=${fixture.button.bounds.y} " + + "scrollY=${state.scrollY} viewportY=${state.viewportRect.y}", ) assertEquals(state.scrollY, debug.resolvedY) assertTrue(kotlin.math.abs(debug.displayedY - debug.resolvedY.toDouble()) <= 1.0) @@ -181,10 +222,14 @@ class ScrollReactiveSmoothTests { previousButtonY = fixture.button.bounds.y } } + @Test fun `drag keeps target and displayed state coherent`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -201,7 +246,10 @@ class ScrollReactiveSmoothTests { @Test fun `fast thumb drag to max remains stable and coherent at boundary`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -232,7 +280,10 @@ class ScrollReactiveSmoothTests { @Test fun `fast thumb drag to min remains stable and coherent at boundary`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -255,7 +306,10 @@ class ScrollReactiveSmoothTests { @Test fun `drag session baseline stays frozen when live scrollbar geometry changes`() { val fixture = createFixture() - val initialVisual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val initialVisual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = initialVisual.thumbRect.x + initialVisual.thumbRect.width / 2 val startY = initialVisual.thumbRect.y + initialVisual.thumbRect.height / 2 @@ -270,7 +324,10 @@ class ScrollReactiveSmoothTests { fixture.tree.render(ctx, 420, 260) fixture.tree.paint(ctx) - val liveAfterResize = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val liveAfterResize = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") assertTrue(liveAfterResize.maxScroll >= baseline.maxScroll) val baselineAfterResize = fixture.viewport.debugScrollbarDragSession() ?: error("Expected active drag session") @@ -290,7 +347,8 @@ class ScrollReactiveSmoothTests { val debug = fixture.viewport.debugScrollAnimationState() assertTrue( kotlin.math.abs(expectedScroll - state.scrollY) <= 1, - "expectedScroll=$expectedScroll actualScroll=${state.scrollY} moveY=$moveY baseline=$baseline stateMax=${state.maxScrollY}" + "expectedScroll=$expectedScroll actualScroll=${state.scrollY} " + + "moveY=$moveY baseline=$baseline stateMax=${state.maxScrollY}", ) assertEquals(state.scrollY, debug.resolvedY) assertEquals(debug.displayedY, debug.resolvedY.toDouble()) @@ -302,19 +360,28 @@ class ScrollReactiveSmoothTests { @Test fun `drag pointer capture continues when pointer leaves container bounds`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 assertTrue(fixture.router.handleMouseDown(dragX, startY, MouseButton.LEFT)) - val before = fixture.viewport.scrollContainerState().scrollY + val before = + fixture.viewport + .scrollContainerState() + .scrollY val outsideX = fixture.viewport.bounds.x + fixture.viewport.bounds.width + 1200 val outsideY = fixture.viewport.bounds.y + fixture.viewport.bounds.height + 1200 assertTrue(fixture.router.handleMouseMove(outsideX, outsideY)) fixture.tree.paint(ctx) - val after = fixture.viewport.scrollContainerState().scrollY + val after = + fixture.viewport + .scrollContainerState() + .scrollY assertTrue(after >= before) assertTrue(fixture.router.handleMouseUp(outsideX, outsideY, MouseButton.LEFT)) } @@ -322,7 +389,10 @@ class ScrollReactiveSmoothTests { @Test fun `drag release is stable without snap back`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -343,11 +413,13 @@ class ScrollReactiveSmoothTests { } } - @Test fun `wheel smoothness remains after thumb drag interaction`() { val fixture = createFixture() - val visual = fixture.viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar") + val visual = + fixture.viewport + .debugScrollbarVisualState() + .vertical ?: error("Expected vertical scrollbar") val dragX = visual.thumbRect.x + visual.thumbRect.width / 2 val startY = visual.thumbRect.y + visual.thumbRect.height / 2 @@ -371,6 +443,7 @@ class ScrollReactiveSmoothTests { val settled = fixture.viewport.debugScrollAnimationState() assertEquals(settled.targetY, settled.resolvedY) } + @Test fun `scroll updates do not depend on unrelated viewport dimension changes`() { val fixture = createFixture() @@ -387,36 +460,40 @@ class ScrollReactiveSmoothTests { assertEquals(afterScrollPaint, fixture.button.bounds.y) } - private fun dispatchClick(tree: DomTree, x: Int, y: Int): Boolean { - return tree.dispatchClick(MouseClickEvent(x, y, MouseButton.LEFT)) - } + private fun dispatchClick(tree: DomTree, x: Int, y: Int): Boolean = tree.dispatchClick(MouseClickEvent(x, y, MouseButton.LEFT)) private fun createFixture(): Fixture { val clickCount = IntBox() val root = ContainerNode(key = "root") - val viewport = ContainerNode(key = "viewport").apply { - width = 160 - height = 90 - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - }.applyParent(root) - val button = ButtonNode("row", key = "row-button").apply { - width = 120 - height = 24 - margin = Insets(top = 40, right = 0, bottom = 0, left = 0) - onClick { - clickCount.value += 1 - } - }.applyParent(viewport) - val filler = ContainerNode(key = "filler").apply { - width = 120 - height = 320 - }.applyParent(viewport) + val viewport = + ContainerNode(key = "viewport") + .apply { + width = 160 + height = 90 + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + }.applyParent(root) + val button = + ButtonNode("row", key = "row-button") + .apply { + width = 120 + height = 24 + margin = Insets(top = 40, right = 0, bottom = 0, left = 0) + onClick { + clickCount.value += 1 + } + }.applyParent(viewport) + val filler = + ContainerNode(key = "filler") + .apply { + width = 120 + height = 320 + }.applyParent(viewport) val tree = DomTree(root) tree.render(ctx, 420, 260) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return Fixture(tree, root, viewport, button, filler, router, clickCount) } @@ -426,17 +503,20 @@ class ScrollReactiveSmoothTests { val viewport: ContainerNode, val button: ButtonNode, val filler: ContainerNode, - val router: LayerDomInputRouter, - val clickCount: IntBox + val router: SurfaceDomInputRouter, + val clickCount: IntBox, ) private fun expectedScrollFromSession(session: ScrollbarDragSessionDebugState, pointerAxisPx: Int): Int { if (session.maxScroll <= 0 || session.maxThumbTravelPx <= 0) return 0 - val desiredThumbStart = (pointerAxisPx - session.trackStartPx - session.grabOffsetPx) - .coerceIn(0, session.maxThumbTravelPx) + val desiredThumbStart = + (pointerAxisPx - session.trackStartPx - session.grabOffsetPx) + .coerceIn(0, session.maxThumbTravelPx) val ratio = desiredThumbStart.toDouble() / session.maxThumbTravelPx.toDouble() return (ratio * session.maxScroll.toDouble()).roundToInt().coerceIn(0, session.maxScroll) } - private data class IntBox(var value: Int = 0) + private data class IntBox( + var value: Int = 0, + ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollbarRenderingInteractionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollbarRenderingInteractionTests.kt index d211660..492bc04 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollbarRenderingInteractionTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/ScrollbarRenderingInteractionTests.kt @@ -1,30 +1,33 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.StyleEngine +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue class ScrollbarRenderingInteractionTests { private val trackColor = 0x55303030 private val thumbColor = 0xAA9AA5B1.toInt() - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -36,59 +39,74 @@ class ScrollbarRenderingInteractionTests { @Test fun `generic scrollbar rendering emits track and thumb when present`() { - val (_, viewport, _, _) = createFixture( - overflowX = Overflow.Visible, - overflowY = Overflow.Scroll, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 40 - ) + val viewport = + createFixture( + overflowX = Overflow.Visible, + overflowY = Overflow.Scroll, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 40, + ).viewport val commands = selfCommands(viewport) val vertical = viewport.debugScrollbarVisualState().vertical assertNotNull(vertical) - assertTrue(commands.any { command -> - command is RenderCommand.DrawRect && - command.color == trackColor && - command.x == vertical.trackRect.x && - command.y == vertical.trackRect.y && - command.width == vertical.trackRect.width && - command.height == vertical.trackRect.height - }) - assertTrue(commands.any { command -> - command is RenderCommand.DrawRect && - command.color == thumbColor && - command.x == vertical.thumbRect.x && - command.y == vertical.thumbRect.y && - command.width == vertical.thumbRect.width && - command.height == vertical.thumbRect.height - }) + assertTrue( + commands.any { command -> + command is RenderCommand.DrawRect && + command.color == trackColor && + command.x == vertical.trackRect.x && + command.y == vertical.trackRect.y && + command.width == vertical.trackRect.width && + command.height == vertical.trackRect.height + }, + ) + assertTrue( + commands.any { command -> + command is RenderCommand.DrawRect && + command.color == thumbColor && + command.x == vertical.thumbRect.x && + command.y == vertical.thumbRect.y && + command.width == vertical.thumbRect.width && + command.height == vertical.thumbRect.height + }, + ) } @Test fun `thumb geometry stays synchronized with scroll offsets`() { - val (_, viewport, _, _) = createFixture( - overflowX = Overflow.Visible, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 260 - ) + val viewport = + createFixture( + overflowX = Overflow.Visible, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 260, + ).viewport val initialState = viewport.scrollContainerState() - val initialThumb = viewport.debugScrollbarVisualState().vertical?.thumbRect - ?: error("Expected vertical scrollbar thumb") + val initialThumb = + viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?: error("Expected vertical scrollbar thumb") viewport.setScrollOffsets(0, initialState.maxScrollY / 2) val middleState = viewport.scrollContainerState() - val middleThumb = viewport.debugScrollbarVisualState().vertical?.thumbRect - ?: error("Expected vertical scrollbar thumb") + val middleThumb = + viewport + .debugScrollbarVisualState() + .vertical + ?.thumbRect + ?: error("Expected vertical scrollbar thumb") viewport.setScrollOffsets(0, middleState.maxScrollY) - val endVisual = viewport.debugScrollbarVisualState().vertical - ?: error("Expected vertical scrollbar thumb") + val endVisual = + viewport.debugScrollbarVisualState().vertical + ?: error("Expected vertical scrollbar thumb") val endThumb = endVisual.thumbRect assertTrue(initialThumb.height >= 8) @@ -102,14 +120,19 @@ class ScrollbarRenderingInteractionTests { @Test fun `wheel scrolling updates generic container scroll state`() { - val (root, viewport, wheelTarget, router) = createFixture( - overflowX = Overflow.Visible, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 260 - ) + val fixture = + createFixture( + overflowX = Overflow.Visible, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 260, + ) + val root = fixture.root + val viewport = fixture.viewport + val wheelTarget = fixture.wheelTarget + val router = fixture.router val wheelX = wheelTarget.bounds.x + 2 val wheelY = wheelTarget.bounds.y + 2 @@ -125,17 +148,21 @@ class ScrollbarRenderingInteractionTests { @Test fun `thumb drag updates scroll offset through generic pointer capture`() { - val (_, viewport, _, router) = createFixture( - overflowX = Overflow.Visible, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 320 - ) + val fixture = + createFixture( + overflowX = Overflow.Visible, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 320, + ) + val viewport = fixture.viewport + val router = fixture.router - val visual = viewport.debugScrollbarVisualState().vertical - ?: error("Expected vertical scrollbar visual state") + val visual = + viewport.debugScrollbarVisualState().vertical + ?: error("Expected vertical scrollbar visual state") val startX = visual.thumbRect.x + (visual.thumbRect.width / 2).coerceAtLeast(1) val startY = visual.thumbRect.y + (visual.thumbRect.height / 2).coerceAtLeast(1) val dragY = (visual.trackRect.y + visual.trackRect.height - 2).coerceAtLeast(startY + 1) @@ -151,65 +178,76 @@ class ScrollbarRenderingInteractionTests { @Test fun `overflow modes control generic scrollbar visuals`() { - val modes = listOf( - Overflow.Visible to false, - Overflow.Hidden to false, - Overflow.Scroll to true, - Overflow.Auto to true - ) - modes.forEach { (mode, expectedVisible) -> - val (_, viewport, _, _) = createFixture( - overflowX = Overflow.Visible, - overflowY = mode, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 240 + val modes = + listOf( + Overflow.Visible to false, + Overflow.Hidden to false, + Overflow.Scroll to true, + Overflow.Auto to true, ) + modes.forEach { (mode, expectedVisible) -> + val viewport = + createFixture( + overflowX = Overflow.Visible, + overflowY = mode, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 240, + ).viewport val commands = selfCommands(viewport) val vertical = viewport.debugScrollbarVisualState().vertical assertEquals(expectedVisible, vertical != null, "Mode=$mode") - val hasTrack = commands.any { command -> - command is RenderCommand.DrawRect && command.color == trackColor - } + val hasTrack = + commands.any { command -> + command is RenderCommand.DrawRect && command.color == trackColor + } assertEquals(expectedVisible, hasTrack, "Mode=$mode") } } @Test fun `horizontal auto scrollbar appears when content width exceeds viewport`() { - val (_, viewport, _, _) = createFixture( - overflowX = Overflow.Auto, - overflowY = Overflow.Hidden, - viewportWidth = 794, - viewportHeight = 634, - contentWidth = 826, - contentHeight = 620 - ) + val viewport = + createFixture( + overflowX = Overflow.Auto, + overflowY = Overflow.Hidden, + viewportWidth = 794, + viewportHeight = 634, + contentWidth = 826, + contentHeight = 620, + ).viewport val commands = selfCommands(viewport) val horizontal = viewport.debugScrollbarVisualState().horizontal assertNotNull(horizontal) - assertTrue(commands.any { command -> - command is RenderCommand.DrawRect && - command.color == trackColor && - command.x == horizontal.trackRect.x && - command.y == horizontal.trackRect.y && - command.width == horizontal.trackRect.width && - command.height == horizontal.trackRect.height - }) + assertTrue( + commands.any { command -> + command is RenderCommand.DrawRect && + command.color == trackColor && + command.x == horizontal.trackRect.x && + command.y == horizontal.trackRect.y && + command.width == horizontal.trackRect.width && + command.height == horizontal.trackRect.height + }, + ) } + @Test fun `normal wheel scrolls only vertical axis`() { - val (_, viewport, wheelTarget, router) = createFixture( - overflowX = Overflow.Auto, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 280, - contentHeight = 260 - ) + val fixture = + createFixture( + overflowX = Overflow.Auto, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 280, + contentHeight = 260, + ) + val viewport = fixture.viewport + val wheelTarget = fixture.wheelTarget + val router = fixture.router KeyModifiers.sync(shift = false, control = false, meta = false) val wheelX = wheelTarget.bounds.x + 2 @@ -224,14 +262,18 @@ class ScrollbarRenderingInteractionTests { @Test fun `shift plus wheel scrolls only horizontal axis`() { - val (_, viewport, wheelTarget, router) = createFixture( - overflowX = Overflow.Auto, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 280, - contentHeight = 260 - ) + val fixture = + createFixture( + overflowX = Overflow.Auto, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 280, + contentHeight = 260, + ) + val viewport = fixture.viewport + val wheelTarget = fixture.wheelTarget + val router = fixture.router KeyModifiers.sync(shift = true, control = false, meta = false) val wheelX = wheelTarget.bounds.x + 2 @@ -246,32 +288,41 @@ class ScrollbarRenderingInteractionTests { @Test fun `wheel chains to ancestor when intended axis cannot scroll on hovered container`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 640, 360) - } - val parent = ContainerNode(key = "parent").apply { - bounds = Rect(20, 20, 260, 120) - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - }.applyParent(root) - ContainerNode(key = "parent-content").apply { - bounds = Rect(parent.bounds.x, parent.bounds.y, 250, 420) - }.applyParent(parent) - - val child = ContainerNode(key = "child").apply { - bounds = Rect(parent.bounds.x + 8, parent.bounds.y + 8, 140, 60) - overflowX = Overflow.Auto - overflowY = Overflow.Hidden - }.applyParent(parent) - ContainerNode(key = "child-content").apply { - bounds = Rect(child.bounds.x, child.bounds.y, 340, 50) - }.applyParent(child) - val childButton = ButtonNode("child", key = "child-button").apply { - bounds = Rect(child.bounds.x + 6, child.bounds.y + 6, 80, 20) - onClick { } - }.applyParent(child) - - val router = LayerDomInputRouter { root } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 640, 360) + } + val parent = + ContainerNode(key = "parent") + .apply { + bounds = Rect(20, 20, 260, 120) + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "parent-content") + .apply { + bounds = Rect(parent.bounds.x, parent.bounds.y, 250, 420) + }.applyParent(parent) + + val child = + ContainerNode(key = "child") + .apply { + bounds = Rect(parent.bounds.x + 8, parent.bounds.y + 8, 140, 60) + overflowX = Overflow.Auto + overflowY = Overflow.Hidden + }.applyParent(parent) + ContainerNode(key = "child-content") + .apply { + bounds = Rect(child.bounds.x, child.bounds.y, 340, 50) + }.applyParent(child) + val childButton = + ButtonNode("child", key = "child-button") + .apply { + bounds = Rect(child.bounds.x + 6, child.bounds.y + 6, 80, 20) + onClick { } + }.applyParent(child) + + val router = SurfaceDomInputRouter { root } KeyModifiers.sync(shift = false, control = false, meta = false) val wheelX = childButton.bounds.x + 1 val wheelY = childButton.bounds.y + 1 @@ -290,50 +341,63 @@ class ScrollbarRenderingInteractionTests { @Test fun `wheel scroll keeps thumb position synchronized`() { - val (_, viewport, wheelTarget, router) = createFixture( - overflowX = Overflow.Visible, - overflowY = Overflow.Auto, - viewportWidth = 120, - viewportHeight = 70, - contentWidth = 90, - contentHeight = 320 - ) + val fixture = + createFixture( + overflowX = Overflow.Visible, + overflowY = Overflow.Auto, + viewportWidth = 120, + viewportHeight = 70, + contentWidth = 90, + contentHeight = 320, + ) + val viewport = fixture.viewport + val wheelTarget = fixture.wheelTarget + val router = fixture.router KeyModifiers.sync(shift = false, control = false, meta = false) - val beforeVisual = viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar visual state") + val beforeVisual = + viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar visual state") val wheelX = wheelTarget.bounds.x + 2 val wheelY = wheelTarget.bounds.y + 2 assertTrue(router.handleMouseWheel(wheelX, wheelY, -120)) advanceScrollAnimation(viewport) - val afterVisual = viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar visual state") + val afterVisual = + viewport.debugScrollbarVisualState().vertical ?: error("Expected vertical scrollbar visual state") assertTrue(afterVisual.thumbRect.y > beforeVisual.thumbRect.y) assertFalse(afterVisual.thumbRect == beforeVisual.thumbRect) } @Test fun `wheel chains to ancestor when inner container is at vertical limit`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 640, 360) - } - val outer = ContainerNode(key = "outer").apply { - bounds = Rect(20, 20, 260, 150) - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - }.applyParent(root) - ContainerNode(key = "outer-content").apply { - bounds = Rect(outer.bounds.x, outer.bounds.y, 240, 460) - }.applyParent(outer) - val inner = ContainerNode(key = "inner").apply { - bounds = Rect(36, 36, 140, 72) - overflowX = Overflow.Hidden - overflowY = Overflow.Auto - }.applyParent(outer) - ContainerNode(key = "inner-content").apply { - bounds = Rect(inner.bounds.x, inner.bounds.y, 120, 320) - }.applyParent(inner) - - val router = LayerDomInputRouter { root } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 640, 360) + } + val outer = + ContainerNode(key = "outer") + .apply { + bounds = Rect(20, 20, 260, 150) + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + }.applyParent(root) + ContainerNode(key = "outer-content") + .apply { + bounds = Rect(outer.bounds.x, outer.bounds.y, 240, 460) + }.applyParent(outer) + val inner = + ContainerNode(key = "inner") + .apply { + bounds = Rect(36, 36, 140, 72) + overflowX = Overflow.Hidden + overflowY = Overflow.Auto + }.applyParent(outer) + ContainerNode(key = "inner-content") + .apply { + bounds = Rect(inner.bounds.x, inner.bounds.y, 120, 320) + }.applyParent(inner) + + val router = SurfaceDomInputRouter { root } KeyModifiers.sync(shift = false, control = false, meta = false) val innerMax = inner.scrollContainerState().maxScrollY inner.setScrollOffsets(0, innerMax) @@ -358,29 +422,40 @@ class ScrollbarRenderingInteractionTests { @Test fun `dragging nested inner thumb does not affect outer scroll state`() { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 680, 420) - } - val outer = ContainerNode(key = "outer").apply { - bounds = Rect(18, 18, 280, 180) - overflowX = Overflow.Hidden - overflowY = Overflow.Scroll - }.applyParent(root) - ContainerNode(key = "outer-content").apply { - bounds = Rect(outer.bounds.x, outer.bounds.y, 250, 520) - }.applyParent(outer) - - val inner = ContainerNode(key = "inner").apply { - bounds = Rect(40, 42, 160, 90) - overflowX = Overflow.Hidden - overflowY = Overflow.Scroll - }.applyParent(outer) - ContainerNode(key = "inner-content").apply { - bounds = Rect(inner.bounds.x, inner.bounds.y, 130, 360) - }.applyParent(inner) - - val router = LayerDomInputRouter { root } - val innerThumb = inner.debugScrollbarVisualState().vertical?.thumbRect ?: error("inner thumb missing") + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 680, 420) + } + val outer = + ContainerNode(key = "outer") + .apply { + bounds = Rect(18, 18, 280, 180) + overflowX = Overflow.Hidden + overflowY = Overflow.Scroll + }.applyParent(root) + ContainerNode(key = "outer-content") + .apply { + bounds = Rect(outer.bounds.x, outer.bounds.y, 250, 520) + }.applyParent(outer) + + val inner = + ContainerNode(key = "inner") + .apply { + bounds = Rect(40, 42, 160, 90) + overflowX = Overflow.Hidden + overflowY = Overflow.Scroll + }.applyParent(outer) + ContainerNode(key = "inner-content") + .apply { + bounds = Rect(inner.bounds.x, inner.bounds.y, 130, 360) + }.applyParent(inner) + + val router = SurfaceDomInputRouter { root } + val innerThumb = + inner + .debugScrollbarVisualState() + .vertical + ?.thumbRect ?: error("inner thumb missing") val dragX = innerThumb.x + innerThumb.width / 2 val startY = innerThumb.y + innerThumb.height / 2 val endY = startY + 32 @@ -402,6 +477,7 @@ class ScrollbarRenderingInteractionTests { assertEquals(innerAfter.scrollY, innerDebug.resolvedY) assertTrue(kotlin.math.abs(innerDebug.displayedY - innerDebug.resolvedY.toDouble()) <= 1.0) } + private fun advanceScrollAnimation(node: DOMNode, frames: Int = 6) { repeat(frames) { node.advanceScrollAnimationsRecursively(1.0 / 60.0) @@ -423,26 +499,31 @@ class ScrollbarRenderingInteractionTests { viewportWidth: Int, viewportHeight: Int, contentWidth: Int, - contentHeight: Int + contentHeight: Int, ): Quad { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 2000, 1200) - } - val viewport = ContainerNode(key = "viewport").apply { - bounds = Rect(40, 30, viewportWidth, viewportHeight) - this.overflowX = overflowX - this.overflowY = overflowY - }.applyParent(root) - ContainerNode(key = "content").apply { - bounds = Rect(viewport.bounds.x, viewport.bounds.y, contentWidth, contentHeight) - }.applyParent(viewport) - val wheelTarget = ButtonNode("wheel", key = "wheel-target").apply { - bounds = Rect(viewport.bounds.x + 6, viewport.bounds.y + 6, 64, 20) - onClick { } - } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 2000, 1200) + } + val viewport = + ContainerNode(key = "viewport") + .apply { + bounds = Rect(40, 30, viewportWidth, viewportHeight) + this.overflowX = overflowX + this.overflowY = overflowY + }.applyParent(root) + ContainerNode(key = "content") + .apply { + bounds = Rect(viewport.bounds.x, viewport.bounds.y, contentWidth, contentHeight) + }.applyParent(viewport) + val wheelTarget = + ButtonNode("wheel", key = "wheel-target").apply { + bounds = Rect(viewport.bounds.x + 6, viewport.bounds.y + 6, 64, 20) + onClick { } + } wheelTarget.applyParent(viewport) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return Quad(root, viewport, wheelTarget, router) } @@ -450,6 +531,6 @@ class ScrollbarRenderingInteractionTests { val root: ContainerNode, val viewport: ContainerNode, val wheelTarget: ButtonNode, - val router: LayerDomInputRouter + val router: SurfaceDomInputRouter, ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectNodeOwnerDomainTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectNodeOwnerDomainTests.kt new file mode 100644 index 0000000..7d35bf8 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectNodeOwnerDomainTests.kt @@ -0,0 +1,185 @@ +package org.dreamfinity.dsgl.core.dom + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.SelectNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.ApplicationPortalHost +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.portal.handlePortalPointerAfterDom +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.portal.syncPortalFrame +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.select.SelectStyle +import org.dreamfinity.dsgl.core.select.selectModel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SelectNodeOwnerDomainTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + DomainPortalServices.closeAllSelects() + DomainPortalServices.applicationSelectEngine.setStyle(SelectStyle()) + DomainPortalServices.systemSelectEngine.setStyle(SelectStyle()) + FocusManager.clearFocus() + } + + @Test + fun `system owner scope routes select popup to system engine`() { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 300, 200) + val ownerKey = "system-select-owner" + val select = + SelectNode( + model = + selectModel(id = "system.select.model") { + option("a", "Alpha") + option("b", "Beta") + }, + ownerDomain = ScreenDomainId.System, + key = ownerKey, + ).apply { + width = 120 + height = 20 + bounds = Rect(20, 20, 120, 20) + } + select.applyParent(root) + + val tree = DomTree(root) + tree.render(ctx, 300, 200) + tree.paint(ctx) + val router = SurfaceDomInputRouter { root } + val clickX = select.bounds.x + (select.bounds.width / 2).coerceAtLeast(1) + val clickY = select.bounds.y + (select.bounds.height / 2).coerceAtLeast(1) + + assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(router.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + + assertFalse(DomainPortalServices.applicationSelectEngine.isOpenFor(ownerKey)) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + } + + @Test + fun `anchor press reopens select while previous close animation is still active`() { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 300, 200) + val ownerKey = "application-select-reopen-owner" + DomainPortalServices.applicationSelectEngine.setStyle( + SelectStyle( + openDurationMs = 1L, + closeDurationMs = 200L, + ), + ) + val select = + SelectNode( + model = + selectModel(id = "application.select.reopen.model") { + option("a", "Alpha") + option("b", "Beta") + }, + ownerDomain = ScreenDomainId.Application, + key = ownerKey, + ).apply { + width = 120 + height = 20 + bounds = Rect(20, 20, 120, 20) + } + select.applyParent(root) + + val tree = DomTree(root) + tree.render(ctx, 300, 200) + tree.paint(ctx) + val router = SurfaceDomInputRouter { root } + val clickX = select.bounds.x + (select.bounds.width / 2).coerceAtLeast(1) + val clickY = select.bounds.y + (select.bounds.height / 2).coerceAtLeast(1) + + assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(ownerKey)) + Thread.sleep(3L) + DomainPortalServices.applicationSelectEngine.onFrame(ctx, 300, 200, 1f) + DomainPortalServices.closeSelect(ownerKey) + assertTrue(DomainPortalServices.isSelectClosingFor(ownerKey)) + + assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(ownerKey)) + assertFalse(DomainPortalServices.isSelectClosingFor(ownerKey)) + } + + @Test + fun `application select portal pointer press preserves focused anchor`() { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 300, 160) + val ownerKey = "application-select-focus-preserve-owner" + DomainPortalServices.applicationSelectEngine.setStyle( + SelectStyle( + openDurationMs = 1L, + closeDurationMs = 1L, + maxPanelHeightPadding = 10, + ), + ) + val select = + SelectNode( + model = + selectModel(id = "application.select.focus.preserve.model") { + repeat(48) { index -> + option("id-$index", "Option $index") + } + }, + ownerDomain = ScreenDomainId.Application, + key = ownerKey, + ).apply { + width = 120 + height = 20 + bounds = Rect(20, 20, 120, 20) + } + select.applyParent(root) + + val tree = DomTree(root) + tree.render(ctx, 300, 160) + tree.paint(ctx) + val router = SurfaceDomInputRouter { root } + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(300, 160) + val clickX = select.bounds.x + (select.bounds.width / 2).coerceAtLeast(1) + val clickY = select.bounds.y + (select.bounds.height / 2).coerceAtLeast(1) + + assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(FocusManager.isFocused(select)) + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(ownerKey)) + + applicationPortalHost.syncPortalFrame(ctx, 300, 160, 1f, 0, 0) + val track = requireNotNull(DomainPortalServices.applicationSelectEngine.debugScrollbarTrackRect(ownerKey)) + val downX = track.x + track.width / 2 + val downY = track.y + 2 + + assertTrue( + applicationPortalHost.handlePortalPointerAfterDom( + mouseX = downX, + mouseY = downY, + dWheel = 0, + button = MouseButton.LEFT, + pressed = true, + ), + ) + + assertTrue(FocusManager.isFocused(select)) + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(ownerKey)) + assertTrue(DomainPortalServices.applicationSelectEngine.isScrollbarDragging()) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectPopupAnchoringStickyTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectPopupAnchoringStickyTests.kt index e262a46..255db25 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectPopupAnchoringStickyTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/SelectPopupAnchoringStickyTests.kt @@ -1,11 +1,5 @@ package org.dreamfinity.dsgl.core.dom -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.elements.SelectNode @@ -13,29 +7,37 @@ import org.dreamfinity.dsgl.core.dom.layout.Insets import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.select.SelectRuntime import org.dreamfinity.dsgl.core.select.selectModel import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class SelectPopupAnchoringStickyTests { private val viewportWidth = 420 private val viewportHeight = 260 - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { - SelectRuntime.host.closeAll() + DomainPortalServices.closeAllSelects() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -94,7 +96,7 @@ class SelectPopupAnchoringStickyTests { val y = visible.y + visible.height / 2 assertTrue(fixture.router.handleMouseDown(x, y, MouseButton.LEFT)) - assertTrue(SelectRuntime.host.isOpenFor(fixture.ownerKey)) + assertTrue(DomainPortalServices.isSelectOpenFor(fixture.ownerKey)) assertTrue(fixture.router.handleMouseUp(x, y, MouseButton.LEFT)) } @@ -104,77 +106,90 @@ class SelectPopupAnchoringStickyTests { val y = visible.y + visible.height / 2 assertTrue(fixture.router.handleMouseDown(x, y, MouseButton.LEFT)) assertTrue(fixture.router.handleMouseUp(x, y, MouseButton.LEFT)) - assertTrue(SelectRuntime.host.isOpenFor(fixture.ownerKey)) + assertTrue(DomainPortalServices.isSelectOpenFor(fixture.ownerKey)) - SelectRuntime.engine.onFrame( + DomainPortalServices.applicationSelectEngine.onFrame( measureContext = ctx, viewportWidth = viewportWidth, viewportHeight = viewportHeight, - viewportScale = 1f + viewportScale = 1f, ) - val anchor = SelectRuntime.engine.debugAnchorRect(fixture.ownerKey) - ?: error("Expected select anchor rect for owner=${fixture.ownerKey}") - val panel = SelectRuntime.engine.debugPanelRect(fixture.ownerKey) - ?: error("Expected select panel rect for owner=${fixture.ownerKey}") + val anchor = + DomainPortalServices.applicationSelectEngine.debugAnchorRect(fixture.ownerKey) + ?: error("Expected select anchor rect for owner=${fixture.ownerKey}") + val panel = + DomainPortalServices.applicationSelectEngine.debugPanelRect(fixture.ownerKey) + ?: error("Expected select panel rect for owner=${fixture.ownerKey}") return PopupGeometry(anchor = anchor, panel = panel) } private fun createNonStickyFixture(): Fixture { val root = ContainerNode(key = "select-anchor-root") - val host = ContainerNode(key = "select-anchor-host").apply { - width = 220 - height = 80 - padding = Insets(0, 20, 0, 0) - }.applyParent(root) + val host = + ContainerNode(key = "select-anchor-host") + .apply { + width = 220 + height = 80 + padding = Insets(0, 20, 0, 0) + }.applyParent(root) val ownerKey = "select-anchor-target" - val select = SelectNode( - model = demoModel(), - key = ownerKey - ).apply { - width = 120 - height = 18 - }.applyParent(host) + val select = + SelectNode( + model = demoModel(), + key = ownerKey, + ).apply { + width = 120 + height = 18 + }.applyParent(host) return buildFixture(root, host, select, ownerKey) } private fun createStickyFixture(): Fixture { val root = ContainerNode(key = "select-anchor-sticky-root") - val scroller = ContainerNode(key = "select-anchor-sticky-scroller").apply { - width = 220 - height = 90 - overflowY = Overflow.Auto - }.applyParent(root) - - ContainerNode(key = "select-anchor-top-spacer").apply { - width = 220 - height = 22 - }.applyParent(scroller) - - val sticky = ContainerNode(key = "select-anchor-sticky-row").apply { - width = 220 - height = 24 - padding = Insets(0, 20, 0, 0) - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "sticky", - StyleProperty.TOP to "0px" - ) - }.applyParent(scroller) + val scroller = + ContainerNode(key = "select-anchor-sticky-scroller") + .apply { + width = 220 + height = 90 + overflowY = Overflow.Auto + }.applyParent(root) + + ContainerNode(key = "select-anchor-top-spacer") + .apply { + width = 220 + height = 22 + }.applyParent(scroller) + + val sticky = + ContainerNode(key = "select-anchor-sticky-row") + .apply { + width = 220 + height = 24 + padding = Insets(0, 20, 0, 0) + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "sticky", + StyleProperty.TOP to "0px", + ) + }.applyParent(scroller) val ownerKey = "select-anchor-sticky-target" - val select = SelectNode( - model = demoModel(), - key = ownerKey - ).apply { - width = 120 - height = 18 - }.applyParent(sticky) - - ContainerNode(key = "select-anchor-filler").apply { - width = 220 - height = 300 - }.applyParent(scroller) + val select = + SelectNode( + model = demoModel(), + key = ownerKey, + ).apply { + width = 120 + height = 18 + }.applyParent(sticky) + + ContainerNode(key = "select-anchor-filler") + .apply { + width = 220 + height = 300 + }.applyParent(scroller) return buildFixture(root, scroller, select, ownerKey) } @@ -183,50 +198,50 @@ class SelectPopupAnchoringStickyTests { root: ContainerNode, scroller: ContainerNode, select: SelectNode, - ownerKey: String + ownerKey: String, ): Fixture { val tree = DomTree(root) tree.render(ctx, viewportWidth, viewportHeight) tree.paint(ctx) - val router = LayerDomInputRouter { root } + val router = SurfaceDomInputRouter { root } return Fixture( tree = tree, scroller = scroller, select = select, ownerKey = ownerKey, - router = router + router = router, ) } - private fun demoModel() = selectModel(id = "select.anchor.model") { - option("a", "Alpha") - option("b", "Beta") - option("c", "Gamma") - } + private fun demoModel() = + selectModel(id = "select.anchor.model") { + option("a", "Alpha") + option("b", "Beta") + option("c", "Gamma") + } private fun visibleRect(node: SelectNode): Rect { val geometry = UsedInteractionGeometryResolver.resolveNodeGeometry(node) return geometry.visibleBorderRect ?: geometry.usedBorderRect } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } private data class Fixture( val tree: DomTree, val scroller: ContainerNode, val select: SelectNode, val ownerKey: String, - val router: LayerDomInputRouter + val router: SurfaceDomInputRouter, ) private data class PopupGeometry( val anchor: Rect, - val panel: Rect + val panel: Rect, ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextChildContextBehaviorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextChildContextBehaviorTests.kt index dab9cf9..871dbb7 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextChildContextBehaviorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextChildContextBehaviorTests.kt @@ -23,11 +23,14 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class StackingContextChildContextBehaviorTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -40,22 +43,30 @@ class StackingContextChildContextBehaviorTests { val root = ContainerNode(key = "trigger-root") val owner = ContainerNode(key = "trigger-owner").applyParent(root) - val staticHigh = ContainerNode(key = "static-high").apply { - position = PositionMode.Static - zIndex = 5 - }.applyParent(owner) - val positionedDefaultZ = ContainerNode(key = "positioned-default").apply { - position = PositionMode.Relative - zIndex = 0 - }.applyParent(owner) - val positionedExplicitZ = ContainerNode(key = "positioned-explicit").apply { - position = PositionMode.Relative - zIndex = 3 - }.applyParent(owner) - val fixedExplicitZ = ContainerNode(key = "fixed-explicit").apply { - position = PositionMode.Fixed - zIndex = 4 - }.applyParent(owner) + val staticHigh = + ContainerNode(key = "static-high") + .apply { + position = PositionMode.Static + zIndex = 5 + }.applyParent(owner) + val positionedDefaultZ = + ContainerNode(key = "positioned-default") + .apply { + position = PositionMode.Relative + zIndex = 0 + }.applyParent(owner) + val positionedExplicitZ = + ContainerNode(key = "positioned-explicit") + .apply { + position = PositionMode.Relative + zIndex = 3 + }.applyParent(owner) + val fixedExplicitZ = + ContainerNode(key = "fixed-explicit") + .apply { + position = PositionMode.Fixed + zIndex = 4 + }.applyParent(owner) assertFalse(PositionedLayoutModel.matchesChildContextTrigger(staticHigh)) assertFalse(PositionedLayoutModel.matchesChildContextTrigger(positionedDefaultZ)) @@ -80,30 +91,36 @@ class StackingContextChildContextBehaviorTests { @Test fun `child context participation is atomic in parent ordering`() { val root = ContainerNode(key = "atomic-root", stackLayout = true) - val lowerContext = ContainerNode(key = "atomic-lower").apply { - position = PositionMode.Relative - zIndex = 1 - }.applyParent(root) - val higherContext = ContainerNode(key = "atomic-higher").apply { - position = PositionMode.Relative - zIndex = 2 - }.applyParent(root) + val lowerContext = + ContainerNode(key = "atomic-lower") + .apply { + position = PositionMode.Relative + zIndex = 1 + }.applyParent(root) + val higherContext = + ContainerNode(key = "atomic-higher") + .apply { + position = PositionMode.Relative + zIndex = 2 + }.applyParent(root) var lowerDescendantClicks = 0 var higherDescendantClicks = 0 - ButtonNode("lower-descendant", key = "atomic-lower-desc", backgroundColor = 0x00_11_44_88).apply { - width = 72 - height = 24 - position = PositionMode.Relative - zIndex = 9_999 - onClick { lowerDescendantClicks += 1 } - }.applyParent(lowerContext) - ButtonNode("higher-descendant", key = "atomic-higher-desc", backgroundColor = 0x00_88_22_44).apply { - width = 72 - height = 24 - onClick { higherDescendantClicks += 1 } - }.applyParent(higherContext) + ButtonNode("lower-descendant", key = "atomic-lower-desc", backgroundColor = 0x00_11_44_88) + .apply { + width = 72 + height = 24 + position = PositionMode.Relative + zIndex = 9_999 + onClick { lowerDescendantClicks += 1 } + }.applyParent(lowerContext) + ButtonNode("higher-descendant", key = "atomic-higher-desc", backgroundColor = 0x00_88_22_44) + .apply { + width = 72 + height = 24 + onClick { higherDescendantClicks += 1 } + }.applyParent(higherContext) renderTree(root, width = 240, height = 140) @@ -116,28 +133,34 @@ class StackingContextChildContextBehaviorTests { @Test fun `local ordering inside child context remains by local z-index and dom tie-break`() { val root = ContainerNode(key = "local-root", stackLayout = true) - val contextOwner = ContainerNode(key = "local-owner").apply { - position = PositionMode.Relative - zIndex = 2 - stackLayout = true - }.applyParent(root) + val contextOwner = + ContainerNode(key = "local-owner") + .apply { + position = PositionMode.Relative + zIndex = 2 + stackLayout = true + }.applyParent(root) var lowClicks = 0 var highClicks = 0 - val low = ButtonNode("low", key = "local-low").apply { - width = 72 - height = 24 - position = PositionMode.Relative - zIndex = 1 - onClick { lowClicks += 1 } - }.applyParent(contextOwner) - val high = ButtonNode("high", key = "local-high").apply { - width = 72 - height = 24 - position = PositionMode.Relative - zIndex = 5 - onClick { highClicks += 1 } - }.applyParent(contextOwner) + val low = + ButtonNode("low", key = "local-low") + .apply { + width = 72 + height = 24 + position = PositionMode.Relative + zIndex = 1 + onClick { lowClicks += 1 } + }.applyParent(contextOwner) + val high = + ButtonNode("high", key = "local-high") + .apply { + width = 72 + height = 24 + position = PositionMode.Relative + zIndex = 5 + onClick { highClicks += 1 } + }.applyParent(contextOwner) renderTree(root, width = 240, height = 140) @@ -153,26 +176,32 @@ class StackingContextChildContextBehaviorTests { val lowColor = 0x00_12_34_56 val highColor = 0x00_65_43_21 val root = ContainerNode(key = "paint-root", stackLayout = true) - val lowerContext = ContainerNode(key = "paint-lower").apply { - position = PositionMode.Relative - zIndex = 1 - }.applyParent(root) - val higherContext = ContainerNode(key = "paint-higher").apply { - position = PositionMode.Relative - zIndex = 2 - }.applyParent(root) - - ButtonNode("lower", backgroundColor = lowColor, key = "paint-lower-btn").apply { - width = 72 - height = 24 - position = PositionMode.Relative - zIndex = 9_999 - }.applyParent(lowerContext) - ButtonNode("higher", backgroundColor = highColor, key = "paint-higher-btn").apply { - width = 72 - height = 24 - zIndex = 0 - }.applyParent(higherContext) + val lowerContext = + ContainerNode(key = "paint-lower") + .apply { + position = PositionMode.Relative + zIndex = 1 + }.applyParent(root) + val higherContext = + ContainerNode(key = "paint-higher") + .apply { + position = PositionMode.Relative + zIndex = 2 + }.applyParent(root) + + ButtonNode("lower", backgroundColor = lowColor, key = "paint-lower-btn") + .apply { + width = 72 + height = 24 + position = PositionMode.Relative + zIndex = 9_999 + }.applyParent(lowerContext) + ButtonNode("higher", backgroundColor = highColor, key = "paint-higher-btn") + .apply { + width = 72 + height = 24 + zIndex = 0 + }.applyParent(higherContext) val tree = DomTree(root) tree.render(ctx, 240, 140) @@ -188,37 +217,46 @@ class StackingContextChildContextBehaviorTests { @Test fun `recursive hit traversal mirrors recursive paint order in reverse`() { val root = ContainerNode(key = "hit-root", stackLayout = true) - val low = ContainerNode(key = "hit-low").apply { - position = PositionMode.Relative - zIndex = 1 - }.applyParent(root) - val mid = ContainerNode(key = "hit-mid").apply { - position = PositionMode.Relative - zIndex = 2 - }.applyParent(root) - val high = ContainerNode(key = "hit-high").apply { - position = PositionMode.Relative - zIndex = 3 - }.applyParent(root) + val low = + ContainerNode(key = "hit-low") + .apply { + position = PositionMode.Relative + zIndex = 1 + }.applyParent(root) + val mid = + ContainerNode(key = "hit-mid") + .apply { + position = PositionMode.Relative + zIndex = 2 + }.applyParent(root) + val high = + ContainerNode(key = "hit-high") + .apply { + position = PositionMode.Relative + zIndex = 3 + }.applyParent(root) var lowClicks = 0 var midClicks = 0 var highClicks = 0 - ButtonNode("low", key = "hit-low-btn").apply { - width = 72 - height = 24 - onClick { lowClicks += 1 } - }.applyParent(low) - ButtonNode("mid", key = "hit-mid-btn").apply { - width = 72 - height = 24 - onClick { midClicks += 1 } - }.applyParent(mid) - ButtonNode("high", key = "hit-high-btn").apply { - width = 72 - height = 24 - onClick { highClicks += 1 } - }.applyParent(high) + ButtonNode("low", key = "hit-low-btn") + .apply { + width = 72 + height = 24 + onClick { lowClicks += 1 } + }.applyParent(low) + ButtonNode("mid", key = "hit-mid-btn") + .apply { + width = 72 + height = 24 + onClick { midClicks += 1 } + }.applyParent(mid) + ButtonNode("high", key = "hit-high-btn") + .apply { + width = 72 + height = 24 + onClick { highClicks += 1 } + }.applyParent(high) renderTree(root, width = 240, height = 140) assertEquals(listOf(high, mid, low), root.orderedChildrenForHitTestingTraversal()) @@ -231,35 +269,43 @@ class StackingContextChildContextBehaviorTests { @Test fun `fixed root participation remains authoritative when fixed matches trigger`() { val root = ContainerNode(key = "fixed-root", stackLayout = true) - val contextA = ContainerNode(key = "fixed-context-a").apply { - position = PositionMode.Relative - zIndex = 1 - margin = Insets(top = 30, right = 0, bottom = 0, left = 50) - }.applyParent(root) - val contextB = ContainerNode(key = "fixed-context-b").apply { - position = PositionMode.Relative - zIndex = 2 - }.applyParent(root) + val contextA = + ContainerNode(key = "fixed-context-a") + .apply { + position = PositionMode.Relative + zIndex = 1 + margin = Insets(top = 30, right = 0, bottom = 0, left = 50) + }.applyParent(root) + val contextB = + ContainerNode(key = "fixed-context-b") + .apply { + position = PositionMode.Relative + zIndex = 2 + }.applyParent(root) var fixedClicks = 0 var normalClicks = 0 - val fixed = ButtonNode("fixed", key = "fixed-node").apply { - width = 72 - height = 24 - position = PositionMode.Fixed - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "9px" - ) - onClick { fixedClicks += 1 } - }.applyParent(contextA) - ButtonNode("normal", key = "fixed-normal").apply { - width = 72 - height = 24 - onClick { normalClicks += 1 } - }.applyParent(contextB) + val fixed = + ButtonNode("fixed", key = "fixed-node") + .apply { + width = 72 + height = 24 + position = PositionMode.Fixed + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "9px", + ) + onClick { fixedClicks += 1 } + }.applyParent(contextA) + ButtonNode("normal", key = "fixed-normal") + .apply { + width = 72 + height = 24 + onClick { normalClicks += 1 } + }.applyParent(contextB) renderTree(root, width = 260, height = 180) @@ -289,11 +335,10 @@ class StackingContextChildContextBehaviorTests { DomTree(root).render(ctx, width, height) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextScaffoldingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextScaffoldingTests.kt index ae9bd7c..a1602fb 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextScaffoldingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StackingContextScaffoldingTests.kt @@ -8,10 +8,10 @@ import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.event.dispatchClick import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleDeclarations import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression -import org.dreamfinity.dsgl.core.style.PositionMode import org.dreamfinity.dsgl.core.style.StyleProperty import kotlin.test.AfterTest import kotlin.test.Test @@ -23,11 +23,14 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class StackingContextScaffoldingTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -59,10 +62,12 @@ class StackingContextScaffoldingTests { val root = ContainerNode(key = "stacking-scaffold-root") val owner = ContainerNode(key = "stacking-scaffold-owner").applyParent(root) val normalChild = ContainerNode(key = "stacking-normal").applyParent(owner) - val fixedChild = ContainerNode(key = "stacking-fixed").apply { - position = PositionMode.Fixed - zIndex = 9999 - }.applyParent(owner) + val fixedChild = + ContainerNode(key = "stacking-fixed") + .apply { + position = PositionMode.Fixed + zIndex = 9999 + }.applyParent(owner) val context = owner.stackingContextScaffoldForTraversalOwner() val rootContext = root.stackingContextScaffoldForTraversalOwner() @@ -91,15 +96,18 @@ class StackingContextScaffoldingTests { val root = ContainerNode(key = "stacking-paint-root", stackLayout = true) val early = ContainerNode(key = "stacking-paint-early").applyParent(root) val later = ContainerNode(key = "stacking-paint-later").applyParent(root) - val fixed = ContainerNode(key = "stacking-paint-fixed").apply { - position = PositionMode.Fixed - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "4px", - StyleProperty.TOP to "4px" - ) - zIndex = 9999 - }.applyParent(early) + val fixed = + ContainerNode(key = "stacking-paint-fixed") + .apply { + position = PositionMode.Fixed + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "4px", + StyleProperty.TOP to "4px", + ) + zIndex = 9999 + }.applyParent(early) assertEquals(listOf(early, later, fixed), root.orderedChildrenForPaintTraversal()) assertTrue(early.orderedChildrenForPaintTraversal().isEmpty()) @@ -116,22 +124,26 @@ class StackingContextScaffoldingTests { var fixedClicks = 0 var laterClicks = 0 - val fixedNode = ButtonNode("fixed", key = "stacking-hit-fixed").apply { - width = 72 - height = 24 - zIndex = 9999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - onClick { fixedClicks += 1 } - }.applyParent(earlySubtree) - ButtonNode("later", key = "stacking-hit-later-node").apply { - width = 72 - height = 24 - onClick { laterClicks += 1 } - }.applyParent(laterSubtree) + val fixedNode = + ButtonNode("fixed", key = "stacking-hit-fixed") + .apply { + width = 72 + height = 24 + zIndex = 9999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + onClick { fixedClicks += 1 } + }.applyParent(earlySubtree) + ButtonNode("later", key = "stacking-hit-later-node") + .apply { + width = 72 + height = 24 + onClick { laterClicks += 1 } + }.applyParent(laterSubtree) renderTree(root, width = 220, height = 140) assertEquals(listOf(fixedNode, laterSubtree, earlySubtree), root.orderedChildrenForHitTestingTraversal()) @@ -144,34 +156,39 @@ class StackingContextScaffoldingTests { @Test fun `nested fixed behavior is root ordered and still root anchored`() { val root = ContainerNode(key = "stacking-phase-root", stackLayout = true) - val earlySubtree = ContainerNode(key = "stacking-phase-early").apply { - width = 120 - height = 60 - } - val laterSubtree = ContainerNode(key = "stacking-phase-later").apply { - width = 120 - height = 60 - } + val earlySubtree = + ContainerNode(key = "stacking-phase-early").apply { + width = 120 + height = 60 + } + val laterSubtree = + ContainerNode(key = "stacking-phase-later").apply { + width = 120 + height = 60 + } var fixedClicks = 0 var laterClicks = 0 - val fixed = ButtonNode("fixed", key = "stacking-phase-fixed").apply { - width = 72 - height = 24 - zIndex = 9999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - onClick { fixedClicks += 1 } - } - val later = ButtonNode("later", key = "stacking-phase-later-node").apply { - width = 72 - height = 24 - onClick { laterClicks += 1 } - } + val fixed = + ButtonNode("fixed", key = "stacking-phase-fixed").apply { + width = 72 + height = 24 + zIndex = 9999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + onClick { fixedClicks += 1 } + } + val later = + ButtonNode("later", key = "stacking-phase-later-node").apply { + width = 72 + height = 24 + onClick { laterClicks += 1 } + } fixed.applyParent(earlySubtree) later.applyParent(laterSubtree) @@ -190,11 +207,10 @@ class StackingContextScaffoldingTests { DomTree(root).render(ctx, width, height) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModelContractTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModelContractTests.kt index 4b0fb81..bbe127f 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModelContractTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/StickyLayoutModelContractTests.kt @@ -13,12 +13,16 @@ class StickyLayoutModelContractTests { @Test fun `sticky scroll-container rule chooses nearest horizontal scroll-container ancestor`() { val root = ContainerNode(key = "sticky-root-x") - val farScroll = ContainerNode(key = "sticky-scroll-far-x").apply { - overflowX = Overflow.Auto - }.applyParent(root) - val nearScroll = ContainerNode(key = "sticky-scroll-near-x").apply { - overflowX = Overflow.Hidden - }.applyParent(farScroll) + val farScroll = + ContainerNode(key = "sticky-scroll-far-x") + .apply { + overflowX = Overflow.Auto + }.applyParent(root) + val nearScroll = + ContainerNode(key = "sticky-scroll-near-x") + .apply { + overflowX = Overflow.Hidden + }.applyParent(farScroll) val parent = ContainerNode(key = "sticky-parent-x").applyParent(nearScroll) val node = ContainerNode(key = "sticky-node-x").applyParent(parent) @@ -38,12 +42,16 @@ class StickyLayoutModelContractTests { @Test fun `sticky scroll-container rule chooses nearest vertical scroll-container ancestor`() { val root = ContainerNode(key = "sticky-root") - val farScroll = ContainerNode(key = "sticky-scroll-far").apply { - overflowY = Overflow.Auto - }.applyParent(root) - val nearScroll = ContainerNode(key = "sticky-scroll-near").apply { - overflowY = Overflow.Hidden - }.applyParent(farScroll) + val farScroll = + ContainerNode(key = "sticky-scroll-far") + .apply { + overflowY = Overflow.Auto + }.applyParent(root) + val nearScroll = + ContainerNode(key = "sticky-scroll-near") + .apply { + overflowY = Overflow.Hidden + }.applyParent(farScroll) val parent = ContainerNode(key = "sticky-parent").applyParent(nearScroll) val node = ContainerNode(key = "sticky-node").applyParent(parent) @@ -63,9 +71,11 @@ class StickyLayoutModelContractTests { @Test fun `sticky containing-block rule is parent slot owner and is independent from sticky scroll-container rule`() { val root = ContainerNode(key = "sticky-container-root") - val scrollAncestor = ContainerNode(key = "sticky-scroll-ancestor").apply { - overflowY = Overflow.Scroll - }.applyParent(root) + val scrollAncestor = + ContainerNode(key = "sticky-scroll-ancestor") + .apply { + overflowY = Overflow.Scroll + }.applyParent(root) val flowParent = ContainerNode(key = "sticky-flow-parent").applyParent(scrollAncestor) val node = ContainerNode(key = "sticky-child").applyParent(flowParent) @@ -140,7 +150,7 @@ class StickyLayoutModelContractTests { assertEquals( StickyLayoutModel.PositionedGeometryIntegrationPoint.SharedUsedGeometryTransform, - node.stickyPositionedGeometryIntegrationPoint() + node.stickyPositionedGeometryIntegrationPoint(), ) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/UnifiedUsedGeometryInspectorCharacterizationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/UnifiedUsedGeometryInspectorCharacterizationTests.kt index 59be6f5..e1b5736 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/UnifiedUsedGeometryInspectorCharacterizationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/UnifiedUsedGeometryInspectorCharacterizationTests.kt @@ -25,11 +25,14 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class UnifiedUsedGeometryInspectorCharacterizationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -41,24 +44,27 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { fun `core click and hover reach render-visible absolute outside ancestor bounds`() { val fixture = createAbsoluteOutsideAncestorFixture() fixture.tree.render(ctx, width = 260, height = 140) - val drawRects = fixture.tree.paint(ctx).filterIsInstance() + val drawRects = + fixture.tree + .paint(ctx) + .filterIsInstance() assertTrue( drawRects.any { it.color == fixture.childColor && it.x == 100 && it.y == 5 }, - "Render draws absolute child at its final positioned rect outside ancestor bounds" + "Render draws absolute child at its final positioned rect outside ancestor bounds", ) var childClicks = 0 fixture.child.onClick { childClicks += 1 } assertTrue( dispatchClick(fixture.root, MouseClickEvent(105, 10, MouseButton.LEFT)), - "Core click now reaches visible absolute child outside ancestor-local bounds" + "Core click now reaches visible absolute child outside ancestor-local bounds", ) assertEquals(1, childClicks) val hoverChain = collectHoverChain(fixture.root, 105, 10) assertEquals( fixture.child.key, hoverChain.lastOrNull()?.key, - "Core hover now resolves the same visible absolute target" + "Core hover now resolves the same visible absolute target", ) } @@ -72,9 +78,10 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.onCursorMoved(105, 10) assertEquals( - fixture.child.key?.toString(), + fixture.child.key + ?.toString(), inspector.hoveredKey, - "Inspector picking now follows shared used geometry for absolute-outside-ancestor case" + "Inspector picking now follows shared used geometry for absolute-outside-ancestor case", ) } @@ -82,7 +89,10 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { fun `render core interaction and inspector all target promoted fixed`() { val fixture = createPositionedOverlapFixture() fixture.tree.render(ctx, width = 220, height = 140) - val drawRects = fixture.tree.paint(ctx).filterIsInstance() + val drawRects = + fixture.tree + .paint(ctx) + .filterIsInstance() val fixedPaintIndex = drawRects.indexOfFirst { it.color == fixture.fixedColor && it.x == 8 && it.y == 8 } val laterPaintIndex = drawRects.indexOfFirst { it.color == fixture.laterColor && it.x == 0 && it.y == 0 } @@ -104,9 +114,10 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.onLayoutCommitted(fixture.root, 1L) inspector.onCursorMoved(10, 10) assertEquals( - fixture.fixed.key?.toString(), + fixture.fixed.key + ?.toString(), inspector.hoveredKey, - "Inspector now resolves the same topmost promoted fixed node as render/core interaction" + "Inspector now resolves the same topmost promoted fixed node as render/core interaction", ) } @@ -118,7 +129,7 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { assertEquals( listOf(fixture.fixed, fixture.laterContainer, fixture.earlyContainer), fixture.root.orderedChildrenForHitTestingTraversal(), - "Positioned hit traversal resolves promoted fixed first" + "Positioned hit traversal resolves promoted fixed first", ) val inspector = InspectorController() @@ -127,9 +138,10 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.onCursorMoved(10, 10) assertEquals( - fixture.fixed.key?.toString(), + fixture.fixed.key + ?.toString(), inspector.hoveredKey, - "Inspector now uses shared ordering for overlap picking" + "Inspector now uses shared ordering for overlap picking", ) } @@ -149,56 +161,70 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { val absoluteHover = collectHoverChain(absoluteFixture.root, 105, 10).lastOrNull()?.key assertEquals(fixedFixture.fixed.key, fixedHover, "Core hover sees promoted fixed in overlap case") - assertEquals(absoluteFixture.child.key, absoluteHover, "Core hover reaches absolute child outside ancestor bounds") assertEquals( - fixedFixture.fixed.key?.toString(), + absoluteFixture.child.key, + absoluteHover, + "Core hover reaches absolute child outside ancestor bounds", + ) + assertEquals( + fixedFixture.fixed.key + ?.toString(), inspector.hoveredKey, - "Inspector now matches core positioned ordering in overlap case" + "Inspector now matches core positioned ordering in overlap case", ) var absoluteClicks = 0 absoluteFixture.child.onClick { absoluteClicks += 1 } assertTrue( dispatchClick(absoluteFixture.root, MouseClickEvent(105, 10, MouseButton.LEFT)), - "Core click reaches absolute-outside-ancestor case after shared used-geometry migration" + "Core click reaches absolute-outside-ancestor case after shared used-geometry migration", ) assertEquals(1, absoluteClicks) inspector.onLayoutCommitted(absoluteFixture.root, 22L) inspector.onCursorMoved(105, 10) assertEquals( - absoluteFixture.child.key?.toString(), + absoluteFixture.child.key + ?.toString(), inspector.hoveredKey, - "Inspector now matches core/app-host geometry for absolute-outside-ancestor case" + "Inspector now matches core/app-host geometry for absolute-outside-ancestor case", ) } @Test fun `core and inspector preserve fixed and non-fixed clip semantics`() { val root = ContainerNode(key = "clip-root", stackLayout = true) - val overflowParent = ContainerNode(key = "clip-parent").apply { - width = 80 - height = 40 - overflowY = Overflow.Hidden - inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") - }.applyParent(root) - val fixed = ButtonNode("fixed", key = "clip-fixed").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "180px", - StyleProperty.TOP to "20px" - ) - }.applyParent(overflowParent) - val absolute = ButtonNode("absolute", key = "clip-absolute").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "140px", - StyleProperty.TOP to "90px" - ) - }.applyParent(overflowParent) + val overflowParent = + ContainerNode(key = "clip-parent") + .apply { + width = 80 + height = 40 + overflowY = Overflow.Hidden + inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") + }.applyParent(root) + val fixed = + ButtonNode("fixed", key = "clip-fixed") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "180px", + StyleProperty.TOP to "20px", + ) + }.applyParent(overflowParent) + val absolute = + ButtonNode("absolute", key = "clip-absolute") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "140px", + StyleProperty.TOP to "90px", + ) + }.applyParent(overflowParent) val tree = DomTree(root) tree.render(ctx, width = 200, height = 120) @@ -232,34 +258,43 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { @Test fun `inspector highlight uses shared used geometry and clipping`() { - val root = ContainerNode(key = "highlight-root", stackLayout = true).apply { - width = 200 - height = 120 - } - val overflowParent = ContainerNode(key = "highlight-parent").apply { - width = 80 - height = 40 - overflowY = Overflow.Hidden - inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") - }.applyParent(root) - val fixed = ButtonNode("fixed", key = "highlight-fixed").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "180px", - StyleProperty.TOP to "20px" - ) - }.applyParent(overflowParent) - val absolute = ButtonNode("absolute", key = "highlight-absolute").apply { - width = 40 - height = 20 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "140px", - StyleProperty.TOP to "90px" - ) - }.applyParent(overflowParent) + val root = + ContainerNode(key = "highlight-root", stackLayout = true).apply { + width = 200 + height = 120 + } + val overflowParent = + ContainerNode(key = "highlight-parent") + .apply { + width = 80 + height = 40 + overflowY = Overflow.Hidden + inlineStyleDeclarations = styleDeclarations(StyleProperty.POSITION to "relative") + }.applyParent(root) + val fixed = + ButtonNode("fixed", key = "highlight-fixed") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "180px", + StyleProperty.TOP to "20px", + ) + }.applyParent(overflowParent) + val absolute = + ButtonNode("absolute", key = "highlight-absolute") + .apply { + width = 40 + height = 20 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "140px", + StyleProperty.TOP to "90px", + ) + }.applyParent(overflowParent) val tree = DomTree(root) tree.render(ctx, width = 200, height = 120) @@ -269,42 +304,47 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.onCursorMoved(185, 25) inspector.buildDomSnapshot(800, 600) - val fixedHighlight = inspector.debugHoveredHighlight() + val fixedHighlight = inspector.portalHoveredHighlight() assertNotNull(fixedHighlight) val fixedUsedGeometry = UsedInteractionGeometryResolver.resolveNodeGeometry(fixed) assertEquals( fixedUsedGeometry.visibleBorderRect ?: Rect(0, 0, 0, 0), - fixedHighlight.borderRect + fixedHighlight.borderRect, ) inspector.onCursorMoved(145, 95) inspector.buildDomSnapshot(800, 600) - val clippedHighlight = inspector.debugHoveredHighlight() + val clippedHighlight = inspector.portalHoveredHighlight() assertNotNull(clippedHighlight) val rootUsedGeometry = UsedInteractionGeometryResolver.resolveNodeGeometry(root) assertEquals( rootUsedGeometry.visibleBorderRect ?: Rect(0, 0, 0, 0), clippedHighlight.borderRect, - "Non-fixed clipped case highlights resolved fallback target with shared used geometry" + "Non-fixed clipped case highlights resolved fallback target with shared used geometry", ) } @Test fun `relative positioned node highlight uses final rendered position`() { val root = ContainerNode(key = "relative-root") - val relative = ButtonNode("relative", key = "relative-target").apply { - width = 60 - height = 24 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative", - StyleProperty.LEFT to "40px", - StyleProperty.TOP to "18px" - ) - }.applyParent(root) - val sibling = ButtonNode("sibling", key = "relative-sibling").apply { - width = 70 - height = 24 - }.applyParent(root) + val relative = + ButtonNode("relative", key = "relative-target") + .apply { + width = 60 + height = 24 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + StyleProperty.LEFT to "40px", + StyleProperty.TOP to "18px", + ) + }.applyParent(root) + val sibling = + ButtonNode("sibling", key = "relative-sibling") + .apply { + width = 70 + height = 24 + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, width = 260, height = 140) @@ -319,12 +359,12 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.buildDomSnapshot(800, 600) assertEquals(relative.key?.toString(), inspector.hoveredKey) - val highlight = inspector.debugHoveredHighlight() + val highlight = inspector.portalHoveredHighlight() assertNotNull(highlight) assertEquals( usedGeometry.visibleBorderRect ?: Rect(0, 0, 0, 0), highlight.borderRect, - "Relative highlight must use final rendered geometry, not original layout slot" + "Relative highlight must use final rendered geometry, not original layout slot", ) // Ensure we did not accidentally break neighboring static hit/highlight behavior. @@ -345,14 +385,18 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { inspector.onCursorMoved(10, 10) inspector.buildDomSnapshot(800, 600) - assertEquals(fixture.fixed.key?.toString(), inspector.hoveredKey) - val highlight = inspector.debugHoveredHighlight() + assertEquals( + fixture.fixed.key + ?.toString(), + inspector.hoveredKey, + ) + val highlight = inspector.portalHoveredHighlight() assertNotNull(highlight) val fixedGeometry = UsedInteractionGeometryResolver.resolveNodeGeometry(fixture.fixed) assertEquals( fixedGeometry.visibleBorderRect ?: Rect(0, 0, 0, 0), highlight.borderRect, - "Inspector highlight must match picked node final used geometry in overlap cases" + "Inspector highlight must match picked node final used geometry in overlap cases", ) } @@ -364,7 +408,7 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { val fixed: ButtonNode, val later: ButtonNode, val fixedColor: Int, - val laterColor: Int + val laterColor: Int, ) private data class AbsoluteOutsideAncestorFixture( @@ -372,35 +416,42 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { val root: ContainerNode, val ancestor: ContainerNode, val child: ButtonNode, - val childColor: Int + val childColor: Int, ) private fun createPositionedOverlapFixture(): PositionedOverlapFixture { val fixedColor = 0x00_27_83_B4 val laterColor = 0x00_B4_5D_27 val root = ContainerNode(key = "root", stackLayout = true) - val early = ContainerNode(key = "early", stackLayout = true).apply { - width = 120 - height = 60 - } - val laterContainer = ContainerNode(key = "later-container", stackLayout = true).apply { - width = 120 - height = 60 - } - val fixed = ButtonNode("fixed", backgroundColor = fixedColor, key = "fixed").apply { - width = 72 - height = 24 - zIndex = 9_999 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "fixed", - StyleProperty.LEFT to "8px", - StyleProperty.TOP to "8px" - ) - }.applyParent(early) - val later = ButtonNode("later", backgroundColor = laterColor, key = "later").apply { - width = 72 - height = 24 - }.applyParent(laterContainer) + val early = + ContainerNode(key = "early", stackLayout = true).apply { + width = 120 + height = 60 + } + val laterContainer = + ContainerNode(key = "later-container", stackLayout = true).apply { + width = 120 + height = 60 + } + val fixed = + ButtonNode("fixed", backgroundColor = fixedColor, key = "fixed") + .apply { + width = 72 + height = 24 + zIndex = 9_999 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "fixed", + StyleProperty.LEFT to "8px", + StyleProperty.TOP to "8px", + ) + }.applyParent(early) + val later = + ButtonNode("later", backgroundColor = laterColor, key = "later") + .apply { + width = 72 + height = 24 + }.applyParent(laterContainer) early.applyParent(root) laterContainer.applyParent(root) return PositionedOverlapFixture( @@ -411,43 +462,48 @@ class UnifiedUsedGeometryInspectorCharacterizationTests { fixed = fixed, later = later, fixedColor = fixedColor, - laterColor = laterColor + laterColor = laterColor, ) } private fun createAbsoluteOutsideAncestorFixture(): AbsoluteOutsideAncestorFixture { val childColor = 0x00_1F_9A_55 val root = ContainerNode(key = "abs-root") - val ancestor = ContainerNode(key = "abs-ancestor").apply { - width = 40 - height = 40 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "relative" - ) - }.applyParent(root) - val child = ButtonNode("abs-child", backgroundColor = childColor, key = "abs-child").apply { - width = 36 - height = 16 - inlineStyleDeclarations = styleDeclarations( - StyleProperty.POSITION to "absolute", - StyleProperty.LEFT to "100px", - StyleProperty.TOP to "5px" - ) - }.applyParent(ancestor) + val ancestor = + ContainerNode(key = "abs-ancestor") + .apply { + width = 40 + height = 40 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "relative", + ) + }.applyParent(root) + val child = + ButtonNode("abs-child", backgroundColor = childColor, key = "abs-child") + .apply { + width = 36 + height = 16 + inlineStyleDeclarations = + styleDeclarations( + StyleProperty.POSITION to "absolute", + StyleProperty.LEFT to "100px", + StyleProperty.TOP to "5px", + ) + }.applyParent(ancestor) return AbsoluteOutsideAncestorFixture( tree = DomTree(root), root = root, ancestor = ancestor, child = child, - childColor = childColor + childColor = childColor, ) } - private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { - return StyleDeclarations().apply { + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations = + StyleDeclarations().apply { entries.forEach { (property, literal) -> set(property, StyleExpression.Literal(literal)) } } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/DomTreeFontSizeInvalidationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/DomTreeFontSizeInvalidationTests.kt new file mode 100644 index 0000000..e30e98c --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/DomTreeFontSizeInvalidationTests.kt @@ -0,0 +1,66 @@ +package org.dreamfinity.dsgl.core.dom.elements + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.math.roundToInt +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DomTreeFontSizeInvalidationTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 10 + + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + val glyphWidth = ((fontSize ?: 16) * 0.5f).roundToInt().coerceAtLeast(1) + return text.length * glyphWidth + } + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = (fontSize ?: fontHeight).coerceAtLeast(1) + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @Test + fun `inspector font-size override relayouts text before repaint`() { + val root = + ContainerNode(key = "inspector.font-size.root").apply { + display = Display.Block + width = 240 + } + val node = + TextNode(TextSource.Static("inspector"), key = "inspector.font-size.text") + .apply { + fontSize = 16 + }.applyParent(root) + val tree = DomTree(root) + + tree.render(ctx, 240, 80) + val baselineHeight = node.bounds.height + + StyleEngine + .setInspectorOverrideLiteral(node, StyleProperty.FONT_SIZE, "32px") + .getOrThrow() + val commands = tree.paint(ctx) + val draw = commands.filterIsInstance().single() + + assertEquals(32, node.appliedComputedStyleSnapshot()?.fontSize) + assertEquals(32, draw.fontSize) + assertTrue(node.bounds.height > baselineHeight) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/InlineLayoutTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/InlineLayoutTests.kt index 1ece9db..99f0302 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/InlineLayoutTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/InlineLayoutTests.kt @@ -1,7 +1,7 @@ package org.dreamfinity.dsgl.core.dom.elements -import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.layout.Border import org.dreamfinity.dsgl.core.dom.layout.Insets import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext @@ -17,24 +17,28 @@ class InlineLayoutTests { @Test fun `inline child measured with parent width constraint expands height after wrap`() { val ctx = testMeasureContext() - val root = ContainerNode(key = "root").apply { - display = Display.Block - width = 30 - } - val inline = ContainerNode(key = "inline").apply { - display = Display.Inline - gap = 2 - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(root) + val root = + ContainerNode(key = "root").apply { + display = Display.Block + width = 30 + } + val inline = + ContainerNode(key = "inline") + .apply { + display = Display.Inline + gap = 2 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(root) repeat(3) { index -> - ContainerNode(key = "item.$index").apply { - display = Display.Inline - width = 10 - height = 4 - margin = Insets(1, 1, 1, 1) - }.applyParent(inline) + ContainerNode(key = "item.$index") + .apply { + display = Display.Inline + width = 10 + height = 4 + margin = Insets(1, 1, 1, 1) + }.applyParent(inline) } val measured = root.measure(ctx) @@ -42,8 +46,19 @@ class InlineLayoutTests { val expectedLineHeight = expectedNormalLineHeightPx(ctx, 16) val expectedTotalHeight = expectedLineHeight * 2 + inline.gap + inline.padding.vertical + inline.border.vertical - assertEquals(expectedTotalHeight, inline.bounds.height, "Inline height must include wrapped second line with line-box reservation.") - assertEquals(2, inline.children.map { it.bounds.y }.distinct().size, "Inline items should wrap to two lines.") + assertEquals( + expectedTotalHeight, + inline.bounds.height, + "Inline height must include wrapped second line with line-box reservation.", + ) + assertEquals( + 2, + inline.children + .map { it.bounds.y } + .distinct() + .size, + "Inline items should wrap to two lines.", + ) assertOuterWidthsFitInlineContent(inline) assertContentFitsIntoInlineBounds(inline) } @@ -51,32 +66,42 @@ class InlineLayoutTests { @Test fun `inline flow with nested flex and block items keeps content inside measured bounds`() { val ctx = testMeasureContext() - val root = ContainerNode(key = "root.nested").apply { - display = Display.Block - width = 36 - } - val inline = ContainerNode(key = "inline.nested").apply { - display = Display.Inline - gap = 3 - padding = Insets.all(2) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(root) + val root = + ContainerNode(key = "root.nested").apply { + display = Display.Block + width = 36 + } + val inline = + ContainerNode(key = "inline.nested") + .apply { + display = Display.Inline + gap = 3 + padding = Insets.all(2) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(root) createInlineFlexItem("flex", 8, 7).applyParent(inline) createInlineBlockItem("block", 10, 6).applyParent(inline) - ContainerNode(key = "simple").apply { - display = Display.Inline - width = 12 - height = 4 - margin = Insets(1, 2, 1, 1) - border = Border.all(1, 0xFF000000.toInt()) - padding = Insets.all(1) - }.applyParent(inline) + ContainerNode(key = "simple") + .apply { + display = Display.Inline + width = 12 + height = 4 + margin = Insets(1, 2, 1, 1) + border = Border.all(1, 0xFF000000.toInt()) + padding = Insets.all(1) + }.applyParent(inline) val measured = root.measure(ctx) root.render(ctx, 0, 0, measured.width, measured.height) - assertTrue(inline.children.map { it.bounds.y }.distinct().size >= 2, "Nested inline items must wrap.") + assertTrue( + inline.children + .map { it.bounds.y } + .distinct() + .size >= 2, + "Nested inline items must wrap.", + ) assertOuterWidthsFitInlineContent(inline) assertContentFitsIntoInlineBounds(inline) } @@ -84,16 +109,19 @@ class InlineLayoutTests { @Test fun `inline wrappers with mixed text plus nested containers do not overlap between lines`() { val ctx = testMeasureContext() - val root = ContainerNode(key = "root.mixed").apply { - display = Display.Block - width = 44 - } - val inline = ContainerNode(key = "inline.mixed").apply { - display = Display.Inline - gap = 2 - padding = Insets.all(2) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(root) + val root = + ContainerNode(key = "root.mixed").apply { + display = Display.Block + width = 44 + } + val inline = + ContainerNode(key = "inline.mixed") + .apply { + display = Display.Inline + gap = 2 + padding = Insets.all(2) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(root) createMixedInlineWrapper("a", includeFlex = true).applyParent(inline) createMixedInlineWrapper("b", includeFlex = false).applyParent(inline) @@ -122,12 +150,14 @@ class InlineLayoutTests { val chipChildren = inline.children.filter { (it.key as? String)?.startsWith("chip.") == true } assertTrue( chipChildren.all { chip -> chip.bounds.width + chip.margin.horizontal < inlineContentWidth }, - "Inline chips should remain shrink-to-fit and not stretch to full line width." + "Inline chips should remain shrink-to-fit and not stretch to full line width.", ) assertOuterWidthsFitInlineContent(inline) assertContentFitsIntoInlineBounds(inline) assertNoVerticalOverlapBetweenSiblings(inline.children) - inline.children.filterIsInstance().forEach { assertContainerChildrenFit(it) } + inline.children + .filterIsInstance() + .forEach { assertContainerChildrenFit(it) } } @Test @@ -157,17 +187,20 @@ class InlineLayoutTests { @Test fun `inline container explicit width larger than parent is constrained without overlap`() { val ctx = testMeasureContext() - val root = ContainerNode(key = "root.small").apply { - display = Display.Block - width = 60 - } - val inline = ContainerNode(key = "inline.explicit.large").apply { - display = Display.Inline - width = 96 - gap = 2 - padding = Insets.all(3) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(root) + val root = + ContainerNode(key = "root.small").apply { + display = Display.Block + width = 60 + } + val inline = + ContainerNode(key = "inline.explicit.large") + .apply { + display = Display.Inline + width = 96 + gap = 2 + padding = Insets.all(3) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(root) createMixedInlineWrapper("x", includeFlex = true).applyParent(inline) createMixedInlineWrapper("y", includeFlex = false).applyParent(inline) @@ -182,85 +215,102 @@ class InlineLayoutTests { } private fun createInlineFlexItem(key: String, aWidth: Int, bWidth: Int): ContainerNode { - val wrapper = ContainerNode(key = "$key.wrapper").apply { - display = Display.Inline - margin = Insets(1, 2, 1, 1) - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - } - val flex = ContainerNode(key = "$key.flex").apply { - display = Display.Flex - flexDirection = FlexDirection.Row - gap = 1 - padding = Insets.all(1) - }.applyParent(wrapper) - ContainerNode(key = "$key.a").apply { - width = aWidth - height = 4 - }.applyParent(flex) - ContainerNode(key = "$key.b").apply { - width = bWidth - height = 4 - }.applyParent(flex) + val wrapper = + ContainerNode(key = "$key.wrapper").apply { + display = Display.Inline + margin = Insets(1, 2, 1, 1) + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + } + val flex = + ContainerNode(key = "$key.flex") + .apply { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 1 + padding = Insets.all(1) + }.applyParent(wrapper) + ContainerNode(key = "$key.a") + .apply { + width = aWidth + height = 4 + }.applyParent(flex) + ContainerNode(key = "$key.b") + .apply { + width = bWidth + height = 4 + }.applyParent(flex) return wrapper } private fun createInlineBlockItem(key: String, topWidth: Int, bottomWidth: Int): ContainerNode { - val wrapper = ContainerNode(key = "$key.wrapper").apply { - display = Display.Inline - margin = Insets(1, 2, 1, 1) - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - } - val block = ContainerNode(key = "$key.block").apply { - display = Display.Block - gap = 1 - padding = Insets.all(1) - }.applyParent(wrapper) - ContainerNode(key = "$key.top").apply { - width = topWidth - height = 3 - }.applyParent(block) - ContainerNode(key = "$key.bottom").apply { - width = bottomWidth - height = 3 - }.applyParent(block) + val wrapper = + ContainerNode(key = "$key.wrapper").apply { + display = Display.Inline + margin = Insets(1, 2, 1, 1) + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + } + val block = + ContainerNode(key = "$key.block") + .apply { + display = Display.Block + gap = 1 + padding = Insets.all(1) + }.applyParent(wrapper) + ContainerNode(key = "$key.top") + .apply { + width = topWidth + height = 3 + }.applyParent(block) + ContainerNode(key = "$key.bottom") + .apply { + width = bottomWidth + height = 3 + }.applyParent(block) return wrapper } private fun createMixedInlineWrapper(key: String, includeFlex: Boolean): ContainerNode { - val wrapper = ContainerNode(key = "$key.mixed.wrapper").apply { - display = Display.Inline - width = 24 - margin = Insets(1, 2, 1, 1) - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - gap = 1 - } - TextNode(TextSource.Static("label-$key"), key = "$key.label").applyParent(wrapper) - if (includeFlex) { - val flex = ContainerNode(key = "$key.flex").apply { - display = Display.Flex - flexDirection = FlexDirection.Row - gap = 1 + val wrapper = + ContainerNode(key = "$key.mixed.wrapper").apply { + display = Display.Inline + width = 24 + margin = Insets(1, 2, 1, 1) padding = Insets.all(1) border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(wrapper) - ContainerNode(key = "$key.flex.a").apply { - width = 9 - height = 4 - }.applyParent(flex) - ContainerNode(key = "$key.flex.b").apply { - width = 8 - height = 4 - }.applyParent(flex) - } else { - val block = ContainerNode(key = "$key.block").apply { - display = Display.Block gap = 1 - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(wrapper) + } + TextNode(TextSource.Static("label-$key"), key = "$key.label").applyParent(wrapper) + if (includeFlex) { + val flex = + ContainerNode(key = "$key.flex") + .apply { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 1 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(wrapper) + ContainerNode(key = "$key.flex.a") + .apply { + width = 9 + height = 4 + }.applyParent(flex) + ContainerNode(key = "$key.flex.b") + .apply { + width = 8 + height = 4 + }.applyParent(flex) + } else { + val block = + ContainerNode(key = "$key.block") + .apply { + display = Display.Block + gap = 1 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(wrapper) TextNode(TextSource.Static("top"), key = "$key.block.top").applyParent(block) TextNode(TextSource.Static("bottom"), key = "$key.block.bottom").applyParent(block) } @@ -268,93 +318,111 @@ class InlineLayoutTests { } private fun createDisplayInlineScenario(rootWidth: Int, inlineContentWidth: Int): Pair { - val root = ContainerNode(key = "root.display.inline").apply { - display = Display.Block - width = rootWidth - } - val inline = ContainerNode(key = "display.inline.container").apply { - display = Display.Inline - width = inlineContentWidth - gap = 2 - padding = Insets.all(3) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(root) + val root = + ContainerNode(key = "root.display.inline").apply { + display = Display.Block + width = rootWidth + } + val inline = + ContainerNode(key = "display.inline.container") + .apply { + display = Display.Inline + width = inlineContentWidth + gap = 2 + padding = Insets.all(3) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(root) listOf("alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta").forEachIndexed { index, label -> - ContainerNode(key = "chip.$index").apply { + ContainerNode(key = "chip.$index") + .apply { + display = Display.Inline + margin = Insets(1, 2, 1, 1) + padding = Insets.all(2) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(inline) + .also { wrapper -> + TextNode(TextSource.Static(label), key = "chip.$index.label").applyParent(wrapper) + } + } + + ContainerNode(key = "display.inline.flex.item") + .apply { display = Display.Inline margin = Insets(1, 2, 1, 1) padding = Insets.all(2) border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(inline).also { wrapper -> - TextNode(TextSource.Static(label), key = "chip.$index.label").applyParent(wrapper) - } - } - - ContainerNode(key = "display.inline.flex.item").apply { - display = Display.Inline - margin = Insets(1, 2, 1, 1) - padding = Insets.all(2) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(inline).also { wrapper -> - TextNode(TextSource.Static("flex"), key = "display.inline.flex.label").applyParent(wrapper) - ContainerNode(key = "display.inline.flex.container").apply { - display = Display.Flex - flexDirection = FlexDirection.Row - gap = 1 - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(wrapper).also { flex -> - ContainerNode(key = "display.inline.flex.a").apply { - width = 8 - height = 4 - }.applyParent(flex) - ContainerNode(key = "display.inline.flex.b").apply { - width = 6 - height = 4 - }.applyParent(flex) + }.applyParent(inline) + .also { wrapper -> + TextNode(TextSource.Static("flex"), key = "display.inline.flex.label").applyParent(wrapper) + ContainerNode(key = "display.inline.flex.container") + .apply { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 1 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(wrapper) + .also { flex -> + ContainerNode(key = "display.inline.flex.a") + .apply { + width = 8 + height = 4 + }.applyParent(flex) + ContainerNode(key = "display.inline.flex.b") + .apply { + width = 6 + height = 4 + }.applyParent(flex) + } } - } - ContainerNode(key = "display.inline.block.item").apply { - display = Display.Inline - margin = Insets(1, 2, 1, 1) - padding = Insets.all(2) - border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(inline).also { wrapper -> - ContainerNode(key = "display.inline.block.container").apply { - display = Display.Block - gap = 1 - padding = Insets.all(1) + ContainerNode(key = "display.inline.block.item") + .apply { + display = Display.Inline + margin = Insets(1, 2, 1, 1) + padding = Insets.all(2) border = Border.all(1, 0xFF000000.toInt()) - }.applyParent(wrapper).also { block -> - TextNode(TextSource.Static("block"), key = "display.inline.block.label").applyParent(block) - TextNode(TextSource.Static("A"), key = "display.inline.block.a").applyParent(block) - TextNode(TextSource.Static("B"), key = "display.inline.block.b").applyParent(block) + }.applyParent(inline) + .also { wrapper -> + ContainerNode(key = "display.inline.block.container") + .apply { + display = Display.Block + gap = 1 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + }.applyParent(wrapper) + .also { block -> + TextNode(TextSource.Static("block"), key = "display.inline.block.label").applyParent(block) + TextNode(TextSource.Static("A"), key = "display.inline.block.a").applyParent(block) + TextNode(TextSource.Static("B"), key = "display.inline.block.b").applyParent(block) + } } - } return root to inline } private fun assertContentFitsIntoInlineBounds(inline: ContainerNode) { val contentBottom = inline.bounds.height - inline.border.bottom - inline.padding.bottom - val usedBottom = inline.children.maxOf { child -> - (child.bounds.y - inline.bounds.y) + child.bounds.height + child.margin.bottom - } + val usedBottom = + inline.children.maxOf { child -> + (child.bounds.y - inline.bounds.y) + child.bounds.height + child.margin.bottom + } assertTrue( usedBottom <= contentBottom, - "Inline child content should fit into measured bounds: used=$usedBottom, contentBottom=$contentBottom" + "Inline child content should fit into measured bounds: used=$usedBottom, contentBottom=$contentBottom", ) } private fun assertOuterWidthsFitInlineContent(inline: ContainerNode) { val contentWidth = inline.bounds.width - inline.border.horizontal - inline.padding.horizontal - val maxOuterWidth = inline.children.maxOf { child -> - child.bounds.width + child.margin.horizontal - } + val maxOuterWidth = + inline.children.maxOf { child -> + child.bounds.width + child.margin.horizontal + } assertTrue( maxOuterWidth <= contentWidth, - "Inline child outer width should fit content width: maxOuterWidth=$maxOuterWidth, contentWidth=$contentWidth" + "Inline child outer width should fit content width: " + + "maxOuterWidth=$maxOuterWidth, contentWidth=$contentWidth", ) } @@ -379,12 +447,13 @@ class InlineLayoutTests { private fun assertContainerChildrenFit(container: ContainerNode) { if (container.children.isEmpty()) return val contentBottom = container.bounds.height - container.border.bottom - container.padding.bottom - val usedBottom = container.children.maxOf { child -> - (child.bounds.y - container.bounds.y) + child.bounds.height + child.margin.bottom - } + val usedBottom = + container.children.maxOf { child -> + (child.bounds.y - container.bounds.y) + child.bounds.height + child.margin.bottom + } assertTrue( usedBottom <= contentBottom, - "Container child content should fit: key=${container.key} used=$usedBottom contentBottom=$contentBottom" + "Container child content should fit: key=${container.key} used=$usedBottom contentBottom=$contentBottom", ) } @@ -396,11 +465,12 @@ class InlineLayoutTests { .coerceAtLeast(1) } - private fun testMeasureContext(): UiMeasureContext { - return object : UiMeasureContext { + private fun testMeasureContext(): UiMeasureContext = + object : UiMeasureContext { override val fontHeight: Int = 8 + override fun measureText(text: String): Int = text.length * 6 + override fun paint(commands: List) = Unit } - } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/LayoutValidatorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/LayoutValidatorTests.kt index 6bc16c6..0730da7 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/LayoutValidatorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/LayoutValidatorTests.kt @@ -1,8 +1,5 @@ package org.dreamfinity.dsgl.core.dom.elements -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.debug.LayoutDebug @@ -13,6 +10,9 @@ import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class LayoutValidatorTests { @Test @@ -22,14 +22,15 @@ class LayoutValidatorTests { LayoutDebug.strictBounds = false val ctx = testMeasureContext() - val root = ContainerNode(key = "root").apply { - width = 28 - padding = Insets.all(2) - border = Border.all(1, 0xFF000000.toInt()) - } + val root = + ContainerNode(key = "root").apply { + width = 28 + padding = Insets.all(2) + border = Border.all(1, 0xFF000000.toInt()) + } TextNode( textSource = TextSource.Static("this is a long sentence that must wrap"), - key = "child.text" + key = "child.text", ).apply { margin = Insets(1, 1, 1, 1) }.applyParent(root) @@ -40,7 +41,7 @@ class LayoutValidatorTests { val violations = LayoutValidator.validate(root, ctx) assertTrue( violations.none { it.code == "CHILD_OUTSIDE_PARENT_CONTENT" }, - "Expected child to stay inside parent content, got: $violations" + "Expected child to stay inside parent content, got: $violations", ) } @@ -51,11 +52,13 @@ class LayoutValidatorTests { LayoutDebug.strictBounds = false val ctx = testMeasureContext() - val root = ContainerNode(key = "root.wrap").apply { - width = 32 - gap = 1 - } - val first = TextNode(TextSource.Static("first wrapped text line that should occupy multiple rows"), key = "text.a") + val root = + ContainerNode(key = "root.wrap").apply { + width = 32 + gap = 1 + } + val first = + TextNode(TextSource.Static("first wrapped text line that should occupy multiple rows"), key = "text.a") val second = TextNode(TextSource.Static("second wrapped text line must be below the first one"), key = "text.b") first.applyParent(root) second.applyParent(root) @@ -65,12 +68,12 @@ class LayoutValidatorTests { assertTrue( second.bounds.y >= first.bounds.y + first.bounds.height, - "Second text node should start after first node bottom." + "Second text node should start after first node bottom.", ) val violations = LayoutValidator.validate(root, ctx) assertTrue( violations.none { it.code == "TEXT_HEIGHT_UNDERSIZED" || it.code == "TEXT_LINE_COLLISION" }, - "Wrapped text must not produce line-stack violations: $violations" + "Wrapped text must not produce line-stack violations: $violations", ) } @@ -81,12 +84,13 @@ class LayoutValidatorTests { LayoutDebug.strictBounds = false val ctx = testMeasureContext() - val root = ContainerNode(key = "root.rogue").apply { - width = 20 - height = 10 - padding = Insets.all(1) - border = Border.all(1, 0xFF000000.toInt()) - } + val root = + ContainerNode(key = "root.rogue").apply { + width = 20 + height = 10 + padding = Insets.all(1) + border = Border.all(1, 0xFF000000.toInt()) + } RogueNode(key = "rogue").applyParent(root) val measured = root.measure(ctx) @@ -96,18 +100,27 @@ class LayoutValidatorTests { assertEquals(1, violations.count { it.code == "CHILD_OUTSIDE_PARENT_CONTENT" }) } - private fun testMeasureContext(): UiMeasureContext { - return object : UiMeasureContext { + private fun testMeasureContext(): UiMeasureContext = + object : UiMeasureContext { override val fontHeight: Int = 8 + override fun measureText(text: String): Int = text.length * 4 + override fun paint(commands: List) = Unit } - } - private class RogueNode(key: Any?) : DOMNode(key) { + private class RogueNode( + key: Any?, + ) : DOMNode(key) { override fun measure(ctx: UiMeasureContext): Size = Size(6, 4) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x + 100, y, width, height) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/SizeConstraintLayoutTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/SizeConstraintLayoutTests.kt index fdf7c31..4491852 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/SizeConstraintLayoutTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/SizeConstraintLayoutTests.kt @@ -4,20 +4,25 @@ import org.dreamfinity.dsgl.core.DomTree import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.StyleEngine import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals class SizeConstraintLayoutTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 10 - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * ((fontSize ?: 10) / 2).coerceAtLeast(1) - override fun fontHeight(fontId: String?, fontSize: Int?): Int = fontSize ?: 10 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 10 + + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * ((fontSize ?: 10) / 2).coerceAtLeast(1) + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = fontSize ?: 10 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLayoutEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLayoutEngineTests.kt index fa5faf7..2b6c272 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLayoutEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLayoutEngineTests.kt @@ -11,13 +11,14 @@ class TextLayoutEngineTests { @Test fun `wrap mode breaks lines by width`() { - val layout = TextLayoutEngine.layout( - text = "one two three", - maxWidth = 5, - wrap = TextWrap.Wrap, - fontHeight = 9, - measureText = measure - ) + val layout = + TextLayoutEngine.layout( + text = "one two three", + maxWidth = 5, + wrap = TextWrap.Wrap, + fontHeight = 9, + measureText = measure, + ) assertTrue(layout.lines.size > 1) assertTrue(layout.lines.all { it.width <= 5 }) @@ -26,27 +27,34 @@ class TextLayoutEngineTests { @Test fun `nowrap mode keeps single line`() { - val layout = TextLayoutEngine.layout( - text = "one two three", - maxWidth = 5, - wrap = TextWrap.NoWrap, - fontHeight = 9, - measureText = measure - ) + val layout = + TextLayoutEngine.layout( + text = "one two three", + maxWidth = 5, + wrap = TextWrap.NoWrap, + fontHeight = 9, + measureText = measure, + ) assertEquals(1, layout.lines.size) - assertEquals("one two three", layout.lines.single().text) + assertEquals( + "one two three", + layout.lines + .single() + .text, + ) } @Test fun `wrap mode hard breaks long words`() { - val layout = TextLayoutEngine.layout( - text = "abcdefghij", - maxWidth = 4, - wrap = TextWrap.Wrap, - fontHeight = 10, - measureText = measure - ) + val layout = + TextLayoutEngine.layout( + text = "abcdefghij", + maxWidth = 4, + wrap = TextWrap.Wrap, + fontHeight = 10, + measureText = measure, + ) assertEquals(listOf("abcd", "efgh", "ij"), layout.lines.map { it.text }) assertTrue(layout.lines.all { it.width <= 4 }) @@ -54,13 +62,14 @@ class TextLayoutEngineTests { @Test fun `newline indices are preserved for caret mapping`() { - val layout = TextLayoutEngine.layout( - text = "ab\ncd", - maxWidth = null, - wrap = TextWrap.NoWrap, - fontHeight = 8, - measureText = measure - ) + val layout = + TextLayoutEngine.layout( + text = "ab\ncd", + maxWidth = null, + wrap = TextWrap.NoWrap, + fontHeight = 8, + measureText = measure, + ) assertEquals(2, layout.lines.size) assertEquals(0, layout.lineForCaret(2)) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLineSpaceReservationBaselineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLineSpaceReservationBaselineTests.kt index 7508d79..82ac8b3 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLineSpaceReservationBaselineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextLineSpaceReservationBaselineTests.kt @@ -12,9 +12,9 @@ import org.dreamfinity.dsgl.core.style.Display import org.dreamfinity.dsgl.core.style.FlexDirection import org.dreamfinity.dsgl.core.style.LineHeightValue import org.dreamfinity.dsgl.core.style.Overflow +import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty -import org.dreamfinity.dsgl.core.style.StyleEngine import kotlin.math.ceil import kotlin.math.roundToInt import kotlin.test.AfterTest @@ -23,51 +23,52 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class TextLineSpaceReservationBaselineTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 10 + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 10 - override fun measureText(text: String): Int = text.length * 6 + override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { - val glyphWidth = ((fontSize ?: 16) * 0.5f).roundToInt().coerceAtLeast(1) - return text.length * glyphWidth - } + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + val glyphWidth = ((fontSize ?: 16) * 0.5f).roundToInt().coerceAtLeast(1) + return text.length * glyphWidth + } - override fun fontHeight(fontId: String?, fontSize: Int?): Int { - val size = (fontSize ?: 16).coerceAtLeast(1) - return (size * 0.625f).roundToInt().coerceAtLeast(1) + override fun fontHeight(fontId: String?, fontSize: Int?): Int { + val size = (fontSize ?: 16).coerceAtLeast(1) + return (size * 0.625f).roundToInt().coerceAtLeast(1) + } + + override fun paint(commands: List) = Unit } - override fun paint(commands: List) = Unit - } + private val nativeMetricsCtx = + object : UiMeasureContext { + override val fontHeight: Int = 10 - private val nativeMetricsCtx = object : UiMeasureContext { - override val fontHeight: Int = 10 + override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String): Int = text.length * 6 + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + val glyphWidth = ((fontSize ?: 16) * 0.5f).roundToInt().coerceAtLeast(1) + return text.length * glyphWidth + } - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { - val glyphWidth = ((fontSize ?: 16) * 0.5f).roundToInt().coerceAtLeast(1) - return text.length * glyphWidth - } + override fun fontHeight(fontId: String?, fontSize: Int?): Int { + val size = (fontSize ?: 16).coerceAtLeast(1) + return (size * 0.625f).roundToInt().coerceAtLeast(1) + } - override fun fontHeight(fontId: String?, fontSize: Int?): Int { - val size = (fontSize ?: 16).coerceAtLeast(1) - return (size * 0.625f).roundToInt().coerceAtLeast(1) - } + override fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics = + FontLineMetrics( + emSize = 1f, + lineHeightEm = 0.9166667f, + ascenderEm = 0.75f, + descenderEm = -0.16666667f, + ) - override fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics { - return FontLineMetrics( - emSize = 1f, - lineHeightEm = 0.9166667f, - ascenderEm = 0.75f, - descenderEm = -0.16666667f - ) + override fun paint(commands: List) = Unit } - override fun paint(commands: List) = Unit - } - @AfterTest fun cleanup() { StyleEngine.clearAllInspectorOverrides() @@ -76,9 +77,10 @@ class TextLineSpaceReservationBaselineTests { @Test fun `text node intrinsic height baseline uses explicit normal line-height rule`() { - val node = TextNode(TextSource.Static("single line"), key = "text.single").apply { - fontSize = 16 - } + val node = + TextNode(TextSource.Static("single line"), key = "text.single").apply { + fontSize = 16 + } val measured = node.measure(ctx) @@ -87,21 +89,25 @@ class TextLineSpaceReservationBaselineTests { @Test fun `div with span text baseline reserves one normal line-height line`() { - val div = ContainerNode(key = "div.root").apply { - display = Display.Block - width = 320 - applyStyle { - fontSize = 20.px - } - } - val span = ContainerNode(key = "span.inline").apply { - display = Display.Inline - }.applyParent(div) - TextNode(TextSource.Static("row"), key = "span.text").apply { - applyStyle { - fontSize = 12.px + val div = + ContainerNode(key = "div.root").apply { + display = Display.Block + width = 320 + applyStyle { + fontSize = 20.px + } } - }.applyParent(span) + val span = + ContainerNode(key = "span.inline") + .apply { + display = Display.Inline + }.applyParent(div) + TextNode(TextSource.Static("row"), key = "span.text") + .apply { + applyStyle { + fontSize = 12.px + } + }.applyParent(span) val tree = DomTree(div) tree.render(ctx, 320, 120) @@ -111,7 +117,7 @@ class TextLineSpaceReservationBaselineTests { assertEquals(expectedContainerLineHeight, div.measure(ctx).height) assertTrue( span.bounds.height > expectedNormalLineHeightPx(12), - "Container reserves a line box from its own computed line-height baseline, not only child text height." + "Container reserves a line box from its own computed line-height baseline, not only child text height.", ) } @@ -120,24 +126,28 @@ class TextLineSpaceReservationBaselineTests { val rowCount = 20 val containerFontSize = 20 val textFontSize = 12 - val root = ContainerNode(key = "rows.root").apply { - display = Display.Flex - flexDirection = FlexDirection.Column - width = 320 - gap = 0 - } + val root = + ContainerNode(key = "rows.root").apply { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 320 + gap = 0 + } repeat(rowCount) { index -> - val row = ContainerNode(key = "row.$index").apply { - display = Display.Block - applyStyle { - fontSize = containerFontSize.px - } - }.applyParent(root) - TextNode(TextSource.Static("row $index"), key = "row.$index.text").apply { - applyStyle { - fontSize = textFontSize.px - } - }.applyParent(row) + val row = + ContainerNode(key = "row.$index") + .apply { + display = Display.Block + applyStyle { + fontSize = containerFontSize.px + } + }.applyParent(root) + TextNode(TextSource.Static("row $index"), key = "row.$index.text") + .apply { + applyStyle { + fontSize = textFontSize.px + } + }.applyParent(row) } val tree = DomTree(root) @@ -154,35 +164,41 @@ class TextLineSpaceReservationBaselineTests { @Test fun `minHeight 1em workaround is not required for ordinary text row reservation`() { - val root = ContainerNode(key = "root").apply { - display = Display.Block - width = 320 - } - val ordinaryRow = ContainerNode(key = "row.ordinary").apply { - display = Display.Block - applyStyle { - fontSize = 20.px + val root = + ContainerNode(key = "root").apply { + display = Display.Block + width = 320 } - } - TextNode(TextSource.Static("row"), key = "row.ordinary.text").apply { - applyStyle { - fontSize = 12.px + val ordinaryRow = + ContainerNode(key = "row.ordinary").apply { + display = Display.Block + applyStyle { + fontSize = 20.px + } } - }.applyParent(ordinaryRow) + TextNode(TextSource.Static("row"), key = "row.ordinary.text") + .apply { + applyStyle { + fontSize = 12.px + } + }.applyParent(ordinaryRow) ordinaryRow.applyParent(root) - val withWorkaround = ContainerNode(key = "row.with").apply { - display = Display.Block - applyStyle { - fontSize = 20.px - minHeight = 1.em - } - }.applyParent(root) - TextNode(TextSource.Static("row"), key = "row.with.text").apply { - applyStyle { - fontSize = 12.px - } - }.applyParent(withWorkaround) + val withWorkaround = + ContainerNode(key = "row.with") + .apply { + display = Display.Block + applyStyle { + fontSize = 20.px + minHeight = 1.em + } + }.applyParent(root) + TextNode(TextSource.Static("row"), key = "row.with.text") + .apply { + applyStyle { + fontSize = 12.px + } + }.applyParent(withWorkaround) val tree = DomTree(root) tree.render(ctx, 320, 120) @@ -191,11 +207,12 @@ class TextLineSpaceReservationBaselineTests { assertEquals(expectedOrdinaryLineBoxHeight, ordinaryRow.bounds.height) assertTrue( ordinaryRow.bounds.height >= expectedOrdinaryLineBoxHeight, - "Ordinary text rows now meet line-box reservation without minHeight workaround." + "Ordinary text rows now meet line-box reservation without minHeight workaround.", ) assertTrue( withWorkaround.bounds.height >= ordinaryRow.bounds.height, - "Workaround may still increase explicit minimums, but ordinary line-box reservation no longer depends on it." + "Workaround may still increase explicit minimums, but ordinary line-box reservation " + + "no longer depends on it.", ) } @@ -203,24 +220,29 @@ class TextLineSpaceReservationBaselineTests { fun `scroll content height grows naturally from stacked text rows`() { val rowCount = 30 val rowFontSize = 20 - val root = ContainerNode(key = "scroll.viewport").apply { - display = Display.Block - width = 220 - height = 80 - overflowY = Overflow.Scroll - } - val list = ContainerNode(key = "scroll.content").apply { - display = Display.Flex - flexDirection = FlexDirection.Column - width = 220 - }.applyParent(root) - repeat(rowCount) { index -> - val row = ContainerNode(key = "scroll.row.$index").apply { + val root = + ContainerNode(key = "scroll.viewport").apply { display = Display.Block - applyStyle { - fontSize = rowFontSize.px - } - }.applyParent(list) + width = 220 + height = 80 + overflowY = Overflow.Scroll + } + val list = + ContainerNode(key = "scroll.content") + .apply { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 220 + }.applyParent(root) + repeat(rowCount) { index -> + val row = + ContainerNode(key = "scroll.row.$index") + .apply { + display = Display.Block + applyStyle { + fontSize = rowFontSize.px + } + }.applyParent(list) TextNode(TextSource.Static("item $index"), key = "scroll.row.$index.text").applyParent(row) } @@ -235,12 +257,13 @@ class TextLineSpaceReservationBaselineTests { @Test fun `explicit line-height override drives text node intrinsic height`() { - val node = TextNode(TextSource.Static("single line"), key = "text.override").apply { - fontSize = 16 - applyStyle { - lineHeight = LineHeightValue.Length(24.px) + val node = + TextNode(TextSource.Static("single line"), key = "text.override").apply { + fontSize = 16 + applyStyle { + lineHeight = LineHeightValue.Length(24.px) + } } - } StyleEngine.applyStylesRecursively(node) val measured = node.measure(ctx) @@ -250,22 +273,26 @@ class TextLineSpaceReservationBaselineTests { @Test fun `explicit line-height override affects container reserved row height`() { - val root = ContainerNode(key = "line-height.root").apply { - display = Display.Block - width = 320 - } - val row = ContainerNode(key = "line-height.row").apply { - display = Display.Block - applyStyle { - fontSize = 20.px - lineHeight = LineHeightValue.Length(26.px) - } - }.applyParent(root) - TextNode(TextSource.Static("row"), key = "line-height.text").apply { - applyStyle { - fontSize = 12.px + val root = + ContainerNode(key = "line-height.root").apply { + display = Display.Block + width = 320 } - }.applyParent(row) + val row = + ContainerNode(key = "line-height.row") + .apply { + display = Display.Block + applyStyle { + fontSize = 20.px + lineHeight = LineHeightValue.Length(26.px) + } + }.applyParent(root) + TextNode(TextSource.Static("row"), key = "line-height.text") + .apply { + applyStyle { + fontSize = 12.px + } + }.applyParent(row) val tree = DomTree(root) tree.render(ctx, 320, 120) @@ -275,16 +302,17 @@ class TextLineSpaceReservationBaselineTests { @Test fun `text width measurement remains stable when line-height changes`() { - val node = TextNode(TextSource.Static("width-check"), key = "text.width").apply { - fontSize = 16 - } + val node = + TextNode(TextSource.Static("width-check"), key = "text.width").apply { + fontSize = 16 + } StyleEngine.applyStylesRecursively(node) val baselineWidth = node.measure(ctx).width node.inlineStyleDeclarations.set( StyleProperty.LINE_HEIGHT, - StyleExpression.Literal("32px") + StyleExpression.Literal("32px"), ) StyleEngine.clearCache() StyleEngine.applyStylesRecursively(node) @@ -295,18 +323,22 @@ class TextLineSpaceReservationBaselineTests { @Test fun `inspector line-height override affects measured text intrinsic height`() { - val root = ContainerNode(key = "root").apply { - display = Display.Block - } - val node = TextNode(TextSource.Static("inspector"), key = "text.inspector").apply { - fontSize = 16 - }.applyParent(root) + val root = + ContainerNode(key = "root").apply { + display = Display.Block + } + val node = + TextNode(TextSource.Static("inspector"), key = "text.inspector") + .apply { + fontSize = 16 + }.applyParent(root) StyleEngine.applyStylesRecursively(root) val baselineHeight = node.measure(ctx).height assertEquals(expectedNormalLineHeightPx(16), baselineHeight) - StyleEngine.setInspectorOverrideLiteral(node, StyleProperty.LINE_HEIGHT, "22px") + StyleEngine + .setInspectorOverrideLiteral(node, StyleProperty.LINE_HEIGHT, "22px") .getOrThrow() StyleEngine.applyStylesRecursively(root) val overriddenHeight = node.measure(ctx).height @@ -317,18 +349,25 @@ class TextLineSpaceReservationBaselineTests { @Test fun `non-text child container sizing remains unchanged`() { - val root = ContainerNode(key = "non-text.root").apply { - display = Display.Block - width = 200 - } - val row = ContainerNode(key = "non-text.row").apply { - display = Display.Block - applyStyle { - fontSize = 20.px + val root = + ContainerNode(key = "non-text.root").apply { + display = Display.Block + width = 200 } - }.applyParent(root) - ImageNode(url = "minecraft:textures/blocks/stone.png", imageWidth = 40, imageHeight = 30, key = "non-text.image") - .applyParent(row) + val row = + ContainerNode(key = "non-text.row") + .apply { + display = Display.Block + applyStyle { + fontSize = 20.px + } + }.applyParent(root) + ImageNode( + url = "minecraft:textures/blocks/stone.png", + imageWidth = 40, + imageHeight = 30, + key = "non-text.image", + ).applyParent(row) val tree = DomTree(root) tree.render(ctx, 200, 80) @@ -338,17 +377,20 @@ class TextLineSpaceReservationBaselineTests { @Test fun `font-size em resolution uses inherited semantic base`() { - val root = ContainerNode(key = "font-size.base.root").apply { - display = Display.Block - applyStyle { - fontSize = 20.px - } - } - val text = TextNode(TextSource.Static("em"), key = "font-size.base.text").apply { - applyStyle { - fontSize = 2.em + val root = + ContainerNode(key = "font-size.base.root").apply { + display = Display.Block + applyStyle { + fontSize = 20.px + } } - }.applyParent(root) + val text = + TextNode(TextSource.Static("em"), key = "font-size.base.text") + .apply { + applyStyle { + fontSize = 2.em + } + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 320, 120) @@ -360,20 +402,24 @@ class TextLineSpaceReservationBaselineTests { @Test fun `changing text font-size changes parent reserved row height in ordinary case`() { fun reservedHeightFor(emValue: Float): Int { - val root = ContainerNode(key = "font-size.grow.root.$emValue").apply { - display = Display.Block - width = 320 - } - val row = ContainerNode(key = "font-size.grow.row.$emValue").apply { - display = Display.Block - padding = Insets.all(1) - border = Border.all(1, 0xFF617A90.toInt()) - }.applyParent(root) - TextNode(TextSource.Static("grow"), key = "font-size.grow.text.$emValue").apply { - applyStyle { - fontSize = emValue.em + val root = + ContainerNode(key = "font-size.grow.root.$emValue").apply { + display = Display.Block + width = 320 } - }.applyParent(row) + val row = + ContainerNode(key = "font-size.grow.row.$emValue") + .apply { + display = Display.Block + padding = Insets.all(1) + border = Border.all(1, 0xFF617A90.toInt()) + }.applyParent(root) + TextNode(TextSource.Static("grow"), key = "font-size.grow.text.$emValue") + .apply { + applyStyle { + fontSize = emValue.em + } + }.applyParent(row) val tree = DomTree(root) tree.render(ctx, 320, 120) return row.bounds.height @@ -389,25 +435,29 @@ class TextLineSpaceReservationBaselineTests { @Test fun `demo-like flex overflow audit keeps measured text reservation and produces scroll`() { val viewportHeight = 120 - val root = ContainerNode(key = "demo.audit.root").apply { - display = Display.Flex - flexDirection = FlexDirection.Column - width = 260 - height = viewportHeight - overflowY = Overflow.Auto - gap = 0 - } + val root = + ContainerNode(key = "demo.audit.root").apply { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 260 + height = viewportHeight + overflowY = Overflow.Auto + gap = 0 + } repeat(20) { index -> - val row = ContainerNode(key = "demo.audit.row.$index").apply { - display = Display.Block - padding = Insets.all(1) - border = Border.all(1, 0xFF617A90.toInt()) - }.applyParent(root) - TextNode(TextSource.Static("Hi there, #$index"), key = "demo.audit.text.$index").apply { - applyStyle { - fontSize = 10.em - } - }.applyParent(row) + val row = + ContainerNode(key = "demo.audit.row.$index") + .apply { + display = Display.Block + padding = Insets.all(1) + border = Border.all(1, 0xFF617A90.toInt()) + }.applyParent(root) + TextNode(TextSource.Static("Hi there, #$index"), key = "demo.audit.text.$index") + .apply { + applyStyle { + fontSize = 10.em + } + }.applyParent(row) } val tree = DomTree(root) @@ -439,13 +489,16 @@ class TextLineSpaceReservationBaselineTests { @Test fun `single source of truth keeps effective font size consistent across layout measurement and render command`() { - val root = ContainerNode(key = "single.source.root").apply { - display = Display.Block - applyStyle { fontSize = 16.px } - } - val textNode = TextNode(TextSource.Static("sync"), key = "single.source.text").apply { - applyStyle { fontSize = 10.em } - }.applyParent(root) + val root = + ContainerNode(key = "single.source.root").apply { + display = Display.Block + applyStyle { fontSize = 16.px } + } + val textNode = + TextNode(TextSource.Static("sync"), key = "single.source.text") + .apply { + applyStyle { fontSize = 10.em } + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 320, 120) @@ -464,16 +517,23 @@ class TextLineSpaceReservationBaselineTests { @Test fun `large font size growth increases width height and render size consistently`() { - data class Snapshot(val width: Int, val height: Int, val renderFontSize: Int) + data class Snapshot( + val width: Int, + val height: Int, + val renderFontSize: Int, + ) fun snapshotFor(emValue: Float): Snapshot { - val root = ContainerNode(key = "single.source.growth.root.$emValue").apply { - display = Display.Block - applyStyle { fontSize = 16.px } - } - val textNode = TextNode(TextSource.Static("MMMM"), key = "single.source.growth.text.$emValue").apply { - applyStyle { fontSize = emValue.em } - }.applyParent(root) + val root = + ContainerNode(key = "single.source.growth.root.$emValue").apply { + display = Display.Block + applyStyle { fontSize = 16.px } + } + val textNode = + TextNode(TextSource.Static("MMMM"), key = "single.source.growth.text.$emValue") + .apply { + applyStyle { fontSize = emValue.em } + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 400, 200) val measured = textNode.measure(ctx) @@ -483,7 +543,7 @@ class TextLineSpaceReservationBaselineTests { return Snapshot( width = measured.width, height = measured.height, - renderFontSize = draw.fontSize ?: -1 + renderFontSize = draw.fontSize ?: -1, ) } @@ -500,13 +560,16 @@ class TextLineSpaceReservationBaselineTests { @Test fun `no hidden clamp on authoritative text path for large computed font size`() { - val root = ContainerNode(key = "single.source.noclamp.root").apply { - display = Display.Block - applyStyle { fontSize = 16.px } - } - val textNode = TextNode(TextSource.Static("MMMM"), key = "single.source.noclamp.text").apply { - applyStyle { fontSize = 20.em } - }.applyParent(root) + val root = + ContainerNode(key = "single.source.noclamp.root").apply { + display = Display.Block + applyStyle { fontSize = 16.px } + } + val textNode = + TextNode(TextSource.Static("MMMM"), key = "single.source.noclamp.text") + .apply { + applyStyle { fontSize = 20.em } + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 640, 240) @@ -525,9 +588,10 @@ class TextLineSpaceReservationBaselineTests { @Test fun `normal line-height uses native metrics when available`() { - val node = TextNode(TextSource.Static("native"), key = "native.metrics.normal").apply { - fontSize = 16 - } + val node = + TextNode(TextSource.Static("native"), key = "native.metrics.normal").apply { + fontSize = 16 + } StyleEngine.applyStylesRecursively(node) val measured = node.measure(nativeMetricsCtx) @@ -542,12 +606,14 @@ class TextLineSpaceReservationBaselineTests { @Test fun `native ascender descender and line-height scale with font size`() { - val small = TextNode(TextSource.Static("small"), key = "native.metrics.small").apply { - fontSize = 16 - } - val large = TextNode(TextSource.Static("large"), key = "native.metrics.large").apply { - fontSize = 32 - } + val small = + TextNode(TextSource.Static("small"), key = "native.metrics.small").apply { + fontSize = 16 + } + val large = + TextNode(TextSource.Static("large"), key = "native.metrics.large").apply { + fontSize = 32 + } val smallLine = invokeProtectedInt(small, "resolveEffectiveLineHeight", nativeMetricsCtx) val largeLine = invokeProtectedInt(large, "resolveEffectiveLineHeight", nativeMetricsCtx) @@ -565,16 +631,19 @@ class TextLineSpaceReservationBaselineTests { @Test fun `explicit line-height adds symmetric leading for text draw origin`() { - val root = ContainerNode(key = "native.leading.root").apply { - display = Display.Block - width = 320 - } - val node = TextNode(TextSource.Static("lead"), key = "native.leading.text").apply { - fontSize = 16 - applyStyle { - lineHeight = LineHeightValue.Length(24.px) + val root = + ContainerNode(key = "native.leading.root").apply { + display = Display.Block + width = 320 } - }.applyParent(root) + val node = + TextNode(TextSource.Static("lead"), key = "native.leading.text") + .apply { + fontSize = 16 + applyStyle { + lineHeight = LineHeightValue.Length(24.px) + } + }.applyParent(root) val tree = DomTree(root) tree.render(nativeMetricsCtx, 320, 80) @@ -591,16 +660,19 @@ class TextLineSpaceReservationBaselineTests { @Test fun `render line advance stays coherent with measured line-height`() { - val root = ContainerNode(key = "native.coherence.root").apply { - display = Display.Block - width = 320 - } - val node = TextNode(TextSource.Static("a\nb\nc"), key = "native.coherence.text").apply { - fontSize = 16 - applyStyle { - lineHeight = LineHeightValue.Length(24.px) + val root = + ContainerNode(key = "native.coherence.root").apply { + display = Display.Block + width = 320 } - }.applyParent(root) + val node = + TextNode(TextSource.Static("a\nb\nc"), key = "native.coherence.text") + .apply { + fontSize = 16 + applyStyle { + lineHeight = LineHeightValue.Length(24.px) + } + }.applyParent(root) val tree = DomTree(root) tree.render(nativeMetricsCtx, 320, 160) @@ -617,18 +689,22 @@ class TextLineSpaceReservationBaselineTests { @Test fun `native metrics path keeps ordinary row reservation non-collapsed`() { - val root = ContainerNode(key = "native.reservation.root").apply { - display = Display.Block - width = 260 - } - val row = ContainerNode(key = "native.reservation.row").apply { - display = Display.Block - }.applyParent(root) - TextNode(TextSource.Static("row"), key = "native.reservation.text").apply { - applyStyle { - fontSize = 10.em + val root = + ContainerNode(key = "native.reservation.root").apply { + display = Display.Block + width = 260 } - }.applyParent(row) + val row = + ContainerNode(key = "native.reservation.row") + .apply { + display = Display.Block + }.applyParent(root) + TextNode(TextSource.Static("row"), key = "native.reservation.text") + .apply { + applyStyle { + fontSize = 10.em + } + }.applyParent(row) val tree = DomTree(root) tree.render(nativeMetricsCtx, 260, 120) @@ -644,9 +720,7 @@ class TextLineSpaceReservationBaselineTests { .coerceAtLeast(1) } - private fun expectedNativeNormalLineHeightPx(fontSize: Int): Int { - return ceil(0.9166667f * fontSize).toInt().coerceAtLeast(1) - } + private fun expectedNativeNormalLineHeightPx(fontSize: Int): Int = ceil(0.9166667f * fontSize).toInt().coerceAtLeast(1) private fun invokeProtectedInt(node: DOMNode, methodName: String, ctx: UiMeasureContext): Int { val method = DOMNode::class.java.getDeclaredMethod(methodName, UiMeasureContext::class.java) @@ -659,5 +733,4 @@ class TextLineSpaceReservationBaselineTests { method.isAccessible = true return method.invoke(node, ctx) as Float } - } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextPerformanceHotPathCharacterizationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextPerformanceHotPathCharacterizationTests.kt index e0dd07d..b629c63 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextPerformanceHotPathCharacterizationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/TextPerformanceHotPathCharacterizationTests.kt @@ -1,10 +1,10 @@ package org.dreamfinity.dsgl.core.dom.elements import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.dom.UsedInteractionGeometryResolver import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.support.MeasuredTextRangeWidthSource import org.dreamfinity.dsgl.core.dom.elements.support.TextLayoutEngine -import org.dreamfinity.dsgl.core.dom.UsedInteractionGeometryResolver import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.collectHoverChain import org.dreamfinity.dsgl.core.font.FontRegistry @@ -16,49 +16,43 @@ import org.dreamfinity.dsgl.core.style.Overflow import org.dreamfinity.dsgl.core.style.TextWrap import org.dreamfinity.dsgl.core.text.TextStyleFlags import java.awt.Font -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue +import kotlin.test.* class TextPerformanceHotPathCharacterizationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 10 - - override fun measureText(text: String): Int { - return FontRegistry.measureText(text, FontRegistry.FONT_MINECRAFT, FontRegistry.DEFAULT_FONT_SIZE) - } - - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int { - return FontRegistry.measureText(text, fontId, fontSize) - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 10 + + override fun measureText(text: String): Int = FontRegistry.measureText(text, FontRegistry.FONT_MINECRAFT, FontRegistry.DEFAULT_FONT_SIZE) + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = FontRegistry.measureText(text, fontId, fontSize) + + override fun measureTextRange( + text: String, + startIndex: Int, + endIndexExclusive: Int, + fontId: String?, + fontSize: Int?, + ): Int { + val shaped = + FontRegistry.shapeTextRange( + text = text, + startIndex = startIndex, + endIndexExclusive = endIndexExclusive, + fontId = fontId, + fontSize = fontSize, + formattingMode = "plain", + ) + return shaped.width + .toInt() + .coerceAtLeast(0) + } - override fun measureTextRange( - text: String, - startIndex: Int, - endIndexExclusive: Int, - fontId: String?, - fontSize: Int? - ): Int { - val shaped = FontRegistry.shapeTextRange( - text = text, - startIndex = startIndex, - endIndexExclusive = endIndexExclusive, - fontId = fontId, - fontSize = fontSize, - formattingMode = "plain" - ) - return shaped.width.toInt().coerceAtLeast(0) - } + override fun fontHeight(fontId: String?, fontSize: Int?): Int = FontRegistry.lineHeight(fontId, fontSize) - override fun fontHeight(fontId: String?, fontSize: Int?): Int { - return FontRegistry.lineHeight(fontId, fontSize) + override fun paint(commands: List) = Unit } - override fun paint(commands: List) = Unit - } - @BeforeTest fun resetInstrumentation() { FontRegistry.clearLoadedCache() @@ -94,7 +88,7 @@ class TextPerformanceHotPathCharacterizationTests { val fallbackOnlyCodepoint = findFallbackOnlyCodepoint(primary, fallback) val fallbackOnlyChar = String(Character.toChars(fallbackOnlyCodepoint)) val missingChar = String(Character.toChars(0x10FFFF)) - val text = "CacheProbe-${fallbackOnlyChar}-${missingChar}-X" + val text = "CacheProbe-$fallbackOnlyChar-$missingChar-X" FontRegistry.shapeText(text, FontRegistry.FONT_MINECRAFT, 16, formattingMode = "cache-probe-first") val first = FontRegistry.textHotPathStats() @@ -151,7 +145,13 @@ class TextPerformanceHotPathCharacterizationTests { val optimized = optimizedWrappedLayout(text = text, width = 120, fontSize = 16) assertEquals(legacy.lines.map { it.text }, optimized.lines.map { it.text }) - assertEquals(legacy.lines.map { it.startIndex to it.endIndexExclusive }, optimized.lines.map { it.startIndex to it.endIndexExclusive }) + assertEquals( + legacy.lines.map { it.startIndex to it.endIndexExclusive }, + optimized.lines.map { + it.startIndex to + it.endIndexExclusive + }, + ) assertEquals(legacy.lines.map { it.width }, optimized.lines.map { it.width }) assertEquals(legacy.maxLineWidth, optimized.maxLineWidth) assertEquals(legacy.totalHeight, optimized.totalHeight) @@ -159,16 +159,19 @@ class TextPerformanceHotPathCharacterizationTests { @Test fun `wrapped text node path uses cache-keyed range source instead of unconditional bypass`() { - val root = ContainerNode(key = "text.wrap.root").apply { - display = Display.Block - width = 180 - height = 120 - } - val node = TextNode( - textSource = TextSource.Static( - "Wrapped text baseline path should repeatedly measure many ranges while fitting lines for wrapping." - ), - key = "text.hotpath.wrap" + val root = + ContainerNode(key = "text.wrap.root").apply { + display = Display.Block + width = 180 + height = 120 + } + TextNode( + textSource = + TextSource.Static( + "Wrapped text baseline path should repeatedly measure many ranges " + + "while fitting lines for wrapping.", + ), + key = "text.hotpath.wrap", ).apply { width = 120 fontId = FontRegistry.FONT_MINECRAFT @@ -192,23 +195,24 @@ class TextPerformanceHotPathCharacterizationTests { assertTrue(layoutStats.finalLineTextSubstringCalls > 0) assertEquals( layoutStats.finalLineTextSubstringCalls + - layoutStats.probeMeasureSubstringCalls + - layoutStats.temporarySegmentSubstringCalls, - layoutStats.substringSliceCalls + layoutStats.probeMeasureSubstringCalls + + layoutStats.temporarySegmentSubstringCalls, + layoutStats.substringSliceCalls, ) assertTrue(fontStats.shapeTextRangeCalls > 0) } @Test fun `wrapped button path uses range measurement without fallback substring measurement`() { - val root = ContainerNode(key = "button.wrap.root").apply { - display = Display.Block - width = 220 - height = 120 - } + val root = + ContainerNode(key = "button.wrap.root").apply { + display = Display.Block + width = 220 + height = 120 + } ButtonNode( text = "Button wrapping should use authoritative range measurement over substring-based fallback.", - key = "button.wrap.hotpath" + key = "button.wrap.hotpath", ).apply { width = 140 fontId = FontRegistry.FONT_MINECRAFT @@ -232,14 +236,15 @@ class TextPerformanceHotPathCharacterizationTests { @Test fun `wrapped optimized path limits substring materialization to final line output boundary`() { val text = "Final materialization boundary should keep hot internal probes on index ranges." - val source = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = 16, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) + val source = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = 16, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) TextLayoutEngine.layout( text = text, maxWidth = 90, @@ -247,7 +252,7 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 16), measureText = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, 16) }, measureRange = source::measureRange, - measureRangeCacheKey = source.cacheKey + measureRangeCacheKey = source.cacheKey, ) val stats = TextLayoutEngine.hotPathStats() @@ -256,7 +261,7 @@ class TextPerformanceHotPathCharacterizationTests { assertTrue(stats.finalLineTextSubstringCalls > 0) assertEquals( stats.finalLineTextSubstringCalls + stats.probeMeasureSubstringCalls + stats.temporarySegmentSubstringCalls, - stats.substringSliceCalls + stats.substringSliceCalls, ) } @@ -272,14 +277,14 @@ class TextPerformanceHotPathCharacterizationTests { maxWidth = 90, wrap = TextWrap.Wrap, fontHeight = 14, - measureText = measureText + measureText = measureText, ) TextLayoutEngine.layout( text = text, maxWidth = 90, wrap = TextWrap.Wrap, fontHeight = 14, - measureText = measureText + measureText = measureText, ) val cachedStats = TextLayoutEngine.hotPathStats() @@ -302,7 +307,7 @@ class TextPerformanceHotPathCharacterizationTests { wrap = TextWrap.Wrap, fontHeight = 14, measureText = measureText, - measureRange = measureRange + measureRange = measureRange, ) TextLayoutEngine.layout( text = text, @@ -310,7 +315,7 @@ class TextPerformanceHotPathCharacterizationTests { wrap = TextWrap.Wrap, fontHeight = 14, measureText = measureText, - measureRange = measureRange + measureRange = measureRange, ) val rangeStats = TextLayoutEngine.hotPathStats() @@ -321,14 +326,15 @@ class TextPerformanceHotPathCharacterizationTests { TextLayoutEngine.clearCache() TextLayoutEngine.resetHotPathStats() - val rangeSource = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = 14, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) + val rangeSource = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = 14, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) TextLayoutEngine.layout( text = text, maxWidth = 90, @@ -336,7 +342,7 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = 14, measureText = measureText, measureRange = rangeSource::measureRange, - measureRangeCacheKey = rangeSource.cacheKey + measureRangeCacheKey = rangeSource.cacheKey, ) TextLayoutEngine.layout( text = text, @@ -345,7 +351,7 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = 14, measureText = measureText, measureRange = rangeSource::measureRange, - measureRangeCacheKey = rangeSource.cacheKey + measureRangeCacheKey = rangeSource.cacheKey, ) val keyedStats = TextLayoutEngine.hotPathStats() assertEquals(0, keyedStats.cacheBypassedForRangeMeasure) @@ -361,49 +367,54 @@ class TextPerformanceHotPathCharacterizationTests { val measureText: (String) -> Int = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, 14) } - val source14 = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = 14, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) - val source18 = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = 18, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) - val first = TextLayoutEngine.layout( - text = text, - maxWidth = 90, - wrap = TextWrap.Wrap, - fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 14), - measureText = measureText, - measureRange = source14::measureRange, - measureRangeCacheKey = source14.cacheKey - ) - val second = TextLayoutEngine.layout( - text = text, - maxWidth = 90, - wrap = TextWrap.Wrap, - fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 18), - measureText = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, 18) }, - measureRange = source18::measureRange, - measureRangeCacheKey = source18.cacheKey - ) - val differentWidth = TextLayoutEngine.layout( - text = text, - maxWidth = 70, - wrap = TextWrap.Wrap, - fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 14), - measureText = measureText, - measureRange = source14::measureRange, - measureRangeCacheKey = source14.cacheKey - ) + val source14 = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = 14, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) + val source18 = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = 18, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) + val first = + TextLayoutEngine.layout( + text = text, + maxWidth = 90, + wrap = TextWrap.Wrap, + fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 14), + measureText = measureText, + measureRange = source14::measureRange, + measureRangeCacheKey = source14.cacheKey, + ) + val second = + TextLayoutEngine.layout( + text = text, + maxWidth = 90, + wrap = TextWrap.Wrap, + fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 18), + measureText = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, 18) }, + measureRange = source18::measureRange, + measureRangeCacheKey = source18.cacheKey, + ) + val differentWidth = + TextLayoutEngine.layout( + text = text, + maxWidth = 70, + wrap = TextWrap.Wrap, + fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, 14), + measureText = measureText, + measureRange = source14::measureRange, + measureRangeCacheKey = source14.cacheKey, + ) val stats = TextLayoutEngine.hotPathStats() assertTrue(stats.cacheMisses >= 3) @@ -413,7 +424,9 @@ class TextPerformanceHotPathCharacterizationTests { @Test fun `optimized wrapped path reduces structural hot-path work versus legacy two-pass baseline`() { - val text = "Structural hot-path reduction should be visible when wrapped layout avoids legacy per-pass repeated range shaping." + val text = + "Structural hot-path reduction should be visible when wrapped layout avoids " + + "legacy per-pass repeated range shaping." val legacyStats = runLegacyTwoPassLayout(text = text, width = 120, fontSize = 16) val optimizedStats = runOptimizedTwoPassLayout(text = text, width = 120, fontSize = 16) @@ -427,28 +440,35 @@ class TextPerformanceHotPathCharacterizationTests { @Test fun `small scroll-heavy text scenario keeps semantics and reduces wrapped-measure work`() { - val root = ContainerNode(key = "scroll-hot-root").apply { - display = Display.Block - width = 300 - height = 120 - overflowY = Overflow.Scroll - } - val content = ContainerNode(key = "scroll-hot-content").apply { - display = Display.Flex - flexDirection = FlexDirection.Column - width = 300 - }.applyParent(root) + val root = + ContainerNode(key = "scroll-hot-root").apply { + display = Display.Block + width = 300 + height = 120 + overflowY = Overflow.Scroll + } + val content = + ContainerNode(key = "scroll-hot-content") + .apply { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 300 + }.applyParent(root) repeat(10) { index -> - val row = ContainerNode(key = "scroll-hot-row-$index").apply { - display = Display.Block - width = 280 - }.applyParent(content) + val row = + ContainerNode(key = "scroll-hot-row-$index") + .apply { + display = Display.Block + width = 280 + }.applyParent(content) TextNode( - textSource = TextSource.Static( - "Row $index wraps repeatedly to characterize current range-based text measurement under scroll-heavy updates." - ), - key = "scroll-hot-text-$index" + textSource = + TextSource.Static( + "Row $index wraps repeatedly to characterize current range-based text " + + "measurement under scroll-heavy updates.", + ), + key = "scroll-hot-text-$index", ).apply { width = 260 fontId = FontRegistry.FONT_MINECRAFT @@ -477,18 +497,20 @@ class TextPerformanceHotPathCharacterizationTests { assertTrue(shapeCallsDelta >= 0) assertTrue( rangeCallsDelta < layoutBeforeScroll.rangeMeasureCalls, - "Scroll update should not repeat the full initial wrapped range-measure workload" + "Scroll update should not repeat the full initial wrapped range-measure workload", ) assertTrue( shapeCallsDelta < fontBeforeScroll.shapeTextRangeCalls, - "Scroll update should not repeat full wrapped range shaping workload" + "Scroll update should not repeat full wrapped range shaping workload", ) assertTrue(shapeCacheAfterScroll.requests > shapeCacheBeforeScroll.requests) } @Test fun `wrapped layout follow-through uses probing cache without changing line results`() { - val text = "Wrapped probing follow-through should keep the same line results while reducing repeated expensive probing." + val text = + "Wrapped probing follow-through should keep the same line results while reducing " + + "repeated expensive probing." val firstLayout = wrappedLayoutForProbing(text = text, width = 120, fontSize = 16, cacheSalt = "first") val first = FontRegistry.textHotPathStats() assertTrue(first.canDisplayAwtCalls > 0) @@ -499,7 +521,13 @@ class TextPerformanceHotPathCharacterizationTests { val secondLayout = wrappedLayoutForProbing(text = text, width = 120, fontSize = 16, cacheSalt = "second") val second = FontRegistry.textHotPathStats() assertEquals(firstLayout.lines.map { it.text }, secondLayout.lines.map { it.text }) - assertEquals(firstLayout.lines.map { it.startIndex to it.endIndexExclusive }, secondLayout.lines.map { it.startIndex to it.endIndexExclusive }) + assertEquals( + firstLayout.lines.map { it.startIndex to it.endIndexExclusive }, + secondLayout.lines.map { + it.startIndex to + it.endIndexExclusive + }, + ) assertEquals(firstLayout.lines.map { it.width }, secondLayout.lines.map { it.width }) assertEquals(firstLayout.maxLineWidth, secondLayout.maxLineWidth) assertEquals(firstLayout.totalHeight, secondLayout.totalHeight) @@ -510,20 +538,22 @@ class TextPerformanceHotPathCharacterizationTests { @Test fun `interaction and inspector picking remain aligned for wrapped text node`() { - val root = ContainerNode(key = "interaction-root").apply { - display = Display.Block - width = 240 - height = 120 - } - val text = TextNode( - textSource = TextSource.Static("Wrapped interaction smoke test for optimized text path."), - key = "interaction-text" - ).apply { - width = 120 - fontId = FontRegistry.FONT_MINECRAFT - fontSize = 16 - textWrap = TextWrap.Wrap - }.applyParent(root) + val root = + ContainerNode(key = "interaction-root").apply { + display = Display.Block + width = 240 + height = 120 + } + val text = + TextNode( + textSource = TextSource.Static("Wrapped interaction smoke test for optimized text path."), + key = "interaction-text", + ).apply { + width = 120 + fontId = FontRegistry.FONT_MINECRAFT + fontSize = 16 + textWrap = TextWrap.Wrap + }.applyParent(root) val tree = DomTree(root) tree.render(ctx, 240, 120) @@ -543,7 +573,7 @@ class TextPerformanceHotPathCharacterizationTests { private data class ProfileSnapshot( val layoutStats: TextLayoutEngine.HotPathStats, val fontStats: FontRegistry.TextHotPathStats, - val rangeSubstringCalls: Long + val rangeSubstringCalls: Long, ) private fun runLegacyTwoPassLayout(text: String, width: Int, fontSize: Int): ProfileSnapshot { @@ -570,13 +600,13 @@ class TextPerformanceHotPathCharacterizationTests { wrap = TextWrap.Wrap, fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, fontSize), measureText = measureText, - measureRange = measureRange + measureRange = measureRange, ) } return ProfileSnapshot( layoutStats = TextLayoutEngine.hotPathStats(), fontStats = FontRegistry.textHotPathStats(), - rangeSubstringCalls = rangeSubstringCalls + rangeSubstringCalls = rangeSubstringCalls, ) } @@ -590,14 +620,15 @@ class TextPerformanceHotPathCharacterizationTests { val measureText: (String) -> Int = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, fontSize) } - val rangeSource = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = fontSize, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) + val rangeSource = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = fontSize, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) repeat(2) { TextLayoutEngine.layout( text = text, @@ -606,18 +637,18 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, fontSize), measureText = measureText, measureRange = rangeSource::measureRange, - measureRangeCacheKey = rangeSource.cacheKey + measureRangeCacheKey = rangeSource.cacheKey, ) } return ProfileSnapshot( layoutStats = TextLayoutEngine.hotPathStats(), fontStats = FontRegistry.textHotPathStats(), - rangeSubstringCalls = 0L + rangeSubstringCalls = 0L, ) } - private fun legacyWrappedLayout(text: String, width: Int, fontSize: Int): TextLayoutEngine.Layout { - return TextLayoutEngine.layout( + private fun legacyWrappedLayout(text: String, width: Int, fontSize: Int): TextLayoutEngine.Layout = + TextLayoutEngine.layout( text = text, maxWidth = width, wrap = TextWrap.Wrap, @@ -627,19 +658,19 @@ class TextPerformanceHotPathCharacterizationTests { val safeStart = start.coerceIn(0, text.length) val safeEnd = end.coerceIn(safeStart, text.length) ctx.measureText(text.substring(safeStart, safeEnd), FontRegistry.FONT_MINECRAFT, fontSize) - } + }, ) - } private fun optimizedWrappedLayout(text: String, width: Int, fontSize: Int): TextLayoutEngine.Layout { - val source = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = fontSize, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) + val source = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = fontSize, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) return TextLayoutEngine.layout( text = text, maxWidth = width, @@ -647,7 +678,7 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, fontSize), measureText = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, fontSize) }, measureRange = source::measureRange, - measureRangeCacheKey = source.cacheKey + measureRangeCacheKey = source.cacheKey, ) } @@ -655,17 +686,18 @@ class TextPerformanceHotPathCharacterizationTests { text: String, width: Int, fontSize: Int, - cacheSalt: String + cacheSalt: String, ): TextLayoutEngine.Layout { TextLayoutEngine.clearCache() - val source = MeasuredTextRangeWidthSource( - plainText = text, - fontId = FontRegistry.FONT_MINECRAFT, - fontSizePx = fontSize, - baseFlags = baseTextFlags(), - spans = emptyList(), - ctx = ctx - ) + val source = + MeasuredTextRangeWidthSource( + plainText = text, + fontId = FontRegistry.FONT_MINECRAFT, + fontSizePx = fontSize, + baseFlags = baseTextFlags(), + spans = emptyList(), + ctx = ctx, + ) return TextLayoutEngine.layout( text = text, maxWidth = width, @@ -673,40 +705,41 @@ class TextPerformanceHotPathCharacterizationTests { fontHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, fontSize), measureText = { value -> ctx.measureText(value, FontRegistry.FONT_MINECRAFT, fontSize) }, measureRange = source::measureRange, - measureRangeCacheKey = source.cacheKey to cacheSalt + measureRangeCacheKey = source.cacheKey to cacheSalt, ) } - private fun baseTextFlags() = TextStyleFlags( - bold = false, - italic = false, - underline = false, - strikethrough = false, - obfuscated = false - ) + private fun baseTextFlags() = + TextStyleFlags( + bold = false, + italic = false, + underline = false, + strikethrough = false, + obfuscated = false, + ) private fun findFallbackOnlyCodepoint(primary: Font?, fallback: Font?): Int { requireNotNull(primary) { "Primary font AWT handle must be available for hot-path characterization" } requireNotNull(fallback) { "Fallback font AWT handle must be available for hot-path characterization" } - val candidateCodepoints = listOf( - 0x1F642, // 🙂 - 0x0E01, // Thai - 0x0531, // Armenian - 0x05D0, // Hebrew - 0x16A0 // Runic - ) - candidateCodepoints.firstOrNull { cp -> - Character.isValidCodePoint(cp) && !primary.canDisplay(cp) && fallback.canDisplay(cp) - }?.let { return it } - - for (cp in 0x20..0x2FFF) { - if (!Character.isValidCodePoint(cp)) continue - if (cp in 0xD800..0xDFFF) continue - if (!primary.canDisplay(cp) && fallback.canDisplay(cp)) { - return cp - } - } - error("Could not find deterministic fallback-only codepoint for characterization test") + val candidateCodepoints = + listOf( + 0x1F642, // 🙂 + 0x0E01, // Thai + 0x0531, // Armenian + 0x05D0, // Hebrew + 0x16A0, // Runic + ) + candidateCodepoints + .firstOrNull { cp -> + Character.isValidCodePoint(cp) && !primary.canDisplay(cp) && fallback.canDisplay(cp) + }?.let { return it } + + return (0x20..0x2FFF).firstOrNull { cp -> + Character.isValidCodePoint(cp) && + cp !in 0xD800..0xDFFF && + !primary.canDisplay(cp) && + fallback.canDisplay(cp) + } ?: error("Could not find deterministic fallback-only codepoint for characterization test") } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNodeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNodeTests.kt index 5fb97be..bc9f95c 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNodeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/elements/ToggleNodeTests.kt @@ -1,11 +1,8 @@ package org.dreamfinity.dsgl.core.dom.elements -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertSame -import kotlin.test.assertTrue +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dsl.toggle +import org.dreamfinity.dsgl.core.dsl.ui import org.dreamfinity.dsgl.core.event.EventBus import org.dreamfinity.dsgl.core.event.FocusManager import org.dreamfinity.dsgl.core.event.KeyCodes @@ -13,19 +10,27 @@ import org.dreamfinity.dsgl.core.event.KeyboardKeyDownEvent import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.event.MouseClickEvent import org.dreamfinity.dsgl.core.event.ValueChangedEvent -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.dsl.toggle import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.dsl.ui +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue class ToggleNodeTests { - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -91,29 +96,35 @@ class ToggleNodeTests { @Test fun `uncontrolled toggle keeps value after reconcile`() { - val current = ui { - toggle({ - key = "toggle.reconcile" - defaultChecked = false - }) - } + val current = + ui { + toggle({ + key = "toggle.reconcile" + defaultChecked = false + }) + } current.render(ctx, 80, 40) - val retainedBefore = current.root.children.single() as ToggleNode + val retainedBefore = + current.root.children + .single() as ToggleNode val click = MouseClickEvent(mouseX = 6, mouseY = 6, mouseButton = MouseButton.LEFT) click.target = retainedBefore EventBus.post(click) assertTrue(retainedBefore.isChecked()) - val next = ui { - toggle({ - key = "toggle.reconcile" - defaultChecked = false - }) - } + val next = + ui { + toggle({ + key = "toggle.reconcile" + defaultChecked = false + }) + } current.reconcileWith(next) - val retainedAfter = current.root.children.single() as ToggleNode + val retainedAfter = + current.root.children + .single() as ToggleNode assertSame(retainedBefore, retainedAfter) assertTrue(retainedAfter.isChecked()) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/ColorPickerCustomNodeReconcileTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/ColorPickerCustomNodeReconcileTests.kt new file mode 100644 index 0000000..d6cd2eb --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/ColorPickerCustomNodeReconcileTests.kt @@ -0,0 +1,129 @@ +package org.dreamfinity.dsgl.core.dom.reconcile + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.colorpicker.internal.AlphaSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorFieldSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.ColorSwatchSurfaceNode +import org.dreamfinity.dsgl.core.colorpicker.internal.HueSurfaceNode +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +class ColorPickerCustomNodeReconcileTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @Test + fun `color field custom bind state syncs on reconcile reuse`() { + val styleA = ColorPickerStyle(inputBorderColor = 0xFF334455.toInt()) + val styleB = ColorPickerStyle(inputBorderColor = 0xFF7799AA.toInt()) + val currentRoot = ContainerNode(key = "root") + val retained = ColorFieldSurfaceNode(key = "field").applyParent(currentRoot) + retained.bind(style = styleA, color = RgbaColor(1f, 0f, 0f, 1f), hueDeg = 0f) + val before = renderCommands(retained) + + val templateRoot = ContainerNode(key = "root") + val template = ColorFieldSurfaceNode(key = "field").applyParent(templateRoot) + template.bind(style = styleB, color = RgbaColor(0f, 1f, 0f, 1f), hueDeg = 120f) + val expected = renderCommands(template) + assertNotEquals(before, expected) + + DomReconciler.reconcile(currentRoot, templateRoot) + val retainedAfter = currentRoot.children.single() as ColorFieldSurfaceNode + val after = renderCommands(retainedAfter) + + assertSame(retained, retainedAfter) + assertEquals(expected, after) + } + + @Test + fun `hue slider custom bind state syncs on reconcile reuse`() { + val style = ColorPickerStyle() + val currentRoot = ContainerNode(key = "root") + val retained = HueSurfaceNode(key = "hue").applyParent(currentRoot) + retained.bind(style = style, hueDeg = 12f) + val before = renderCommands(retained) + + val templateRoot = ContainerNode(key = "root") + val template = HueSurfaceNode(key = "hue").applyParent(templateRoot) + template.bind(style = style, hueDeg = 222f) + val expected = renderCommands(template) + assertNotEquals(before, expected) + + DomReconciler.reconcile(currentRoot, templateRoot) + val retainedAfter = currentRoot.children.single() as HueSurfaceNode + val after = renderCommands(retainedAfter) + + assertSame(retained, retainedAfter) + assertEquals(expected, after) + } + + @Test + fun `alpha slider custom bind state syncs on reconcile reuse`() { + val style = ColorPickerStyle() + val currentRoot = ContainerNode(key = "root") + val retained = AlphaSurfaceNode(key = "alpha").applyParent(currentRoot) + retained.bind(style = style, color = RgbaColor(0.5f, 0.4f, 0.3f, 1f)) + val before = renderCommands(retained) + + val templateRoot = ContainerNode(key = "root") + val template = AlphaSurfaceNode(key = "alpha").applyParent(templateRoot) + template.bind(style = style, color = RgbaColor(0.5f, 0.4f, 0.3f, 0.2f)) + val expected = renderCommands(template) + assertNotEquals(before, expected) + + DomReconciler.reconcile(currentRoot, templateRoot) + val retainedAfter = currentRoot.children.single() as AlphaSurfaceNode + val after = renderCommands(retainedAfter) + + assertSame(retained, retainedAfter) + assertEquals(expected, after) + } + + @Test + fun `color swatch custom bind state syncs on reconcile reuse`() { + val style = ColorPickerStyle() + val currentRoot = ContainerNode(key = "root") + val retained = ColorSwatchSurfaceNode(key = "swatch").applyParent(currentRoot) + retained.bind(style = style, color = RgbaColor(1f, 0f, 0f, 1f), highlighted = false) + val before = renderCommands(retained) + + val templateRoot = ContainerNode(key = "root") + val template = ColorSwatchSurfaceNode(key = "swatch").applyParent(templateRoot) + template.bind(style = style, color = RgbaColor(0f, 0f, 1f, 1f), highlighted = true) + val expected = renderCommands(template) + assertNotEquals(before, expected) + + DomReconciler.reconcile(currentRoot, templateRoot) + val retainedAfter = currentRoot.children.single() as ColorSwatchSurfaceNode + val after = renderCommands(retainedAfter) + + assertSame(retained, retainedAfter) + assertEquals(expected, after) + } + + private fun renderCommands(node: DOMNode): List { + node.render(ctx, NODE_RECT.x, NODE_RECT.y, NODE_RECT.width, NODE_RECT.height) + return buildList { + node.buildRenderCommands(ctx, this) + } + } + + private companion object { + val NODE_RECT: Rect = Rect(12, 24, 56, 14) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/TextSourceReconcileTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/TextSourceReconcileTests.kt index 02d8ba8..f870cfb 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/TextSourceReconcileTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/dom/reconcile/TextSourceReconcileTests.kt @@ -1,7 +1,7 @@ package org.dreamfinity.dsgl.core.dom.reconcile -import org.dreamfinity.dsgl.core.dsl.TextProps import org.dreamfinity.dsgl.core.dom.elements.TextNode +import org.dreamfinity.dsgl.core.dsl.TextProps import org.dreamfinity.dsgl.core.dsl.text import org.dreamfinity.dsgl.core.dsl.ui import kotlin.test.Test @@ -25,20 +25,30 @@ class TextSourceReconcileTests { @Test fun `dynamic text updates on reconcile without replacing text node`() { var counter = 0 - val current = ui { - text("Count=$counter") - } - val retainedBefore = assertIs(current.root.children.single()) + val current = + ui { + text("Count=$counter") + } + val retainedBefore = + assertIs( + current.root.children + .single(), + ) assertEquals("Count=0", retainedBefore.text) counter = 7 - val next = ui { - text("Count=$counter") - } + val next = + ui { + text("Count=$counter") + } current.reconcileWith(next) - val retainedAfter = assertIs(current.root.children.single()) + val retainedAfter = + assertIs( + current.root.children + .single(), + ) assertSame(retainedBefore, retainedAfter) assertEquals("Count=7", retainedAfter.text) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/event/TransformHitTestTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/event/TransformHitTestTests.kt index b5e2b81..81bb0ff 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/event/TransformHitTestTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/event/TransformHitTestTests.kt @@ -10,11 +10,14 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class TransformHitTestTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @Test fun `hover chain follows translated transform`() { diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/FontDiscoveryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/FontDiscoveryTests.kt index 0b9f114..b96ba5a 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/FontDiscoveryTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/FontDiscoveryTests.kt @@ -13,25 +13,29 @@ class FontDiscoveryTests { fun `fontId mapping preserves subdirectories`() { assertEquals("Minecraft", FontDiscovery.fontIdFromRelativeTtfPath("Minecraft.ttf")) assertEquals("ui/Ubuntu", FontDiscovery.fontIdFromRelativeTtfPath("ui/Ubuntu.ttf")) - assertEquals("noto/Noto_Sans/NotoSans-Regular", FontDiscovery.fontIdFromRelativeTtfPath("noto\\Noto_Sans\\NotoSans-Regular.ttf")) + assertEquals( + "noto/Noto_Sans/NotoSans-Regular", + FontDiscovery.fontIdFromRelativeTtfPath("noto\\Noto_Sans\\NotoSans-Regular.ttf"), + ) } @Test fun `generated index parsing keeps only ttf entries`() { - val parsed = FontDiscovery.parseGeneratedFontIndex( - """ - minecraft/MinecraftDefault-Regular.ttf - not-a-font.txt + val parsed = + FontDiscovery.parseGeneratedFontIndex( + """ + minecraft/MinecraftDefault-Regular.ttf + not-a-font.txt - ubuntu/Ubuntu-Regular.ttf - """.trimIndent() - ) + ubuntu/Ubuntu-Regular.ttf + """.trimIndent(), + ) assertEquals( listOf( "minecraft/MinecraftDefault-Regular.ttf", - "ubuntu/Ubuntu-Regular.ttf" + "ubuntu/Ubuntu-Regular.ttf", ), - parsed + parsed, ) } @@ -65,10 +69,11 @@ class FontDiscoveryTests { @Test fun `external source overrides jar for same font id`() { - val prioritized = FontDiscovery.resolveSourcePriority( - jarFontIds = listOf("minecraft/MinecraftDefault-Regular", "ui/Ubuntu"), - externalFontIds = listOf("ui/Ubuntu") - ) + val prioritized = + FontDiscovery.resolveSourcePriority( + jarFontIds = listOf("minecraft/MinecraftDefault-Regular", "ui/Ubuntu"), + externalFontIds = listOf("ui/Ubuntu"), + ) assertEquals(FontAssetSource.Jar, prioritized["minecraft/MinecraftDefault-Regular"]) assertEquals(FontAssetSource.External, prioritized["ui/Ubuntu"]) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontTests.kt index 3cb4740..a24e0fd 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/font/MsdfFontTests.kt @@ -28,7 +28,8 @@ class MsdfFontTests { @Test fun `parser ignores unknown keys and preserves glyph fields`() { - val raw = """ + val raw = + """ { "atlas": { "type": "mtsdf", @@ -58,7 +59,7 @@ class MsdfFontTests { "kerning": [], "unknownTopLevel": { "nested": 1 } } - """.trimIndent() + """.trimIndent() val meta = MsdfFontMetaParser.parse(raw) val glyph = meta.glyphByIndex(65) @@ -70,7 +71,8 @@ class MsdfFontTests { @Test fun `parser handles missing optional fields`() { - val raw = """ + val raw = + """ { "atlas": { "type": "mtsdf", @@ -90,7 +92,7 @@ class MsdfFontTests { { "index": 63, "advance": 0.5 } ] } - """.trimIndent() + """.trimIndent() val meta = MsdfFontMetaParser.parse(raw) val glyph = meta.glyphByIndex(63) @@ -101,7 +103,8 @@ class MsdfFontTests { @Test fun `parser resolves codepoint from unicode and char fields`() { - val raw = """ + val raw = + """ { "atlas": { "type": "mtsdf", "distanceRange": 4, "size": 32, "width": 64, "height": 64, "yOrigin": "bottom" }, "metrics": { "emSize": 1, "lineHeight": 1.1, "ascender": 0.8, "descender": -0.2 }, @@ -110,7 +113,7 @@ class MsdfFontTests { { "index": 66, "char": "\uD83D\uDE00", "advance": 0.7 } ] } - """.trimIndent() + """.trimIndent() val meta = MsdfFontMetaParser.parse(raw) assertNotNull(meta.glyph(65)) @@ -122,7 +125,7 @@ class MsdfFontTests { assertEquals(listOf(72, 101, 108, 108, 111), "Hello".toCodepointList()) assertEquals( listOf(0x041F, 0x0440, 0x0438, 0x0432, 0x0435, 0x0442), - "\u041F\u0440\u0438\u0432\u0435\u0442".toCodepointList() + "\u041F\u0440\u0438\u0432\u0435\u0442".toCodepointList(), ) assertEquals(listOf(0x1F600), "\uD83D\uDE00".toCodepointList()) } @@ -171,9 +174,10 @@ class MsdfFontTests { @Test fun `missing glyph in both fonts falls back without throwing`() { val missing = String(Character.toChars(0x10FFFF)) - val shaped = runCatching { - FontRegistry.shapeText(missing, FontRegistry.FONT_MINECRAFT, 16) - } + val shaped = + runCatching { + FontRegistry.shapeText(missing, FontRegistry.FONT_MINECRAFT, 16) + } assertTrue(shaped.isSuccess) assertTrue((shaped.getOrNull()?.width ?: -1f) >= 0f) } @@ -246,12 +250,24 @@ class MsdfFontTests { FontRegistry.clearLoadedCache() FontRegistry.resetShapeCacheStats() FontRegistry.resetTextHotPathStats() - val cold = FontRegistry.shapeText(mixed, FontRegistry.FONT_MINECRAFT, 16, formattingMode = "probe-semantic-cold") + val cold = + FontRegistry.shapeText( + mixed, + FontRegistry.FONT_MINECRAFT, + 16, + formattingMode = "probe-semantic-cold", + ) val coldStats = FontRegistry.textHotPathStats() assertTrue(coldStats.requiresReplacementGlyphEvaluations > 0) FontRegistry.resetTextHotPathStats() - val warm = FontRegistry.shapeText(mixed, FontRegistry.FONT_MINECRAFT, 16, formattingMode = "probe-semantic-warm") + val warm = + FontRegistry.shapeText( + mixed, + FontRegistry.FONT_MINECRAFT, + 16, + formattingMode = "probe-semantic-warm", + ) val warmStats = FontRegistry.textHotPathStats() assertTrue(warmStats.requiresReplacementGlyphCacheHits > 0) @@ -268,13 +284,14 @@ class MsdfFontTests { val lineHeight = FontRegistry.lineHeight(FontRegistry.FONT_MINECRAFT, fontSize) val text = "MSDF wrapping should keep lines within container width and avoid overlap for long text runs." - val layout = TextLayoutEngine.layout( - text = text, - maxWidth = maxWidth, - wrap = TextWrap.Wrap, - fontHeight = lineHeight, - measureText = { value -> FontRegistry.measureText(value, FontRegistry.FONT_MINECRAFT, fontSize) } - ) + val layout = + TextLayoutEngine.layout( + text = text, + maxWidth = maxWidth, + wrap = TextWrap.Wrap, + fontHeight = lineHeight, + measureText = { value -> FontRegistry.measureText(value, FontRegistry.FONT_MINECRAFT, fontSize) }, + ) assertTrue(layout.lines.isNotEmpty()) assertTrue(layout.lines.all { it.width <= maxWidth + 1 }) @@ -286,20 +303,22 @@ class MsdfFontTests { val source = "A\uD83D\uDE00B\u0416C" val start = source.indexOf('B') val end = source.length - val range = FontRegistry.shapeTextRange( - text = source, - startIndex = start, - endIndexExclusive = end, - fontId = FontRegistry.FONT_MINECRAFT, - fontSize = 16, - formattingMode = "plain" - ) - val plain = FontRegistry.shapeText( - text = source.substring(start, end), - fontId = FontRegistry.FONT_MINECRAFT, - fontSize = 16, - formattingMode = "plain" - ) + val range = + FontRegistry.shapeTextRange( + text = source, + startIndex = start, + endIndexExclusive = end, + fontId = FontRegistry.FONT_MINECRAFT, + fontSize = 16, + formattingMode = "plain", + ) + val plain = + FontRegistry.shapeText( + text = source.substring(start, end), + fontId = FontRegistry.FONT_MINECRAFT, + fontSize = 16, + formattingMode = "plain", + ) assertEquals(plain.glyphs.size, range.glyphs.size) assertEquals(plain.runs.size, range.runs.size) assertTrue(abs(plain.width - range.width) <= 0.01f) @@ -316,22 +335,24 @@ class MsdfFontTests { @Test fun `shapeTextRange returns empty for empty or inverted range`() { val source = "Hello" - val empty = FontRegistry.shapeTextRange( - text = source, - startIndex = 2, - endIndexExclusive = 2, - fontId = FontRegistry.FONT_MINECRAFT, - fontSize = 14, - formattingMode = "plain" - ) - val inverted = FontRegistry.shapeTextRange( - text = source, - startIndex = 4, - endIndexExclusive = 1, - fontId = FontRegistry.FONT_MINECRAFT, - fontSize = 14, - formattingMode = "plain" - ) + val empty = + FontRegistry.shapeTextRange( + text = source, + startIndex = 2, + endIndexExclusive = 2, + fontId = FontRegistry.FONT_MINECRAFT, + fontSize = 14, + formattingMode = "plain", + ) + val inverted = + FontRegistry.shapeTextRange( + text = source, + startIndex = 4, + endIndexExclusive = 1, + fontId = FontRegistry.FONT_MINECRAFT, + fontSize = 14, + formattingMode = "plain", + ) assertTrue(empty.glyphs.isEmpty()) assertTrue(inverted.glyphs.isEmpty()) assertEquals(0f, empty.width) @@ -346,10 +367,11 @@ class MsdfFontTests { image.setRGB(0, 1, 0xFF0000FF.toInt()) image.setRGB(1, 1, 0xFFFFFFFF.toInt()) - val pngBytes = ByteArrayOutputStream().use { output -> - ImageIO.write(image, "png", output) - output.toByteArray() - } + val pngBytes = + ByteArrayOutputStream().use { output -> + ImageIO.write(image, "png", output) + output.toByteArray() + } val decoded = AtlasPayload(pngBytes).ensureDecoded() assertEquals(2, decoded.width) assertEquals(2, decoded.height) @@ -371,26 +393,32 @@ class MsdfFontTests { @Test fun `deflated atlas decode path remains supported`() { - val expected = byteArrayOf( - 0x01, 0x02, 0x03, 0x04 - ) - val rawPayload = ByteArrayOutputStream().use { rawOut -> - DataOutputStream(rawOut).use { data -> - data.writeInt(0x4453474C) - data.writeInt(1) - data.writeInt(1) - data.write(expected) + val expected = + byteArrayOf( + 0x01, + 0x02, + 0x03, + 0x04, + ) + val rawPayload = + ByteArrayOutputStream().use { rawOut -> + DataOutputStream(rawOut).use { data -> + data.writeInt(0x4453474C) + data.writeInt(1) + data.writeInt(1) + data.write(expected) + } + rawOut.toByteArray() } - rawOut.toByteArray() - } - val deflated = ByteArrayOutputStream().use { compressedOut -> - val deflater = Deflater(Deflater.BEST_SPEED, true) - DeflaterOutputStream(compressedOut, deflater).use { zipOut -> - zipOut.write(rawPayload) + val deflated = + ByteArrayOutputStream().use { compressedOut -> + val deflater = Deflater(Deflater.BEST_SPEED, true) + DeflaterOutputStream(compressedOut, deflater).use { zipOut -> + zipOut.write(rawPayload) + } + deflater.end() + compressedOut.toByteArray() } - deflater.end() - compressedOut.toByteArray() - } val decoded = AtlasPayload(deflated).ensureDecoded() assertEquals(1, decoded.width) @@ -398,16 +426,13 @@ class MsdfFontTests { assertTrue(decoded.rgbaBytes.contentEquals(expected)) } - private fun findFallbackOnlyCodepoint(primary: Font, fallback: Font): Int? { - for (cp in 0x20..0x10FFFF) { - if (!Character.isValidCodePoint(cp)) continue - if (cp in 0xD800..0xDFFF) continue - if (!primary.canDisplay(cp) && fallback.canDisplay(cp)) { - return cp - } + private fun findFallbackOnlyCodepoint(primary: Font, fallback: Font): Int? = + (0x20..0x10FFFF).firstOrNull { cp -> + Character.isValidCodePoint(cp) && + cp !in 0xD800..0xDFFF && + !primary.canDisplay(cp) && + fallback.canDisplay(cp) } - return null - } private fun loadResource(path: String): String { val stream = javaClass.classLoader.getResourceAsStream(path) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntimeTests.kt index 1797f75..7889fbe 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntimeTests.kt @@ -6,8 +6,8 @@ import kotlin.ExperimentalStdlibApi import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail @@ -118,9 +118,10 @@ class ComponentHookRuntimeTests { render(runtime, owner) { resolveNamedEntry(HookEntryKind.State, "count") { 0 } - val error = assertFailsWith { - resolveNamedEntry(HookEntryKind.State, "count") { 0 } - } + val error = + assertFailsWith { + resolveNamedEntry(HookEntryKind.State, "count") { 0 } + } assertTrue(error.message?.contains("Duplicate hook path") == true) } } @@ -131,9 +132,10 @@ class ComponentHookRuntimeTests { val owner = Any() render(runtime, owner) { - val error = assertFailsWith { - resolveDuplicateHookPathProbe() - } + val error = + assertFailsWith { + resolveDuplicateHookPathProbe() + } assertEquals(error.message?.contains("Duplicate hook path"), true) } } @@ -149,9 +151,10 @@ class ComponentHookRuntimeTests { assertTrue(first.created) first.entry.value = 5 - val error = assertFailsWith { - resolveStateHookFromHelper("count") - } + val error = + assertFailsWith { + resolveStateHookFromHelper("count") + } assertEquals(error.message?.contains("Duplicate hook path"), true) } } @@ -175,9 +178,10 @@ class ComponentHookRuntimeTests { } render(runtime, owner) { - val error = assertFailsWith { - resolveNamedEntry(HookEntryKind.State, "shared") { 0 } - } + val error = + assertFailsWith { + resolveNamedEntry(HookEntryKind.State, "shared") { 0 } + } assertTrue(error.message?.contains("Hook kind mismatch") == true) } } @@ -212,9 +216,10 @@ class ComponentHookRuntimeTests { render(runtime, owner) { resolveNamedEntry(HookEntryKind.State, "useEffect#0") { 0 } - val error = assertFailsWith { - resolveUnnamedEntry(HookEntryKind.Custom, "useEffect") { Unit } - } + val error = + assertFailsWith { + resolveUnnamedEntry(HookEntryKind.Custom, "useEffect") { Unit } + } assertTrue(error.message?.contains("Synthetic hook key collision") == true) } } @@ -224,15 +229,17 @@ class ComponentHookRuntimeTests { val runtime = ComponentHookRuntime() val owner = Any() - val outsideRender = assertFailsWith { - runtime.enterCustomHookScope("counter") - } + val outsideRender = + assertFailsWith { + runtime.enterCustomHookScope("counter") + } assertTrue(outsideRender.message?.contains("outside active component render") == true) render(runtime, owner) { - val noScope = assertFailsWith { - leaveCustomHookScope() - } + val noScope = + assertFailsWith { + leaveCustomHookScope() + } assertTrue(noScope.message?.contains("no active custom hook scope") == true) } } @@ -244,30 +251,32 @@ class ComponentHookRuntimeTests { val owner = Any() render(runtime, owner) { - val value = resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = "counter", - signature = HookSignatures.state(typeOf()), - expectedRawType = CounterHolder::class.java - ) { - CounterHolder(0) - } - value.value.count = 7 - } - - val error = assertFailsWith { - render(runtime, owner) { + val value = resolveNamedTypedEntry( kind = HookEntryKind.State, delegateName = "counter", - signature = HookSignatures.state(typeOf()), - expectedRawType = CounterHolder::class.java + signature = HookSignatures.state(typeOf()), + expectedRawType = CounterHolder::class.java, ) { CounterHolder(0) } - } + value.value.count = 7 } + val error = + assertFailsWith { + render(runtime, owner) { + resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = "counter", + signature = HookSignatures.state(typeOf()), + expectedRawType = CounterHolder::class.java, + ) { + CounterHolder(0) + } + } + } + assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("counter") == true) } @@ -283,24 +292,25 @@ class ComponentHookRuntimeTests { kind = HookEntryKind.Ref, delegateName = "inputRef", signature = HookSignatures.ref(typeOf()), - expectedRawType = Ref::class.java + expectedRawType = Ref::class.java, ) { RefObject() } } - val error = assertFailsWith { - render(runtime, owner) { - resolveNamedTypedEntry( - kind = HookEntryKind.Ref, - delegateName = "inputRef", - signature = HookSignatures.ref(typeOf()), - expectedRawType = Ref::class.java - ) { - RefObject() + val error = + assertFailsWith { + render(runtime, owner) { + resolveNamedTypedEntry( + kind = HookEntryKind.Ref, + delegateName = "inputRef", + signature = HookSignatures.ref(typeOf()), + expectedRawType = Ref::class.java, + ) { + RefObject() + } } } - } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("inputRef") == true) @@ -321,7 +331,7 @@ class ComponentHookRuntimeTests { kind = HookEntryKind.State, delegateName = "branchValue", signature = HookSignatures.state(typeOf()), - expectedRawType = BranchHolder::class.java + expectedRawType = BranchHolder::class.java, ) { BranchHolder("s") } @@ -330,7 +340,7 @@ class ComponentHookRuntimeTests { kind = HookEntryKind.State, delegateName = "branchValue", signature = HookSignatures.state(typeOf()), - expectedRawType = BranchHolder::class.java + expectedRawType = BranchHolder::class.java, ) { BranchHolder(1) } @@ -338,29 +348,30 @@ class ComponentHookRuntimeTests { } useStringBranch = true - val error = assertFailsWith { - render(runtime, owner) { - if (useStringBranch) { - resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = "branchValue", - signature = HookSignatures.state(typeOf()), - expectedRawType = BranchHolder::class.java - ) { - BranchHolder("s") - } - } else { - resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = "branchValue", - signature = HookSignatures.state(typeOf()), - expectedRawType = BranchHolder::class.java - ) { - BranchHolder(1) + val error = + assertFailsWith { + render(runtime, owner) { + if (useStringBranch) { + resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = "branchValue", + signature = HookSignatures.state(typeOf()), + expectedRawType = BranchHolder::class.java, + ) { + BranchHolder("s") + } + } else { + resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = "branchValue", + signature = HookSignatures.state(typeOf()), + expectedRawType = BranchHolder::class.java, + ) { + BranchHolder(1) + } } } } - } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("branchValue") == true) @@ -373,29 +384,32 @@ class ComponentHookRuntimeTests { val owner = Any() render(runtime, owner) { - val value = resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = "counter", - signature = HookSignatures.state(typeOf()), - expectedRawType = CounterHolder::class.java - ) { - CounterHolder(0) - } + val value = + resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = "counter", + signature = HookSignatures.state(typeOf()), + expectedRawType = CounterHolder::class.java, + ) { + CounterHolder(0) + } value.value.count = 42 } - val attempts = renderWithHotReloadRecovery(runtime, owner) { - val value = resolveNamedTypedEntry( - kind = HookEntryKind.State, - delegateName = "counter", - signature = HookSignatures.state(typeOf()), - expectedRawType = StringHolder::class.java - ) { - StringHolder("fresh") + val attempts = + renderWithHotReloadRecovery(runtime, owner) { + val value = + resolveNamedTypedEntry( + kind = HookEntryKind.State, + delegateName = "counter", + signature = HookSignatures.state(typeOf()), + expectedRawType = StringHolder::class.java, + ) { + StringHolder("fresh") + } + assertTrue(value.created) + assertEquals("fresh", value.value.value) } - assertTrue(value.created) - assertEquals("fresh", value.value.value) - } assertEquals(2, attempts) } @@ -422,7 +436,7 @@ class ComponentHookRuntimeTests { runtime: ComponentHookRuntime, owner: Any, mode: HookRenderSessionMode = HookRenderSessionMode.Normal, - block: ComponentHookRuntime.() -> Unit + block: ComponentHookRuntime.() -> Unit, ) { runtime.beginRender(owner, mode) try { @@ -436,7 +450,7 @@ class ComponentHookRuntimeTests { runtime: ComponentHookRuntime, owner: Any, maxAttempts: Int = 8, - block: ComponentHookRuntime.() -> Unit + block: ComponentHookRuntime.() -> Unit, ): Int { var attempt = 0 var lastWarning: HookHotReloadRemountException? = null @@ -454,15 +468,15 @@ class ComponentHookRuntimeTests { } private data class CounterHolder( - var count: Int + var count: Int, ) private data class StringHolder( - var value: String + var value: String, ) private data class BranchHolder( - var value: Any + var value: Any, ) private class AlphaRefTarget @@ -474,7 +488,8 @@ class ComponentHookRuntimeTests { resolveNamedEntry(HookEntryKind.State, "count") { 0 } } - private fun ComponentHookRuntime.resolveStateHookFromHelper(name: String): ResolvedHookEntry { - return resolveNamedEntry(HookEntryKind.State, name) { 0 } - } + private fun ComponentHookRuntime.resolveStateHookFromHelper(name: String): ResolvedHookEntry = + resolveNamedEntry(HookEntryKind.State, name) { + 0 + } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/host/ViewportMappingTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/host/ViewportMappingTests.kt index ba7f691..2a00cb9 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/host/ViewportMappingTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/host/ViewportMappingTests.kt @@ -24,12 +24,13 @@ class ViewportMappingTests { fun `DSGL clip rect converts to GL scissor using viewport height and origin`() { val viewport = Viewport(width = 800, height = 600, scale = 1f, x = 40, y = 30) - val scissor = viewport.dsglRectToGlScissor( - dsglX = 100, - dsglY = 120, - dsglWidth = 240, - dsglHeight = 80 - ) + val scissor = + viewport.dsglRectToGlScissor( + dsglX = 100, + dsglY = 120, + dsglWidth = 240, + dsglHeight = 80, + ) assertEquals(140, scissor.x) assertEquals(430, scissor.y) @@ -41,12 +42,13 @@ class ViewportMappingTests { fun `scissor conversion clamps negative width and height to zero`() { val viewport = Viewport(width = 640, height = 360, scale = 1f, x = 0, y = 0) - val scissor = viewport.dsglRectToGlScissor( - dsglX = 10, - dsglY = 20, - dsglWidth = -2, - dsglHeight = -3 - ) + val scissor = + viewport.dsglRectToGlScissor( + dsglX = 10, + dsglY = 20, + dsglWidth = -2, + dsglHeight = -3, + ) assertEquals(10, scissor.x) assertEquals(340, scissor.y) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorControllerTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorControllerTests.kt index 1929a8c..b6b5575 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorControllerTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorControllerTests.kt @@ -1,28 +1,25 @@ package org.dreamfinity.dsgl.core.inspector -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.test.fail import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle import org.dreamfinity.dsgl.core.colorpicker.RgbaColor -import org.dreamfinity.dsgl.core.colorpicker.internal.InspectorColorPickerHost +import org.dreamfinity.dsgl.core.colorpicker.internal.SystemColorPickerPortalService import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.event.KeyCodes -import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.input.ClipboardAccess -import org.dreamfinity.dsgl.core.input.ClipboardBridge import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleExpression import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail class InspectorControllerTests { @AfterTest @@ -258,7 +255,7 @@ class InspectorControllerTests { controller.handleMouseDown(990, 230, MouseButton.LEFT) renderFrame(controller, 420, 280) - val thumb = controller.debugScrollbarThumbRect() + val thumb = controller.portalScrollbarThumbRect() if (thumb.width <= 0 || thumb.height <= 0) { fail("Expected inspector scrollbar thumb to be available.") } @@ -303,17 +300,22 @@ class InspectorControllerTests { controller.handleMouseDown(1006, 126, MouseButton.LEFT) val snapshot = renderFrame(controller, 520, 340) - val pathLines = snapshot.infoLines - .filter { - it.startsWith("Path:") || it.startsWith(" >") || it.startsWith(" root") || - it.startsWith(" middle") || it.startsWith(" leaf") - } + val pathLines = + snapshot.infoLines + .filter { + it.startsWith("Path:") || + it.startsWith(" >") || + it.startsWith(" root") || + it.startsWith(" middle") || + it.startsWith(" leaf") + } assertTrue(pathLines.size >= 2) pathLines.forEach { line -> assertTrue(line.length <= 45) } } + @Test fun `expanded snapshot includes full computed style property list`() { val controller = InspectorController() @@ -357,10 +359,11 @@ class InspectorControllerTests { assertTrue(snapshot.childLabels.isNotEmpty()) assertTrue(snapshot.childLabels.any { it.endsWith("...") }) } + @Test fun `inspector opens picker for color property and stays interactive after preview commit`() { - val pickerHost = RecordingInspectorColorPickerHost() - val controller = InspectorController(colorPickerManager = pickerHost) + val pickerService = RecordingSystemColorPickerPortalService() + val controller = InspectorController(colorPickerManager = pickerService) controller.toggle() val root = container("root", 0, 0, 1200, 800) @@ -376,31 +379,42 @@ class InspectorControllerTests { assertTrue( controller.debugOpenColorPickerForSelection( StyleProperty.BACKGROUND_COLOR, - Rect(120, 80, 16, 16) - ) + Rect(120, 80, 16, 16), + ), ) - val opened = pickerHost.lastOpen + val opened = pickerService.lastOpen assertNotNull(opened) - assertTrue(pickerHost.isOpen()) + assertTrue(pickerService.isOpen()) opened.onPreview?.invoke(RgbaColor(0.25f, 0.5f, 0.75f, 1f)) - val previewLiteral = (StyleEngine.inspectorOverrideFor(selected, StyleProperty.BACKGROUND_COLOR) as? StyleExpression.Literal)?.value + val previewLiteral = + ( + StyleEngine.inspectorOverrideFor( + selected, + StyleProperty.BACKGROUND_COLOR, + ) as? StyleExpression.Literal + )?.value assertEquals("#FF4080BF", previewLiteral) opened.onCommit?.invoke(RgbaColor(1f, 0f, 0f, 1f)) - val committedLiteral = (StyleEngine.inspectorOverrideFor(selected, StyleProperty.BACKGROUND_COLOR) as? StyleExpression.Literal)?.value + val committedLiteral = + ( + StyleEngine.inspectorOverrideFor( + selected, + StyleProperty.BACKGROUND_COLOR, + ) as? StyleExpression.Literal + )?.value assertEquals("#FFFF0000", committedLiteral) - pickerHost.close() - assertFalse(pickerHost.isOpen()) + pickerService.close() + assertFalse(pickerService.isOpen()) assertTrue(controller.handleMouseDown(38, 30, MouseButton.LEFT)) assertTrue(controller.isDraggingPanel) assertTrue(controller.handleMouseUp(38, 30, MouseButton.LEFT)) assertFalse(controller.isDraggingPanel) } - @Test fun `native inspector rows no longer activate controller text edit session`() { val controller = InspectorController() @@ -417,9 +431,10 @@ class InspectorControllerTests { assertTrue(controller.handleMouseDown(988, 126, MouseButton.LEFT)) renderFrame(controller, 1200, 700) - val row = controller.debugStyleEditorRows().firstOrNull { - it.property == StyleProperty.BACKGROUND_COLOR && it.editorKind == InspectorEditorKind.StringInput - } ?: error("Expected color string input row.") + val row = + controller.portalStyleEditorRows().firstOrNull { + it.property == StyleProperty.BACKGROUND_COLOR && it.editorKind == InspectorEditorKind.StringInput + } ?: error("Expected color string input row.") val inputRect = row.inputRect ?: row.controlRect val clickX = inputRect.x + 10 val clickY = inputRect.y + inputRect.height / 2 @@ -447,7 +462,7 @@ class InspectorControllerTests { controller.handleMouseDown(988, 126, MouseButton.LEFT) renderFrame(controller, 260, 240) - val rows = controller.debugStyleEditorRows() + val rows = controller.portalStyleEditorRows() assertTrue(rows.isNotEmpty()) assertTrue(rows.any { it.rowRect.height > it.controlRect.height + 8 }) @@ -455,6 +470,7 @@ class InspectorControllerTests { assertTrue(next.rowRect.y >= prev.rowRect.y + prev.rowRect.height + 4) } } + @Test fun `inspector dropdown option click is consumed and applied over underlying controls`() { val controller = InspectorController() @@ -470,18 +486,20 @@ class InspectorControllerTests { controller.handleMouseDown(988, 126, MouseButton.LEFT) renderFrame(controller, 1200, 700) - val row = controller.debugStyleEditorRows().firstOrNull { it.editorKind == InspectorEditorKind.EnumSelect } - ?: error("Expected enum select row.") + val row = + controller.portalStyleEditorRows().firstOrNull { it.editorKind == InspectorEditorKind.EnumSelect } + ?: error("Expected enum select row.") val openX = row.controlRect.x + 4 val openY = row.controlRect.y + row.controlRect.height / 2 controller.onCursorMoved(openX, openY) assertTrue(controller.handleMouseDown(openX, openY, MouseButton.LEFT)) renderFrame(controller, 1200, 700) - val dropdown = controller.debugStyleEditorDropdowns().firstOrNull() ?: error("Expected open dropdown.") - val option = dropdown.options.firstOrNull { !it.text.equals(row.controlValue, ignoreCase = true) } - ?: dropdown.options.firstOrNull() - ?: error("Expected dropdown option.") + val dropdown = controller.portalStyleEditorDropdowns().firstOrNull() ?: error("Expected open dropdown.") + val option = + dropdown.options.firstOrNull { !it.text.equals(row.controlValue, ignoreCase = true) } + ?: dropdown.options.firstOrNull() + ?: error("Expected dropdown option.") val optionX = option.rect.x + 2 val optionY = option.rect.y + option.rect.height / 2 @@ -490,13 +508,12 @@ class InspectorControllerTests { assertTrue(controller.handleMouseDown(optionX, optionY, MouseButton.LEFT)) renderFrame(controller, 1200, 700) - assertTrue(controller.debugStyleEditorDropdowns().isEmpty()) + assertTrue(controller.portalStyleEditorDropdowns().isEmpty()) val literal = (StyleEngine.inspectorOverrideFor(selected, row.property) as? StyleExpression.Literal)?.value assertNotNull(literal) assertTrue(literal.equals(option.text, ignoreCase = true)) } - @Test fun `numeric override commit follows property grammar`() { val controller = InspectorController() @@ -511,36 +528,43 @@ class InspectorControllerTests { controller.onCursorMoved(988, 126) controller.handleMouseDown(988, 126, MouseButton.LEFT) - assertTrue(controller.debugApplyNumericOverride(StyleProperty.Z_INDEX, "5", "px")) - val zIndexLiteral = (StyleEngine.inspectorOverrideFor(selected, StyleProperty.Z_INDEX) as? StyleExpression.Literal)?.value + assertTrue(controller.portalApplyNumericOverride(StyleProperty.Z_INDEX, "5", "px")) + val zIndexLiteral = + ( + StyleEngine.inspectorOverrideFor( + selected, + StyleProperty.Z_INDEX, + ) as? StyleExpression.Literal + )?.value assertEquals("5", zIndexLiteral) - assertTrue(controller.debugApplyNumericOverride(StyleProperty.WIDTH, "24", "em")) - val widthLiteral = (StyleEngine.inspectorOverrideFor(selected, StyleProperty.WIDTH) as? StyleExpression.Literal)?.value + assertTrue(controller.portalApplyNumericOverride(StyleProperty.WIDTH, "24", "em")) + val widthLiteral = + ( + StyleEngine.inspectorOverrideFor( + selected, + StyleProperty.WIDTH, + ) as? StyleExpression.Literal + )?.value assertEquals("24em", widthLiteral) } - private fun renderFrame(controller: InspectorController, viewportWidth: Int, viewportHeight: Int): InspectorDomSnapshot { - return controller.buildDomSnapshot(viewportWidth, viewportHeight) + + private fun renderFrame(controller: InspectorController, viewportWidth: Int, viewportHeight: Int): InspectorDomSnapshot = + controller.buildDomSnapshot(viewportWidth, viewportHeight) ?: error("Inspector snapshot must exist while active.") - } - private fun container(key: Any, x: Int, y: Int, width: Int, height: Int): ContainerNode { - return ContainerNode(key = key).apply { + private fun container( + key: Any, + x: Int, + y: Int, + width: Int, + height: Int, + ): ContainerNode = + ContainerNode(key = key).apply { bounds = Rect(x, y, width, height) } - } - - - private class RecordingClipboardAccess : ClipboardAccess { - var contents: String = "" - - override fun readText(): String = contents - override fun writeText(value: String) { - contents = value - } - } - private class RecordingInspectorColorPickerHost : InspectorColorPickerHost { + private class RecordingSystemColorPickerPortalService : SystemColorPickerPortalService { var lastOpen: OpenCall? = null private var open: Boolean = false @@ -555,17 +579,18 @@ class InspectorControllerTests { onPreview: ((RgbaColor) -> Unit)?, onChange: ((RgbaColor) -> Unit)?, onCommit: ((RgbaColor) -> Unit)?, - onClose: (() -> Unit)? + onClose: (() -> Unit)?, ) { - lastOpen = OpenCall( - anchorRect = anchorRect, - title = title, - state = state, - onPreview = onPreview, - onChange = onChange, - onCommit = onCommit, - onClose = onClose - ) + lastOpen = + OpenCall( + anchorRect = anchorRect, + title = title, + state = state, + onPreview = onPreview, + onChange = onChange, + onCommit = onCommit, + onClose = onClose, + ) open = true } @@ -585,7 +610,6 @@ class InspectorControllerTests { val onPreview: ((RgbaColor) -> Unit)?, val onChange: ((RgbaColor) -> Unit)?, val onCommit: ((RgbaColor) -> Unit)?, - val onClose: (() -> Unit)? + val onClose: (() -> Unit)?, ) } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSessionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSessionTests.kt index 9e044e1..e5e72b5 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSessionTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditSessionTests.kt @@ -16,7 +16,7 @@ class InspectorEditSessionTests { property = StyleProperty.WIDTH, initialBuffer = "42", initialUnit = CssUnit.Px, - isNumeric = true + isNumeric = true, ) assertEquals(StyleProperty.WIDTH, session.activeProperty) @@ -27,12 +27,13 @@ class InspectorEditSessionTests { @Test fun `closeAllDropdowns clears dropdown ownership and scroll state`() { - val session = InspectorEditSession().apply { - openValueProperty = StyleProperty.DISPLAY - openValueScrollIndex = 4 - openUnitProperty = StyleProperty.WIDTH - openUnitScrollIndex = 3 - } + val session = + InspectorEditSession().apply { + openValueProperty = StyleProperty.DISPLAY + openValueScrollIndex = 4 + openUnitProperty = StyleProperty.WIDTH + openUnitScrollIndex = 3 + } session.closeAllDropdowns() @@ -44,16 +45,17 @@ class InspectorEditSessionTests { @Test fun `resetAll clears both edit and dropdown state`() { - val session = InspectorEditSession().apply { - begin( - property = StyleProperty.HEIGHT, - initialBuffer = "11", - initialUnit = CssUnit.Em, - isNumeric = false - ) - openValueProperty = StyleProperty.DISPLAY - openValueScrollIndex = 1 - } + val session = + InspectorEditSession().apply { + begin( + property = StyleProperty.HEIGHT, + initialBuffer = "11", + initialUnit = CssUnit.Em, + isNumeric = false, + ) + openValueProperty = StyleProperty.DISPLAY + openValueScrollIndex = 1 + } session.resetAll() diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistryTests.kt index 7bac786..7e731b4 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistryTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorEditorRegistryTests.kt @@ -12,30 +12,33 @@ import kotlin.test.assertTrue class InspectorEditorRegistryTests { @Test fun `maps enum and font properties to dropdown editors`() { - val display = InspectorEditorRegistry.describe( - property = StyleProperty.DISPLAY, - literal = "block", - expression = StyleExpression.Literal("block") - ) + val display = + InspectorEditorRegistry.describe( + property = StyleProperty.DISPLAY, + literal = "block", + expression = StyleExpression.Literal("block"), + ) assertEquals(InspectorEditorKind.EnumSelect, display.kind) assertTrue(display.options.isNotEmpty()) - val font = InspectorEditorRegistry.describe( - property = StyleProperty.FONT_ID, - literal = "minecraft", - expression = StyleExpression.Literal("minecraft") - ) + val font = + InspectorEditorRegistry.describe( + property = StyleProperty.FONT_ID, + literal = "minecraft", + expression = StyleExpression.Literal("minecraft"), + ) assertEquals(InspectorEditorKind.FontSelect, font.kind) assertTrue(font.options.isNotEmpty()) } @Test fun `position is exposed as enum select with explicit options`() { - val position = InspectorEditorRegistry.describe( - property = StyleProperty.POSITION, - literal = "relative", - expression = StyleExpression.Literal("relative") - ) + val position = + InspectorEditorRegistry.describe( + property = StyleProperty.POSITION, + literal = "relative", + expression = StyleExpression.Literal("relative"), + ) assertEquals(InspectorEditorKind.EnumSelect, position.kind) assertEquals(listOf("static", "relative", "absolute", "fixed", "sticky"), position.options) @@ -43,11 +46,12 @@ class InspectorEditorRegistryTests { @Test fun `z-index uses unitless numeric grammar in inspector`() { - val descriptor = InspectorEditorRegistry.describe( - property = StyleProperty.Z_INDEX, - literal = "5", - expression = StyleExpression.Literal("5") - ) + val descriptor = + InspectorEditorRegistry.describe( + property = StyleProperty.Z_INDEX, + literal = "5", + expression = StyleExpression.Literal("5"), + ) assertEquals(InspectorEditorKind.NumericInput, descriptor.kind) assertFalse(descriptor.supportsUnits) @@ -62,11 +66,12 @@ class InspectorEditorRegistryTests { @Test fun `line-height supports normal and length values in inspector editor`() { - val descriptor = InspectorEditorRegistry.describe( - property = StyleProperty.LINE_HEIGHT, - literal = "normal", - expression = StyleExpression.Literal("normal") - ) + val descriptor = + InspectorEditorRegistry.describe( + property = StyleProperty.LINE_HEIGHT, + literal = "normal", + expression = StyleExpression.Literal("normal"), + ) assertEquals(InspectorEditorKind.NumericInput, descriptor.kind) assertTrue(descriptor.supportsUnits) assertEquals(listOf("normal"), descriptor.options) @@ -87,11 +92,12 @@ class InspectorEditorRegistryTests { @Test fun `length-like numeric properties keep unit-aware grammar`() { - val width = InspectorEditorRegistry.describe( - property = StyleProperty.WIDTH, - literal = "12px", - expression = StyleExpression.Literal("12px") - ) + val width = + InspectorEditorRegistry.describe( + property = StyleProperty.WIDTH, + literal = "12px", + expression = StyleExpression.Literal("12px"), + ) assertEquals(InspectorEditorKind.NumericInput, width.kind) assertTrue(width.supportsUnits) @@ -103,15 +109,15 @@ class InspectorEditorRegistryTests { assertEquals("18em", InspectorEditorRegistry.formatNumericLiteral(StyleProperty.WIDTH, "18", "em")) } - @Test fun `offset properties expose unit-aware numeric editor`() { listOf(StyleProperty.LEFT, StyleProperty.TOP, StyleProperty.RIGHT, StyleProperty.BOTTOM).forEach { property -> - val descriptor = InspectorEditorRegistry.describe( - property = property, - literal = "auto", - expression = StyleExpression.Literal("auto") - ) + val descriptor = + InspectorEditorRegistry.describe( + property = property, + literal = "auto", + expression = StyleExpression.Literal("auto"), + ) assertEquals(InspectorEditorKind.NumericInput, descriptor.kind) assertTrue(descriptor.supportsUnits) } @@ -133,19 +139,21 @@ class InspectorEditorRegistryTests { @Test fun `detects color-like values for preview`() { - val stringColor = InspectorEditorRegistry.describe( - property = StyleProperty.BACKGROUND_IMAGE, - literal = "#AABBCC", - expression = StyleExpression.Literal("#AABBCC") - ) + val stringColor = + InspectorEditorRegistry.describe( + property = StyleProperty.BACKGROUND_IMAGE, + literal = "#AABBCC", + expression = StyleExpression.Literal("#AABBCC"), + ) assertEquals(InspectorEditorKind.StringInput, stringColor.kind) assertTrue(stringColor.showColorPreview) - val plain = InspectorEditorRegistry.describe( - property = StyleProperty.BACKGROUND_IMAGE, - literal = "textures/gui/options_background.png", - expression = StyleExpression.Literal("textures/gui/options_background.png") - ) + val plain = + InspectorEditorRegistry.describe( + property = StyleProperty.BACKGROUND_IMAGE, + literal = "textures/gui/options_background.png", + expression = StyleExpression.Literal("textures/gui/options_background.png"), + ) assertFalse(plain.showColorPreview) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilderTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilderTests.kt new file mode 100644 index 0000000..07a95d9 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/InspectorStyleEditorSnapshotBuilderTests.kt @@ -0,0 +1,213 @@ +package org.dreamfinity.dsgl.core.inspector + +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.style.ComputedStyle +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.StyleExpression +import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.* + +class InspectorStyleEditorSnapshotBuilderTests { + @AfterTest + fun cleanup() { + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @Test + fun `builder creates row snapshots dropdown layouts and color-preview action specs`() { + val (_, selected) = inspectedSelection() + StyleEngine.setInspectorOverrideLiteral(selected, StyleProperty.BACKGROUND_COLOR, "#FF336699").getOrThrow() + val inspection = StyleEngine.inspect(selected) + + val result = + builder().build( + context( + selected = selected, + inspection = inspection, + editableProperties = + listOf( + StyleProperty.BACKGROUND_COLOR, + StyleProperty.WIDTH, + StyleProperty.DISPLAY, + ), + openValueSelectProperty = StyleProperty.DISPLAY, + openUnitSelectProperty = StyleProperty.WIDTH, + openValueSelectScrollIndex = 99, + openUnitSelectScrollIndex = 99, + ), + ) + + assertEquals(3, result.rows.size) + val colorRow = + result.rows.firstOrNull { it.property == StyleProperty.BACKGROUND_COLOR } + ?: error("background color row missing") + val widthRow = + result.rows.firstOrNull { it.property == StyleProperty.WIDTH } + ?: error("width row missing") + val displayRow = + result.rows.firstOrNull { it.property == StyleProperty.DISPLAY } + ?: error("display row missing") + + assertNotNull(colorRow.colorPreviewRect) + assertNotNull(colorRow.colorPreviewColor) + assertNotNull(widthRow.decrementRect) + assertNotNull(widthRow.inputRect) + assertNotNull(widthRow.incrementRect) + assertNotNull(widthRow.unitRect) + assertTrue(displayRow.controlOpen) + assertTrue(widthRow.unitOpen) + + assertTrue(result.dropdowns.any { it.property == StyleProperty.DISPLAY && !it.unitSelect }) + assertTrue(result.dropdowns.any { it.property == StyleProperty.WIDTH && it.unitSelect }) + assertEquals(2, result.dropdownLayouts.size) + assertEquals(0, result.openValueSelectScrollIndex) + assertEquals(0, result.openUnitSelectScrollIndex) + + assertTrue( + result.actionSpecs.any { + it.type == InspectorStyleEditorActionType.OpenColorPicker && + it.property == StyleProperty.BACKGROUND_COLOR + }, + ) + assertTrue(result.resetRect.width > 0 && result.resetRect.height > 0) + assertTrue(result.clearRect.width > 0 && result.clearRect.height > 0) + } + + @Test + fun `builder projects dropdown hover through pointer projection scroll and preserves option value`() { + val (_, selected) = inspectedSelection() + val inspection = StyleEngine.inspect(selected) + val baseContext = + context( + selected = selected, + inspection = inspection, + editableProperties = listOf(StyleProperty.DISPLAY), + openValueSelectProperty = StyleProperty.DISPLAY, + pointerProjectionScrollY = 32, + mouseX = 0, + mouseY = 0, + ) + val baseline = builder().build(baseContext) + val dropdown = baseline.dropdowns.firstOrNull() ?: error("display dropdown missing") + val option = dropdown.options.firstOrNull() ?: error("display dropdown option missing") + val projectedOptionY = option.rect.y - 32 + + val hovered = + builder().build( + baseContext.copy( + mouseX = option.rect.x + 2, + mouseY = projectedOptionY + (option.rect.height / 2).coerceAtLeast(1), + ), + ) + val hoveredDropdown = hovered.dropdowns.firstOrNull() ?: error("display dropdown missing after hover pass") + val hoveredOption = + hoveredDropdown.options.firstOrNull { it.value == option.value } + ?: error("hovered option missing") + + assertTrue(hoveredOption.hovered) + assertEquals(option.value, hoveredOption.value) + } + + @Test + fun `builder emits variable tooltip when pointer hovers variable-backed row`() { + val (_, selected) = inspectedSelection() + StyleEngine.setInspectorOverride( + selected, + StyleProperty.BACKGROUND_COLOR, + StyleExpression.VariableRef("--missing-color"), + ) + val inspection = StyleEngine.inspect(selected) + val panelRect = Rect(20, 20, 360, 260) + val rowY = 64 + 32 + + val result = + builder().build( + context( + selected = selected, + inspection = inspection, + panelRect = panelRect, + editableProperties = listOf(StyleProperty.BACKGROUND_COLOR), + mouseX = panelRect.x + 18, + mouseY = rowY + 8, + ), + ) + + val tooltip = result.variableTooltip ?: error("expected variable tooltip") + assertTrue(tooltip.text.contains("--missing-color")) + assertTrue(tooltip.rect.width > 0 && tooltip.rect.height > 0) + } + + private fun builder(): InspectorStyleEditorSnapshotBuilder = + InspectorStyleEditorSnapshotBuilder( + resolveLiteralFromComputed = ::literalForProperty, + renderExpressionLabel = ::expressionLabel, + ) + + private fun context( + selected: ContainerNode, + inspection: org.dreamfinity.dsgl.core.style.StyleInspection, + panelRect: Rect = Rect(20, 20, 360, 260), + editableProperties: List, + pointerProjectionScrollY: Int = 0, + mouseX: Int = 180, + mouseY: Int = 120, + openValueSelectProperty: StyleProperty? = null, + openUnitSelectProperty: StyleProperty? = null, + openValueSelectScrollIndex: Int = 0, + openUnitSelectScrollIndex: Int = 0, + ): InspectorStyleEditorSnapshotBuildContext = + InspectorStyleEditorSnapshotBuildContext( + panelRect = panelRect, + panelBounds = panelRect, + selected = selected, + inspection = inspection, + editableProperties = editableProperties, + startY = 64, + lineHeightPx = 32, + rowHeightPx = 34, + secondaryFontSizePx = 24, + pointerProjectionScrollY = pointerProjectionScrollY, + mouseX = mouseX, + mouseY = mouseY, + viewportWidth = 1280, + viewportHeight = 720, + openValueSelectProperty = openValueSelectProperty, + openUnitSelectProperty = openUnitSelectProperty, + openValueSelectScrollIndex = openValueSelectScrollIndex, + openUnitSelectScrollIndex = openUnitSelectScrollIndex, + ) + + private fun inspectedSelection(): Pair { + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 1280, 720) + } + val selected = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } + selected.applyParent(root) + return root to selected + } + + private fun literalForProperty(style: ComputedStyle, property: StyleProperty): String = + when (property) { + StyleProperty.BACKGROUND_COLOR -> "#FF336699" + StyleProperty.WIDTH -> "24px" + StyleProperty.DISPLAY -> "block" + else -> + when (property) { + StyleProperty.FONT_ID -> style.fontId ?: "minecraft" + else -> "0" + } + } + + private fun expressionLabel(expression: StyleExpression): String = + when (expression) { + is StyleExpression.Literal -> expression.value + is StyleExpression.VariableRef -> "var(${expression.name})" + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayFocusIsolationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalFocusIsolationTests.kt similarity index 62% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayFocusIsolationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalFocusIsolationTests.kt index 77f4a0f..8600c6b 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorOverlayFocusIsolationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalFocusIsolationTests.kt @@ -1,10 +1,5 @@ package org.dreamfinity.dsgl.core.inspector.internal -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -20,18 +15,26 @@ import org.dreamfinity.dsgl.core.input.ClipboardAccess import org.dreamfinity.dsgl.core.input.ClipboardBridge import org.dreamfinity.dsgl.core.inspector.InspectorController import org.dreamfinity.dsgl.core.inspector.InspectorMode -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayHost +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.portal.system.SystemPortalHost import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue -class SystemInspectorOverlayFocusIsolationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } +class SystemInspectorPortalFocusIsolationTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } private val clipboard = RecordingClipboardAccess() @@ -46,24 +49,26 @@ class SystemInspectorOverlayFocusIsolationTests { @Test fun `text input keeps focus while inspector is mounted and rendered`() { - val appRoot = ContainerNode(key = "app-root").apply { - bounds = Rect(0, 0, 1280, 720) - } - val input = TextInputNode(text = "value", key = "app-input").apply { - bounds = Rect(24, 24, 180, 24) - } + val appRoot = + ContainerNode(key = "app-root").apply { + bounds = Rect(0, 0, 1280, 720) + } + val input = + TextInputNode(text = "value", key = "app-input").apply { + bounds = Rect(24, 24, 180, 24) + } input.applyParent(appRoot) - val router = LayerDomInputRouter { appRoot } + val router = SurfaceDomInputRouter { appRoot } - val (inspector, overlayNode, inspectedRoot) = createMountedInspector() - renderInspectorFrame(overlayNode, inspectedRoot, 1L) + val (inspector, portalNode, inspectedRoot) = createMountedInspector() + renderInspectorFrame(portalNode, inspectedRoot, 1L) assertTrue(router.handleMouseDown(30, 30, MouseButton.LEFT)) assertTrue(router.handleMouseUp(30, 30, MouseButton.LEFT)) assertTrue(FocusManager.isFocused(input)) repeat(4) { frame -> - renderInspectorFrame(overlayNode, inspectedRoot, 2L + frame) + renderInspectorFrame(portalNode, inspectedRoot, 2L + frame) assertTrue(FocusManager.isFocused(input)) } @@ -76,21 +81,23 @@ class SystemInspectorOverlayFocusIsolationTests { fun `text selection drag and keyboard edit work while inspector is mounted`() { ClipboardBridge.install(clipboard) - val appRoot = ContainerNode(key = "app-root").apply { - bounds = Rect(0, 0, 1280, 720) - } - val input = TextInputNode(text = "abcdef", key = "app-input").apply { - bounds = Rect(24, 24, 180, 24) - } + val appRoot = + ContainerNode(key = "app-root").apply { + bounds = Rect(0, 0, 1280, 720) + } + val input = + TextInputNode(text = "abcdef", key = "app-input").apply { + bounds = Rect(24, 24, 180, 24) + } input.applyParent(appRoot) - val router = LayerDomInputRouter { appRoot } + val router = SurfaceDomInputRouter { appRoot } - val (_, overlayNode, inspectedRoot) = createMountedInspector() - renderInspectorFrame(overlayNode, inspectedRoot, 1L) + val (_, portalNode, inspectedRoot) = createMountedInspector() + renderInspectorFrame(portalNode, inspectedRoot, 1L) assertTrue(router.handleMouseDown(28, 30, MouseButton.LEFT)) assertTrue(router.handleMouseMove(70, 30)) - renderInspectorFrame(overlayNode, inspectedRoot, 2L) + renderInspectorFrame(portalNode, inspectedRoot, 2L) assertTrue(router.handleMouseMove(88, 30)) assertTrue(router.handleMouseUp(88, 30, MouseButton.LEFT)) @@ -105,45 +112,48 @@ class SystemInspectorOverlayFocusIsolationTests { @Test fun `range drag works while inspector is mounted`() { - val appRoot = ContainerNode(key = "app-root").apply { - bounds = Rect(0, 0, 1280, 720) - } + val appRoot = + ContainerNode(key = "app-root").apply { + bounds = Rect(0, 0, 1280, 720) + } val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = "app-range") range.applyParent(appRoot) range.render(ctx, 24, 24, 120, 12) - val router = LayerDomInputRouter { appRoot } + val router = SurfaceDomInputRouter { appRoot } - val (_, overlayNode, inspectedRoot) = createMountedInspector() - renderInspectorFrame(overlayNode, inspectedRoot, 1L) + val (_, portalNode, inspectedRoot) = createMountedInspector() + renderInspectorFrame(portalNode, inspectedRoot, 1L) assertTrue(router.handleMouseDown(24, 30, MouseButton.LEFT)) - renderInspectorFrame(overlayNode, inspectedRoot, 2L) + renderInspectorFrame(portalNode, inspectedRoot, 2L) assertTrue(router.handleMouseMove(200, 30)) - renderInspectorFrame(overlayNode, inspectedRoot, 3L) + renderInspectorFrame(portalNode, inspectedRoot, 3L) assertTrue(router.handleMouseUp(200, 30, MouseButton.LEFT)) assertTrue(range.value > 0L) } @Test fun `focused app control keeps keyboard routing with inspector mounted`() { - val appRoot = ContainerNode(key = "app-root").apply { - bounds = Rect(0, 0, 1280, 720) - } - val input = TextInputNode(text = "x", key = "app-input").apply { - bounds = Rect(24, 24, 180, 24) - } + val appRoot = + ContainerNode(key = "app-root").apply { + bounds = Rect(0, 0, 1280, 720) + } + val input = + TextInputNode(text = "x", key = "app-input").apply { + bounds = Rect(24, 24, 180, 24) + } input.applyParent(appRoot) - val router = LayerDomInputRouter { appRoot } + val router = SurfaceDomInputRouter { appRoot } - val (_, overlayNode, inspectedRoot) = createMountedInspector() - renderInspectorFrame(overlayNode, inspectedRoot, 1L) + val (_, portalNode, inspectedRoot) = createMountedInspector() + renderInspectorFrame(portalNode, inspectedRoot, 1L) assertTrue(router.handleMouseDown(30, 30, MouseButton.LEFT)) assertTrue(router.handleMouseUp(30, 30, MouseButton.LEFT)) assertTrue(FocusManager.isFocused(input)) repeat(3) { frame -> - renderInspectorFrame(overlayNode, inspectedRoot, 2L + frame) + renderInspectorFrame(portalNode, inspectedRoot, 2L + frame) assertTrue(router.handleKeyDown(KeyCodes.Z, 'z')) } @@ -153,7 +163,7 @@ class SystemInspectorOverlayFocusIsolationTests { @Test fun `inspector still handles its own input interactions after focus isolation fix`() { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) + val host = SystemPortalHost(inspector) val root = inspectedRoot() inspector.toggle() @@ -162,34 +172,36 @@ class SystemInspectorOverlayFocusIsolationTests { host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) host.render(ctx, 1280, 720) - val pickRect = inspector.debugPickToggleBounds() ?: error("pick toggle missing") + val pickRect = inspector.portalPickToggleBounds() ?: error("pick toggle missing") assertTrue(host.handleMouseDown(pickRect.x + 2, pickRect.y + 2, MouseButton.LEFT)) assertTrue(host.handleMouseUp(pickRect.x + 2, pickRect.y + 2, MouseButton.LEFT)) assertEquals(InspectorMode.Pick, inspector.mode) } - private fun createMountedInspector(): Triple { + private fun createMountedInspector(): Triple { val inspector = InspectorController() inspector.toggle() inspector.setPickMode(false) - val overlayNode = SystemInspectorOverlayNode(inspector) + val portalNode = SystemInspectorPortalNode(inspector) val inspectedRoot = inspectedRoot() - return Triple(inspector, overlayNode, inspectedRoot) + return Triple(inspector, portalNode, inspectedRoot) } - private fun renderInspectorFrame(node: SystemInspectorOverlayNode, inspectedRoot: DOMNode, revision: Long) { + private fun renderInspectorFrame(node: SystemInspectorPortalNode, inspectedRoot: DOMNode, revision: Long) { node.bindInspectedTree(inspectedRoot, revision) node.updateCursor(mouseX = 32, mouseY = 32, pointerCaptured = false) node.render(ctx, 0, 0, 1280, 720) } private fun inspectedRoot(): ContainerNode { - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 1280, 720) - } - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 1280, 720) + } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() return root @@ -197,7 +209,9 @@ class SystemInspectorOverlayFocusIsolationTests { private class RecordingClipboardAccess : ClipboardAccess { var value: String = "" + override fun readText(): String = value + override fun writeText(value: String) { this.value = value } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalInputBoundsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalInputBoundsTests.kt new file mode 100644 index 0000000..80c9e5c --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/inspector/internal/SystemInspectorPortalInputBoundsTests.kt @@ -0,0 +1,59 @@ +package org.dreamfinity.dsgl.core.inspector.internal + +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.inspector.InspectorDropdownOptionSnapshot +import org.dreamfinity.dsgl.core.inspector.InspectorDropdownSnapshot +import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SystemInspectorPortalInputBoundsTests { + @Test + fun `input bounds include rendered dropdown popup outside panel`() { + val controller = + InspectorController().also { + it.toggle() + it.setPickMode(false) + } + val node = SystemInspectorPortalNode(controller) + val panelRect = controller.floatingPanelRect() ?: error("expected panel rect") + + val popupRect = + Rect( + x = panelRect.x + panelRect.width + 32, + y = panelRect.y + 80, + width = 180, + height = 120, + ) + controller.onNativeDomDropdownSnapshots( + listOf( + InspectorDropdownSnapshot( + popupRect = popupRect, + property = StyleProperty.ALIGN, + unitSelect = false, + options = + listOf( + InspectorDropdownOptionSnapshot( + rect = Rect(popupRect.x + 2, popupRect.y + 2, popupRect.width - 4, 24), + text = "start", + value = "start", + hovered = false, + ), + ), + footerText = null, + ), + ), + ) + + node.syncInputBounds(viewportWidth = 1400, viewportHeight = 800) + val popupProbeX = popupRect.x + 12 + val popupProbeY = popupRect.y + 12 + assertTrue(node.bounds.contains(popupProbeX, popupProbeY)) + + controller.onNativeDomDropdownSnapshots(emptyList()) + node.syncInputBounds(viewportWidth = 1400, viewportHeight = 800) + assertFalse(node.bounds.contains(popupProbeX, popupProbeY)) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/LiveLayerInteractionPathTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/LiveLayerInteractionPathTests.kt deleted file mode 100644 index 870e143..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/LiveLayerInteractionPathTests.kt +++ /dev/null @@ -1,214 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayEntryId -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayHost -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayPanelDemoNode -import org.dreamfinity.dsgl.core.render.RenderCommand - -class LiveLayerInteractionPathTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - - @Test - fun `runtime layer path resolves in debug system app-overlay app-root order`() { - val callOrder = ArrayList(4) - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> - callOrder += UiLayerId.Debug - false - }, - systemOverlayHandler = { _, _, _ -> - callOrder += UiLayerId.SystemOverlay - false - }, - applicationOverlayHandler = { _, _, _ -> - callOrder += UiLayerId.ApplicationOverlay - false - } - ) - val consumedBy = harness.dispatchMouseDown(10, 10, MouseButton.LEFT) { - callOrder += UiLayerId.ApplicationRoot - true - } - - assertEquals(UiLayerId.ApplicationRoot, consumedBy) - assertEquals( - listOf(UiLayerId.Debug, UiLayerId.SystemOverlay, UiLayerId.ApplicationOverlay, UiLayerId.ApplicationRoot), - callOrder - ) - } - - @Test - fun `debug layer consumption prevents lower-layer fallthrough`() { - var systemReceived = false - var appOverlayReceived = false - var appRootReceived = false - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> true }, - systemOverlayHandler = { _, _, _ -> - systemReceived = true - false - }, - applicationOverlayHandler = { _, _, _ -> - appOverlayReceived = true - false - } - ) - val consumedBy = harness.dispatchMouseDown(12, 14, MouseButton.LEFT) { - appRootReceived = true - true - } - - assertEquals(UiLayerId.Debug, consumedBy) - assertFalse(systemReceived) - assertFalse(appOverlayReceived) - assertFalse(appRootReceived) - } - - @Test - fun `system overlay consumption prevents lower-layer fallthrough`() { - val systemHost = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - systemHost.onInputFrame(1280, 720) - systemHost.togglePanelDemo(anchorX = 240, anchorY = 180) - systemHost.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 244, cursorY = 186, inspectorPointerCaptured = false) - - val entryState = systemHost.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("panel demo state missing") - val panelRect = entryState.panelState.currentRectOrNull() ?: error("panel demo rect missing") - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> false }, - systemOverlayHandler = { x, y, button -> systemHost.handleMouseDown(x, y, button) }, - applicationOverlayHandler = { _, _, _ -> false } - ) - var appRootReceived = false - val consumedBy = harness.dispatchMouseDown(panelRect.x + 20, panelRect.y + 70, MouseButton.LEFT) { - appRootReceived = true - true - } - - assertEquals(UiLayerId.SystemOverlay, consumedBy) - assertFalse(appRootReceived) - } - - @Test - fun `locked inspector consumes only inside panel and falls through outside panel`() { - val inspector = InspectorController() - val systemHost = SystemOverlayHost(inspector) - val root = inspectedRoot() - systemHost.onInputFrame(1280, 720) - inspector.toggle() - inspector.setPickMode(false) - systemHost.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - systemHost.render(ctx, 1280, 720) - - val panelRect = inspector.debugPanelRect() ?: error("inspector panel rect missing") - val outsideX = if (panelRect.x > 40) panelRect.x - 20 else panelRect.x + panelRect.width + 20 - val outsideY = (panelRect.y + panelRect.height / 2).coerceIn(1, 719) - assertFalse(panelRect.contains(outsideX, outsideY)) - - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> false }, - systemOverlayHandler = { x, y, button -> systemHost.handleMouseDown(x, y, button) }, - applicationOverlayHandler = { _, _, _ -> false } - ) - - var appRootReceivedOutside = false - val consumedOutside = harness.dispatchMouseDown(outsideX, outsideY, MouseButton.LEFT) { - appRootReceivedOutside = true - true - } - assertEquals(UiLayerId.ApplicationRoot, consumedOutside) - assertTrue(appRootReceivedOutside) - } - @Test - fun `application overlay consumption prevents app-root fallthrough`() { - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> false }, - systemOverlayHandler = { _, _, _ -> false }, - applicationOverlayHandler = { _, _, _ -> true } - ) - var appRootReceived = false - val consumedBy = harness.dispatchMouseDown(24, 30, MouseButton.LEFT) { - appRootReceived = true - true - } - - assertEquals(UiLayerId.ApplicationOverlay, consumedBy) - assertFalse(appRootReceived) - } - - @Test - fun `rendered system overlay content is reachable through same live interaction path`() { - val systemHost = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - systemHost.onInputFrame(1280, 720) - systemHost.togglePanelDemo(anchorX = 260, anchorY = 200) - systemHost.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 260, cursorY = 200, inspectorPointerCaptured = false) - systemHost.render(ctx, 1280, 720) - - val demoNode = systemHost.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("panel demo node missing") - val buttonRect = demoNode.buttonRect() - assertNotNull(buttonRect) - val harness = LiveLayerInputHarness( - debugHandler = { _, _, _ -> false }, - systemOverlayHandler = { x, y, button -> systemHost.handleMouseDown(x, y, button) }, - applicationOverlayHandler = { _, _, _ -> false } - ) - var appRootReceived = false - val consumedBy = harness.dispatchMouseDown(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT) { - appRootReceived = true - true - } - - assertEquals(UiLayerId.SystemOverlay, consumedBy) - assertFalse(appRootReceived) - } - - private fun inspectedRoot(): ContainerNode { - val root = ContainerNode(key = "root") - root.bounds = Rect(0, 0, 1280, 720) - ContainerNode(key = "child").apply { - bounds = Rect(20, 20, 120, 32) - }.applyParent(root) - return root - } - - private class LiveLayerInputHarness( - private val debugHandler: (Int, Int, MouseButton) -> Boolean, - private val systemOverlayHandler: (Int, Int, MouseButton) -> Boolean, - private val applicationOverlayHandler: (Int, Int, MouseButton) -> Boolean - ) { - fun dispatchMouseDown( - mouseX: Int, - mouseY: Int, - button: MouseButton, - applicationRootHandler: () -> Boolean - ): UiLayerId? { - return OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - when (layer) { - UiLayerId.Debug -> debugHandler(mouseX, mouseY, button) - UiLayerId.SystemOverlay -> systemOverlayHandler(mouseX, mouseY, button) - UiLayerId.ApplicationOverlay -> applicationOverlayHandler(mouseX, mouseY, button) - UiLayerId.ApplicationRoot -> applicationRootHandler() - } - } - ) - } - } -} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualizationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualizationTests.kt deleted file mode 100644 index f1d6fff..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayDebugVisualizationTests.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.DomTree -import org.dreamfinity.dsgl.core.debug.OverlayLayerDebugState -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayRootNode -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.StyleApplicationScope - -class OverlayDebugVisualizationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - - @AfterTest - fun cleanup() { - OverlayDebugVisualization.setTestOverride(null) - OverlayLayerDebugState.resetAll() - } - - @Test - fun `debug visualization disabled by default`() { - OverlayLayerDebugState.resetAll() - OverlayDebugVisualization.setTestOverride(false) - val appTree = DomTree(root = ApplicationOverlayRootNode(), styleScope = StyleApplicationScope.Application) - val systemTree = DomTree(root = SystemOverlayRootNode(), styleScope = StyleApplicationScope.SystemOverlay) - - appTree.render(ctx, 800, 480) - systemTree.render(ctx, 800, 480) - val appCommands = appTree.paint(ctx, applyStyles = true) - val systemCommands = systemTree.paint(ctx, applyStyles = true) - - assertFalse(appCommands.any { command -> - command is RenderCommand.DrawRect && - (command.color == OverlayDebugVisualization.applicationOverlayFillColor || - command.color == OverlayDebugVisualization.applicationOverlayBorderColor) - }) - assertFalse(systemCommands.any { command -> - command is RenderCommand.DrawRect && - (command.color == OverlayDebugVisualization.systemOverlayFillColor || - command.color == OverlayDebugVisualization.systemOverlayBorderColor) - }) - } - - @Test - fun `debug visualization can be enabled without changing overlay logic contracts`() { - OverlayLayerDebugState.resetAll() - OverlayLayerDebugState.applicationOverlayTintEnabled = true - OverlayLayerDebugState.systemOverlayTintEnabled = true - OverlayDebugVisualization.setTestOverride(true) - val appTree = DomTree(root = ApplicationOverlayRootNode(), styleScope = StyleApplicationScope.Application) - val systemTree = DomTree(root = SystemOverlayRootNode(), styleScope = StyleApplicationScope.SystemOverlay) - - appTree.render(ctx, 800, 480) - systemTree.render(ctx, 800, 480) - val appCommands = appTree.paint(ctx, applyStyles = true) - val systemCommands = systemTree.paint(ctx, applyStyles = true) - - assertTrue(appCommands.any { command -> - command is RenderCommand.DrawRect && - (command.color == OverlayDebugVisualization.applicationOverlayFillColor || - command.color == OverlayDebugVisualization.applicationOverlayBorderColor) - }) - assertTrue(systemCommands.any { command -> - command is RenderCommand.DrawRect && - (command.color == OverlayDebugVisualization.systemOverlayFillColor || - command.color == OverlayDebugVisualization.systemOverlayBorderColor) - }) - } -} - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContractsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContractsTests.kt deleted file mode 100644 index da5635a..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayLayerContractsTests.kt +++ /dev/null @@ -1,183 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay - -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState -import org.dreamfinity.dsgl.core.colorpicker.RgbaColor -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.render.RenderCommand -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class OverlayLayerContractsTests { - @Test - fun `paint order is app root then app overlay then system overlay then debug`() { - assertEquals( - listOf(UiLayerId.ApplicationRoot, UiLayerId.ApplicationOverlay, UiLayerId.SystemOverlay, UiLayerId.Debug), - OverlayLayerContracts.paintOrder - ) - } - - @Test - fun `input priority is debug then system overlay then app overlay then app root`() { - assertEquals( - listOf(UiLayerId.Debug, UiLayerId.SystemOverlay, UiLayerId.ApplicationOverlay, UiLayerId.ApplicationRoot), - OverlayLayerContracts.inputPriority - ) - } - - @Test - fun `firstInputConsumer respects configured input priority`() { - val consumed = OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - layer == UiLayerId.Debug || layer == UiLayerId.ApplicationOverlay || layer == UiLayerId.ApplicationRoot - } - ) - assertEquals(UiLayerId.Debug, consumed) - } - - @Test - fun `firstInputConsumer returns null when no layer consumes`() { - val consumed = OverlayLayerContracts.firstInputConsumer(canConsume = { false }) - assertNull(consumed) - } - - @Test - fun `transient ownership uses owner scope and not cursor position`() { - val appAtA = OverlayLayerContracts.resolveTransientLayer( - ownerScope = OverlayOwnerScope.Application, - cursorX = 10, - cursorY = 20 - ) - val appAtB = OverlayLayerContracts.resolveTransientLayer( - ownerScope = OverlayOwnerScope.Application, - cursorX = 800, - cursorY = 640 - ) - val systemAtA = OverlayLayerContracts.resolveTransientLayer( - ownerScope = OverlayOwnerScope.System, - cursorX = 10, - cursorY = 20 - ) - val systemAtB = OverlayLayerContracts.resolveTransientLayer( - ownerScope = OverlayOwnerScope.System, - cursorX = 800, - cursorY = 640 - ) - assertEquals(UiLayerId.ApplicationOverlay, appAtA) - assertEquals(UiLayerId.ApplicationOverlay, appAtB) - assertEquals(UiLayerId.SystemOverlay, systemAtA) - assertEquals(UiLayerId.SystemOverlay, systemAtB) - } - - @Test - fun `composePaintCommands follows configured layer order`() { - val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) - val appOverlay = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000002.toInt())) - val system = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000003.toInt())) - val debug = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) - val out = ArrayList() - - OverlayLayerContracts.composePaintCommands(root, appOverlay, system, debug, out) - - assertEquals(4, out.size) - assertEquals(0xFF000001.toInt(), (out[0] as RenderCommand.DrawRect).color) - assertEquals(0xFF000002.toInt(), (out[1] as RenderCommand.DrawRect).color) - assertEquals(0xFF000003.toInt(), (out[2] as RenderCommand.DrawRect).color) - assertEquals(0xFF000004.toInt(), (out[3] as RenderCommand.DrawRect).color) - } - - @Test - fun `composePaintCommands skips app overlay render when disabled`() { - val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) - val appOverlay = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000002.toInt())) - val system = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000003.toInt())) - val debug = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) - val out = ArrayList() - - OverlayLayerContracts.composePaintCommands( - applicationRoot = root, - applicationOverlay = appOverlay, - systemOverlay = system, - debug = debug, - out = out, - shouldRenderLayer = { layer -> layer != UiLayerId.ApplicationOverlay } - ) - - assertEquals(listOf(0xFF000001.toInt(), 0xFF000003.toInt(), 0xFF000004.toInt()), out.map { - (it as RenderCommand.DrawRect).color - }) - } - - @Test - fun `composePaintCommands skips system overlay render when disabled`() { - val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) - val appOverlay = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000002.toInt())) - val system = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000003.toInt())) - val debug = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) - val out = ArrayList() - - OverlayLayerContracts.composePaintCommands( - applicationRoot = root, - applicationOverlay = appOverlay, - systemOverlay = system, - debug = debug, - out = out, - shouldRenderLayer = { layer -> layer != UiLayerId.SystemOverlay } - ) - - assertEquals(listOf(0xFF000001.toInt(), 0xFF000002.toInt(), 0xFF000004.toInt()), out.map { - (it as RenderCommand.DrawRect).color - }) - } - - @Test - fun `firstInputConsumer skips app overlay input when disabled`() { - val order = ArrayList() - val consumed = OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - order += layer - layer == UiLayerId.ApplicationOverlay || layer == UiLayerId.ApplicationRoot - }, - isLayerInputEnabled = { layer -> layer != UiLayerId.ApplicationOverlay } - ) - assertEquals(UiLayerId.ApplicationRoot, consumed) - assertEquals(listOf(UiLayerId.Debug, UiLayerId.SystemOverlay, UiLayerId.ApplicationRoot), order) - } - - @Test - fun `firstInputConsumer skips system overlay input when disabled`() { - val order = ArrayList() - val consumed = OverlayLayerContracts.firstInputConsumer( - canConsume = { layer -> - order += layer - layer == UiLayerId.SystemOverlay || layer == UiLayerId.ApplicationRoot - }, - isLayerInputEnabled = { layer -> layer != UiLayerId.SystemOverlay } - ) - assertEquals(UiLayerId.ApplicationRoot, consumed) - assertEquals(listOf(UiLayerId.Debug, UiLayerId.ApplicationOverlay, UiLayerId.ApplicationRoot), order) - } - - @Test - fun `color picker popup defaults to application overlay ownership`() { - val request = ColorPickerPopupRequest( - owner = "owner", - anchorRect = Rect(10, 12, 20, 18), - state = ColorPickerState(color = RgbaColor.WHITE) - ) - assertEquals(OverlayOwnerScope.Application, request.ownerScope) - assertEquals(UiLayerId.ApplicationOverlay, ColorPickerPopupOverlayOwnership.resolveLayer(request)) - } - - @Test - fun `system-owned color picker popup resolves to system overlay`() { - val request = ColorPickerPopupRequest( - owner = "owner", - ownerScope = OverlayOwnerScope.System, - anchorRect = Rect(10, 12, 20, 18), - state = ColorPickerState(color = RgbaColor.WHITE) - ) - assertEquals(UiLayerId.SystemOverlay, ColorPickerPopupOverlayOwnership.resolveLayer(request)) - } -} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayColorPickerEntryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayColorPickerEntryTests.kt deleted file mode 100644 index b7be430..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayColorPickerEntryTests.kt +++ /dev/null @@ -1,497 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState -import org.dreamfinity.dsgl.core.colorpicker.RgbaColor -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.KeyCodes -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope - -class SystemOverlayColorPickerEntryTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - @Test - fun `system picker popup lifecycle is entry owned and stable`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - assertFalse(host.isSystemColorPickerOpen()) - assertFalse(ColorPickerRuntime.engine.isOpen()) - - pickerHost.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(960, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 44, cursorY = 48, inspectorPointerCaptured = false) - val firstNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val firstState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - assertEquals(OverlayOwnerScope.System, host.debugSystemColorPickerPopupOwnerScope()) - assertTrue(firstState.active) - assertNotNull(firstState.panelState.currentRectOrNull()) - assertFalse(ColorPickerRuntime.engine.isOpen()) - - host.onInputFrame(960, 720) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 50, cursorY = 56, inspectorPointerCaptured = false) - val secondNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val secondState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - assertSame(firstNode, secondNode) - assertSame(firstState, secondState) - - pickerHost.close() - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 50, cursorY = 56, inspectorPointerCaptured = false) - assertFalse(host.isSystemColorPickerOpen()) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerPopup)) - - pickerHost.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(960, 720) - host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = 52, cursorY = 58, inspectorPointerCaptured = false) - val reopenedNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val reopenedState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - assertSame(firstNode, reopenedNode) - assertSame(firstState, reopenedState) - assertTrue(reopenedState.active) - } - - @Test - fun `system picker entry path stays independent from application runtime popup path`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - val appOwner = Any() - - try { - ColorPickerRuntime.engine.open( - ColorPickerPopupRequest( - owner = appOwner, - ownerScope = OverlayOwnerScope.Application, - anchorRect = Rect(240, 210, 20, 18), - title = "App Popup", - state = popupState() - ) - ) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - - pickerHost.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(960, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 44, cursorY = 48, inspectorPointerCaptured = false) - - val node = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val styleTypes = collectStyleTypes(node) - assertTrue(styleTypes.contains("dsgl-overlay-panel")) - assertTrue(styleTypes.contains("dsgl-system-color-picker-native-body")) - assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) - assertEquals(OverlayOwnerScope.System, host.debugSystemColorPickerPopupOwnerScope()) - assertTrue(host.isSystemColorPickerOpen()) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - - pickerHost.close() - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 44, cursorY = 48, inspectorPointerCaptured = false) - assertFalse(host.isSystemColorPickerOpen()) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - } finally { - pickerHost.close() - ColorPickerRuntime.engine.close(appOwner) - } - } - - @Test - fun `system picker popup drag uses persistent entry drag session and keeps node stable`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) - - val stableNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val stateBefore = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - val panelBefore = stateBefore.panelState.currentRectOrNull() ?: error("panel missing") - val header = host.debugSystemColorPickerHeaderRect() ?: error("header missing") - val startX = header.x + 8 - val startY = header.y + 8 - assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) - assertTrue(stateBefore.dragSession.active) - - host.handleMouseMove(startX + 50, startY + 30) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = startX + 50, cursorY = startY + 30, inspectorPointerCaptured = false) - val midState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - val panelMid = midState.panelState.currentRectOrNull() ?: error("panel missing") - assertNotEquals(panelBefore.x, panelMid.x) - - host.handleMouseMove(startX + 90, startY + 60) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = startX + 90, cursorY = startY + 60, inspectorPointerCaptured = false) - val movingNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val movingState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - val panelAfter = movingState.panelState.currentRectOrNull() ?: error("panel missing") - assertSame(stableNode, movingNode) - assertSame(stateBefore, movingState) - assertTrue(movingState.dragSession.active) - assertNotEquals(panelMid.x, panelAfter.x) - - assertTrue(host.handleMouseUp(startX + 90, startY + 60, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = startX + 90, cursorY = startY + 60, inspectorPointerCaptured = false) - val finalState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - val panelFinal = finalState.panelState.currentRectOrNull() ?: error("panel missing") - assertFalse(finalState.dragSession.active) - assertEquals(panelAfter.x, panelFinal.x) - assertEquals(panelAfter.y, panelFinal.y) - } - - @Test - fun `system picker popup survives routine sync updates without remount during drag`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(120, 100, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 126, cursorY = 108, inspectorPointerCaptured = false) - - val initialNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val header = host.debugSystemColorPickerHeaderRect() ?: error("header missing") - val startX = header.x + 6 - val startY = header.y + 6 - assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) - - repeat(5) { step -> - val mx = startX + 20 + step * 10 - val my = startY + 15 + step * 7 - host.handleMouseMove(mx, my) - host.syncFrame( - inspectedRoot = root, - inspectedLayoutRevision = 2L + step, - cursorX = mx, - cursorY = my, - inspectorPointerCaptured = false - ) - val node = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val state = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - assertSame(initialNode, node) - assertTrue(state.dragSession.active) - } - } - - @Test - fun `system picker popup close button closes entry through panel panel`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(80, 86, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 84, cursorY = 92, inspectorPointerCaptured = false) - val closeRect = host.debugSystemColorPickerCloseRect() ?: error("close rect missing") - assertTrue(host.handleMouseDown(closeRect.x + 1, closeRect.y + 1, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = closeRect.x + 1, cursorY = closeRect.y + 1, inspectorPointerCaptured = false) - assertFalse(host.isSystemColorPickerOpen()) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerPopup)) - } - - @Test - fun `system picker keyboard-open path uses valid viewport after input frame sync`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - val anchor = Rect(360, 220, 1, 1) - - pickerHost.open(anchorRect = anchor, title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 364, cursorY = 226, inspectorPointerCaptured = false) - val state = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry state missing") - val panel = state.panelState.currentRectOrNull() ?: error("panel missing") - - assertNotEquals(2, panel.x) - assertNotEquals(2, panel.y) - assertTrue(panel.x >= 8) - assertTrue(panel.y >= 8) - } - - - @Test - fun `system picker entry mounts native body subtree without command bridge`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(60, 70, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 64, cursorY = 74, inspectorPointerCaptured = false) - - val node = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("entry node missing") - val styleTypes = collectStyleTypes(node) - assertTrue(styleTypes.contains("dsgl-overlay-panel")) - assertTrue(styleTypes.contains("dsgl-system-color-picker-native-body")) - assertFalse(styleTypes.contains("dsgl-system-color-picker-command-bridge")) - assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) - } - - @Test - fun `system picker color field drag updates color continuously`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - val previews = ArrayList() - - pickerHost.open( - anchorRect = Rect(80, 90, 20, 18), - title = "Popup", - state = popupState(), - onPreview = { previews += it } - ) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) - - val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val field = layout.colorFieldRect - val startX = field.x + 2 - val startY = field.y + 2 - val midX = field.x + field.width / 2 - val midY = field.y + field.height / 2 - val endX = field.x + field.width - 2 - val endY = field.y + field.height - 2 - - assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) - host.handleMouseMove(midX, midY) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = midX, cursorY = midY, inspectorPointerCaptured = false) - host.handleMouseMove(endX, endY) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = endX, cursorY = endY, inspectorPointerCaptured = false) - assertTrue(host.handleMouseUp(endX, endY, MouseButton.LEFT)) - - val state = host.debugSystemColorPickerState() ?: error("state missing") - assertTrue(previews.size >= 2) - assertNotEquals(0.3f, state.color.r) - } - - @Test - fun `system picker hue and alpha drag update state`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) - - val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val hue = layout.hueRect - val alpha = layout.alphaRect ?: error("alpha rect missing") - val initial = host.debugSystemColorPickerState() ?: error("state missing") - - val hueStartX = hue.x + 2 - val hueEndX = hue.x + hue.width - 2 - val hueY = hue.y + hue.height / 2 - assertTrue(host.handleMouseDown(hueStartX, hueY, MouseButton.LEFT)) - host.handleMouseMove(hueEndX, hueY) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = hueEndX, cursorY = hueY, inspectorPointerCaptured = false) - assertTrue(host.handleMouseUp(hueEndX, hueY, MouseButton.LEFT)) - - val alphaStartX = alpha.x + alpha.width / 2 - val alphaEndX = alpha.x + 2 - val alphaY = alpha.y + alpha.height / 2 - assertTrue(host.handleMouseDown(alphaStartX, alphaY, MouseButton.LEFT)) - host.handleMouseMove(alphaEndX, alphaY) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = alphaEndX, cursorY = alphaY, inspectorPointerCaptured = false) - assertTrue(host.handleMouseUp(alphaEndX, alphaY, MouseButton.LEFT)) - - val changed = host.debugSystemColorPickerState() ?: error("state missing") - assertNotEquals(initial.color.toArgbInt(), changed.color.toArgbInt()) - assertTrue(changed.color.a < initial.color.a) - } - - @Test - fun `system picker text input and mode controls stay synchronized`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 128, cursorY = 128, inspectorPointerCaptured = false) - - val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val modeSelect = initialLayout.modeSelectRect - assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = modeSelect.x + 2, cursorY = modeSelect.y + 2, inspectorPointerCaptured = false) - val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val hslOption = expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") - assertTrue(host.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = hslOption.rect.x + 2, cursorY = hslOption.rect.y + 2, inspectorPointerCaptured = false) - - val modeChanged = host.debugSystemColorPickerState() ?: error("state missing") - assertEquals(ColorFormatMode.HSL, modeChanged.mode) - - val hslLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val hueInput = hslLayout.inputSlots.firstOrNull { it.key == "h" } ?: error("h input missing") - assertTrue(host.handleMouseDown(hueInput.inputRect.x + 2, hueInput.inputRect.y + 2, MouseButton.LEFT)) - assertTrue(host.handleKeyDown(KeyCodes.DELETE, 0.toChar())) - assertTrue(host.handleKeyDown(0, '1')) - assertTrue(host.handleKeyDown(0, '8')) - assertTrue(host.handleKeyDown(0, '0')) - assertTrue(host.handleKeyDown(KeyCodes.ENTER, '\n')) - - val updated = host.debugSystemColorPickerState() ?: error("state missing") - assertEquals(ColorFormatMode.HSL, updated.mode) - assertTrue(updated.color.toArgbInt() != modeChanged.color.toArgbInt()) - } - - - @Test - fun `system picker mode dropdown is mounted in transient lane and stays interactive`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 128, cursorY = 128, inspectorPointerCaptured = false) - - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerTransient)) - - val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val modeSelect = initialLayout.modeSelectRect - assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = modeSelect.x + 2, cursorY = modeSelect.y + 2, inspectorPointerCaptured = false) - - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerTransient)) - val transientNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerTransient) ?: error("transient node missing") - val transientStyleTypes = collectStyleTypes(transientNode) - assertTrue(transientStyleTypes.contains("dsgl-system-color-picker-native-mode-dropdown-overlay")) - - val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("expanded layout missing") - val hslOption = expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") - assertTrue(host.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = hslOption.rect.x + 2, cursorY = hslOption.rect.y + 2, inspectorPointerCaptured = false) - - assertEquals(ColorFormatMode.HSL, host.debugSystemColorPickerState()?.mode) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerTransient)) - } - - @Test - fun `system picker pipette keeps system overlay visible and uses transient lane`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - - pickerHost.open(anchorRect = Rect(140, 140, 20, 18), title = "Popup", state = popupState()) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 146, cursorY = 146, inspectorPointerCaptured = false) - - val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val pipette = layout.pipetteRect - assertTrue(host.handleMouseDown(pipette.x + 2, pipette.y + 2, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = pipette.x + 2, cursorY = pipette.y + 2, inspectorPointerCaptured = false) - - val mounted = host.debugMountedEntryIds() - assertTrue(mounted.contains(SystemOverlayEntryId.ColorPickerPopup)) - assertTrue(mounted.contains(SystemOverlayEntryId.ColorPickerTransient)) - assertTrue(host.isSystemColorPickerOpen()) - assertTrue(host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup)?.active == true) - assertEquals(OverlayOwnerScope.System, host.debugSystemColorPickerPopupOwnerScope()) - - val moveConsumed = host.handleMouseMove(pipette.x + 40, pipette.y + 40) - assertTrue(moveConsumed) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = pipette.x + 40, cursorY = pipette.y + 40, inspectorPointerCaptured = false) - - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerPopup)) - } - - - @Test - fun `system picker pipette transient entry emits visible tooltip commands`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() - val root = inspectedRoot() - val gridColor = 0x7F4C93FF - val checkerLight = 0x7F0AA0A0 - val checkerDark = 0x7F104040 - - pickerHost.open( - anchorRect = Rect(140, 140, 20, 18), - title = "Popup", - state = popupState(), - style = ColorPickerStyle( - eyedropperGridSize = 5, - eyedropperCellSize = 3, - eyedropperGridOverlayEnabled = true, - eyedropperGridOverlayColor = gridColor, - checkerLightColor = checkerLight, - checkerDarkColor = checkerDark - ) - ) - host.onInputFrame(1200, 800) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 146, cursorY = 146, inspectorPointerCaptured = false) - - val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") - val pipette = layout.pipetteRect - assertTrue(host.handleMouseDown(pipette.x + 2, pipette.y + 2, MouseButton.LEFT)) - host.handleMouseMove(pipette.x + 32, pipette.y + 28) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = pipette.x + 32, cursorY = pipette.y + 28, inspectorPointerCaptured = false) - - host.render(ctx, 1200, 800) - val commands = host.paint(ctx) - assertTrue(commands.any { it is RenderCommand.CaptureScreenRegion }) - assertTrue(commands.any { it is RenderCommand.DrawCapturedScreenRegion }) - assertTrue(commands.none { command -> - command is RenderCommand.DrawRect && command.width == 3 && command.height == 3 - }) - assertTrue(commands.any { it is RenderCommand.DrawCheckerboard }) - assertTrue(commands.none { command -> - command is RenderCommand.DrawRect && (command.color == checkerLight || command.color == checkerDark) - }) - val gridLines = commands.filterIsInstance().filter { it.color == gridColor } - assertEquals(8, gridLines.size) - assertTrue(gridLines.any { it.width == 1 && it.height == 15 }) - assertTrue(gridLines.any { it.width == 15 && it.height == 1 }) - assertTrue(commands.any { command -> - command is RenderCommand.DrawText && command.text.startsWith("Mode:") - }) - } - private fun collectStyleTypes(root: DOMNode): Set { - val out = LinkedHashSet() - fun walk(node: DOMNode) { - out += node.styleType - node.children.forEach(::walk) - } - walk(root) - return out - } - private fun popupState(): ColorPickerState { - return ColorPickerState( - color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), - previous = RgbaColor(0.3f, 0.5f, 0.7f, 1f), - mode = ColorFormatMode.RGB, - alphaEnabled = true, - closeOnSelect = false - ) - } - - private fun inspectedRoot(): ContainerNode { - val root = ContainerNode(key = "root") - root.bounds = Rect(0, 0, 1200, 800) - ContainerNode(key = "child").apply { - bounds = Rect(16, 18, 120, 30) - }.applyParent(root) - return root - } -} - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDomBridgeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDomBridgeTests.kt deleted file mode 100644 index f6b9668..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayDomBridgeTests.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertSame -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.FocusManager -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.overlay.input.LayerDomInputRouter -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorOverlayNode -import org.dreamfinity.dsgl.core.render.RenderCommand - -class SystemOverlayDomBridgeTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - - @Test - fun `renderer maps legacy commands to dom nodes`() { - val host = ContainerNode(stackLayout = true, key = "host") - val commands = listOf( - RenderCommand.DrawRect(10, 12, 30, 14, 0xFF112233.toInt()), - RenderCommand.DrawText("Hello", 18, 20, 0xFFEEDDCC.toInt()) - ) - - SystemOverlayCommandDslRenderer.rebuildInto(host, commands, "test") - - assertEquals(2, host.children.size) - assertTrue(host.children.all { it.styleType == "dsgl-system-raw-render-command" }) - } - - @Test - fun `renderer reuses raw nodes instead of recreating them`() { - val host = ContainerNode(stackLayout = true, key = "host") - val first = listOf( - RenderCommand.DrawRect(2, 4, 12, 10, 0xFF223344.toInt()), - RenderCommand.DrawText("A", 6, 7, 0xFFFFFFFF.toInt()) - ) - val second = listOf( - RenderCommand.DrawRect(2, 4, 12, 10, 0xFF556677.toInt()), - RenderCommand.DrawText("B", 6, 7, 0xFFFFFFFF.toInt()) - ) - - SystemOverlayCommandDslRenderer.rebuildInto(host, first, "reuse") - val firstNode0 = host.children[0] - val firstNode1 = host.children[1] - - SystemOverlayCommandDslRenderer.rebuildInto(host, second, "reuse") - assertSame(firstNode0, host.children[0]) - assertSame(firstNode1, host.children[1]) - } - - @Test - fun `system inspector overlay creates native dom children from controller frame`() { - val controller = InspectorController() - controller.toggle() - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 420, 280) - } - ContainerNode(key = "child").apply { - bounds = Rect(16, 18, 120, 28) - }.applyParent(root) - - val overlay = SystemInspectorOverlayNode(controller) - overlay.bindInspectedTree(root, layoutRevision = 1L) - overlay.updateCursor(mouseX = 22, mouseY = 22, pointerCaptured = false) - overlay.render(ctx, 0, 0, 420, 280) - - assertTrue(overlay.children.isNotEmpty()) - assertTrue(overlay.children.none { it.styleType == "dsgl-system-raw-render-command" }) - } - - @Test - fun `system inspector overlay retains focused native input across frame rebuild`() { - val controller = InspectorController() - controller.toggle() - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 1280, 720) - } - ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - }.applyParent(root) - - val overlay = SystemInspectorOverlayNode(controller) - controller.onLayoutCommitted(root, 1L) - controller.onCursorMoved(984, 144) - controller.handleMouseDown(984, 144, MouseButton.LEFT) - - overlay.bindInspectedTree(root, layoutRevision = 2L) - overlay.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) - overlay.render(ctx, 0, 0, 1280, 720) - - fun findFirstInput(node: DOMNode): SingleLineInputNode? { - if (node is SingleLineInputNode) return node - node.children.forEach { child -> - val found = findFirstInput(child) - if (found != null) return found - } - return null - } - - val initialInput = findFirstInput(overlay) - assertNotNull(initialInput) - val router = LayerDomInputRouter { overlay } - val clickX = initialInput.bounds.x + 2 - val clickY = initialInput.bounds.y + initialInput.bounds.height / 2 - assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) - assertTrue(router.handleMouseUp(clickX, clickY, MouseButton.LEFT)) - - val focusedAfterClick = FocusManager.focusedNode() - assertNotNull(focusedAfterClick) - val focusedKey = focusedAfterClick.key - assertEquals(initialInput.key, focusedKey) - - overlay.bindInspectedTree(root, layoutRevision = 3L) - overlay.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) - overlay.render(ctx, 0, 0, 1280, 720) - - val focusedAfterRebuild = FocusManager.focusedNode() - assertTrue(focusedAfterRebuild is SingleLineInputNode) - assertEquals(focusedKey, focusedAfterRebuild.key) - assertTrue(focusedAfterRebuild !== initialInput) - } - @Test - fun `system inspector overlay mounts only while inspector is active`() { - val controller = InspectorController() - val root = ContainerNode(key = "root").apply { - bounds = Rect(0, 0, 420, 280) - } - val overlay = SystemInspectorOverlayNode(controller) - - overlay.bindInspectedTree(root, layoutRevision = 1L) - overlay.render(ctx, 0, 0, 420, 280) - assertTrue(overlay.children.isEmpty()) - - controller.toggle() - overlay.render(ctx, 0, 0, 420, 280) - assertTrue(overlay.children.isNotEmpty()) - assertTrue(overlay.children.none { it.styleType == "dsgl-system-raw-render-command" }) - - controller.deactivate() - overlay.render(ctx, 0, 0, 420, 280) - assertTrue(overlay.children.isEmpty()) - } -} - - - - - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayInspectorNativeEntryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayInspectorNativeEntryTests.kt deleted file mode 100644 index 6135045..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayInspectorNativeEntryTests.kt +++ /dev/null @@ -1,1029 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import java.io.File -import java.nio.file.Files -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState -import org.dreamfinity.dsgl.core.colorpicker.RgbaColor -import org.dreamfinity.dsgl.core.dom.DOMNode -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.EventBus -import org.dreamfinity.dsgl.core.event.KeyModifiers -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorMode -import org.dreamfinity.dsgl.core.inspector.InspectorPanelState -import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorOverlayNode -import org.dreamfinity.dsgl.core.render.RenderCommand -import org.dreamfinity.dsgl.core.style.Display -import org.dreamfinity.dsgl.core.style.Overflow -import org.dreamfinity.dsgl.core.style.StyleEngine -import org.dreamfinity.dsgl.core.style.StyleExpression -import org.dreamfinity.dsgl.core.style.StyleProperty -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope - -class SystemOverlayInspectorNativeEntryTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - - @AfterTest - fun cleanup() { - KeyModifiers.sync(shift = false, control = false, meta = false) - StyleEngine.setStylesDirectory(null) - StyleEngine.clearAllInspectorOverrides() - StyleEngine.clearCache() - } - - @Test - fun `inspector migration removes intermediate native overlay model classes`() { - val loadResult = runCatching { - Class.forName("org.dreamfinity.dsgl.core.inspector.InspectorNativeOverlayModel") - } - assertTrue(loadResult.isFailure) - } - - @Test - fun `inspector controller no longer exposes manual append overlay commands path`() { - val methodNames = InspectorController::class.java.methods.map { it.name } - assertFalse(methodNames.contains("appendOverlayCommands")) - } - - @Test - fun `inspector overlay rebuild does not leak event bus registrations`() { - val controller = InspectorController() - val overlay = SystemInspectorOverlayNode(controller) - val root = inspectedRoot() - - controller.toggle() - overlay.bindInspectedTree(root, layoutRevision = 1L) - overlay.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) - overlay.render(ctx, 0, 0, 1280, 720) - val firstSnapshot = EventBus.debugListenerSnapshot() - - repeat(24) { frame -> - overlay.bindInspectedTree(root, layoutRevision = 2L + frame) - overlay.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) - overlay.render(ctx, 0, 0, 1280, 720) - } - val repeatedSnapshot = EventBus.debugListenerSnapshot() - - assertTrue(repeatedSnapshot.registeredNodes <= firstSnapshot.registeredNodes + 4) - assertTrue(repeatedSnapshot.registeredCallbacks <= firstSnapshot.registeredCallbacks + 24) - - controller.deactivate() - overlay.render(ctx, 0, 0, 1280, 720) - val deactivatedSnapshot = EventBus.debugListenerSnapshot() - assertTrue(deactivatedSnapshot.registeredNodes <= firstSnapshot.registeredNodes) - assertTrue(deactivatedSnapshot.registeredCallbacks <= firstSnapshot.registeredCallbacks) - } - - @Test - fun `live inspector path is native system-overlay entry and anti-legacy guarded`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRoot() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.Inspector)) - val node = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector entry missing") - val styleTypes = collectStyleTypes(node) - assertTrue(styleTypes.contains("dsgl-system-inspector")) - assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) - assertFalse(styleTypes.contains("dsgl-system-inspector-command-bridge")) - } - - @Test - fun `inspector runtime interaction path supports selection controls and system-owned color edit`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRoot() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 80, cursorY = 52, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - val pickToggle = inspector.debugPickToggleBounds() ?: error("pick toggle missing") - assertTrue(host.handleMouseDown(pickToggle.x + 1, pickToggle.y + 1, MouseButton.LEFT)) - assertTrue(host.handleMouseUp(pickToggle.x + 1, pickToggle.y + 1, MouseButton.LEFT)) - assertEquals(InspectorMode.Pick, inspector.mode) - - val colorAction = inspector.debugColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) - val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) - val openedByClick = if (colorAction != null) { - host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && - host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) - } else { - false - } - if (!openedByClick) { - assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) - } - - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = colorAnchor.x + 1, cursorY = colorAnchor.y + 1, inspectorPointerCaptured = false) - assertTrue(host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, host.debugSystemColorPickerPopupOwnerScope()) - - val pickerNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("picker node missing") - val pickerStyles = collectStyleTypes(pickerNode) - assertTrue(pickerStyles.contains("dsgl-system-color-picker-native-body")) - assertFalse(pickerStyles.contains("dsgl-system-raw-render-command")) - } - - @Test - fun `pick selection resolves from latest synced tree before render`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - val root = inspectedRoot() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - } - - @Test - fun `inspector minimize restore and close reopen remain stable`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRoot() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - val initialNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - val minimizeRect = inspector.debugMinimizeBounds() ?: error("minimize bounds missing") - assertTrue(host.handleMouseDown(minimizeRect.x + 1, minimizeRect.y + 1, MouseButton.LEFT)) - assertTrue(host.handleMouseUp(minimizeRect.x + 1, minimizeRect.y + 1, MouseButton.LEFT)) - assertEquals(InspectorPanelState.Minimized, inspector.panelState) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - val (chipX, chipY) = inspector.panelPosition - assertTrue(host.handleMouseDown(chipX + 2, chipY + 2, MouseButton.LEFT)) - assertTrue(host.handleMouseUp(chipX + 2, chipY + 2, MouseButton.LEFT)) - assertEquals(InspectorPanelState.Expanded, inspector.panelState) - - inspector.deactivate() - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.Inspector)) - - inspector.toggle() - host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - val reopenedNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - assertSame(initialNode, reopenedNode) - } - - @Test - fun `inspector native path preserves scroll and scrollbar drag behavior`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val contentRect = inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 12 - assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) - - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - val afterWheel = inspector.panelScrollOffsetY - assertTrue(afterWheel > 0, "expected wheel scroll > 0, actual=$afterWheel") - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val thumbX = thumb.x + 1 - val thumbY = thumb.y + thumb.height / 2 - assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) - assertTrue(host.handleMouseMove(thumbX, thumbY + 70)) - assertTrue(host.handleMouseUp(thumbX, thumbY + 70, MouseButton.LEFT)) - assertTrue(inspector.panelScrollOffsetY >= afterWheel) - } - - - @Test - fun `scrollbar drag release over control ends capture and does not trigger control click`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val pickToggle = inspector.debugPickToggleBounds() ?: error("pick toggle missing") - val modeBeforeRelease = inspector.mode - - val thumbX = thumb.x + thumb.width / 2 - val thumbY = thumb.y + thumb.height / 2 - assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) - - val releaseX = pickToggle.x + 2 - val releaseY = pickToggle.y + pickToggle.height / 2 - assertTrue(host.handleMouseMove(releaseX, releaseY)) - assertTrue(host.handleMouseUp(releaseX, releaseY, MouseButton.LEFT)) - - assertFalse(inspector.isPointerCaptured) - assertEquals(modeBeforeRelease, inspector.mode) - - val scrollAfterRelease = inspector.panelScrollOffsetY - host.syncFrame( - root, - inspectedLayoutRevision = 3L, - cursorX = releaseX, - cursorY = releaseY + 48, - inspectorPointerCaptured = inspector.isPointerCaptured - ) - host.render(ctx, 420, 280) - assertEquals(scrollAfterRelease, inspector.panelScrollOffsetY) - } - @Test - fun `scrollbar drag release outside inspector consumes mouse up and stops capture`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val panelRect = inspector.debugPanelRect() ?: error("panel rect missing") - val modeBeforeRelease = inspector.mode - - val candidatePoints = listOf( - Pair((panelRect.x + panelRect.width + 4).coerceAtMost(419), panelRect.y + 6), - Pair((panelRect.x - 4).coerceAtLeast(0), panelRect.y + 6), - Pair(panelRect.x + 6, (panelRect.y - 4).coerceAtLeast(0)), - Pair(panelRect.x + 6, (panelRect.y + panelRect.height + 4).coerceAtMost(279)) - ) - val outsidePoint = candidatePoints.firstOrNull { (x, y) -> !panelRect.contains(x, y) } - ?: error("failed to find outside release point") - val releaseX = outsidePoint.first - val releaseY = outsidePoint.second - - val thumbX = thumb.x + thumb.width / 2 - val thumbY = thumb.y + thumb.height / 2 - assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) - - assertTrue(host.handleMouseMove(releaseX, releaseY)) - assertTrue(host.handleMouseUp(releaseX, releaseY, MouseButton.LEFT)) - assertFalse(inspector.isPointerCaptured) - assertEquals(modeBeforeRelease, inspector.mode) - - val scrollAfterRelease = inspector.panelScrollOffsetY - host.syncFrame( - root, - inspectedLayoutRevision = 3L, - cursorX = (releaseX + 32).coerceAtMost(419), - cursorY = (releaseY + 32).coerceAtMost(279), - inspectorPointerCaptured = inspector.isPointerCaptured - ) - host.render(ctx, 420, 280) - assertEquals(scrollAfterRelease, inspector.panelScrollOffsetY) - } - @Test - fun `inspector color edit ownership stays system-owned and independent from app runtime popup`() { - val appOwner = Any() - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRoot() - - try { - ColorPickerRuntime.engine.open( - ColorPickerPopupRequest( - owner = appOwner, - ownerScope = OverlayOwnerScope.Application, - anchorRect = Rect(240, 210, 20, 18), - title = "App Popup", - state = popupState() - ) - ) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 80, cursorY = 52, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - val colorAction = inspector.debugColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) - val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) - val openedByClick = if (colorAction != null) { - host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && - host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) - } else { - false - } - if (!openedByClick) { - assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) - } - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = colorAnchor.x + 1, cursorY = colorAnchor.y + 1, inspectorPointerCaptured = false) - - assertTrue(host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, host.debugSystemColorPickerPopupOwnerScope()) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - - host.systemInspectorColorPickerPopupHost().close() - host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = colorAnchor.x + 1, cursorY = colorAnchor.y + 1, inspectorPointerCaptured = false) - assertFalse(host.isSystemColorPickerOpen()) - assertTrue(ColorPickerRuntime.engine.isOpenFor(appOwner)) - } finally { - host.systemInspectorColorPickerPopupHost().close() - ColorPickerRuntime.engine.close(appOwner) - } - } - @Test - fun `inspector native body content remains clipped in narrow viewport`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(320, 220) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 320, 220) - - val bodyRect = inspector.debugContentRect() - val inspectorNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - val bodyNode = collectNodes(inspectorNode) - .firstOrNull { it.key?.toString() == "dsgl-system-inspector-body" } - ?: error("inspector body node missing") - assertEquals(Overflow.Hidden, bodyNode.overflowX) - assertEquals(Overflow.Auto, bodyNode.overflowY) - val bodyViewport = bodyNode.overflowViewportRect() ?: bodyRect - - val initialCommands = host.paint(ctx) - assertTrue(initialCommands.any { command -> - command is RenderCommand.PushClip && - command.x == bodyViewport.x && - command.y == bodyViewport.y && - command.width == bodyViewport.width && - command.height == bodyViewport.height - }) - - assertTrue(host.handleMouseWheel(bodyRect.x + 4, bodyRect.y + 12, -120)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = bodyRect.x + 4, cursorY = bodyRect.y + 12, inspectorPointerCaptured = false) - host.render(ctx, 320, 220) - - val bodyLines = collectNodes(inspectorNode).filter { node -> - if (node.display == Display.None) return@filter false - val key = node.key?.toString() ?: return@filter false - key.startsWith("dsgl-system-inspector-info-line-") || - key.startsWith("dsgl-system-inspector-style-line-") - } - - assertTrue(bodyLines.isNotEmpty()) - assertTrue(bodyLines.any { node -> - node.bounds.y < bodyRect.y || node.bounds.y + node.bounds.height > bodyRect.y + bodyRect.height - }) - - val edgeIntersecting = bodyLines.filter { node -> - intersects(node.bounds, bodyRect) && !containsFully(bodyRect, node.bounds) - } - assertTrue(edgeIntersecting.isNotEmpty()) - assertTrue(edgeIntersecting.all { it.bounds.height >= 24 }) - - val scrolledCommands = host.paint(ctx) - assertTrue(scrolledCommands.any { command -> - command is RenderCommand.PushClip && - command.x == bodyViewport.x && - command.y == bodyViewport.y && - command.width == bodyViewport.width && - command.height == bodyViewport.height - }) - } - @Test - fun `inspector clipped body blocks hidden row input and accepts visible portion`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - - host.onInputFrame(320, 213) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 320, 213) - - val bodyRect = inspector.debugContentRect() - val wheelX = bodyRect.x + 4 - val wheelY = bodyRect.y + 12 - - var revision = 3L - var edgeNode: DOMNode? = null - var hiddenNode: DOMNode? = null - var visibleNode: DOMNode? = null - var latestInteractiveNodes: List = emptyList() - repeat(24) { - val inspectorNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - val interactiveNodes = collectNodes(inspectorNode).filter { node -> - if (node.display == Display.None) return@filter false - val key = node.key?.toString() ?: return@filter false - isInteractiveInspectorControlKey(key) - } - latestInteractiveNodes = interactiveNodes - edgeNode = interactiveNodes.firstOrNull { node -> intersects(node.bounds, bodyRect) && !containsFully(bodyRect, node.bounds) } - hiddenNode = interactiveNodes.firstOrNull { node -> !intersects(node.bounds, bodyRect) } - visibleNode = interactiveNodes.firstOrNull { node -> containsFully(bodyRect, node.bounds) } - if (edgeNode != null && visibleNode != null) return@repeat - assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) - host.syncFrame(root, inspectedLayoutRevision = revision, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 320, 213) - revision += 1L - } - - val hiddenTarget = edgeNode ?: hiddenNode ?: latestInteractiveNodes.firstOrNull { node -> !intersects(node.bounds, bodyRect) } ?: error("failed to find hidden interactive inspector control") - val visibleTarget = visibleNode ?: latestInteractiveNodes.firstOrNull { node -> intersects(node.bounds, bodyRect) } ?: edgeNode - - val hiddenX = if (edgeNode != null) { - maxOf(hiddenTarget.bounds.x, bodyRect.x) + 2 - } else { - hiddenTarget.bounds.x + (hiddenTarget.bounds.width / 2).coerceAtLeast(1) - } - val hiddenY = if (edgeNode != null) { - if (hiddenTarget.bounds.y < bodyRect.y) { - hiddenTarget.bounds.y + 1 - } else { - hiddenTarget.bounds.y + hiddenTarget.bounds.height - 1 - } - } else { - hiddenTarget.bounds.y + (hiddenTarget.bounds.height / 2).coerceAtLeast(1) - } - - assertFalse(bodyRect.contains(hiddenX, hiddenY)) - assertTrue(hiddenTarget.bounds.contains(hiddenX, hiddenY)) - - assertFalse(host.handleMouseDown(hiddenX, hiddenY, MouseButton.LEFT)) - host.handleMouseUp(hiddenX, hiddenY, MouseButton.LEFT) - assertEquals("target", inspector.selectedKey) - - if (visibleTarget == null) return - - val visibleX = maxOf(visibleTarget.bounds.x, bodyRect.x) + 2 - val visibleY = maxOf(visibleTarget.bounds.y, bodyRect.y) + 1 - assertTrue(bodyRect.contains(visibleX, visibleY)) - assertTrue(visibleTarget.bounds.contains(visibleX, visibleY)) - - assertTrue(host.handleMouseDown(visibleX, visibleY, MouseButton.LEFT)) - assertTrue(host.handleMouseUp(visibleX, visibleY, MouseButton.LEFT)) - } - @Test - fun `inspector body consumes generic scroll viewport and content state`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val inspectorNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - val bodyNode = collectNodes(inspectorNode).firstOrNull { it.key == "dsgl-system-inspector-body" } - ?: error("inspector body node missing") - val scrollState = bodyNode.scrollContainerState() - - assertTrue(scrollState.axisY.scrollContainer) - assertTrue(scrollState.axisY.clipsToViewport) - assertTrue(scrollState.viewportRect.width > 0 && scrollState.viewportRect.height > 0) - assertTrue(scrollState.contentExtent.height >= scrollState.viewportRect.height) - assertTrue(!scrollState.axisX.scrollbarPresent) - assertEquals(0, scrollState.horizontalScrollbarGutter) - if (scrollState.axisY.scrollbarPresent) { - assertTrue(scrollState.verticalScrollbarGutter > 0) - assertTrue(scrollState.viewportRect.width < scrollState.baseViewportRect.width) - } else { - assertEquals(0, scrollState.verticalScrollbarGutter) - assertEquals(scrollState.baseViewportRect.width, scrollState.viewportRect.width) - } - assertEquals(scrollState.baseViewportRect.height, scrollState.viewportRect.height) - assertEquals(scrollState.viewportRect, bodyNode.overflowViewportRect()) - } - - @Test - fun `inspector wheel scrolling works when hovering interactive input`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - assertEquals("target", inspector.selectedKey) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val inspectorNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector node missing") - val bodyRect = inspector.debugContentRect() - val allNodes = collectNodes(inspectorNode) - val interactiveNode = allNodes.firstOrNull { node -> - val key = node.key?.toString() ?: return@firstOrNull false - val interactiveControl = key.startsWith("dsgl-system-inspector-editor-input-") || - key.startsWith("dsgl-system-inspector-editor-numeric-input-") || - key.startsWith("dsgl-system-inspector-editor-select-") || - key.startsWith("dsgl-system-inspector-editor-color-preview-") - if (!interactiveControl) return@firstOrNull false - val probeX = node.bounds.x + 2 - val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) - bodyRect.contains(probeX, probeY) - } - val wheelNode = interactiveNode ?: allNodes.firstOrNull { node -> - val probeX = node.bounds.x + 2 - val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) - bodyRect.contains(probeX, probeY) - } ?: error("visible inspector body content node missing") - - val wheelX = wheelNode.bounds.x + 2 - val wheelY = wheelNode.bounds.y + (wheelNode.bounds.height / 2).coerceAtLeast(1) - - val before = inspector.panelScrollOffsetY - assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - assertTrue(inspector.panelScrollOffsetY > before) - } - @Test - fun `inspector shift wheel does not consume vertical wheel path`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - - val bodyRect = inspector.debugContentRect() - val wheelX = bodyRect.x + 4 - val wheelY = bodyRect.y + 12 - val before = inspector.panelScrollOffsetY - - KeyModifiers.sync(shift = true, control = false, meta = false) - host.handleMouseWheel(wheelX, wheelY, -120) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - assertEquals(before, inspector.panelScrollOffsetY) - KeyModifiers.sync(shift = false, control = false, meta = false) - } - - @Test - fun `inspector wheel scrolling remains symmetric across rebuilds`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - host.paint(ctx) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - val contentRect = inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 12 - - repeat(4) { step -> - assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) - host.syncFrame(root, inspectedLayoutRevision = 3L + step, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - } - repeat(16) { settle -> - host.syncFrame(root, inspectedLayoutRevision = 20L + settle, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - } - val scrolledDown = inspector.panelScrollOffsetY - assertTrue(scrolledDown > 0, "expected downward wheel to increase scroll: down=$scrolledDown") - - var consumedUpWheel = false - repeat(8) { step -> - val consumed = host.handleMouseWheel(wheelX, wheelY, 120) - consumedUpWheel = consumedUpWheel || consumed - host.syncFrame(root, inspectedLayoutRevision = 40L + step, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - } - assertTrue(consumedUpWheel, "expected at least one upward wheel step to be consumed") - var scrolledUp = inspector.panelScrollOffsetY - repeat(24) { settle -> - if (scrolledUp < scrolledDown) return@repeat - host.syncFrame(root, inspectedLayoutRevision = 60L + settle, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - scrolledUp = inspector.panelScrollOffsetY - } - assertTrue(scrolledUp < scrolledDown, "expected upward wheel to reduce scroll: down=$scrolledDown up=$scrolledUp") - } - - @Test - fun `inspector thumb drag remains active across rebuild without controller pointer capture`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - host.paint(ctx) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val dragX = thumb.x + thumb.width / 2 - val dragStartY = thumb.y + thumb.height / 2 - - assertTrue(host.handleMouseDown(dragX, dragStartY, MouseButton.LEFT)) - assertFalse(inspector.isPointerCaptured) - val beforeDrag = inspector.panelScrollOffsetY - - assertTrue(host.handleMouseMove(dragX, dragStartY + 18)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = dragX, cursorY = dragStartY + 18, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - val afterFirstMove = inspector.panelScrollOffsetY - assertTrue(afterFirstMove > beforeDrag) - - assertTrue(host.handleMouseMove(dragX, dragStartY + 42)) - host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = dragX, cursorY = dragStartY + 42, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - val afterSecondMove = inspector.panelScrollOffsetY - assertTrue(afterSecondMove > afterFirstMove) - - assertTrue(host.handleMouseUp(dragX, dragStartY + 42, MouseButton.LEFT)) - } - @Test - fun `inspector style boundary stays isolated from application stylesheet`() { - val stylesDir = createTempStylesDir( - """ - text { color: #FF00FF00; } - div { background-color: #FFFF00FF; } - """.trimIndent() - ) - StyleEngine.setStylesDirectory(stylesDir) - StyleEngine.forceReloadStylesheets() - - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRoot() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - val commands = host.paint(ctx) - val headerTexts = commands - .filterIsInstance() - .filter { it.text.startsWith("Inspector") } - - assertTrue(headerTexts.isNotEmpty()) - assertTrue(headerTexts.none { it.color == 0xFF00FF00.toInt() }) - assertTrue(headerTexts.any { it.color == 0xFFE6EDF6.toInt() }) - } - - private fun inspectedRoot(): ContainerNode { - val root = ContainerNode(key = "root") - root.bounds = Rect(0, 0, 1280, 720) - ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - }.applyParent(root) - StyleEngine.setInspectorOverrideLiteral(root.children.first(), StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() - return root - } - - private fun inspectedRootWithManyChildren(): ContainerNode { - val root = ContainerNode(key = "root") - root.bounds = Rect(0, 0, 1800, 1200) - val selected = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 260, 180) - }.applyParent(root) - repeat(60) { index -> - ContainerNode(key = "child-$index").apply { - bounds = Rect(980, 180 + index * 12, 180, 10) - }.applyParent(selected) - } - StyleEngine.setInspectorOverrideLiteral(selected, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() - return root - } - - private fun popupState(): ColorPickerState { - return ColorPickerState( - color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), - previous = RgbaColor(0.3f, 0.5f, 0.7f, 1f), - mode = ColorFormatMode.RGB, - alphaEnabled = true, - closeOnSelect = false - ) - } - - private fun intersects(a: Rect, b: Rect): Boolean { - return a.x < b.x + b.width && - a.x + a.width > b.x && - a.y < b.y + b.height && - a.y + a.height > b.y - } - - private fun containsFully(outer: Rect, inner: Rect): Boolean { - return inner.x >= outer.x && - inner.y >= outer.y && - inner.x + inner.width <= outer.x + outer.width && - inner.y + inner.height <= outer.y + outer.height - } - - private fun isInteractiveInspectorControlKey(key: String): Boolean { - return key == "dsgl-system-inspector-parent-row" || - key.startsWith("dsgl-system-inspector-child-row-") || - key.startsWith("dsgl-system-inspector-editor-reset-") || - key.startsWith("dsgl-system-inspector-editor-select-") || - key.startsWith("dsgl-system-inspector-editor-dec-") || - key.startsWith("dsgl-system-inspector-editor-inc-") || - key.startsWith("dsgl-system-inspector-editor-unit-") || - key.startsWith("dsgl-system-inspector-editor-color-preview-") || - key == "dsgl-system-inspector-reset-node" || - key == "dsgl-system-inspector-clear-all" - } - private fun collectNodes(root: DOMNode): List { - val out = ArrayList() - fun walk(node: DOMNode) { - out += node - node.children.forEach(::walk) - } - walk(root) - return out - } - private fun collectStyleTypes(root: DOMNode): Set { - val out = LinkedHashSet() - fun walk(node: DOMNode) { - out += node.styleType - node.children.forEach(::walk) - } - walk(root) - return out - } - - private fun createTempStylesDir(dss: String): File { - val root = Files.createTempDirectory("dsgl-system-inspector-style-").toFile() - root.resolve("test.dss").writeText(dss) - return root - } - - @Test - fun `inspector consumer scroll reacts on frame update without viewport resize`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - host.paint(ctx) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - val contentRect = inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 14 - val before = inspector.panelScrollOffsetY - - assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = wheelX, cursorY = wheelY, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - assertTrue(inspector.panelScrollOffsetY > before) - } - - @Test - fun `inspector consumer thumb drag remains smooth and stable on release`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - host.paint(ctx) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val dragX = thumb.x + thumb.width / 2 - val startY = thumb.y + thumb.height / 2 - - assertTrue(host.handleMouseDown(dragX, startY, MouseButton.LEFT)) - var previousScroll = inspector.panelScrollOffsetY - var previousThumbY = inspector.debugScrollbarThumbRect().y - - repeat(6) { step -> - val nextY = startY + (step + 1) * 9 - assertTrue(host.handleMouseMove(dragX, nextY)) - host.syncFrame(root, inspectedLayoutRevision = 3L + step, cursorX = dragX, cursorY = nextY, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - val currentScroll = inspector.panelScrollOffsetY - val currentThumbY = inspector.debugScrollbarThumbRect().y - assertTrue(currentScroll >= previousScroll, "scroll regressed: prev=$previousScroll current=$currentScroll step=$step") - assertTrue(currentThumbY >= previousThumbY, "thumb regressed: prev=$previousThumbY current=$currentThumbY step=$step") - previousScroll = currentScroll - previousThumbY = currentThumbY - } - - assertTrue(host.handleMouseUp(dragX, startY + 6 * 9, MouseButton.LEFT)) - val settledScroll = inspector.panelScrollOffsetY - val settledThumbY = inspector.debugScrollbarThumbRect().y - - repeat(6) { idx -> - host.syncFrame(root, inspectedLayoutRevision = 20L + idx, cursorX = dragX, cursorY = startY, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - assertEquals(settledScroll, inspector.panelScrollOffsetY) - assertEquals(settledThumbY, inspector.debugScrollbarThumbRect().y) - } - } - @Test - fun `inspector consumer fast thumb drag to boundary stays stable`() { - val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) - val root = inspectedRootWithManyChildren() - - inspector.toggle() - host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - host.paint(ctx) - assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) - - host.onInputFrame(420, 280) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) - host.render(ctx, 420, 280) - host.paint(ctx) - - val thumb = inspector.debugScrollbarThumbRect() - assertTrue(thumb.width > 0 && thumb.height > 0) - val dragX = thumb.x + thumb.width / 2 - val startY = thumb.y + thumb.height / 2 - - assertTrue(host.handleMouseDown(dragX, startY, MouseButton.LEFT)) - var previousScroll = inspector.panelScrollOffsetY - var previousThumbY = inspector.debugScrollbarThumbRect().y - repeat(7) { step -> - val nextY = startY + (step + 1) * 120 - assertTrue(host.handleMouseMove(dragX, nextY)) - host.syncFrame(root, inspectedLayoutRevision = 20L + step, cursorX = dragX, cursorY = nextY, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - val currentScroll = inspector.panelScrollOffsetY - val currentThumbY = inspector.debugScrollbarThumbRect().y - assertTrue(currentScroll >= previousScroll, "scroll regressed: prev=$previousScroll current=$currentScroll step=$step") - assertTrue(currentThumbY >= previousThumbY, "thumb regressed: prev=$previousThumbY current=$currentThumbY step=$step") - previousScroll = currentScroll - previousThumbY = currentThumbY - } - - val settledScroll = inspector.panelScrollOffsetY - val settledThumbY = inspector.debugScrollbarThumbRect().y - repeat(8) { idx -> - val boundaryY = startY + 2000 - assertTrue(host.handleMouseMove(dragX, boundaryY)) - host.syncFrame(root, inspectedLayoutRevision = 40L + idx, cursorX = dragX, cursorY = boundaryY, inspectorPointerCaptured = inspector.isPointerCaptured) - host.render(ctx, 420, 280) - host.paint(ctx) - assertEquals(settledScroll, inspector.panelScrollOffsetY) - assertEquals(settledThumbY, inspector.debugScrollbarThumbRect().y) - } - - assertTrue(host.handleMouseUp(dragX, startY + 2000, MouseButton.LEFT)) - } -} - - - - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoEntryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoEntryTests.kt deleted file mode 100644 index 15c79d4..0000000 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayPanelDemoEntryTests.kt +++ /dev/null @@ -1,230 +0,0 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode -import org.dreamfinity.dsgl.core.dom.layout.Rect -import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.event.MouseButton -import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.render.RenderCommand - -class SystemOverlayPanelDemoEntryTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } - - @Test - fun `panel panel demo entry toggles mounts and keeps stable identity while open`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.onInputFrame(1280, 720) - host.togglePanelDemo(anchorX = 160, anchorY = 120) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 162, cursorY = 122, inspectorPointerCaptured = false) - val firstNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) ?: error("node missing") - val firstState = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - assertTrue(firstState.active) - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.PanelDemo)) - - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 170, cursorY = 134, inspectorPointerCaptured = false) - val secondNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) ?: error("node missing") - val secondState = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - assertSame(firstNode, secondNode) - assertSame(firstState, secondState) - - host.togglePanelDemo(anchorX = 160, anchorY = 120) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 170, cursorY = 134, inspectorPointerCaptured = false) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.PanelDemo)) - } - - @Test - fun `panel panel demo supports drag and body button click`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.onInputFrame(1280, 720) - host.togglePanelDemo(anchorX = 220, anchorY = 160) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 224, cursorY = 166, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - val state = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - val before = state.panelState.currentRectOrNull() ?: error("panel missing") - val node = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - val buttonRect = node.buttonRect() ?: error("button rect missing") - - val headerStartX = before.x + 10 - val headerStartY = before.y + 10 - assertTrue(host.handleMouseDown(headerStartX, headerStartY, MouseButton.LEFT)) - assertTrue(host.handleMouseMove(headerStartX + 60, headerStartY + 30)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = headerStartX + 60, cursorY = headerStartY + 30, inspectorPointerCaptured = false) - val moved = state.panelState.currentRectOrNull() ?: error("panel missing") - assertTrue(moved.x > before.x) - assertTrue(host.handleMouseUp(headerStartX + 60, headerStartY + 30, MouseButton.LEFT)) - - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = moved.x + 8, cursorY = moved.y + 8, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - val updatedNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - val movedButtonRect = updatedNode.buttonRect() ?: error("button rect missing") - assertTrue(host.handleMouseDown(movedButtonRect.x + 1, movedButtonRect.y + 1, MouseButton.LEFT)) - host.syncFrame( - root, - inspectedLayoutRevision = 4L, - cursorX = movedButtonRect.x + 1, - cursorY = movedButtonRect.y + 1, - inspectorPointerCaptured = false - ) - host.render(ctx, 1280, 720) - assertEquals(1, updatedNode.currentButtonClicks()) - assertNotNull(updatedNode.buttonRect()) - } - - @Test - fun `panel panel demo close button closes and reopen restores interactions`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.onInputFrame(1280, 720) - host.togglePanelDemo(anchorX = 280, anchorY = 180) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 282, cursorY = 182, inspectorPointerCaptured = false) - val state = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - val rect = state.panelState.currentRectOrNull() ?: error("panel missing") - val closeX = rect.x + rect.width - 4 - 16 + 1 - val closeY = rect.y + 4 + 1 - - assertTrue(host.handleMouseDown(closeX, closeY, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = closeX, cursorY = closeY, inspectorPointerCaptured = false) - assertFalse(host.isOverlayPanelDemoOpen()) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.PanelDemo)) - - host.togglePanelDemo(anchorX = 280, anchorY = 180) - host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 284, cursorY = 184, inspectorPointerCaptured = false) - assertTrue(host.isOverlayPanelDemoOpen()) - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.PanelDemo)) - } - - @Test - fun `panel panel demo remains stable across open drag body click drag close reopen sequence`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.onInputFrame(1280, 720) - host.togglePanelDemo(anchorX = 260, anchorY = 170) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 260, cursorY = 170, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - val initialNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - val state = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - val initialRect = state.panelState.currentRectOrNull() ?: error("panel missing") - - val firstDragStartX = initialRect.x + 10 - val firstDragStartY = initialRect.y + 10 - assertTrue(host.handleMouseDown(firstDragStartX, firstDragStartY, MouseButton.LEFT)) - assertTrue(host.handleMouseMove(firstDragStartX + 40, firstDragStartY + 20)) - assertTrue(host.handleMouseUp(firstDragStartX + 40, firstDragStartY + 20, MouseButton.LEFT)) - host.syncFrame( - root, - inspectedLayoutRevision = 2L, - cursorX = firstDragStartX + 40, - cursorY = firstDragStartY + 20, - inspectorPointerCaptured = false - ) - host.render(ctx, 1280, 720) - - val movedRect = state.panelState.currentRectOrNull() ?: error("panel missing") - assertTrue(movedRect.x > initialRect.x) - val movedNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - assertSame(initialNode, movedNode) - - val buttonRect = movedNode.buttonRect() ?: error("button missing") - assertTrue(host.handleMouseDown(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT)) - host.syncFrame( - root, - inspectedLayoutRevision = 3L, - cursorX = buttonRect.x + 1, - cursorY = buttonRect.y + 1, - inspectorPointerCaptured = false - ) - host.render(ctx, 1280, 720) - assertEquals(1, movedNode.currentButtonClicks()) - - val secondDragStartX = movedRect.x + 10 - val secondDragStartY = movedRect.y + 10 - assertTrue(host.handleMouseDown(secondDragStartX, secondDragStartY, MouseButton.LEFT)) - assertTrue(host.handleMouseMove(secondDragStartX + 30, secondDragStartY + 10)) - assertTrue(host.handleMouseUp(secondDragStartX + 30, secondDragStartY + 10, MouseButton.LEFT)) - host.syncFrame( - root, - inspectedLayoutRevision = 4L, - cursorX = secondDragStartX + 30, - cursorY = secondDragStartY + 10, - inspectorPointerCaptured = false - ) - - val secondMovedRect = state.panelState.currentRectOrNull() ?: error("panel missing") - assertTrue(secondMovedRect.x > movedRect.x) - - val closeX = secondMovedRect.x + secondMovedRect.width - 4 - 16 + 1 - val closeY = secondMovedRect.y + 4 + 1 - assertTrue(host.handleMouseDown(closeX, closeY, MouseButton.LEFT)) - host.syncFrame(root, inspectedLayoutRevision = 5L, cursorX = closeX, cursorY = closeY, inspectorPointerCaptured = false) - assertFalse(host.isOverlayPanelDemoOpen()) - - host.togglePanelDemo(anchorX = 260, anchorY = 170) - host.syncFrame(root, inspectedLayoutRevision = 6L, cursorX = 262, cursorY = 172, inspectorPointerCaptured = false) - val reopenedNode = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - assertSame(initialNode, reopenedNode) - assertTrue(host.isOverlayPanelDemoOpen()) - } - - @Test - fun `panel panel demo uses render viewport before first mouse input`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.render(ctx, 1280, 720) - host.togglePanelDemo(anchorX = 460, anchorY = 320) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 460, cursorY = 320, inspectorPointerCaptured = false) - - val state = host.debugEntryState(SystemOverlayEntryId.PanelDemo) ?: error("state missing") - val rect = state.panelState.currentRectOrNull() ?: error("panel missing") - assertEquals(460, rect.x) - assertEquals(320, rect.y) - } - - @Test - fun `panel panel demo uses native overlay panel node in live path`() { - val host = SystemOverlayHost(InspectorController()) - val root = inspectedRoot() - - host.onInputFrame(1280, 720) - host.togglePanelDemo(anchorX = 180, anchorY = 140) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 182, cursorY = 142, inspectorPointerCaptured = false) - host.render(ctx, 1280, 720) - - val node = host.debugEntryNode(SystemOverlayEntryId.PanelDemo) as? SystemOverlayPanelDemoNode - ?: error("demo node missing") - assertTrue(node.children.any { it.styleType == "dsgl-overlay-panel" }) - assertTrue(node.children.none { it.styleType == "dsgl-system-raw-render-command" }) - } - - private fun inspectedRoot(): ContainerNode { - val root = ContainerNode(key = "root") - root.bounds = Rect(0, 0, 1280, 720) - ContainerNode(key = "child").apply { - bounds = Rect(20, 20, 120, 32) - }.applyParent(root) - return root - } -} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModelTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModelTests.kt index a5c7e1e..ff09e39 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModelTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/popup/FloatingPaneDragModelTests.kt @@ -13,13 +13,14 @@ class FloatingPaneDragModelTests { val start = Rect(100, 80, 200, 120) drag.begin(mouseX = 130, mouseY = 100, rect = start) - val next = drag.update( - mouseX = 20, - mouseY = 10, - viewportWidth = 500, - viewportHeight = 400, - clamp = ::clampRect - ) + val next = + drag.update( + mouseX = 20, + mouseY = 10, + viewportWidth = 500, + viewportHeight = 400, + clamp = ::clampRect, + ) assertEquals(2, next.x) assertEquals(2, next.y) @@ -32,13 +33,14 @@ class FloatingPaneDragModelTests { val start = Rect(40, 50, 120, 90) drag.begin(mouseX = 60, mouseY = 70, rect = start) - val next = drag.update( - mouseX = 61, - mouseY = 71, - viewportWidth = 400, - viewportHeight = 300, - clamp = ::clampRect - ) + val next = + drag.update( + mouseX = 61, + mouseY = 71, + viewportWidth = 400, + viewportHeight = 300, + clamp = ::clampRect, + ) assertEquals(41, next.x) assertEquals(51, next.y) @@ -54,7 +56,7 @@ class FloatingPaneDragModelTests { x = rect.x.coerceIn(minX, maxX), y = rect.y.coerceIn(minY, maxY), width = rect.width, - height = rect.height + height = rect.height, ) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationDndGhostPortalTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationDndGhostPortalTests.kt new file mode 100644 index 0000000..3ca8268 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationDndGhostPortalTests.kt @@ -0,0 +1,96 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.dnd.DndRuntime +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ApplicationDndGhostPortalTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + DndRuntime.engine.cancelActiveDrag() + } + + @Test + fun `application drag ghost paints through application portal entry`() { + val host = ApplicationPortalHost() + val root = draggableRoot() + val draggable = root.children.first() + + startDrag(root, draggable) + + val commands = ArrayList() + host.appendDndGhostPortalCommands( + root = root, + measureContext = ctx, + viewportWidth = 300, + viewportHeight = 120, + out = commands, + ) + + val state = host.debugDndGhostPortalState() + assertTrue(state.active) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, state.surface) + assertEquals("application.dnd-ghost", state.id.value) + assertTrue(commands.any { command -> command is RenderCommand.DrawText && command.text == "drag" }) + } + + @Test + fun `application drag ghost portal deactivates after drag cancel`() { + val host = ApplicationPortalHost() + val root = draggableRoot() + val draggable = root.children.first() + + startDrag(root, draggable) + host.appendDndGhostPortalCommands(root, ctx, 300, 120, ArrayList()) + assertTrue(host.debugDndGhostPortalState().active) + + DndRuntime.engine.cancelActiveDrag() + val commands = ArrayList() + host.appendDndGhostPortalCommands(root, ctx, 300, 120, commands) + + assertFalse(host.debugDndGhostPortalState().active) + assertTrue(commands.isEmpty()) + } + + private fun draggableRoot(): ContainerNode { + val root = ContainerNode(key = "dnd-portal-root") + ContainerNode(key = "dnd-portal-source") + .apply { + draggable = true + width = 80 + height = 20 + }.applyParent(root) + root.render(ctx, 0, 0, 300, 120) + return root + } + + private fun startDrag(root: ContainerNode, draggable: DOMNode) { + DndRuntime.engine.cancelActiveDrag() + val down = + MouseDownEvent(10, 10, MouseButton.LEFT).also { event -> + event.target = draggable + } + DndRuntime.engine.onMouseDown(root, draggable, down) + DndRuntime.engine.onMouseMove(root, 80, 10) + assertTrue(DndRuntime.engine.isDragging) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalTests.kt new file mode 100644 index 0000000..ef32249 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationFloatingWindowPortalTests.kt @@ -0,0 +1,189 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ApplicationFloatingWindowPortalTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @Test + fun `F10 floating window toggles through application portal and keeps stable identity while open`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 160, anchorY = 120) + host.render(ctx, 1280, 720) + val firstNode = host.floatingWindowPortal.debugNode() + val firstState = host.floatingWindowPortal.debugState() + + assertTrue(host.isFloatingWindowDemoOpen()) + assertTrue(firstState.active) + assertEquals("application.f10-floating-window", firstState.id.value) + assertTrue(firstNode.parent === host.rootNode) + + host.render(ctx, 1280, 720) + assertSame(firstNode, host.floatingWindowPortal.debugNode()) + assertSame(firstState, host.floatingWindowPortal.debugState()) + + host.toggleFloatingWindowDemo(anchorX = 160, anchorY = 120) + host.render(ctx, 1280, 720) + assertFalse(host.isFloatingWindowDemoOpen()) + assertFalse(firstState.active) + assertTrue(firstNode.parent == null) + } + + @Test + fun `F10 floating window supports DOM button click and pointer-captured drag`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 220, anchorY = 160) + host.render(ctx, 1280, 720) + val node = host.floatingWindowPortal.debugNode() + val before = node.panelRect() ?: error("panel missing") + val buttonRect = node.buttonRect() ?: error("button missing") + + assertTrue(host.handleMouseDown(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT)) + host.render(ctx, 1280, 720) + assertEquals(1, node.currentButtonClicks()) + assertNotNull(node.buttonRect()) + + val headerStartX = before.x + 10 + val headerStartY = before.y + 10 + assertTrue(host.handleMouseDown(headerStartX, headerStartY, MouseButton.LEFT)) + assertTrue(host.handleMouseMove(headerStartX + 60, headerStartY + 30)) + assertTrue(host.handleMouseMove(headerStartX + 180, headerStartY + 70)) + assertTrue(host.handleMouseUp(headerStartX + 180, headerStartY + 70, MouseButton.LEFT)) + host.render(ctx, 1280, 720) + + val moved = node.panelRect() ?: error("panel missing") + assertTrue(moved.x > before.x) + assertTrue(moved.y > before.y) + assertSame(node, host.floatingWindowPortal.debugNode()) + } + + @Test + fun `F10 floating window consumes body hover and pointer input without blocking child controls`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 220, anchorY = 160) + host.render(ctx, 1280, 720) + val node = host.floatingWindowPortal.debugNode() + val bodyRect = node.bodyRect() ?: error("body missing") + val bodyX = bodyRect.x + bodyRect.width - 8 + val bodyY = bodyRect.y + bodyRect.height - 8 + + assertTrue(host.handleMouseMove(bodyX, bodyY)) + assertTrue(host.handleMouseDown(bodyX, bodyY, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(bodyX, bodyY, MouseButton.LEFT)) + assertEquals(0, node.currentButtonClicks()) + + val buttonRect = node.buttonRect() ?: error("button missing") + assertTrue(host.handleMouseDown(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT)) + assertEquals(1, node.currentButtonClicks()) + } + + @Test + fun `F10 floating window does not consume outside panel input`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 220, anchorY = 160) + host.render(ctx, 1280, 720) + val panelRect = + host.floatingWindowPortal + .debugNode() + .panelRect() ?: error("panel missing") + val outsideX = panelRect.x + panelRect.width + 12 + val outsideY = panelRect.y + panelRect.height + 12 + + assertFalse(host.handleMouseMove(outsideX, outsideY)) + assertFalse(host.handleMouseDown(outsideX, outsideY, MouseButton.LEFT)) + } + + @Test + fun `F10 floating window follows frame cursor during active drag`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 220, anchorY = 160) + host.render(ctx, 1280, 720) + val node = host.floatingWindowPortal.debugNode() + val before = node.panelRect() ?: error("panel missing") + val headerStartX = before.x + 10 + val headerStartY = before.y + 10 + + assertTrue(host.handleMouseDown(headerStartX, headerStartY, MouseButton.LEFT)) + host.syncPortalFrame( + measureContext = ctx, + viewportWidth = 1280, + viewportHeight = 720, + viewportScale = 1f, + mouseX = headerStartX + 90, + mouseY = headerStartY + 40, + ) + host.render(ctx, 1280, 720) + + val moved = node.panelRect() ?: error("panel missing") + assertTrue(moved.x > before.x) + assertTrue(moved.y > before.y) + } + + @Test + fun `F10 floating window close button closes and reopen restores interactions`() { + val host = ApplicationPortalHost() + + host.onInputFrame(1280, 720) + host.toggleFloatingWindowDemo(anchorX = 280, anchorY = 180) + host.render(ctx, 1280, 720) + val node = host.floatingWindowPortal.debugNode() + val rect = node.panelRect() ?: error("panel missing") + val closeX = rect.x + rect.width - 4 - 16 + 1 + val closeY = rect.y + 4 + 1 + + assertTrue(host.handleMouseDown(closeX, closeY, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(closeX, closeY, MouseButton.LEFT)) + host.render(ctx, 1280, 720) + assertFalse(host.isFloatingWindowDemoOpen()) + assertTrue(node.parent == null) + + host.toggleFloatingWindowDemo(anchorX = 280, anchorY = 180) + host.render(ctx, 1280, 720) + assertTrue(host.isFloatingWindowDemoOpen()) + assertSame(node, host.floatingWindowPortal.debugNode()) + assertTrue(node.parent === host.rootNode) + } + + @Test + fun `F10 floating window uses render viewport before first mouse input`() { + val host = ApplicationPortalHost() + + host.render(ctx, 1280, 720) + host.toggleFloatingWindowDemo(anchorX = 460, anchorY = 320) + host.render(ctx, 1280, 720) + + val rect = + host.floatingWindowPortal + .debugNode() + .panelRect() ?: error("panel missing") + assertEquals(460, rect.x) + assertEquals(320, rect.y) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualizationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualizationTests.kt new file mode 100644 index 0000000..487262f --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceDebugVisualizationTests.kt @@ -0,0 +1,95 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.debug.DomainSurfaceDebugState +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.portal.system.SystemPortalRootNode +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.StyleApplicationScope +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DomainSurfaceDebugVisualizationTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + DomainSurfaceDebugVisualization.setTestOverride(null) + DomainSurfaceDebugState.resetAll() + } + + @Test + fun `debug visualization disabled by default`() { + DomainSurfaceDebugState.resetAll() + DomainSurfaceDebugVisualization.setTestOverride(false) + val appTree = DomTree(root = ApplicationPortalRootNode(), styleScope = StyleApplicationScope.Application) + val systemTree = DomTree(root = SystemPortalRootNode(), styleScope = StyleApplicationScope.System) + + appTree.render(ctx, 800, 480) + systemTree.render(ctx, 800, 480) + val appCommands = appTree.paint(ctx, applyStyles = true) + val systemCommands = systemTree.paint(ctx, applyStyles = true) + + assertFalse( + appCommands.any { command -> + command is RenderCommand.DrawRect && + ( + command.color == DomainSurfaceDebugVisualization.applicationPortalFillColor || + command.color == DomainSurfaceDebugVisualization.applicationPortalBorderColor + ) + }, + ) + assertFalse( + systemCommands.any { command -> + command is RenderCommand.DrawRect && + ( + command.color == DomainSurfaceDebugVisualization.systemPortalFillColor || + command.color == DomainSurfaceDebugVisualization.systemPortalBorderColor + ) + }, + ) + } + + @Test + fun `debug visualization can be enabled without changing domain-surface logic contracts`() { + DomainSurfaceDebugState.resetAll() + DomainSurfaceDebugState.applicationPortalTintEnabled = true + DomainSurfaceDebugState.systemPortalTintEnabled = true + DomainSurfaceDebugVisualization.setTestOverride(true) + val appTree = DomTree(root = ApplicationPortalRootNode(), styleScope = StyleApplicationScope.Application) + val systemTree = DomTree(root = SystemPortalRootNode(), styleScope = StyleApplicationScope.System) + + appTree.render(ctx, 800, 480) + systemTree.render(ctx, 800, 480) + val appCommands = appTree.paint(ctx, applyStyles = true) + val systemCommands = systemTree.paint(ctx, applyStyles = true) + + assertTrue( + appCommands.any { command -> + command is RenderCommand.DrawRect && + ( + command.color == DomainSurfaceDebugVisualization.applicationPortalFillColor || + command.color == DomainSurfaceDebugVisualization.applicationPortalBorderColor + ) + }, + ) + assertTrue( + systemCommands.any { command -> + command is RenderCommand.DrawRect && + ( + command.color == DomainSurfaceDebugVisualization.systemPortalFillColor || + command.color == DomainSurfaceDebugVisualization.systemPortalBorderColor + ) + }, + ) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceInteractionPathTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceInteractionPathTests.kt new file mode 100644 index 0000000..6fc0971 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/DomainSurfaceInteractionPathTests.kt @@ -0,0 +1,852 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.colorpicker.ScreenColorSampler +import org.dreamfinity.dsgl.core.colorpicker.ScreenColorSamplerBridge +import org.dreamfinity.dsgl.core.contextmenu.contextMenu +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.KeyCodes +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.portal.system.SystemPortalEntryId +import org.dreamfinity.dsgl.core.portal.system.SystemPortalHost +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.select.SelectEntry +import org.dreamfinity.dsgl.core.select.SelectOpenRequest +import org.dreamfinity.dsgl.core.select.selectModel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("LargeClass") +class DomainSurfaceInteractionPathTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanupDomainContextMenuPortalService() { + DomainPortalServices.applicationContextMenuEngine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() + ScreenColorSamplerBridge.install(null) + DomainPortalServices.closeAllSelects() + } + + @Test + fun `runtime input path resolves in full domain surface order`() { + val callOrder = ArrayList(6) + val fixture = + DomainSurfaceInputFixture( + debugPortalHandler = { _, _, _ -> + callOrder += ScreenDomainSurfaces.DebugPortal + false + }, + debugHandler = { _, _, _ -> + callOrder += ScreenDomainSurfaces.DebugRoot + false + }, + systemPortalHandler = { _, _, _ -> + callOrder += ScreenDomainSurfaces.SystemPortal + false + }, + systemRootHandler = { _, _, _ -> + callOrder += ScreenDomainSurfaces.SystemRoot + false + }, + applicationPortalHandler = { _, _, _ -> + callOrder += ScreenDomainSurfaces.ApplicationPortal + false + }, + ) + val consumedBy = + fixture.dispatchMouseDown(10, 10, MouseButton.LEFT) { + callOrder += ScreenDomainSurfaces.ApplicationRoot + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumedBy) + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.ApplicationRoot, + ), + callOrder, + ) + } + + @Test + fun `debug portal consumption prevents lower-domain fallthrough`() { + var debugRootReceived = false + var systemReceived = false + var appPortalReceived = false + var appRootReceived = false + val fixture = + DomainSurfaceInputFixture( + debugPortalHandler = { _, _, _ -> true }, + debugHandler = { _, _, _ -> + debugRootReceived = true + false + }, + systemPortalHandler = { _, _, _ -> + systemReceived = true + false + }, + applicationPortalHandler = { _, _, _ -> + appPortalReceived = true + false + }, + ) + val consumedBy = + fixture.dispatchMouseDown(12, 14, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.DebugPortal, consumedBy) + assertFalse(debugRootReceived) + assertFalse(systemReceived) + assertFalse(appPortalReceived) + assertFalse(appRootReceived) + } + + @Test + fun `debug root consumption prevents lower-domain fallthrough`() { + var systemReceived = false + var appPortalReceived = false + var appRootReceived = false + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> true }, + systemPortalHandler = { _, _, _ -> + systemReceived = true + false + }, + applicationPortalHandler = { _, _, _ -> + appPortalReceived = true + false + }, + ) + val consumedBy = + fixture.dispatchMouseDown(12, 14, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.DebugRoot, consumedBy) + assertFalse(systemReceived) + assertFalse(appPortalReceived) + assertFalse(appRootReceived) + } + + @Test + fun `system root consumption prevents lower-domain fallthrough`() { + var appPortalReceived = false + var appRootReceived = false + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + systemRootHandler = { _, _, _ -> true }, + applicationPortalHandler = { _, _, _ -> + appPortalReceived = true + false + }, + ) + val consumedBy = + fixture.dispatchMouseDown(12, 14, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.SystemRoot, consumedBy) + assertFalse(appPortalReceived) + assertFalse(appRootReceived) + } + + @Test + fun `system portal consumption prevents lower-domain fallthrough`() { + val inspector = InspectorController() + val systemHost = SystemPortalHost(inspector) + val root = inspectedRoot() + systemHost.onInputFrame(1280, 720) + inspector.toggle() + systemHost.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 244, + cursorY = 186, + inspectorPointerCaptured = false, + ) + systemHost.render(ctx, 1280, 720) + + val entryState = systemHost.debugEntryState(SystemPortalEntryId.Inspector) ?: error("inspector state missing") + val panelRect = entryState.panelState.currentRectOrNull() ?: error("inspector panel rect missing") + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { x, y, button -> systemHost.handleMouseDown(x, y, button) }, + applicationPortalHandler = { _, _, _ -> false }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(panelRect.x + 20, panelRect.y + 70, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.SystemPortal, consumedBy) + assertFalse(appRootReceived) + } + + @Test + fun `locked inspector consumes only inside panel and falls through outside panel`() { + val inspector = InspectorController() + val systemHost = SystemPortalHost(inspector) + val root = inspectedRoot() + systemHost.onInputFrame(1280, 720) + inspector.toggle() + inspector.setPickMode(false) + systemHost.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + systemHost.render(ctx, 1280, 720) + + val panelRect = inspector.floatingPanelRect() ?: error("inspector panel rect missing") + val outsideX = if (panelRect.x > 40) panelRect.x - 20 else panelRect.x + panelRect.width + 20 + val outsideY = (panelRect.y + panelRect.height / 2).coerceIn(1, 719) + assertFalse(panelRect.contains(outsideX, outsideY)) + + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { x, y, button -> systemHost.handleMouseDown(x, y, button) }, + applicationPortalHandler = { _, _, _ -> false }, + ) + + var appRootReceivedOutside = false + val consumedOutside = + fixture.dispatchMouseDown(outsideX, outsideY, MouseButton.LEFT) { + appRootReceivedOutside = true + true + } + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumedOutside) + assertTrue(appRootReceivedOutside) + } + + @Test + fun `application portal consumption prevents app-root fallthrough`() { + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { _, _, _ -> true }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(24, 30, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + } + + @Test + fun `application portal host dom bridge consumes mounted node and blocks app-root fallthrough`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(1280, 720) + var clicks = 0 + ButtonNode("Portal", key = "app-portal-button") + .apply { + bounds = Rect(40, 44, 120, 24) + onClick { clicks += 1 } + }.applyParent(applicationPortalRoot(applicationPortalHost)) + + assertTrue(applicationPortalHost.handleMouseDown(50, 50, MouseButton.LEFT)) + assertTrue(applicationPortalHost.handleMouseUp(50, 50, MouseButton.LEFT)) + assertEquals(1, clicks) + + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> + applicationPortalHost.handleMouseDown(x, y, button) + }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(50, 50, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + } + + @Test + fun `application context menu is rendered and consumed through application portal path`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 180) + var actionHits = 0 + DomainPortalServices.applicationContextMenuEngine.openAtCursor( + contextMenu(id = "portal.context") { + item("Run") { + onClick { actionHits += 1 } + } + }, + x = 24, + y = 24, + ) + + applicationPortalHost.syncPortalFrame(ctx, 320, 180, 1f, 24, 24) + val commands = ArrayList() + applicationPortalHost.appendFloatingPortalCommands(ctx, 320, 180, commands) + val firstEntryRect = DomainPortalServices.applicationContextMenuEngine.debugEntryRect(levelIndex = 0, entryIndex = 0) + assertNotNull(firstEntryRect) + + val consumedByMenu = + applicationPortalHost.handlePortalPointerAfterDom( + mouseX = firstEntryRect.x + 1, + mouseY = firstEntryRect.y + 1, + dWheel = 0, + button = MouseButton.LEFT, + pressed = true, + ) + + assertTrue(commands.isNotEmpty()) + assertTrue(consumedByMenu) + assertEquals(1, actionHits) + assertFalse(applicationPortalHost.hasOpenContextMenuPortal()) + } + + @Test + fun `application context menu portal blocks app-root fallthrough on outside dismiss`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 180) + DomainPortalServices.applicationContextMenuEngine.openAtCursor( + contextMenu(id = "portal.dismiss") { + item("Run") + item("Build") + }, + x = 24, + y = 24, + ) + applicationPortalHost.syncPortalFrame(ctx, 320, 180, 1f, 24, 24) + val panel = DomainPortalServices.applicationContextMenuEngine.debugPanelRect(0) + assertNotNull(panel) + val outsideX = panel.x + panel.width + 24 + val outsideY = panel.y + panel.height + 24 + + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> + applicationPortalHost.handlePortalPointerAfterDom(x, y, 0, button, true) + }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(outsideX, outsideY, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + assertFalse(applicationPortalHost.hasOpenContextMenuPortal()) + } + + @Test + fun `application context menu portal consumes wheel and escape while open`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 180) + DomainPortalServices.applicationContextMenuEngine.openAtCursor( + contextMenu(id = "portal.keyboard") { + item("Run") + item("Build") + }, + x = 24, + y = 24, + ) + applicationPortalHost.syncPortalFrame(ctx, 320, 180, 1f, 24, 24) + + assertTrue(applicationPortalHost.handlePortalPointerAfterDom(26, 26, -120, null, false)) + assertTrue(applicationPortalHost.handlePortalKeyDownAfterDom(KeyCodes.ESCAPE, Char.MIN_VALUE)) + assertFalse(applicationPortalHost.hasOpenContextMenuPortal()) + } + + @Test + fun `application select is rendered and consumed through application portal path`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 180) + var selected: String? = null + val owner = "application-select-portal" + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.Application) { selected = it }) + + applicationPortalHost.syncPortalFrame(ctx, 320, 180, 1f, 0, 0) + val commands = ArrayList() + applicationPortalHost.appendFloatingPortalCommands(ctx, 320, 180, commands) + val panel = DomainPortalServices.applicationSelectEngine.debugPanelRect(owner) + assertNotNull(panel) + + val style = DomainPortalServices.applicationSelectEngine.currentStyle() + val consumed = + applicationPortalHost.handlePortalPointerAfterDom( + mouseX = panel.x + style.panelPaddingX + 1, + mouseY = panel.y + style.panelPaddingY + 1, + dWheel = 0, + button = MouseButton.LEFT, + pressed = true, + ) + + assertTrue(commands.isNotEmpty()) + assertTrue(consumed) + assertEquals(null, selected) + assertTrue( + applicationPortalHost.handlePortalPointerAfterDom( + mouseX = panel.x + style.panelPaddingX + 1, + mouseY = panel.y + style.panelPaddingY + 1, + dWheel = 0, + button = MouseButton.LEFT, + pressed = false, + ), + ) + assertEquals("a", selected) + } + + @Test + fun `application select portal closes and allows app-root fallthrough on outside dismiss`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 180) + val owner = "application-select-dismiss" + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.Application)) + applicationPortalHost.syncPortalFrame(ctx, 320, 180, 1f, 0, 0) + val panel = DomainPortalServices.applicationSelectEngine.debugPanelRect(owner) + assertNotNull(panel) + val outsideX = panel.x + panel.width + 24 + val outsideY = panel.y + panel.height + 24 + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> + applicationPortalHost.handlePortalPointerAfterDom(x, y, 0, button, true) + }, + ) + + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(outsideX, outsideY, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumedBy) + assertTrue(appRootReceived) + } + + @Test + fun `application select portal consumes wheel typeahead and escape`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(320, 120) + val owner = "application-select-keyboard" + var selected: String? = null + DomainPortalServices.openSelect( + selectRequest( + owner = owner, + ownerDomain = ScreenDomainId.Application, + entries = + listOf( + SelectEntry.Option("a", labelProvider = { "Alpha" }), + SelectEntry.Option("b", labelProvider = { "Beta" }), + SelectEntry.Option("c", labelProvider = { "Charlie" }), + SelectEntry.Option("d", labelProvider = { "Delta" }), + SelectEntry.Option("e", labelProvider = { "Echo" }), + SelectEntry.Option("f", labelProvider = { "Foxtrot" }), + ), + onSelect = { selected = it }, + ), + ) + applicationPortalHost.syncPortalFrame(ctx, 320, 120, 1f, 0, 0) + val panel = DomainPortalServices.applicationSelectEngine.debugPanelRect(owner) + assertNotNull(panel) + + assertTrue(applicationPortalHost.handlePortalPointerAfterDom(panel.x + 2, panel.y + 2, -120, null, false)) + assertTrue(applicationPortalHost.handlePortalKeyDownAfterDom(0, 'd')) + assertTrue(applicationPortalHost.handlePortalKeyDownAfterDom(KeyCodes.ENTER, Char.MIN_VALUE)) + assertEquals("d", selected) + + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.Application)) + applicationPortalHost.syncPortalFrame(ctx, 320, 120, 1f, 0, 0) + assertTrue(applicationPortalHost.handlePortalKeyDownAfterDom(KeyCodes.ESCAPE, Char.MIN_VALUE)) + } + + @Test + fun `application color picker is rendered and consumed through application portal path`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(360, 240) + val owner = "application-color-picker-portal" + DomainPortalServices.applicationColorPickerEngine.open(colorPickerRequest(owner, ScreenDomainId.Application)) + + applicationPortalHost.syncPortalFrame(ctx, 360, 240, 1f, 42, 48) + val commands = ArrayList() + applicationPortalHost.appendFloatingPortalCommands(ctx, 360, 240, commands) + val layout = DomainPortalServices.applicationColorPickerEngine.debugBodyLayout(owner) + assertNotNull(layout) + + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> + applicationPortalHost.handlePortalPointerBeforeDom(x, y, 0, button, true) + }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown( + layout.colorFieldRect.x + 4, + layout.colorFieldRect.y + 4, + MouseButton.LEFT, + ) { + appRootReceived = true + true + } + + assertTrue(commands.isNotEmpty()) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + assertTrue(applicationPortalHost.hasOpenColorPickerPortal()) + } + + @Test + fun `application color picker portal preserves drag close and eyedropper capture hooks`() { + ScreenColorSamplerBridge.install(ScreenColorSampler { x, y -> (0xFF shl 24) or (x shl 16) or (y shl 8) or 0x44 }) + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(480, 320) + val owner = "application-color-picker-drag-eyedropper" + var committed: RgbaColor? = null + DomainPortalServices.applicationColorPickerEngine.open( + colorPickerRequest(owner, ScreenDomainId.Application) { + committed = it + }, + ) + applicationPortalHost.syncPortalFrame(ctx, 480, 320, 1f, 120, 80) + + val panelBefore = DomainPortalServices.applicationColorPickerEngine.debugPanelRect(owner) ?: error("panel missing") + val header = DomainPortalServices.applicationColorPickerEngine.debugHeaderRect(owner) ?: error("header missing") + val dragStartX = header.x + 6 + val dragStartY = header.y + 6 + assertTrue(applicationPortalHost.handlePortalPointerBeforeDom(dragStartX, dragStartY, 0, MouseButton.LEFT, true)) + assertTrue( + applicationPortalHost.handlePortalPointerBeforeDom( + mouseX = dragStartX + 40, + mouseY = dragStartY + 30, + dWheel = 0, + button = null, + pressed = false, + ), + ) + assertTrue( + applicationPortalHost.handlePortalPointerBeforeDom( + mouseX = dragStartX + 40, + mouseY = dragStartY + 30, + dWheel = 0, + button = MouseButton.LEFT, + pressed = false, + ), + ) + val panelAfter = DomainPortalServices.applicationColorPickerEngine.debugPanelRect(owner) ?: error("panel missing") + assertNotEquals(panelBefore.x, panelAfter.x) + + val layout = DomainPortalServices.applicationColorPickerEngine.debugBodyLayout(owner) ?: error("layout missing") + assertTrue( + applicationPortalHost.handlePortalPointerBeforeDom( + mouseX = layout.pipetteRect.x + 2, + mouseY = layout.pipetteRect.y + 2, + dWheel = 0, + button = MouseButton.LEFT, + pressed = true, + ), + ) + assertTrue(applicationPortalHost.hasActiveColorPickerEyedropper()) + assertTrue(applicationPortalHost.handlePortalPointerBeforeDom(25, 52, 0, null, false)) + applicationPortalHost.captureColorPickerEyedropperSample() + val firstExpected = RgbaColor.fromArgbInt((0xFF shl 24) or (25 shl 16) or (52 shl 8) or 0x44) + assertEquals( + firstExpected.toArgbInt(), + DomainPortalServices.applicationColorPickerEngine + .debugController(owner) + ?.snapshot() + ?.color + ?.toArgbInt(), + ) + + assertTrue(applicationPortalHost.handlePortalPointerBeforeDom(31, 64, 0, null, false)) + applicationPortalHost.captureColorPickerEyedropperSample() + assertTrue(applicationPortalHost.handlePortalPointerBeforeDom(31, 64, 0, MouseButton.LEFT, true)) + assertTrue(applicationPortalHost.handlePortalPointerBeforeDom(31, 64, 0, MouseButton.LEFT, false)) + val expected = RgbaColor.fromArgbInt((0xFF shl 24) or (31 shl 16) or (64 shl 8) or 0x44) + assertEquals(expected.toArgbInt(), committed?.toArgbInt()) + + val closeRect = DomainPortalServices.applicationColorPickerEngine.debugCloseRect(owner) ?: error("close missing") + assertTrue( + applicationPortalHost.handlePortalPointerBeforeDom( + mouseX = closeRect.x + 1, + mouseY = closeRect.y + 1, + dWheel = 0, + button = MouseButton.LEFT, + pressed = true, + ), + ) + assertFalse(applicationPortalHost.hasOpenColorPickerPortal()) + } + + @Test + fun `application color picker portal does not consume system owned popup`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(360, 240) + val owner = "system-color-picker-owner" + DomainPortalServices.applicationColorPickerEngine.open(colorPickerRequest(owner, ScreenDomainId.System)) + + applicationPortalHost.syncPortalFrame(ctx, 360, 240, 1f, 42, 48) + val commands = ArrayList() + applicationPortalHost.appendFloatingPortalCommands(ctx, 360, 240, commands) + val panel = DomainPortalServices.applicationColorPickerEngine.debugPanelRect(owner) + assertNotNull(panel) + + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(owner)) + assertFalse(applicationPortalHost.hasOpenColorPickerPortal()) + assertFalse(applicationPortalHost.handlePortalPointerBeforeDom(panel.x + 2, panel.y + 2, 0, MouseButton.LEFT, true)) + assertTrue(commands.isEmpty()) + } + + @Test + fun `system select is rendered and consumed through system portal path`() { + val systemHost = SystemPortalHost(InspectorController()) + systemHost.onInputFrame(320, 180) + val owner = "system-select-portal" + var selected: String? = null + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.System) { selected = it }) + + systemHost.syncPortalFrame(ctx, 320, 180, 1f) + val commands = ArrayList() + systemHost.appendFloatingPortalCommands(ctx, 320, 180, commands) + val panel = DomainPortalServices.systemSelectEngine.debugPanelRect(owner) + assertNotNull(panel) + val style = DomainPortalServices.systemSelectEngine.currentStyle() + + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { x, y, button -> + systemHost.handlePortalMouseDown(x, y, button) + }, + applicationPortalHandler = { _, _, _ -> false }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown( + panel.x + style.panelPaddingX + 1, + panel.y + style.panelPaddingY + 1, + MouseButton.LEFT, + ) { + appRootReceived = true + true + } + + assertTrue(commands.isNotEmpty()) + assertEquals(ScreenDomainSurfaces.SystemPortal, consumedBy) + assertFalse(appRootReceived) + assertEquals(null, selected) + assertTrue( + systemHost.handlePortalMouseUp( + panel.x + style.panelPaddingX + 1, + panel.y + style.panelPaddingY + 1, + MouseButton.LEFT, + ), + ) + assertEquals("a", selected) + assertFalse(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + } + + @Test + fun `select owner migration preserves application system routing`() { + val owner = "select-owner-migration" + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.Application)) + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + + DomainPortalServices.openSelect(selectRequest(owner, ScreenDomainId.System)) + + assertFalse(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + } + + @Test + fun `F10 application portal content is reachable through same live interaction path`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(1280, 720) + applicationPortalHost.toggleFloatingWindowDemo(anchorX = 260, anchorY = 200) + applicationPortalHost.render(ctx, 1280, 720) + + val demoNode = applicationPortalHost.floatingWindowPortal.debugNode() + val buttonRect = demoNode.buttonRect() + assertNotNull(buttonRect) + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> applicationPortalHost.handleMouseDown(x, y, button) }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(buttonRect.x + 1, buttonRect.y + 1, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + } + + @Test + fun `F10 application portal body blocks app-root fallthrough`() { + val applicationPortalHost = ApplicationPortalHost() + applicationPortalHost.onInputFrame(1280, 720) + applicationPortalHost.toggleFloatingWindowDemo(anchorX = 260, anchorY = 200) + applicationPortalHost.render(ctx, 1280, 720) + + val demoNode = applicationPortalHost.floatingWindowPortal.debugNode() + val bodyRect = demoNode.bodyRect() + assertNotNull(bodyRect) + val fixture = + DomainSurfaceInputFixture( + debugHandler = { _, _, _ -> false }, + systemPortalHandler = { _, _, _ -> false }, + applicationPortalHandler = { x, y, button -> applicationPortalHost.handleMouseDown(x, y, button) }, + ) + var appRootReceived = false + val consumedBy = + fixture.dispatchMouseDown(bodyRect.x + bodyRect.width - 8, bodyRect.y + bodyRect.height - 8, MouseButton.LEFT) { + appRootReceived = true + true + } + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, consumedBy) + assertFalse(appRootReceived) + } + + private fun inspectedRoot(): ContainerNode { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 1280, 720) + ContainerNode(key = "child") + .apply { + bounds = Rect(20, 20, 120, 32) + }.applyParent(root) + return root + } + + private fun selectRequest( + owner: Any, + ownerDomain: ScreenDomainId, + entries: List = + listOf( + SelectEntry.Option("a", labelProvider = { "Alpha" }), + SelectEntry.Option("b", labelProvider = { "Beta" }), + ), + onSelect: ((String) -> Unit)? = null, + ): SelectOpenRequest { + val model = + selectModel(id = "live-layer-select") { + entries.forEach { entry -> + when (entry) { + is SelectEntry.Option -> option(entry.id, entry.labelProvider) + is SelectEntry.Group -> group(entry.labelProvider, entry.id) {} + is SelectEntry.Separator -> separator(entry.id) + } + } + } + return SelectOpenRequest( + owner = owner, + modelToken = model.token, + entries = entries, + selectedId = null, + anchorRect = Rect(24, 24, 100, 18), + closeOnSelect = true, + onSelect = onSelect, + ownerDomain = ownerDomain, + ) + } + + private fun colorPickerRequest(owner: Any, ownerDomain: ScreenDomainId, onCommit: ((RgbaColor) -> Unit)? = null): ColorPickerPopupRequest = + ColorPickerPopupRequest( + owner = owner, + ownerDomain = ownerDomain, + anchorRect = Rect(32, 36, 24, 18), + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + onCommit = onCommit, + ) + + private class DomainSurfaceInputFixture( + private val debugHandler: (Int, Int, MouseButton) -> Boolean, + private val systemPortalHandler: (Int, Int, MouseButton) -> Boolean, + private val applicationPortalHandler: (Int, Int, MouseButton) -> Boolean, + private val debugPortalHandler: (Int, Int, MouseButton) -> Boolean = { _, _, _ -> false }, + private val systemRootHandler: (Int, Int, MouseButton) -> Boolean = { _, _, _ -> false }, + ) { + fun dispatchMouseDown( + mouseX: Int, + mouseY: Int, + button: MouseButton, + applicationRootHandler: () -> Boolean, + ): ScreenDomainSurface? = + ScreenDomainSurfaces.firstInputConsumer( + canConsume = { surface -> + when (surface) { + ScreenDomainSurfaces.DebugPortal -> debugPortalHandler(mouseX, mouseY, button) + ScreenDomainSurfaces.DebugRoot -> debugHandler(mouseX, mouseY, button) + ScreenDomainSurfaces.SystemPortal -> systemPortalHandler(mouseX, mouseY, button) + ScreenDomainSurfaces.SystemRoot -> systemRootHandler(mouseX, mouseY, button) + ScreenDomainSurfaces.ApplicationPortal -> applicationPortalHandler(mouseX, mouseY, button) + ScreenDomainSurfaces.ApplicationRoot -> applicationRootHandler() + else -> false + } + }, + ) + } + + private fun applicationPortalRoot(host: ApplicationPortalHost): DOMNode { + val field = ApplicationPortalHost::class.java.getDeclaredField("rootNode") + field.isAccessible = true + return field.get(host) as DOMNode + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayGeometryIntegrationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalGeometryIntegrationTests.kt similarity index 82% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayGeometryIntegrationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalGeometryIntegrationTests.kt index b87a15b..26ae403 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/OverlayGeometryIntegrationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalGeometryIntegrationTests.kt @@ -1,10 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay +package org.dreamfinity.dsgl.core.portal -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupEngine import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState @@ -12,21 +7,29 @@ import org.dreamfinity.dsgl.core.colorpicker.RgbaColor import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.system.SystemOverlayHost +import org.dreamfinity.dsgl.core.portal.system.SystemPortalHost import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -class OverlayGeometryIntegrationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } +class PortalGeometryIntegrationTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @Test - fun `system overlay root uses full game viewport bounds in live host path`() { + fun `system portal root uses full game viewport bounds in live host path`() { StyleEngine.setViewportSize(120, 90) - val host = SystemOverlayHost(InspectorController()) + val host = SystemPortalHost(InspectorController()) host.onInputFrame(1280, 720) host.render(ctx, 1280, 720) @@ -35,9 +38,9 @@ class OverlayGeometryIntegrationTests { } @Test - fun `application overlay root uses full top-level window viewport bounds`() { + fun `application portal root uses full top-level window viewport bounds`() { StyleEngine.setViewportSize(160, 120) - val host = ApplicationOverlayHost() + val host = ApplicationPortalHost() host.render(ctx, 1024, 768) @@ -55,8 +58,8 @@ class OverlayGeometryIntegrationTests { ColorPickerPopupRequest( owner = owner, anchorRect = anchor, - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val panel = engine.debugPanelRect(owner) @@ -77,8 +80,8 @@ class OverlayGeometryIntegrationTests { ColorPickerPopupRequest( owner = owner, anchorRect = anchor, - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val preFramePanel = engine.debugPanelRect(owner) assertNotNull(preFramePanel) @@ -103,8 +106,8 @@ class OverlayGeometryIntegrationTests { ColorPickerPopupRequest( owner = owner, anchorRect = anchor, - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val panel = engine.debugPanelRect(owner) @@ -114,7 +117,7 @@ class OverlayGeometryIntegrationTests { } @Test - fun `reopen remembered popup position remains clamped for current overlay viewport`() { + fun `reopen remembered popup position remains clamped for current portal viewport`() { val engine = ColorPickerPopupEngine() val owner = "remember-owner" @@ -123,8 +126,8 @@ class OverlayGeometryIntegrationTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(420, 260, 24, 18), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) engine.forcePanelRect(owner, Rect(980, 200, 320, 340)) engine.close(owner) @@ -134,8 +137,8 @@ class OverlayGeometryIntegrationTests { ColorPickerPopupRequest( owner = owner, anchorRect = Rect(20, 20, 10, 10), - state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false) - ) + state = ColorPickerState(color = RgbaColor.WHITE, closeOnSelect = false), + ), ) val reopened = engine.debugPanelRect(owner) assertNotNull(reopened) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContractsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContractsTests.kt new file mode 100644 index 0000000..a459c34 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/PortalHostContractsTests.kt @@ -0,0 +1,570 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.Events +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.event.MouseDownEvent +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.portal.system.SystemPortalHost +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PortalHostContractsTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + FocusManager.clearFocus() + } + + @Test + fun `multiple portal entries preserve deterministic paint and input order`() { + val host = PortalHost(applicationSurface()) + val low = FakePortalEntry("low", FakePortalEntryConfig(order = PortalEntryOrder(zIndex = 10))) + val high = FakePortalEntry("high", FakePortalEntryConfig(order = PortalEntryOrder(zIndex = 20))) + val sameZLater = + FakePortalEntry( + "same-z-later", + FakePortalEntryConfig(order = PortalEntryOrder(zIndex = 10, sequence = 1)), + ) + host.register(high) + host.register(sameZLater) + host.register(low) + + low.activate() + high.activate() + sameZLater.activate() + + assertEquals(listOf("low", "same-z-later", "high"), host.entriesInPaintOrder().map { it.state.id.value }) + assertEquals(listOf("high", "same-z-later", "low"), host.entriesInInputOrder().map { it.state.id.value }) + } + + @Test + fun `entry cleanup removes refs and active state`() { + val host = PortalHost(applicationSurface()) + val entry = FakePortalEntry("entry") + host.register(entry) + entry.activate() + + assertTrue(host.unregister(entry.state.id)) + + assertEquals(1, entry.clearRefsCalls) + assertEquals(1, entry.closeCalls) + assertFalse(entry.state.active) + assertNull(entry.state.placement) + assertTrue(host.entriesInPaintOrder().isEmpty()) + assertFalse(host.unregister(entry.state.id)) + } + + @Test + fun `higher priority input consumes before lower priority entries`() { + val host = PortalHost(applicationSurface()) + val low = FakePortalEntry("low", FakePortalEntryConfig(order = PortalEntryOrder(zIndex = 1))) + val high = + FakePortalEntry( + id = "high", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 2), + consumeMouseDown = true, + ), + ) + host.register(low) + host.register(high) + low.activate() + high.activate() + + assertTrue(host.dispatchInput { it.handleMouseDown(24, 28, MouseButton.LEFT) }) + + assertEquals(1, high.mouseDownCalls) + assertEquals(0, low.mouseDownCalls) + } + + @Test + fun `input falls through portal entries until one consumes`() { + val host = PortalHost(applicationSurface()) + val low = + FakePortalEntry( + id = "low", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 1), + consumeMouseDown = true, + ), + ) + val high = FakePortalEntry("high", FakePortalEntryConfig(order = PortalEntryOrder(zIndex = 2))) + host.register(low) + host.register(high) + low.activate() + high.activate() + + assertTrue(host.dispatchInput { it.handleMouseDown(24, 28, MouseButton.LEFT) }) + + assertEquals(1, high.mouseDownCalls) + assertEquals(1, low.mouseDownCalls) + } + + @Test + fun `bounds and viewport are explicit and never default to hidden origin placement`() { + val valid = + PortalEntryPlacement( + anchorBounds = Rect(12, 14, 20, 18), + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, 320, 240), + entryBounds = Rect(18, 24, 120, 80), + ), + ) + val entry = FakePortalEntry("entry") + entry.state.activate(valid) + + assertEquals(valid, entry.state.placement) + assertFailsWith { + PortalEntryBounds( + viewportBounds = Rect(0, 0, 0, 240), + entryBounds = Rect(18, 24, 120, 80), + ) + } + assertFailsWith { + PortalEntryBounds( + viewportBounds = Rect(0, 0, 320, 240), + entryBounds = Rect(0, 0, 0, 0), + ) + } + assertFailsWith { + PortalFrameContext(viewportBounds = Rect(0, 0, 0, 0)) + } + } + + @Test + fun `focus policy declares preserve request and trap without changing global focus by default`() { + val preserve = FakePortalEntry("preserve", FakePortalEntryConfig(focusPolicy = PortalFocusPolicy.Preserve)) + val request = FakePortalEntry("request", FakePortalEntryConfig(focusPolicy = PortalFocusPolicy.RequestFocus)) + val trap = FakePortalEntry("trap", FakePortalEntryConfig(focusPolicy = PortalFocusPolicy.TrapFocus)) + + preserve.activate() + request.activate() + trap.activate() + + assertEquals(PortalFocusPolicy.Preserve, preserve.state.focusPolicy) + assertEquals(PortalFocusPolicy.RequestFocus, request.state.focusPolicy) + assertEquals(PortalFocusPolicy.TrapFocus, trap.state.focusPolicy) + assertNull(FocusManager.focusedNode()) + } + + @Test + fun `domain surface adapter maps current application and system hosts to portal surfaces`() { + assertEquals( + ScreenDomainSurfaces.ApplicationPortal, + DomainSurfacePortalHostAdapter(ApplicationPortalHost()).surface, + ) + assertEquals( + ScreenDomainSurfaces.SystemPortal, + DomainSurfacePortalHostAdapter(SystemPortalHost(InspectorController())).surface, + ) + } + + @Test + fun `portal host rejects entries for another domain surface`() { + val host = PortalHost(applicationSurface()) + val systemEntry = + FakePortalEntry( + id = "system", + config = + FakePortalEntryConfig( + surface = ScreenDomainSurface(ScreenDomainId.System, ScreenDomainSurfaceRole.Portal), + ), + ) + + assertFailsWith { + host.register(systemEntry) + } + } + + @Test + fun `render and paint use active entries in paint order`() { + val host = PortalHost(applicationSurface()) + val renderOrder = ArrayList() + val low = + FakePortalEntry( + id = "low", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 1), + paintColor = 0xFF000001.toInt(), + renderOrder = renderOrder, + ), + ) + val high = + FakePortalEntry( + id = "high", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 2), + paintColor = 0xFF000002.toInt(), + renderOrder = renderOrder, + ), + ) + host.register(high) + host.register(low) + low.activate() + high.activate() + + host.render(ctx, 320, 240) + val commands = host.paint(ctx) + + assertEquals(listOf("low", "high"), renderOrder) + assertEquals( + listOf(0xFF000001.toInt(), 0xFF000002.toInt()), + commands.map { (it as RenderCommand.DrawRect).color }, + ) + } + + @Test + fun `outside pointer policy evaluates topmost portal entry first`() { + val host = PortalHost(applicationSurface()) + val low = + FakePortalEntry( + id = "low", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 1), + dismissPolicy = PortalDismissPolicy.OutsidePointerDown, + ), + ) + val high = + FakePortalEntry( + id = "high", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 2), + dismissPolicy = PortalDismissPolicy.EscapeOrOutsidePointerDown, + ), + ) + host.register(low) + host.register(high) + low.activate(entryBounds = Rect(20, 20, 40, 40)) + high.activate(entryBounds = Rect(100, 20, 40, 40)) + + val result = host.evaluateOutsidePointerDown(mouseX = 180, mouseY = 180) ?: error("outside policy missing") + + assertEquals(high, result.entry) + assertEquals(PortalPointerRegion.OutsideEntry, result.region) + assertTrue(result.shouldClose) + assertTrue(result.consumed) + } + + @Test + fun `inside topmost portal entry blocks lower outside dismiss policy`() { + val host = PortalHost(applicationSurface()) + val low = + FakePortalEntry( + id = "low", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 1), + dismissPolicy = PortalDismissPolicy.OutsidePointerDown, + ), + ) + val high = + FakePortalEntry( + id = "high", + config = + FakePortalEntryConfig( + order = PortalEntryOrder(zIndex = 2), + dismissPolicy = PortalDismissPolicy.None, + ), + ) + host.register(low) + host.register(high) + low.activate(entryBounds = Rect(20, 20, 40, 40)) + high.activate(entryBounds = Rect(100, 20, 40, 40)) + + val result = host.evaluateOutsidePointerDown(mouseX = 110, mouseY = 30) ?: error("inside policy missing") + + assertEquals(high, result.entry) + assertEquals(PortalPointerRegion.InsideEntry, result.region) + assertFalse(result.shouldClose) + assertFalse(result.consumed) + assertTrue(low.state.active) + } + + @Test + fun `outside pointer policy closes dismissible entry and consumes click-through`() { + val host = PortalHost(applicationSurface()) + val entry = + FakePortalEntry( + id = "dismissible", + config = + FakePortalEntryConfig( + dismissPolicy = PortalDismissPolicy.OutsidePointerDown, + ), + ) + host.register(entry) + entry.activate(entryBounds = Rect(20, 20, 40, 40)) + + assertTrue(host.handleOutsidePointerDownPolicy(mouseX = 200, mouseY = 200)) + + assertEquals(1, entry.closeCalls) + assertFalse(entry.state.active) + } + + @Test + fun `backdrop policy can consume outside pointer without closing entry`() { + val host = PortalHost(applicationSurface()) + val entry = + FakePortalEntry( + id = "backdrop", + config = + FakePortalEntryConfig( + backdropPolicy = PortalBackdropPolicy.ConsumeOutsidePointerDown, + ), + ) + host.register(entry) + entry.activate(entryBounds = Rect(20, 20, 40, 40)) + + assertTrue(host.handleOutsidePointerDownPolicy(mouseX = 200, mouseY = 200)) + + assertEquals(0, entry.closeCalls) + assertTrue(entry.state.active) + } + + @Test + fun `protected bounds count as inside portal interaction for outside dismiss`() { + val host = PortalHost(applicationSurface()) + val entry = + FakePortalEntry( + id = "protected", + config = + FakePortalEntryConfig( + dismissPolicy = PortalDismissPolicy.OutsidePointerDown, + ), + ) + host.register(entry) + entry.activate(entryBounds = Rect(20, 20, 40, 40)) + entry.state.updateProtectedBounds(listOf(Rect(120, 120, 40, 40))) + + val result = host.evaluateOutsidePointerDown(mouseX = 130, mouseY = 130) ?: error("protected policy missing") + + assertEquals(entry, result.entry) + assertEquals(PortalPointerRegion.InsideEntry, result.region) + assertFalse(result.shouldClose) + assertFalse(result.consumed) + assertTrue(entry.state.active) + } + + @Test + fun `protected-only containment treats full viewport portal backdrop as outside`() { + val host = PortalHost(applicationSurface()) + val entry = + FakePortalEntry( + id = "modal", + config = + FakePortalEntryConfig( + dismissPolicy = PortalDismissPolicy.OutsidePointerDown, + insidePointerPolicy = PortalInsidePointerPolicy.ConsumePointerDown, + pointerContainmentPolicy = PortalPointerContainmentPolicy.ProtectedBoundsOnly, + ), + ) + host.register(entry) + entry.activate(entryBounds = Rect(0, 0, 320, 240)) + entry.state.updateProtectedBounds(listOf(Rect(80, 60, 120, 80))) + + val inside = host.evaluateOutsidePointerDown(mouseX = 100, mouseY = 80) ?: error("inside policy missing") + val outside = host.evaluateOutsidePointerDown(mouseX = 20, mouseY = 20) ?: error("outside policy missing") + + assertEquals(PortalPointerRegion.InsideEntry, inside.region) + assertTrue(inside.consumed) + assertFalse(inside.shouldClose) + assertEquals(PortalPointerRegion.OutsideEntry, outside.region) + assertTrue(outside.consumed) + assertTrue(outside.shouldClose) + } + + @Test + fun `manual lifecycle entry deactivates on host clear without component close`() { + val host = PortalHost(applicationSurface()) + val entry = + FakePortalEntry( + id = "manual", + config = + FakePortalEntryConfig( + lifecyclePolicy = PortalLifecyclePolicy.Manual, + ), + ) + host.register(entry) + entry.activate() + + host.clearRefs() + + assertEquals(1, entry.clearRefsCalls) + assertEquals(0, entry.closeCalls) + assertFalse(entry.state.active) + assertTrue(host.entriesInPaintOrder().isEmpty()) + } + + @Test + fun `portal dom event bubbles inside portal tree only`() { + val calls = ArrayList() + val applicationRoot = + ContainerNode(key = "application-root").apply { + bounds = Rect(0, 0, 320, 240) + } + val portalRoot = + ContainerNode(key = "portal-root").apply { + bounds = Rect(20, 20, 200, 120) + } + val portalParent = + ContainerNode(key = "portal-parent") + .apply { + bounds = Rect(30, 30, 120, 80) + }.applyParent(portalRoot) + val portalChild = + ContainerNode(key = "portal-child") + .apply { + bounds = Rect(40, 40, 40, 30) + }.applyParent(portalParent) + EventBus.run { + applicationRoot.addEventListener(Events.MOUSEDOWN) { calls += "application-root" } + portalRoot.addEventListener(Events.MOUSEDOWN) { calls += "portal-root" } + portalParent.addEventListener(Events.MOUSEDOWN) { calls += "portal-parent" } + portalChild.addEventListener(Events.MOUSEDOWN) { calls += "portal-child" } + } + + EventBus.post(MouseDownEvent(45, 45, MouseButton.LEFT).apply { target = portalChild }) + + assertEquals(listOf("portal-child", "portal-parent", "portal-root"), calls) + } + + @Test + fun `portal input router does not bubble portal subtree events into domain root`() { + val calls = ArrayList() + val applicationRoot = + ContainerNode(key = "application-root").apply { + bounds = Rect(0, 0, 320, 240) + } + val portalRoot = + ContainerNode(key = "portal-root").apply { + bounds = Rect(20, 20, 200, 120) + } + val portalParent = + ContainerNode(key = "portal-parent") + .apply { + bounds = Rect(30, 30, 120, 80) + }.applyParent(portalRoot) + ContainerNode(key = "portal-child") + .apply { + bounds = Rect(40, 40, 40, 30) + }.applyParent(portalParent) + EventBus.run { + applicationRoot.addEventListener(Events.MOUSEDOWN) { calls += "application-root" } + portalRoot.addEventListener(Events.MOUSEDOWN) { calls += "portal-root" } + portalParent.addEventListener(Events.MOUSEDOWN) { calls += "portal-parent" } + } + val portalRouter = SurfaceDomInputRouter { portalRoot } + + assertTrue(portalRouter.handleMouseDown(45, 45, MouseButton.LEFT)) + + assertEquals(listOf("portal-parent", "portal-root"), calls) + } + + private fun applicationSurface(): ScreenDomainSurface = ScreenDomainSurface(ScreenDomainId.Application, ScreenDomainSurfaceRole.Portal) + + private class FakePortalEntry( + id: String, + private val config: FakePortalEntryConfig = FakePortalEntryConfig(), + ) : PortalEntry { + override val state: PortalEntryState = + PortalEntryState( + id = PortalEntryId(id), + ownerToken = config.ownerToken, + surface = config.surface, + order = config.order, + dismissPolicy = config.dismissPolicy, + focusPolicy = config.focusPolicy, + backdropPolicy = config.backdropPolicy, + insidePointerPolicy = config.insidePointerPolicy, + pointerContainmentPolicy = config.pointerContainmentPolicy, + lifecyclePolicy = config.lifecyclePolicy, + ) + override val node: DOMNode? = config.node + var clearRefsCalls: Int = 0 + private set + var closeCalls: Int = 0 + private set + var mouseDownCalls: Int = 0 + private set + var renderCalls: Int = 0 + private set + + fun activate(entryBounds: Rect = Rect(12, 12, 100, 80)) { + state.activate( + PortalEntryPlacement( + anchorBounds = Rect(10, 10, 20, 20), + bounds = + PortalEntryBounds( + viewportBounds = Rect(0, 0, 320, 240), + entryBounds = entryBounds, + ), + ), + ) + } + + override fun render(ctx: UiMeasureContext, width: Int, height: Int) { + renderCalls += 1 + config.renderOrder?.add(state.id.value) + } + + override fun paint(ctx: UiMeasureContext): List = listOf(RenderCommand.DrawRect(0, 0, 1, 1, config.paintColor)) + + override fun clearRefs() { + clearRefsCalls += 1 + } + + override fun close() { + closeCalls += 1 + super.close() + } + + override fun handleMouseDown(mouseX: Int, mouseY: Int, button: MouseButton): Boolean { + mouseDownCalls += 1 + return config.consumeMouseDown + } + } + + private data class FakePortalEntryConfig( + val ownerToken: Any = Any(), + val surface: ScreenDomainSurface = ScreenDomainSurface(ScreenDomainId.Application, ScreenDomainSurfaceRole.Portal), + val order: PortalEntryOrder = PortalEntryOrder(zIndex = 0), + val dismissPolicy: PortalDismissPolicy = PortalDismissPolicy.None, + val focusPolicy: PortalFocusPolicy = PortalFocusPolicy.Preserve, + val backdropPolicy: PortalBackdropPolicy = PortalBackdropPolicy.None, + val insidePointerPolicy: PortalInsidePointerPolicy = PortalInsidePointerPolicy.PassThrough, + val pointerContainmentPolicy: PortalPointerContainmentPolicy = PortalPointerContainmentPolicy.DomOrEntryBounds, + val lifecyclePolicy: PortalLifecyclePolicy = PortalLifecyclePolicy.CloseOnUnmount, + val node: DOMNode? = null, + val consumeMouseDown: Boolean = false, + val paintColor: Int = 0xFFFFFFFF.toInt(), + val renderOrder: MutableList? = null, + ) +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContractsTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContractsTests.kt new file mode 100644 index 0000000..2ec27ba --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/ScreenDomainContractsTests.kt @@ -0,0 +1,249 @@ +package org.dreamfinity.dsgl.core.portal + +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ScreenDomainContractsTests { + @Test + fun `all domains expose root and portal surfaces`() { + assertEquals( + listOf( + ScreenDomainSurfaces.ApplicationRoot, + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.DebugPortal, + ), + ScreenDomainSurfaces.allSurfaces, + ) + } + + @Test + fun `paint order is application root portal then system root portal then debug root portal`() { + assertEquals( + listOf( + ScreenDomainSurfaces.ApplicationRoot, + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.DebugPortal, + ), + ScreenDomainSurfaces.paintOrder, + ) + } + + @Test + fun `input priority is reverse domain surface order`() { + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.ApplicationRoot, + ), + ScreenDomainSurfaces.inputPriority, + ) + } + + @Test + fun `owner scope resolves to compatible portal domain surfaces`() { + assertEquals( + ScreenDomainSurfaces.ApplicationPortal, + ScreenDomainSurfaces.portalSurfaceForDomain(ScreenDomainId.Application), + ) + assertEquals( + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.portalSurfaceForDomain(ScreenDomainId.System), + ) + } + + @Test + fun `transient ownership uses owner scope and not cursor position`() { + val appAtA = + ScreenDomainSurfaces.portalSurfaceForDomain( + ownerDomain = ScreenDomainId.Application, + cursorX = 10, + cursorY = 20, + ) + val appAtB = + ScreenDomainSurfaces.portalSurfaceForDomain( + ownerDomain = ScreenDomainId.Application, + cursorX = 800, + cursorY = 640, + ) + val systemAtA = + ScreenDomainSurfaces.portalSurfaceForDomain( + ownerDomain = ScreenDomainId.System, + cursorX = 10, + cursorY = 20, + ) + val systemAtB = + ScreenDomainSurfaces.portalSurfaceForDomain( + ownerDomain = ScreenDomainId.System, + cursorX = 800, + cursorY = 640, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationPortal, appAtA) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, appAtB) + assertEquals(ScreenDomainSurfaces.SystemPortal, systemAtA) + assertEquals(ScreenDomainSurfaces.SystemPortal, systemAtB) + } + + @Test + fun `firstInputConsumer respects configured input priority`() { + val consumed = + ScreenDomainSurfaces.firstInputConsumer( + canConsume = { surface -> + surface == ScreenDomainSurfaces.DebugRoot || + surface == ScreenDomainSurfaces.ApplicationPortal || + surface == ScreenDomainSurfaces.ApplicationRoot + }, + ) + assertEquals(ScreenDomainSurfaces.DebugRoot, consumed) + } + + @Test + fun `firstInputConsumer returns null when no surface consumes`() { + val consumed = ScreenDomainSurfaces.firstInputConsumer(canConsume = { false }) + assertNull(consumed) + } + + @Test + fun `composePaintCommands follows configured domain surface order`() { + val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) + val appPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000002.toInt())) + val systemRoot = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000003.toInt())) + val systemPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) + val debugRoot = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000005.toInt())) + val debugPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000006.toInt())) + val out = ArrayList() + + ScreenDomainSurfaces.composePaintCommands( + applicationRoot = root, + applicationPortal = appPortal, + systemRoot = systemRoot, + systemPortal = systemPortal, + debugRoot = debugRoot, + debugPortal = debugPortal, + out = out, + ) + + assertEquals( + listOf( + 0xFF000001.toInt(), + 0xFF000002.toInt(), + 0xFF000003.toInt(), + 0xFF000004.toInt(), + 0xFF000005.toInt(), + 0xFF000006.toInt(), + ), + out.map { (it as RenderCommand.DrawRect).color }, + ) + } + + @Test + fun `composePaintCommands accepts empty root and portal surfaces`() { + val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) + val systemPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) + val out = ArrayList() + + ScreenDomainSurfaces.composePaintCommands( + applicationRoot = root, + applicationPortal = emptyList(), + systemRoot = emptyList(), + systemPortal = systemPortal, + debugRoot = emptyList(), + debugPortal = emptyList(), + out = out, + ) + + assertEquals( + listOf(0xFF000001.toInt(), 0xFF000004.toInt()), + out.map { (it as RenderCommand.DrawRect).color }, + ) + } + + @Test + fun `composePaintCommands skips application portal render when disabled`() { + val root = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000001.toInt())) + val appPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000002.toInt())) + val systemPortal = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000003.toInt())) + val debugRoot = listOf(RenderCommand.DrawRect(0, 0, 1, 1, 0xFF000004.toInt())) + val out = ArrayList() + + ScreenDomainSurfaces.composePaintCommands( + applicationRoot = root, + applicationPortal = appPortal, + systemPortal = systemPortal, + debugRoot = debugRoot, + out = out, + shouldRenderSurface = { surface -> surface != ScreenDomainSurfaces.ApplicationPortal }, + ) + + assertEquals( + listOf(0xFF000001.toInt(), 0xFF000003.toInt(), 0xFF000004.toInt()), + out.map { (it as RenderCommand.DrawRect).color }, + ) + } + + @Test + fun `firstInputConsumer skips configured surface input`() { + val order = ArrayList() + val consumed = + ScreenDomainSurfaces.firstInputConsumer( + canConsume = { surface -> + order += surface + surface == ScreenDomainSurfaces.ApplicationPortal || + surface == ScreenDomainSurfaces.ApplicationRoot + }, + isSurfaceInputEnabled = { surface -> surface != ScreenDomainSurfaces.ApplicationPortal }, + ) + + assertEquals(ScreenDomainSurfaces.ApplicationRoot, consumed) + assertEquals( + listOf( + ScreenDomainSurfaces.DebugPortal, + ScreenDomainSurfaces.DebugRoot, + ScreenDomainSurfaces.SystemPortal, + ScreenDomainSurfaces.SystemRoot, + ScreenDomainSurfaces.ApplicationRoot, + ), + order, + ) + } + + @Test + fun `color picker popup defaults to application portal ownership`() { + val request = + ColorPickerPopupRequest( + owner = "owner", + anchorRect = Rect(10, 12, 20, 18), + state = ColorPickerState(color = RgbaColor.WHITE), + ) + assertEquals(ScreenDomainId.Application, request.ownerDomain) + assertEquals(ScreenDomainSurfaces.ApplicationPortal, ColorPickerPopupPortalOwnership.resolveSurface(request)) + } + + @Test + fun `system-owned color picker popup resolves to system portal`() { + val request = + ColorPickerPopupRequest( + owner = "owner", + ownerDomain = ScreenDomainId.System, + anchorRect = Rect(10, 12, 20, 18), + state = ColorPickerState(color = RgbaColor.WHITE), + ) + assertEquals(ScreenDomainSurfaces.SystemPortal, ColorPickerPopupPortalOwnership.resolveSurface(request)) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSessionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSessionTests.kt new file mode 100644 index 0000000..c23fc28 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSessionTests.kt @@ -0,0 +1,102 @@ +package org.dreamfinity.dsgl.core.portal.input + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode +import org.dreamfinity.dsgl.core.dom.elements.TextInputNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.event.FocusManager +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PointerCaptureSessionTests { + @AfterTest + fun cleanup() { + FocusManager.clearFocus() + } + + @Test + fun `restores keyed capture target after root subtree replacement`() { + val firstRoot = ContainerNode(key = "root") + val firstTarget = CapturingNode(key = "target").applyParent(firstRoot) + val session = PointerCaptureSession() + session.capture(firstTarget) + + val secondRoot = ContainerNode(key = "root") + val secondTarget = CapturingNode(key = "target").applyParent(secondRoot) + + session.restore(secondRoot, pointerPressed = true) + + assertSame(secondTarget, session.target) + assertFalse(firstTarget.cancelled) + } + + @Test + fun `keeps unkeyed capture target while pointer is still pressed`() { + val root = ContainerNode(key = "root") + val target = CapturingNode().applyParent(root) + val session = PointerCaptureSession() + session.capture(target) + + session.restore(ContainerNode(key = "other-root"), pointerPressed = true) + + assertSame(target, session.target) + assertFalse(target.cancelled) + } + + @Test + fun `releases unkeyed capture target when detached after pointer is no longer pressed`() { + val root = ContainerNode(key = "root") + val target = CapturingNode().applyParent(root) + val session = PointerCaptureSession() + session.capture(target) + + session.restore(ContainerNode(key = "other-root"), pointerPressed = false) + + assertFalse(session.hasCapture) + assertTrue(target.cancelled) + } + + @Test + fun `focus moving into captured subtree does not cancel capture`() { + val root = ContainerNode(key = "root") + val target = CapturingNode(key = "target").applyParent(root) + val child = TextInputNode(text = "", key = "child").applyParent(target) + val previous = TextInputNode(text = "", key = "previous").applyParent(root) + val session = PointerCaptureSession() + + FocusManager.requestFocus(previous) + session.capture(target) + FocusManager.requestFocus(child) + + assertFalse(session.hasFocusChanged()) + } + + @Test + fun `capture target resolution includes control-owned drag nodes`() { + val root = ContainerNode(key = "root").apply { bounds = Rect(0, 0, 200, 80) } + val range = + RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range") + .apply { + bounds = Rect(20, 20, 120, 12) + }.applyParent(root) + + val resolved = PointerCaptureSession.resolveCaptureTarget(range, 24, 24) + + assertSame(range, resolved) + } + + private class CapturingNode( + key: Any? = null, + ) : DOMNode(key) { + var cancelled: Boolean = false + + override fun cancelPointerCapture() { + cancelled = true + } + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouterTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouterTests.kt similarity index 53% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouterTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouterTests.kt index 589814d..b146fd1 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/input/LayerDomInputRouterTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/input/SurfaceDomInputRouterTests.kt @@ -1,19 +1,14 @@ -package org.dreamfinity.dsgl.core.overlay.input +package org.dreamfinity.dsgl.core.portal.input -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.applyParent -import org.dreamfinity.dsgl.core.dom.onInput import org.dreamfinity.dsgl.core.dom.elements.ButtonNode import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.elements.RangeInputNode -import org.dreamfinity.dsgl.core.dom.elements.TextInputNode import org.dreamfinity.dsgl.core.dom.elements.TextAreaNode +import org.dreamfinity.dsgl.core.dom.elements.TextInputNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.dom.onInput import org.dreamfinity.dsgl.core.event.FocusManager import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.KeyModifiers @@ -22,15 +17,23 @@ import org.dreamfinity.dsgl.core.input.ClipboardAccess import org.dreamfinity.dsgl.core.input.ClipboardBridge import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.Overflow +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue -class LayerDomInputRouterTests { +class SurfaceDomInputRouterTests { private val clipboard = RecordingClipboardAccess() - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) {} - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + override fun measureText(text: String): Int = text.length * 6 + + @Suppress("EmptyFunctionBlock") + override fun paint(commands: List) {} + } @AfterTest fun cleanup() { @@ -42,12 +45,13 @@ class LayerDomInputRouterTests { @Test fun `text input editing semantics stay consistent across all layers`() { ClipboardBridge.install(clipboard) - listOf("app-dom", "app-overlay", "system-overlay").forEach { layer -> + listOf("app-dom", "app-portal", "system-portal").forEach { layer -> clipboard.value = "" - val (root, router) = createLayerRouter(layer) - val input = TextInputNode(text = "abcdef", key = "$layer-input").apply { - bounds = Rect(20, 20, 180, 24) - } + val (root, router) = createSurfaceRouter(layer) + val input = + TextInputNode(text = "abcdef", key = "$layer-input").apply { + bounds = Rect(20, 20, 180, 24) + } input.applyParent(root) assertTrue(router.handleMouseDown(24, 24, MouseButton.LEFT)) @@ -88,21 +92,23 @@ class LayerDomInputRouterTests { @Test fun `dropdown-like topmost option wins and blocks click-through across layers`() { - listOf("app-dom", "app-overlay", "system-overlay").forEach { layer -> - val (root, router) = createLayerRouter(layer) + listOf("app-dom", "app-portal", "system-portal").forEach { layer -> + val (root, router) = createSurfaceRouter(layer) var underClicks = 0 var topClicks = 0 - val under = ButtonNode("under", key = "$layer-under").apply { - bounds = Rect(40, 40, 120, 24) - onClick { underClicks += 1 } - } + val under = + ButtonNode("under", key = "$layer-under").apply { + bounds = Rect(40, 40, 120, 24) + onClick { underClicks += 1 } + } under.applyParent(root) - val top = ButtonNode("top", key = "$layer-top").apply { - bounds = Rect(40, 40, 120, 24) - onClick { topClicks += 1 } - } + val top = + ButtonNode("top", key = "$layer-top").apply { + bounds = Rect(40, 40, 120, 24) + onClick { topClicks += 1 } + } top.applyParent(root) assertTrue(router.handleMouseMove(50, 48)) @@ -116,16 +122,18 @@ class LayerDomInputRouterTests { @Test fun `keyboard dispatch follows focused node root membership`() { - val (rootA, routerA) = createLayerRouter("layer-a") - val (rootB, routerB) = createLayerRouter("layer-b") + val (rootA, routerA) = createSurfaceRouter("layer-a") + val (rootB, routerB) = createSurfaceRouter("layer-b") - val inputA = TextInputNode(text = "a", key = "a-input").apply { - bounds = Rect(10, 10, 100, 20) - } + val inputA = + TextInputNode(text = "a", key = "a-input").apply { + bounds = Rect(10, 10, 100, 20) + } inputA.applyParent(rootA) - val inputB = TextInputNode(text = "b", key = "b-input").apply { - bounds = Rect(10, 10, 100, 20) - } + val inputB = + TextInputNode(text = "b", key = "b-input").apply { + bounds = Rect(10, 10, 100, 20) + } inputB.applyParent(rootB) FocusManager.requestFocus(inputA) @@ -135,15 +143,47 @@ class LayerDomInputRouterTests { assertEquals("b", inputB.text) } + @Test + fun `keyboard key up follows focused node root membership`() { + val (rootA, routerA) = createSurfaceRouter("layer-a") + val (rootB, routerB) = createSurfaceRouter("layer-b") + val keyUps = mutableListOf() + + val parentA = + ContainerNode(key = "a-parent").apply { + onKeyUp = { keyUps += "parent:${it.keyChar}" } + } + parentA.applyParent(rootA) + val inputA = + TextInputNode(text = "a", key = "a-input").apply { + bounds = Rect(10, 10, 100, 20) + onKeyUp = { keyUps += "input:${it.keyChar}" } + } + inputA.applyParent(parentA) + + val inputB = + TextInputNode(text = "b", key = "b-input").apply { + bounds = Rect(10, 10, 100, 20) + onKeyUp = { keyUps += "wrong:${it.keyChar}" } + } + inputB.applyParent(rootB) + + FocusManager.requestFocus(inputA) + assertTrue(routerA.handleKeyUp(KeyCodes.Z, 'z')) + assertFalse(routerB.handleKeyUp(KeyCodes.X, 'x')) + assertEquals(listOf("input:z", "parent:z"), keyUps) + } + @Test fun `pointer drag capture is generic for header and thumb style controls`() { listOf("header-drag", "thumb-drag").forEach { key -> - val (root, router) = createLayerRouter(key) + val (root, router) = createSurfaceRouter(key) var dragEvents = 0 - val dragNode = ContainerNode(key = "$key-node").apply { - bounds = Rect(60, 20, 90, 20) - onMouseDrag = { dragEvents += 1 } - } + val dragNode = + ContainerNode(key = "$key-node").apply { + bounds = Rect(60, 20, 90, 20) + onMouseDrag = { dragEvents += 1 } + } dragNode.applyParent(root) assertTrue(router.handleMouseDown(64, 28, MouseButton.LEFT)) @@ -155,19 +195,21 @@ class LayerDomInputRouterTests { @Test fun `release after drag does not synthesize click on hovered target`() { - val (root, router) = createLayerRouter("drag-release") + val (root, router) = createSurfaceRouter("drag-release") var buttonClicks = 0 - val dragSurface = ContainerNode(key = "drag-surface").apply { - bounds = Rect(20, 20, 80, 24) - onMouseMove = {} - } + val dragSurface = + ContainerNode(key = "drag-surface").apply { + bounds = Rect(20, 20, 80, 24) + onMouseMove = {} + } dragSurface.applyParent(root) - val releaseButton = ButtonNode("release", key = "release-button").apply { - bounds = Rect(120, 20, 100, 24) - onClick { buttonClicks += 1 } - } + val releaseButton = + ButtonNode("release", key = "release-button").apply { + bounds = Rect(120, 20, 100, 24) + onClick { buttonClicks += 1 } + } releaseButton.applyParent(root) assertTrue(router.handleMouseDown(24, 24, MouseButton.LEFT)) @@ -178,12 +220,13 @@ class LayerDomInputRouterTests { @Test fun `unkeyed drag capture remains active across pointer move`() { - val (root, router) = createLayerRouter("unkeyed-drag") + val (root, router) = createSurfaceRouter("unkeyed-drag") var dragEvents = 0 - val dragNode = ContainerNode().apply { - bounds = Rect(60, 20, 90, 20) - onMouseDrag = { dragEvents += 1 } - } + val dragNode = + ContainerNode().apply { + bounds = Rect(60, 20, 90, 20) + onMouseDrag = { dragEvents += 1 } + } dragNode.applyParent(root) assertTrue(router.handleMouseDown(64, 28, MouseButton.LEFT)) @@ -194,7 +237,7 @@ class LayerDomInputRouterTests { @Test fun `range input drag updates value when pointer leaves bounds`() { - val (root, router) = createLayerRouter("range-drag") + val (root, router) = createSurfaceRouter("range-drag") val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = null) range.applyParent(root) range.render(ctx, 20, 20, 120, 12) @@ -205,10 +248,66 @@ class LayerDomInputRouterTests { assertTrue(range.value > 0L) } + @Test + fun `range input drag survives focus changing to captured control`() { + val (root, router) = createSurfaceRouter("range-drag-focus") + val previousFocus = + TextInputNode(text = "before", key = "range-drag-focus-before").apply { + bounds = Rect(4, 4, 80, 20) + } + previousFocus.applyParent(root) + val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = "range-drag-focus-range") + range.applyParent(root) + range.render(ctx, 20, 40, 120, 12) + + FocusManager.requestFocus(previousFocus) + assertTrue(router.handleMouseDown(20, 46, MouseButton.LEFT)) + assertTrue(FocusManager.isFocused(range)) + assertTrue(router.handleMouseMove(220, 46)) + assertTrue(router.handleMouseUp(220, 46, MouseButton.LEFT)) + assertTrue(range.value > 0L) + } + + @Test + fun `range input drag is not cancelled by unrelated router clear`() { + val (rootA, routerA) = createSurfaceRouter("range-drag-owner") + val (_, routerB) = createSurfaceRouter("unrelated-router") + val range = RangeInputNode(value = 0L, min = 0L, max = 100L, key = "owned-range") + range.applyParent(rootA) + range.render(ctx, 20, 20, 120, 12) + + assertTrue(routerA.handleMouseDown(20, 26, MouseButton.LEFT)) + routerB.clear() + assertTrue(routerA.handleMouseMove(220, 26)) + assertTrue(routerA.handleMouseUp(220, 26, MouseButton.LEFT)) + + assertEquals(100L, range.value) + } + + @Test + fun `text selection drag is not cancelled by unrelated router clear`() { + ClipboardBridge.install(clipboard) + val (rootA, routerA) = createSurfaceRouter("text-drag-owner") + val (_, routerB) = createSurfaceRouter("unrelated-text-router") + val input = + TextInputNode(text = "abcdef", key = "owned-text").apply { + bounds = Rect(20, 20, 160, 20) + } + input.applyParent(rootA) + + assertTrue(routerA.handleMouseDown(20, 26, MouseButton.LEFT)) + routerB.clear() + assertTrue(routerA.handleMouseMove(80, 26)) + assertTrue(routerA.handleMouseUp(80, 26, MouseButton.LEFT)) + KeyModifiers.sync(shift = false, control = true, meta = false) + assertTrue(routerA.handleKeyDown(KeyCodes.C, 'c')) + + assertTrue(clipboard.value.isNotEmpty()) + } @Test fun `range input drag survives unkeyed rerender replacement`() { - val (root, router) = createLayerRouter("range-rerender") + val (root, router) = createSurfaceRouter("range-rerender") var model = 0L lateinit var mount: (Long) -> RangeInputNode @@ -231,15 +330,17 @@ class LayerDomInputRouterTests { assertTrue(router.handleMouseUp(220, 26, MouseButton.LEFT)) assertEquals(100L, model) } + @Test fun `mouse up stays consumed after press when pointer is released outside targets`() { - val (root, router) = createLayerRouter("outside-release") + val (root, router) = createSurfaceRouter("outside-release") var buttonClicks = 0 - val button = ButtonNode("press", key = "outside-release-button").apply { - bounds = Rect(20, 20, 100, 24) - onClick { buttonClicks += 1 } - } + val button = + ButtonNode("press", key = "outside-release-button").apply { + bounds = Rect(20, 20, 100, 24) + onClick { buttonClicks += 1 } + } button.applyParent(root) assertTrue(router.handleMouseDown(24, 24, MouseButton.LEFT)) @@ -250,49 +351,55 @@ class LayerDomInputRouterTests { @Test fun `wheel over non-handling interactive child bubbles to ancestor wheel handler`() { - val (root, router) = createLayerRouter("wheel-bubble") + val (root, router) = createSurfaceRouter("wheel-bubble") var wheelEvents = 0 - val scrollHost = ContainerNode(key = "wheel-host").apply { - bounds = Rect(10, 10, 220, 140) - onMouseWheel = { event -> - wheelEvents += 1 - event.cancelled = true + val scrollHost = + ContainerNode(key = "wheel-host").apply { + bounds = Rect(10, 10, 220, 140) + onMouseWheel = { event -> + wheelEvents += 1 + event.cancelled = true + } } - } scrollHost.applyParent(root) - val input = TextInputNode(text = "value", key = "wheel-input").apply { - bounds = Rect(24, 24, 150, 24) - } + val input = + TextInputNode(text = "value", key = "wheel-input").apply { + bounds = Rect(24, 24, 150, 24) + } input.applyParent(scrollHost) assertTrue(router.handleMouseDown(28, 28, MouseButton.LEFT)) assertTrue(router.handleMouseWheel(28, 28, -120)) assertEquals(1, wheelEvents) } + @Test fun `focused textarea does not steal wheel from hovered control`() { - val (root, router) = createLayerRouter("wheel-focused-textarea") + val (root, router) = createSurfaceRouter("wheel-focused-textarea") var hostWheelEvents = 0 - val scrollHost = ContainerNode(key = "wheel-focused-host").apply { - bounds = Rect(10, 10, 260, 180) - onMouseWheel = { event -> - hostWheelEvents += 1 - event.cancelled = true + val scrollHost = + ContainerNode(key = "wheel-focused-host").apply { + bounds = Rect(10, 10, 260, 180) + onMouseWheel = { event -> + hostWheelEvents += 1 + event.cancelled = true + } } - } scrollHost.applyParent(root) - val textArea = TextAreaNode(text = "first\nsecond\nthird", key = "wheel-focused-textarea").apply { - bounds = Rect(24, 24, 160, 48) - } + val textArea = + TextAreaNode(text = "first\nsecond\nthird", key = "wheel-focused-textarea").apply { + bounds = Rect(24, 24, 160, 48) + } textArea.applyParent(scrollHost) - val input = TextInputNode(text = "value", key = "wheel-focused-input").apply { - bounds = Rect(24, 92, 150, 24) - } + val input = + TextInputNode(text = "value", key = "wheel-focused-input").apply { + bounds = Rect(24, 92, 150, 24) + } input.applyParent(scrollHost) assertTrue(router.handleMouseDown(28, 28, MouseButton.LEFT)) @@ -304,32 +411,37 @@ class LayerDomInputRouterTests { @Test fun `wheel without cancellation is not consumed`() { - val (root, router) = createLayerRouter("wheel-unhandled") - val input = TextInputNode(text = "value", key = "wheel-unhandled-input").apply { - bounds = Rect(24, 24, 150, 24) - } + val (root, router) = createSurfaceRouter("wheel-unhandled") + val input = + TextInputNode(text = "value", key = "wheel-unhandled-input").apply { + bounds = Rect(24, 24, 150, 24) + } input.applyParent(root) assertTrue(router.handleMouseDown(28, 28, MouseButton.LEFT)) assertFalse(router.handleMouseWheel(28, 28, -120)) } + @Test fun `wheel axis semantics stay consistent across layers`() { - listOf("app-dom", "app-overlay", "system-overlay").forEach { layer -> - val (root, router) = createLayerRouter("wheel-axis-$layer") - val viewport = ContainerNode(key = "$layer-scroll").apply { - bounds = Rect(20, 20, 150, 70) - overflowX = Overflow.Auto - overflowY = Overflow.Auto - } + listOf("app-dom", "app-portal", "system-portal").forEach { layer -> + val (root, router) = createSurfaceRouter("wheel-axis-$layer") + val viewport = + ContainerNode(key = "$layer-scroll").apply { + bounds = Rect(20, 20, 150, 70) + overflowX = Overflow.Auto + overflowY = Overflow.Auto + } viewport.applyParent(root) - ContainerNode(key = "$layer-scroll-content").apply { - bounds = Rect(20, 20, 320, 220) - }.applyParent(viewport) - val wheelTarget = ButtonNode("wheel", key = "$layer-wheel-target").apply { - bounds = Rect(26, 26, 64, 20) - onClick { } - } + ContainerNode(key = "$layer-scroll-content") + .apply { + bounds = Rect(20, 20, 320, 220) + }.applyParent(viewport) + val wheelTarget = + ButtonNode("wheel", key = "$layer-wheel-target").apply { + bounds = Rect(26, 26, 64, 20) + onClick { } + } wheelTarget.applyParent(viewport) val wheelX = wheelTarget.bounds.x + 1 @@ -353,11 +465,13 @@ class LayerDomInputRouterTests { assertEquals(0, horizontalState.scrollY) } } - private fun createLayerRouter(key: String): Pair { - val root = ContainerNode(key = "$key-root").apply { - bounds = Rect(0, 0, 320, 200) - } - return root to LayerDomInputRouter { root } + + private fun createSurfaceRouter(key: String): Pair { + val root = + ContainerNode(key = "$key-root").apply { + bounds = Rect(0, 0, 320, 200) + } + return root to SurfaceDomInputRouter { root } } private class RecordingClipboardAccess : ClipboardAccess { @@ -370,4 +484,3 @@ class LayerDomInputRouterTests { } } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelTests.kt similarity index 61% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelTests.kt index 5a7564c..adc4968 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/panel/OverlayPanelTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/panel/FloatingPanelTests.kt @@ -1,36 +1,44 @@ -package org.dreamfinity.dsgl.core.overlay.panel +package org.dreamfinity.dsgl.core.portal.panel -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -class OverlayPanelTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } +class FloatingPanelTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 - @Test - fun `panel uses native node path and does not expose legacy append commands api`() { - assertTrue(OverlayPanel::class.java.methods.none { it.name == "appendCommands" }) + override fun measureText(text: String): Int = text.length * 6 - val panelState = OverlayPanelState().apply { - updateFromRect(Rect(30, 40, 240, 180)) + override fun paint(commands: List) = Unit } - val panel = OverlayPanel( - ownerId = "demo-owner", - panelState = panelState, - dragSession = OverlayPanelDragSession() + + @Test + fun `panel uses native node path and does not expose legacy append commands api`() { + assertTrue( + FloatingPanel::class.java.methods + .none { it.name == "appendCommands" }, ) + + val panelState = + FloatingPanelState().apply { + updateFromRect(Rect(30, 40, 240, 180)) + } + val panel = + FloatingPanel( + ownerId = "demo-owner", + panelState = panelState, + dragSession = FloatingPanelDragSession(), + ) panel.configure(title = "Demo", draggable = true) panel.setBodyContent(FillNode("body")) @@ -46,15 +54,17 @@ class OverlayPanelTests { @Test fun `panel drag keeps persistent drag session and updates panel state`() { - val panelState = OverlayPanelState().apply { - updateFromRect(Rect(60, 70, 260, 180)) - } - val dragSession = OverlayPanelDragSession() - val panel = OverlayPanel( - ownerId = "drag-owner", - panelState = panelState, - dragSession = dragSession - ) + val panelState = + FloatingPanelState().apply { + updateFromRect(Rect(60, 70, 260, 180)) + } + val dragSession = FloatingPanelDragSession() + val panel = + FloatingPanel( + ownerId = "drag-owner", + panelState = panelState, + dragSession = dragSession, + ) panel.configure(title = "Drag", draggable = true) val header = panel.headerRect() ?: error("header rect missing") @@ -70,10 +80,10 @@ class OverlayPanelTests { mouseX = startX + 42, mouseY = startY + 26, viewportWidth = 1200, - viewportHeight = 800 + viewportHeight = 800, ) { rect -> lastRect = rect - } + }, ) val movedRect = panelState.currentRectOrNull() ?: error("panel rect missing") assertNotNull(lastRect) @@ -86,10 +96,10 @@ class OverlayPanelTests { mouseY = startY + 26, button = MouseButton.LEFT, viewportWidth = 1200, - viewportHeight = 800 + viewportHeight = 800, ) { rect -> lastRect = rect - } + }, ) assertFalse(dragSession.active) assertEquals(null, dragSession.ownerId) @@ -98,14 +108,16 @@ class OverlayPanelTests { @Test fun `panel close button invokes close callback`() { - val panelState = OverlayPanelState().apply { - updateFromRect(Rect(12, 20, 220, 140)) - } - val panel = OverlayPanel( - ownerId = Any(), - panelState = panelState, - dragSession = OverlayPanelDragSession() - ) + val panelState = + FloatingPanelState().apply { + updateFromRect(Rect(12, 20, 220, 140)) + } + val panel = + FloatingPanel( + ownerId = Any(), + panelState = panelState, + dragSession = FloatingPanelDragSession(), + ) var closed = 0 panel.configure(title = "Closable", draggable = true, onClose = { closed += 1 }) val closeRect = panel.closeRect() ?: error("close rect missing") @@ -115,13 +127,17 @@ class OverlayPanelTests { } private class FillNode( - key: Any? + key: Any?, ) : DOMNode(key) { - override fun measure(ctx: UiMeasureContext): Size { - return Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - } + override fun measure(ctx: UiMeasureContext): Size = Size(bounds.width.coerceAtLeast(0), bounds.height.coerceAtLeast(0)) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDragScrollDomMigrationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDragScrollDomMigrationTests.kt similarity index 59% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDragScrollDomMigrationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDragScrollDomMigrationTests.kt index cf472fa..ae29a72 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDragScrollDomMigrationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDragScrollDomMigrationTests.kt @@ -1,12 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime +package org.dreamfinity.dsgl.core.portal.system + import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -19,23 +12,29 @@ import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.inspector.InspectorController import org.dreamfinity.dsgl.core.inspector.InspectorEditorKind import org.dreamfinity.dsgl.core.inspector.InspectorStyleEditorRowSnapshot -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.* class InspectorDragScrollDomMigrationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { FocusManager.clearFocus() KeyModifiers.sync(shift = false, control = false, meta = false) - ColorPickerRuntime.engine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() + DomainPortalServices.closeAllSelects() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -44,7 +43,7 @@ class InspectorDragScrollDomMigrationTests { fun `inspector panel drag is dom-first and controller drag authority stays demoted`() { val fixture = openInspectorAndSelectTarget(withManyChildren = true) - val before = fixture.inspector.debugPanelRect() ?: error("expected panel rect") + val before = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") val downX = before.x + 18 val downY = before.y + 14 val moveX = downX + 90 @@ -55,11 +54,16 @@ class InspectorDragScrollDomMigrationTests { assertFalse(fixture.inspector.isDraggingPanel) assertFalse(fixture.inspector.isPointerCaptured) - assertTrue(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertTrue( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) assertTrue(fixture.host.handleMouseMove(moveX, moveY)) syncAndRender(fixture, moveX, moveY) - val moved = fixture.inspector.debugPanelRect() ?: error("expected moved panel rect") + val moved = fixture.inspector.floatingPanelRect() ?: error("expected moved panel rect") assertTrue(moved.x != before.x || moved.y != before.y) assertTrue(fixture.host.handleMouseUp(moveX, moveY, MouseButton.LEFT)) @@ -67,7 +71,66 @@ class InspectorDragScrollDomMigrationTests { assertFalse(fixture.inspector.isDraggingPanel) assertFalse(fixture.inspector.isPointerCaptured) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) + } + + @Test + fun `inspector panel drag stays monotonic when sync cursor lags behind dom drag updates`() { + val fixture = openInspectorAndSelectTarget(withManyChildren = true) + + val before = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") + val downX = before.x + 18 + val downY = before.y + 14 + val dragX = downX + 92 + val dragY = downY + 34 + + assertTrue(fixture.host.handleMouseDown(downX, downY, MouseButton.LEFT)) + syncAndRender(fixture, downX, downY) + + assertTrue(fixture.host.handleMouseMove(dragX, dragY)) + val afterDomDrag = fixture.inspector.floatingPanelRect() ?: error("expected moved panel rect") + assertTrue( + afterDomDrag.x > before.x || afterDomDrag.y > before.y, + "expected drag move to advance panel: before=$before afterDomDrag=$afterDomDrag", + ) + + syncAndRender(fixture, downX, downY) + val afterStaleSync = fixture.inspector.floatingPanelRect() ?: error("expected panel rect after stale sync") + + if (afterDomDrag.x > before.x) { + assertTrue( + afterStaleSync.x >= afterDomDrag.x, + "stale sync cursor regressed panel x: before=$before dom=$afterDomDrag stale=$afterStaleSync", + ) + } + if (afterDomDrag.y > before.y) { + assertTrue( + afterStaleSync.y >= afterDomDrag.y, + "stale sync cursor regressed panel y: before=$before dom=$afterDomDrag stale=$afterStaleSync", + ) + } + + assertTrue(fixture.host.handleMouseMove(dragX + 28, dragY + 16)) + syncAndRender(fixture, dragX + 28, dragY + 16) + val afterNextMove = fixture.inspector.floatingPanelRect() ?: error("expected panel rect after next drag move") + assertTrue( + afterNextMove.x >= afterStaleSync.x && afterNextMove.y >= afterStaleSync.y, + "expected monotonic drag progression across sync/render cycle: stale=$afterStaleSync next=$afterNextMove", + ) + + assertTrue(fixture.host.handleMouseUp(dragX + 28, dragY + 16, MouseButton.LEFT)) + syncAndRender(fixture, dragX + 28, dragY + 16) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) } @Test @@ -75,7 +138,7 @@ class InspectorDragScrollDomMigrationTests { val fixture = openInspectorAndSelectTarget(withManyChildren = true) setViewport(fixture, 420, 280) - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 val before = fixture.inspector.panelScrollOffsetY @@ -87,7 +150,12 @@ class InspectorDragScrollDomMigrationTests { assertTrue(after > before) assertFalse(fixture.inspector.isPointerCaptured) assertFalse(fixture.inspector.isDraggingPanel) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) } @Test @@ -96,7 +164,7 @@ class InspectorDragScrollDomMigrationTests { setViewport(fixture, 420, 280) scrollInspectorBodyDown(fixture, steps = 2) - val thumb = fixture.inspector.debugScrollbarThumbRect() + val thumb = fixture.inspector.portalScrollbarThumbRect() assertTrue(thumb.width > 0 && thumb.height > 0) val dragX = thumb.x + thumb.width / 2 @@ -108,7 +176,12 @@ class InspectorDragScrollDomMigrationTests { assertFalse(fixture.inspector.isPointerCaptured) assertFalse(fixture.inspector.isDraggingPanel) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) assertTrue(fixture.host.handleMouseMove(dragX, startY + 40)) syncAndRender(fixture, dragX, startY + 40) @@ -117,7 +190,12 @@ class InspectorDragScrollDomMigrationTests { assertTrue(fixture.host.handleMouseUp(dragX, startY + 40, MouseButton.LEFT)) syncAndRender(fixture, dragX, startY + 40) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) } @Test @@ -126,21 +204,31 @@ class InspectorDragScrollDomMigrationTests { setViewport(fixture, 420, 280) scrollInspectorBodyDown(fixture, steps = 3) - val thumb = fixture.inspector.debugScrollbarThumbRect() + val thumb = fixture.inspector.portalScrollbarThumbRect() assertTrue(thumb.width > 0 && thumb.height > 0) val dragX = thumb.x + thumb.width / 2 val startY = thumb.y + thumb.height / 2 assertTrue(fixture.host.handleMouseDown(dragX, startY, MouseButton.LEFT)) syncAndRender(fixture, dragX, startY) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) assertTrue(fixture.host.handleMouseMove(dragX, startY + 22)) syncAndRender(fixture, dragX, startY + 22) val afterFirstMove = fixture.inspector.panelScrollOffsetY syncAndRender(fixture, dragX, startY + 22) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) assertTrue(fixture.host.handleMouseMove(dragX, startY + 54)) syncAndRender(fixture, dragX, startY + 54) @@ -149,7 +237,12 @@ class InspectorDragScrollDomMigrationTests { assertTrue(fixture.host.handleMouseUp(dragX, startY + 54, MouseButton.LEFT)) syncAndRender(fixture, dragX, startY + 54) - assertFalse(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertFalse( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) } @Test @@ -157,14 +250,19 @@ class InspectorDragScrollDomMigrationTests { val fixture = openInspectorAndSelectTarget(withManyChildren = true) setViewport(fixture, 420, 280) - val panelRect = fixture.inspector.debugPanelRect() ?: error("expected panel rect") + val panelRect = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") val downX = panelRect.x + 14 val downY = panelRect.y + 12 assertTrue(fixture.host.handleMouseDown(downX, downY, MouseButton.LEFT)) syncAndRender(fixture, downX, downY) assertFalse(fixture.inspector.isDraggingPanel) assertFalse(fixture.inspector.isPointerCaptured) - assertTrue(fixture.host.debugEntryState(SystemOverlayEntryId.Inspector)?.dragSession?.active == true) + assertTrue( + fixture.host + .debugEntryState(SystemPortalEntryId.Inspector) + ?.dragSession + ?.active == true, + ) assertTrue(fixture.host.handleMouseUp(downX, downY, MouseButton.LEFT)) syncAndRender(fixture, downX, downY) @@ -191,6 +289,12 @@ class InspectorDragScrollDomMigrationTests { fun `dropdown migration remains intact after drag-scroll migration`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) val row = findVisibleSelectRow(fixture) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: error("expected style row index for ${row.property.key}") + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" val trigger = visibleControlRect(fixture, row) val clickX = trigger.x + 2 val clickY = trigger.y + (trigger.height / 2).coerceAtLeast(1) @@ -199,12 +303,12 @@ class InspectorDragScrollDomMigrationTests { fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) syncAndRender(fixture, clickX, clickY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) syncAndRender(fixture, clickX, clickY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) } @Test @@ -216,33 +320,40 @@ class InspectorDragScrollDomMigrationTests { syncAndRender(fixture, anchor.x + 1, anchor.y + 1) assertTrue(fixture.host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, fixture.host.debugSystemColorPickerPopupOwnerScope()) - assertNotNull(fixture.host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup)) + assertEquals(ScreenDomainId.System, fixture.host.debugSystemColorPickerPopupOwnerDomain()) + assertNotNull(fixture.host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup)) } private fun openInspectorAndSelectTarget(withManyChildren: Boolean): Fixture { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) val root = inspectedRoot(withManyChildren) inspector.toggle() host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) host.paint(ctx) assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) assertTrue(host.handleMouseUp(984, 144, MouseButton.LEFT)) inspector.setPickMode(false) - val fixture = Fixture( - inspector = inspector, - host = host, - root = root, - revision = 2L, - viewportWidth = 1280, - viewportHeight = 720 - ) + val fixture = + Fixture( + inspector = inspector, + host = host, + root = root, + revision = 2L, + viewportWidth = 1280, + viewportHeight = 720, + ) syncAndRender(fixture, 984, 144) return fixture } @@ -260,14 +371,14 @@ class InspectorDragScrollDomMigrationTests { inspectedLayoutRevision = fixture.revision++, cursorX = cursorX, cursorY = cursorY, - inspectorPointerCaptured = fixture.inspector.isPointerCaptured + inspectorPointerCaptured = fixture.inspector.isPointerCaptured, ) fixture.host.render(ctx, fixture.viewportWidth, fixture.viewportHeight) fixture.host.paint(ctx) } private fun scrollInspectorBodyDown(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 repeat(steps) { @@ -278,22 +389,25 @@ class InspectorDragScrollDomMigrationTests { private fun findVisibleSelectRow(fixture: Fixture): InspectorStyleEditorRowSnapshot { repeat(120) { - val rows = fixture.inspector.debugStyleEditorRows().filter { row -> - row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect - } - val contentRect = fixture.inspector.debugContentRect() + val rows = + fixture.inspector.portalStyleEditorRows().filter { row -> + row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect + } + val contentRect = fixture.inspector.portalContentRect() val bodyScrollY = fixture.inspector.panelScrollOffsetY - rows.firstOrNull { row -> - val rect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) - val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) - val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - }?.let { return it } + rows + .firstOrNull { row -> + val rect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) + val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) + contentRect.contains(centerX, centerY) + }?.let { return it } scrollInspectorBodyDown(fixture, steps = 1) } @@ -306,7 +420,7 @@ class InspectorDragScrollDomMigrationTests { row.controlRect.x, row.controlRect.y - bodyScrollY, row.controlRect.width, - row.controlRect.height + row.controlRect.height, ) } @@ -329,12 +443,14 @@ class InspectorDragScrollDomMigrationTests { } private fun findVisibleInputNode(fixture: Fixture, propertyKey: String): TextInputNode { - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") - val contentRect = fixture.inspector.debugContentRect() - val candidates = collectNodes(inspectorNode) - .filterIsInstance() - .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } + val inspectorNode = + fixture.host.debugEntryNode(SystemPortalEntryId.Inspector) + ?: error("inspector entry missing") + val contentRect = fixture.inspector.portalContentRect() + val candidates = + collectNodes(inspectorNode) + .filterIsInstance() + .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } return candidates.firstOrNull { node -> val probeX = node.bounds.x + 2 @@ -346,15 +462,17 @@ class InspectorDragScrollDomMigrationTests { private fun inspectedRoot(withManyChildren: Boolean): ContainerNode { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 1280, 720) - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) if (withManyChildren) { repeat(60) { index -> - ContainerNode(key = "child-$index").apply { - bounds = Rect(980, 180 + index * 12, 180, 10) - }.applyParent(target) + ContainerNode(key = "child-$index") + .apply { + bounds = Rect(980, 180 + index * 12, 180, 10) + }.applyParent(target) } } StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() @@ -363,6 +481,7 @@ class InspectorDragScrollDomMigrationTests { private fun collectNodes(root: DOMNode): List { val out = ArrayList() + fun walk(node: DOMNode) { out += node node.children.forEach(::walk) @@ -373,11 +492,10 @@ class InspectorDragScrollDomMigrationTests { private data class Fixture( val inspector: InspectorController, - val host: SystemOverlayHost, + val host: SystemPortalHost, val root: ContainerNode, var revision: Long, var viewportWidth: Int, - var viewportHeight: Int + var viewportHeight: Int, ) } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDropdownCorrectiveTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDropdownCorrectiveTests.kt similarity index 50% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDropdownCorrectiveTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDropdownCorrectiveTests.kt index c7d5066..460c8f3 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorDropdownCorrectiveTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorDropdownCorrectiveTests.kt @@ -1,11 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime +package org.dreamfinity.dsgl.core.portal.system + import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -13,29 +7,33 @@ import org.dreamfinity.dsgl.core.dom.elements.TextInputNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext import org.dreamfinity.dsgl.core.event.FocusManager -import org.dreamfinity.dsgl.core.event.KeyCodes import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorDropdownSnapshot import org.dreamfinity.dsgl.core.inspector.InspectorEditorKind -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.* class InspectorDropdownCorrectiveTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { FocusManager.clearFocus() KeyModifiers.sync(shift = false, control = false, meta = false) - ColorPickerRuntime.engine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() + DomainPortalServices.closeAllSelects() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -46,38 +44,36 @@ class InspectorDropdownCorrectiveTests { setViewport(fixture, 420, 280) scrollInspectorBodyDown(fixture, steps = 10) - val (trigger, dropdown) = openInspectorSelectDropdown(fixture, requireScrollable = false) - val contentRect = fixture.inspector.debugContentRect() - var outsideX = contentRect.x + 4 - var outsideY = contentRect.y + 4 - if (dropdown.popupRect.contains(outsideX, outsideY) || trigger.contains(outsideX, outsideY)) { - val panelRect = fixture.inspector.debugPanelRect() ?: error("expected panel rect") - outsideX = panelRect.x + 8 - outsideY = panelRect.y + 8 - } + val (trigger, ownerKey) = openInspectorSelectDropdown(fixture, requireScrollable = false) + val dropdown = selectPanelRect(ownerKey, fixture) + val panelRect = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") + val outsideX = (panelRect.x - 12).coerceAtLeast(1) + val outsideY = (panelRect.y - 12).coerceAtLeast(1) + assertFalse(dropdown.contains(outsideX, outsideY)) + assertFalse(trigger.contains(outsideX, outsideY)) - assertTrue(fixture.host.handleMouseDown(outsideX, outsideY, MouseButton.LEFT)) - fixture.host.handleMouseUp(outsideX, outsideY, MouseButton.LEFT) + assertTrue(dispatchSystemMouseDown(fixture, outsideX, outsideY)) + dispatchSystemMouseUp(fixture, outsideX, outsideY) syncAndRender(fixture, outsideX, outsideY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + waitForSystemSelectClosed(fixture, ownerKey, outsideX, outsideY) } @Test fun `wheel is routed to active dropdown before inspector body`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - openVisibleInspectorSelectDropdownWithoutBodyScroll(fixture) + val (_, ownerKey) = openVisibleInspectorSelectDropdownWithoutBodyScroll(fixture) settleFrames(fixture, steps = 1) val beforePanelScroll = fixture.inspector.panelScrollOffsetY - val contentRect = fixture.inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 10 + val popup = selectPanelRect(ownerKey, fixture) + val wheelX = popup.x + (popup.width / 2).coerceAtLeast(1) + val wheelY = popup.y + (popup.height / 2).coerceAtLeast(1) - assertTrue(fixture.host.handleMouseWheel(wheelX, wheelY, -120)) + assertTrue(dispatchSystemMouseWheel(fixture, wheelX, wheelY, -120)) syncAndRender(fixture, wheelX, wheelY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) assertEquals(beforePanelScroll, fixture.inspector.panelScrollOffsetY) } @@ -87,12 +83,10 @@ class InspectorDropdownCorrectiveTests { setViewport(fixture, 420, 280) scrollInspectorBodyDown(fixture, steps = 12) - val (trigger, _) = openInspectorSelectDropdown(fixture, requireScrollable = false) - val popup = findDropdownPopupNode(fixture) - - val expectedY = (trigger.y + trigger.height + 2) - .coerceIn(2, (fixture.viewportHeight - popup.bounds.height - 2).coerceAtLeast(2)) - assertEquals(expectedY, popup.bounds.y) + val (trigger, ownerKey) = openInspectorSelectDropdown(fixture, requireScrollable = false) + val popup = selectPanelRect(ownerKey, fixture) + assertTrue(popup.y >= 0) + assertTrue(popup.y + popup.height <= fixture.viewportHeight) } @Test @@ -101,12 +95,13 @@ class InspectorDropdownCorrectiveTests { setViewport(fixture, 420, 280) scrollInspectorBodyDown(fixture, steps = 12) - val (trigger, _) = openInspectorSelectDropdown(fixture, requireScrollable = false) - val popup = findDropdownPopupNode(fixture) + val (trigger, ownerKey) = openInspectorSelectDropdown(fixture, requireScrollable = false) + val popup = selectPanelRect(ownerKey, fixture) - val expectedX = trigger.x - .coerceIn(2, (fixture.viewportWidth - popup.bounds.width - 2).coerceAtLeast(2)) - assertEquals(expectedX, popup.bounds.x) + val expectedX = + trigger.x + .coerceIn(2, (fixture.viewportWidth - popup.width - 2).coerceAtLeast(2)) + assertEquals(expectedX, popup.x) } @Test @@ -142,33 +137,40 @@ class InspectorDropdownCorrectiveTests { syncAndRender(fixture, anchor.x + 1, anchor.y + 1) assertTrue(fixture.host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, fixture.host.debugSystemColorPickerPopupOwnerScope()) - assertNotNull(fixture.host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup)) + assertEquals(ScreenDomainId.System, fixture.host.debugSystemColorPickerPopupOwnerDomain()) + assertNotNull(fixture.host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup)) } private fun openInspectorAndSelectTarget(withManyChildren: Boolean): Fixture { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) val root = inspectedRoot(withManyChildren) inspector.toggle() host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) host.paint(ctx) assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) assertTrue(host.handleMouseUp(984, 144, MouseButton.LEFT)) inspector.setPickMode(false) - val fixture = Fixture( - inspector = inspector, - host = host, - root = root, - revision = 2L, - viewportWidth = 1280, - viewportHeight = 720 - ) + val fixture = + Fixture( + inspector = inspector, + host = host, + root = root, + revision = 2L, + viewportWidth = 1280, + viewportHeight = 720, + ) syncAndRender(fixture, 984, 144) return fixture } @@ -186,14 +188,14 @@ class InspectorDropdownCorrectiveTests { inspectedLayoutRevision = fixture.revision++, cursorX = cursorX, cursorY = cursorY, - inspectorPointerCaptured = fixture.inspector.isPointerCaptured + inspectorPointerCaptured = fixture.inspector.isPointerCaptured, ) fixture.host.render(ctx, fixture.viewportWidth, fixture.viewportHeight) fixture.host.paint(ctx) } private fun scrollInspectorBodyDown(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 repeat(steps) { @@ -203,7 +205,7 @@ class InspectorDropdownCorrectiveTests { } private fun settleFrames(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val cursorX = contentRect.x + 4 val cursorY = contentRect.y + 10 repeat(steps) { @@ -211,85 +213,104 @@ class InspectorDropdownCorrectiveTests { } } - private fun openVisibleInspectorSelectDropdownWithoutBodyScroll( - fixture: Fixture - ): Pair { - val contentRect = fixture.inspector.debugContentRect() + private fun openVisibleInspectorSelectDropdownWithoutBodyScroll(fixture: Fixture): Pair { + val contentRect = fixture.inspector.portalContentRect() val bodyScrollY = fixture.inspector.panelScrollOffsetY - val row = fixture.inspector.debugStyleEditorRows().firstOrNull { row -> - if (row.editorKind != InspectorEditorKind.EnumSelect && row.editorKind != InspectorEditorKind.FontSelect) { - return@firstOrNull false - } - val visibleRect = Rect( + val row = + fixture.inspector.portalStyleEditorRows().firstOrNull { row -> + if (row.editorKind != InspectorEditorKind.EnumSelect && + row.editorKind != InspectorEditorKind.FontSelect + ) { + return@firstOrNull false + } + val visibleRect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val centerX = visibleRect.x + (visibleRect.width / 2).coerceAtLeast(1) + val centerY = visibleRect.y + (visibleRect.height / 2).coerceAtLeast(1) + contentRect.contains(centerX, centerY) + } ?: error("expected visible inspector select row without body scrolling") + + val triggerRect = + Rect( row.controlRect.x, row.controlRect.y - bodyScrollY, row.controlRect.width, - row.controlRect.height + row.controlRect.height, ) - val centerX = visibleRect.x + (visibleRect.width / 2).coerceAtLeast(1) - val centerY = visibleRect.y + (visibleRect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - } ?: error("expected visible inspector select row without body scrolling") - - val triggerRect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: error("expected style row index for ${row.property.key}") + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" val clickX = triggerRect.x + 2 val clickY = triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1) - fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) - fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) + dispatchSystemMouseDown(fixture, clickX, clickY) + dispatchSystemMouseUp(fixture, clickX, clickY) syncAndRender(fixture, clickX, clickY) - val opened = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - ?: error("expected inspector dropdown to open from visible select row") - return triggerRect to opened + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + return triggerRect to ownerKey } - private fun openInspectorSelectDropdown( - fixture: Fixture, - requireScrollable: Boolean - ): Pair { + private fun openInspectorSelectDropdown(fixture: Fixture, requireScrollable: Boolean): Pair { repeat(120) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val bodyScrollY = fixture.inspector.panelScrollOffsetY - val visibleSelectRows = fixture.inspector.debugStyleEditorRows().filter { row -> - if (row.editorKind != InspectorEditorKind.EnumSelect && row.editorKind != InspectorEditorKind.FontSelect) { - return@filter false + val visibleSelectRows = + fixture.inspector.portalStyleEditorRows().filter { row -> + if (row.editorKind != InspectorEditorKind.EnumSelect && + row.editorKind != InspectorEditorKind.FontSelect + ) { + return@filter false + } + val visibleRect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val centerX = visibleRect.x + (visibleRect.width / 2).coerceAtLeast(1) + val centerY = visibleRect.y + (visibleRect.height / 2).coerceAtLeast(1) + contentRect.contains(centerX, centerY) } - val visibleRect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) - val centerX = visibleRect.x + (visibleRect.width / 2).coerceAtLeast(1) - val centerY = visibleRect.y + (visibleRect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - } visibleSelectRows.forEach { row -> - val triggerRect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) + val triggerRect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: return@forEach + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" val clickX = triggerRect.x + 2 val clickY = triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1) - fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) - fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) + dispatchSystemMouseDown(fixture, clickX, clickY) + dispatchSystemMouseUp(fixture, clickX, clickY) syncAndRender(fixture, clickX, clickY) - val opened = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - if (opened != null && (!requireScrollable || opened.footerText != null)) { - return triggerRect to opened + val opened = DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey) + if (opened) { + val popup = selectPanelRect(ownerKey, fixture) + if (!requireScrollable || popup.height > triggerRect.height + 24) { + return triggerRect to ownerKey + } } - if (opened != null) { - fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) - fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) + if (opened) { + dispatchSystemMouseDown(fixture, clickX, clickY) + dispatchSystemMouseUp(fixture, clickX, clickY) syncAndRender(fixture, clickX, clickY) } } @@ -299,15 +320,42 @@ class InspectorDropdownCorrectiveTests { error("expected inspector select dropdown to open") } - private fun findDropdownPopupNode(fixture: Fixture): DOMNode { - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") - return collectNodes(inspectorNode).firstOrNull { node -> - val key = node.key?.toString() ?: return@firstOrNull false - key.startsWith("dsgl-system-inspector-dropdown-") && - !key.contains("-option-") && - !key.endsWith("-footer") - } ?: error("expected dropdown popup node") + private fun selectPanelRect(ownerKey: String, fixture: Fixture): Rect { + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + return DomainPortalServices.systemSelectEngine.debugPanelRect(ownerKey) + ?: error("expected system select popup for owner=$ownerKey") + } + + private fun dispatchSystemMouseDown(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseDown(x, y, MouseButton.LEFT) || + fixture.host.handleMouseDown(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseUp(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseUp(x, y, MouseButton.LEFT) || + fixture.host.handleMouseUp(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseWheel( + fixture: Fixture, + x: Int, + y: Int, + delta: Int, + ): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseWheel(x, y, delta) || + fixture.host.handleMouseWheel(x, y, delta) + + private fun waitForSystemSelectClosed( + fixture: Fixture, + ownerKey: String, + cursorX: Int, + cursorY: Int, + ) { + repeat(30) { + if (!DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) return + Thread.sleep(5) + syncAndRender(fixture, cursorX, cursorY) + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + } + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) } private fun focusInputByClick(fixture: Fixture, input: TextInputNode): Pair { @@ -329,12 +377,14 @@ class InspectorDropdownCorrectiveTests { } private fun findVisibleInputNode(fixture: Fixture, propertyKey: String): TextInputNode { - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") - val contentRect = fixture.inspector.debugContentRect() - val candidates = collectNodes(inspectorNode) - .filterIsInstance() - .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } + val inspectorNode = + fixture.host.debugEntryNode(SystemPortalEntryId.Inspector) + ?: error("inspector entry missing") + val contentRect = fixture.inspector.portalContentRect() + val candidates = + collectNodes(inspectorNode) + .filterIsInstance() + .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } return candidates.firstOrNull { node -> val probeX = node.bounds.x + 2 @@ -346,15 +396,17 @@ class InspectorDropdownCorrectiveTests { private fun inspectedRoot(withManyChildren: Boolean): ContainerNode { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 1280, 720) - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) if (withManyChildren) { repeat(24) { index -> - ContainerNode(key = "child-$index").apply { - bounds = Rect(980, 170 + index * 14, 120, 10) - }.applyParent(target) + ContainerNode(key = "child-$index") + .apply { + bounds = Rect(980, 170 + index * 14, 120, 10) + }.applyParent(target) } } StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() @@ -363,6 +415,7 @@ class InspectorDropdownCorrectiveTests { private fun collectNodes(root: DOMNode): List { val out = ArrayList() + fun walk(node: DOMNode) { out += node node.children.forEach(::walk) @@ -373,11 +426,10 @@ class InspectorDropdownCorrectiveTests { private data class Fixture( val inspector: InspectorController, - val host: SystemOverlayHost, + val host: SystemPortalHost, val root: ContainerNode, var revision: Long, var viewportWidth: Int, - var viewportHeight: Int + var viewportHeight: Int, ) } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorInputPathBaselineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorInputPathBaselineTests.kt similarity index 52% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorInputPathBaselineTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorInputPathBaselineTests.kt index 0bfc4da..6f45034 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorInputPathBaselineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorInputPathBaselineTests.kt @@ -1,12 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system - -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime +package org.dreamfinity.dsgl.core.portal.system + import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -17,26 +10,31 @@ import org.dreamfinity.dsgl.core.event.FocusManager import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorDropdownSnapshot import org.dreamfinity.dsgl.core.inspector.InspectorEditorKind import org.dreamfinity.dsgl.core.inspector.InspectorStyleEditorRowSnapshot -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.* class InspectorInputPathBaselineTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { FocusManager.clearFocus() KeyModifiers.sync(shift = false, control = false, meta = false) - ColorPickerRuntime.engine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() + DomainPortalServices.closeAllSelects() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -44,92 +42,92 @@ class InspectorInputPathBaselineTests { @Test fun `inspector dropdown live path is dom first`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - val (trigger, opened) = openDropdownFromVisibleSelectRow(fixture) + val (trigger, ownerKey) = openDropdownFromVisibleSelectRow(fixture) - assertTrue(opened.options.isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + assertNotNull(selectPanelRect(ownerKey, fixture)) assertFalse(fixture.inspector.hasOpenStyleDropdown()) assertFalse(fixture.inspector.closeOpenStyleDropdowns()) assertFalse(fixture.inspector.handleOpenStyleDropdownWheel(-120)) - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") + val inspectorNode = + fixture.host.debugEntryNode(SystemPortalEntryId.Inspector) + ?: error("inspector entry missing") val routeProbe = inspectorNode.javaClass.getDeclaredMethod("isDomOwnedInteractionTarget", DOMNode::class.java) routeProbe.isAccessible = true - val dropdownPopupNode = findDropdownPopupNode(fixture) - val dropdownOptionNode = collectNodes(inspectorNode).firstOrNull { - (it.key?.toString() ?: "").contains("-option-") - } ?: error("expected dropdown option node") - - assertTrue(routeProbe.invoke(inspectorNode, dropdownPopupNode) as Boolean) - assertTrue(routeProbe.invoke(inspectorNode, dropdownOptionNode) as Boolean) - - fixture.host.handleMouseDown(trigger.x + 2, trigger.y + 2, MouseButton.LEFT) - fixture.host.handleMouseUp(trigger.x + 2, trigger.y + 2, MouseButton.LEFT) - syncAndRender(fixture, trigger.x + 2, trigger.y + 2) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + val triggerNode = + collectNodes(inspectorNode).firstOrNull { it.key?.toString() == ownerKey } + ?: error("expected select trigger node") + assertTrue(routeProbe.invoke(inspectorNode, triggerNode) as Boolean) + + val triggerCenterX = triggerNode.bounds.x + (triggerNode.bounds.width / 2).coerceAtLeast(1) + val triggerCenterY = triggerNode.bounds.y + (triggerNode.bounds.height / 2).coerceAtLeast(1) + dispatchSystemMouseDown(fixture, triggerCenterX, triggerCenterY) + dispatchSystemMouseUp(fixture, triggerCenterX, triggerCenterY) + syncAndRender(fixture, triggerCenterX, triggerCenterY) + waitForSystemSelectClosed(fixture, ownerKey, triggerCenterX, triggerCenterY) } @Test fun `inspector dropdown opens and closes from dom interactions`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - val (trigger, _) = openDropdownFromVisibleSelectRow(fixture) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + val (trigger, ownerKey) = openDropdownFromVisibleSelectRow(fixture) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) - fixture.host.handleMouseDown(trigger.x + 2, trigger.y + 2, MouseButton.LEFT) - fixture.host.handleMouseUp(trigger.x + 2, trigger.y + 2, MouseButton.LEFT) + dispatchSystemMouseDown(fixture, trigger.x + 2, trigger.y + 2) + dispatchSystemMouseUp(fixture, trigger.x + 2, trigger.y + 2) syncAndRender(fixture, trigger.x + 2, trigger.y + 2) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + waitForSystemSelectClosed(fixture, ownerKey, trigger.x + 2, trigger.y + 2) } @Test fun `inspector dropdown option selection is consumed without click through`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - val (_, opened) = openDropdownFromVisibleSelectRow(fixture) - val option = opened.options.firstOrNull() ?: error("expected dropdown option") - val optionX = option.rect.x + 2 - val optionY = option.rect.y + (option.rect.height / 2).coerceAtLeast(1) - - assertTrue(fixture.host.handleMouseDown(optionX, optionY, MouseButton.LEFT)) - assertTrue(fixture.host.handleMouseUp(optionX, optionY, MouseButton.LEFT)) + val (_, ownerKey) = openDropdownFromVisibleSelectRow(fixture) + val panel = selectPanelRect(ownerKey, fixture) + val optionX = panel.x + 6 + val optionY = panel.y + 10 + + DomainPortalServices.systemSelectEngine.handleMouseMove(optionX, optionY) + assertTrue(DomainPortalServices.systemSelectEngine.handleMouseDown(optionX, optionY, MouseButton.LEFT)) + assertTrue(DomainPortalServices.systemSelectEngine.handleMouseUp(optionX, optionY, MouseButton.LEFT)) syncAndRender(fixture, optionX, optionY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + waitForSystemSelectClosed(fixture, ownerKey, optionX, optionY) assertFalse(fixture.inspector.hasOpenStyleDropdown()) } @Test fun `inspector dropdown continuity survives rebuild`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - val (_, opened) = openDropdownFromVisibleSelectRow(fixture) - - syncAndRender(fixture, opened.popupRect.x + 2, opened.popupRect.y + 2) - val reopened = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - ?: error("expected dropdown after rebuild") + val (_, ownerKey) = openDropdownFromVisibleSelectRow(fixture) - assertEquals(opened.property, reopened.property) - assertEquals(opened.unitSelect, reopened.unitSelect) + val panel = selectPanelRect(ownerKey, fixture) + syncAndRender(fixture, panel.x + 2, panel.y + 2) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) assertFalse(fixture.inspector.hasOpenStyleDropdown()) } @Test fun `controller dropdown authority stays demoted while dom dropdown is active`() { val fixture = openInspectorAndSelectTarget(withManyChildren = false) - openDropdownFromVisibleSelectRow(fixture) + val (_, ownerKey) = openDropdownFromVisibleSelectRow(fixture) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) assertFalse(fixture.inspector.hasOpenStyleDropdown()) assertFalse(fixture.inspector.handleOpenStyleDropdownWheel(-120)) - val contentRect = fixture.inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 10 + val contentRect = fixture.inspector.portalContentRect() + val popup = selectPanelRect(ownerKey, fixture) + val wheelX = popup.x + (popup.width / 2).coerceAtLeast(1) + val wheelY = popup.y + (popup.height / 2).coerceAtLeast(1) val beforePanelScroll = fixture.inspector.panelScrollOffsetY - assertTrue(fixture.host.handleMouseWheel(wheelX, wheelY, -120)) + assertTrue(dispatchSystemMouseWheel(fixture, wheelX, wheelY, -120)) syncAndRender(fixture, wheelX, wheelY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) assertEquals(beforePanelScroll, fixture.inspector.panelScrollOffsetY) assertFalse(fixture.inspector.hasOpenStyleDropdown()) } @@ -167,8 +165,8 @@ class InspectorInputPathBaselineTests { syncAndRender(fixture, anchor.x + 1, anchor.y + 1) assertTrue(fixture.host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, fixture.host.debugSystemColorPickerPopupOwnerScope()) - assertNotNull(fixture.host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup)) + assertEquals(ScreenDomainId.System, fixture.host.debugSystemColorPickerPopupOwnerDomain()) + assertNotNull(fixture.host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup)) } @Test @@ -185,56 +183,60 @@ class InspectorInputPathBaselineTests { syncAndRender(fixture, visibleX, visibleY) assertTrue(findRowByProperty(fixture, row.property).controlHovered) - val (trigger, opened) = openDropdownFromVisibleSelectRow(fixture, row) - val popup = findDropdownPopupNode(fixture) - val expectedY = (trigger.y + trigger.height + 2) - .coerceIn(2, (fixture.viewportHeight - popup.bounds.height - 2).coerceAtLeast(2)) - assertEquals(expectedY, popup.bounds.y) + val (trigger, ownerKey) = openDropdownFromVisibleSelectRow(fixture, row) + val popup = selectPanelRect(ownerKey, fixture) + assertTrue(popup.y >= 0) + assertTrue(popup.y + popup.height <= fixture.viewportHeight) - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 - assertTrue(fixture.host.handleMouseWheel(wheelX, wheelY, -120)) + assertTrue(dispatchSystemMouseWheel(fixture, wheelX, wheelY, -120)) syncAndRender(fixture, wheelX, wheelY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) - - var outsideX = contentRect.x + 4 - var outsideY = contentRect.y + 4 - if (opened.popupRect.contains(outsideX, outsideY) || trigger.contains(outsideX, outsideY)) { - val panelRect = fixture.inspector.debugPanelRect() ?: error("expected panel rect") - outsideX = panelRect.x + 8 - outsideY = panelRect.y + 8 - } + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + + val panelRect = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") + val outsideX = (panelRect.x - 12).coerceAtLeast(1) + val outsideY = (panelRect.y - 12).coerceAtLeast(1) + assertFalse(popup.contains(outsideX, outsideY)) + assertFalse(trigger.contains(outsideX, outsideY)) - fixture.host.handleMouseDown(outsideX, outsideY, MouseButton.LEFT) - fixture.host.handleMouseUp(outsideX, outsideY, MouseButton.LEFT) + dispatchSystemMouseDown(fixture, outsideX, outsideY) + dispatchSystemMouseUp(fixture, outsideX, outsideY) syncAndRender(fixture, outsideX, outsideY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + waitForSystemSelectClosed(fixture, ownerKey, outsideX, outsideY) } private fun openInspectorAndSelectTarget(withManyChildren: Boolean): Fixture { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) val root = inspectedRoot(withManyChildren) inspector.toggle() host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) host.paint(ctx) assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) assertTrue(host.handleMouseUp(984, 144, MouseButton.LEFT)) inspector.setPickMode(false) - val fixture = Fixture( - inspector = inspector, - host = host, - root = root, - revision = 2L, - viewportWidth = 1280, - viewportHeight = 720 - ) + val fixture = + Fixture( + inspector = inspector, + host = host, + root = root, + revision = 2L, + viewportWidth = 1280, + viewportHeight = 720, + ) syncAndRender(fixture, 984, 144) return fixture } @@ -252,40 +254,43 @@ class InspectorInputPathBaselineTests { inspectedLayoutRevision = fixture.revision++, cursorX = cursorX, cursorY = cursorY, - inspectorPointerCaptured = fixture.inspector.isPointerCaptured + inspectorPointerCaptured = fixture.inspector.isPointerCaptured, ) fixture.host.render(ctx, fixture.viewportWidth, fixture.viewportHeight) fixture.host.paint(ctx) } private fun scrollInspectorBodyDown(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 repeat(steps) { - fixture.host.handleMouseWheel(wheelX, wheelY, -120) + dispatchSystemMouseWheel(fixture, wheelX, wheelY, -120) syncAndRender(fixture, wheelX, wheelY) } } private fun findOrScrollToVisibleSelectRow(fixture: Fixture): InspectorStyleEditorRowSnapshot { repeat(120) { - val rows = fixture.inspector.debugStyleEditorRows().filter { row -> - row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect - } - val contentRect = fixture.inspector.debugContentRect() + val rows = + fixture.inspector.portalStyleEditorRows().filter { row -> + row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect + } + val contentRect = fixture.inspector.portalContentRect() val bodyScrollY = fixture.inspector.panelScrollOffsetY - rows.firstOrNull { row -> - val rect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) - val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) - val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - }?.let { return it } + rows + .firstOrNull { row -> + val rect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) + val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) + contentRect.contains(centerX, centerY) + }?.let { return it } scrollInspectorBodyDown(fixture, steps = 1) } @@ -298,40 +303,73 @@ class InspectorInputPathBaselineTests { row.controlRect.x, row.controlRect.y - bodyScrollY, row.controlRect.width, - row.controlRect.height + row.controlRect.height, ) } - private fun findRowByProperty(fixture: Fixture, property: StyleProperty): InspectorStyleEditorRowSnapshot { - return fixture.inspector.debugStyleEditorRows().firstOrNull { it.property == property } + private fun findRowByProperty(fixture: Fixture, property: StyleProperty): InspectorStyleEditorRowSnapshot = + fixture.inspector + .portalStyleEditorRows() + .firstOrNull { it.property == property } ?: error("expected row for $property") - } private fun openDropdownFromVisibleSelectRow( fixture: Fixture, - row: InspectorStyleEditorRowSnapshot = findOrScrollToVisibleSelectRow(fixture) - ): Pair { + row: InspectorStyleEditorRowSnapshot = findOrScrollToVisibleSelectRow(fixture), + ): Pair { val triggerRect = visibleControlRect(fixture, row) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: error("expected style row index for ${row.property.key}") + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" val clickX = triggerRect.x + 2 val clickY = triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1) - fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) - fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) + dispatchSystemMouseDown(fixture, clickX, clickY) + dispatchSystemMouseUp(fixture, clickX, clickY) syncAndRender(fixture, clickX, clickY) - val opened = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - ?: error("expected opened inspector dropdown") - return triggerRect to opened + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + return triggerRect to ownerKey } - private fun findDropdownPopupNode(fixture: Fixture): DOMNode { - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") - return collectNodes(inspectorNode).firstOrNull { node -> - val key = node.key?.toString() ?: return@firstOrNull false - key.startsWith("dsgl-system-inspector-dropdown-") && - !key.contains("-option-") && - !key.endsWith("-footer") - } ?: error("expected dropdown popup node") + private fun selectPanelRect(ownerKey: String, fixture: Fixture): Rect { + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + return DomainPortalServices.systemSelectEngine.debugPanelRect(ownerKey) + ?: error("expected system select popup for owner=$ownerKey") + } + + private fun dispatchSystemMouseDown(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseDown(x, y, MouseButton.LEFT) || + fixture.host.handleMouseDown(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseUp(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseUp(x, y, MouseButton.LEFT) || + fixture.host.handleMouseUp(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseWheel( + fixture: Fixture, + x: Int, + y: Int, + delta: Int, + ): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseWheel(x, y, delta) || + fixture.host.handleMouseWheel(x, y, delta) + + private fun waitForSystemSelectClosed( + fixture: Fixture, + ownerKey: String, + cursorX: Int, + cursorY: Int, + ) { + repeat(30) { + if (!DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) return + Thread.sleep(5) + syncAndRender(fixture, cursorX, cursorY) + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + } + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) } private fun focusInputByClick(fixture: Fixture, input: TextInputNode): Pair { @@ -353,12 +391,14 @@ class InspectorInputPathBaselineTests { } private fun findVisibleInputNode(fixture: Fixture, propertyKey: String): TextInputNode { - val inspectorNode = fixture.host.debugEntryNode(SystemOverlayEntryId.Inspector) - ?: error("inspector entry missing") - val contentRect = fixture.inspector.debugContentRect() - val candidates = collectNodes(inspectorNode) - .filterIsInstance() - .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } + val inspectorNode = + fixture.host.debugEntryNode(SystemPortalEntryId.Inspector) + ?: error("inspector entry missing") + val contentRect = fixture.inspector.portalContentRect() + val candidates = + collectNodes(inspectorNode) + .filterIsInstance() + .filter { (it.key?.toString() ?: "") == "dsgl-system-inspector-editor-numeric-input-$propertyKey" } return candidates.firstOrNull { node -> val probeX = node.bounds.x + 2 @@ -370,15 +410,17 @@ class InspectorInputPathBaselineTests { private fun inspectedRoot(withManyChildren: Boolean): ContainerNode { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 1280, 720) - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) if (withManyChildren) { repeat(24) { index -> - ContainerNode(key = "child-$index").apply { - bounds = Rect(980, 170 + index * 14, 120, 10) - }.applyParent(target) + ContainerNode(key = "child-$index") + .apply { + bounds = Rect(980, 170 + index * 14, 120, 10) + }.applyParent(target) } } StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() @@ -387,6 +429,7 @@ class InspectorInputPathBaselineTests { private fun collectNodes(root: DOMNode): List { val out = ArrayList() + fun walk(node: DOMNode) { out += node node.children.forEach(::walk) @@ -397,11 +440,10 @@ class InspectorInputPathBaselineTests { private data class Fixture( val inspector: InspectorController, - val host: SystemOverlayHost, + val host: SystemPortalHost, val root: ContainerNode, var revision: Long, var viewportWidth: Int, - var viewportHeight: Int + var viewportHeight: Int, ) } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorPointerAlignmentTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorPointerAlignmentTests.kt similarity index 59% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorPointerAlignmentTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorPointerAlignmentTests.kt index cd95035..87cb6b2 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorPointerAlignmentTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorPointerAlignmentTests.kt @@ -1,12 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect @@ -15,25 +8,33 @@ import org.dreamfinity.dsgl.core.event.FocusManager import org.dreamfinity.dsgl.core.event.KeyModifiers import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.inspector.InspectorDropdownSnapshot import org.dreamfinity.dsgl.core.inspector.InspectorEditorKind import org.dreamfinity.dsgl.core.inspector.InspectorStyleEditorRowSnapshot +import org.dreamfinity.dsgl.core.portal.DomainPortalServices import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue class InspectorPointerAlignmentTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { FocusManager.clearFocus() KeyModifiers.sync(shift = false, control = false, meta = false) - ColorPickerRuntime.engine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() + DomainPortalServices.closeAllSelects() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -112,14 +113,28 @@ class InspectorPointerAlignmentTests { assertTrue(fixture.inspector.panelScrollOffsetY > 0) val row = findOrScrollToVisibleSelectRow(fixture) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: error("expected style row index for ${row.property.key}") + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" val property = row.property - val (triggerRect, openedOnVisible) = openDropdownFromVisibleSelectRow(fixture, row) - assertEquals(property, openedOnVisible.property) + val triggerRect = openDropdownFromVisibleSelectRow(fixture, row) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) - fixture.host.handleMouseDown(triggerRect.x + 2, triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1), MouseButton.LEFT) - fixture.host.handleMouseUp(triggerRect.x + 2, triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1), MouseButton.LEFT) + fixture.host.handleMouseDown( + triggerRect.x + 2, + triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1), + MouseButton.LEFT, + ) + fixture.host.handleMouseUp( + triggerRect.x + 2, + triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1), + MouseButton.LEFT, + ) syncAndRender(fixture, triggerRect.x + 2, triggerRect.y + 2) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) + waitForSystemSelectClosed(fixture, ownerKey, triggerRect.x + 2, triggerRect.y + 2) val rawX = row.controlRect.x + 2 val rawY = row.controlRect.y + (row.controlRect.height / 2).coerceAtLeast(1) @@ -127,8 +142,7 @@ class InspectorPointerAlignmentTests { fixture.host.handleMouseUp(rawX, rawY, MouseButton.LEFT) syncAndRender(fixture, rawX, rawY) - val openedOnRaw = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - assertTrue(openedOnRaw == null || openedOnRaw.property != property) + waitForSystemSelectClosed(fixture, ownerKey, rawX, rawY) } @Test @@ -137,55 +151,64 @@ class InspectorPointerAlignmentTests { setViewport(fixture, 420, 280) val row = findOrScrollToVisibleSelectRow(fixture) - val (triggerRect, dropdown) = openDropdownFromVisibleSelectRow(fixture, row) + val rowIndex = + fixture.inspector + .portalStyleEditorRows() + .indexOfFirst { it.property == row.property } + .takeIf { it >= 0 } ?: error("expected style row index for ${row.property.key}") + val ownerKey = "dsgl-system-inspector-editor-select-$rowIndex" + val triggerRect = openDropdownFromVisibleSelectRow(fixture, row) + val dropdown = selectPanelRect(ownerKey, fixture) settleFrames(fixture, steps = 1) - val contentRect = fixture.inspector.debugContentRect() - val wheelX = contentRect.x + 4 - val wheelY = contentRect.y + 10 + val wheelX = dropdown.x + (dropdown.width / 2).coerceAtLeast(1) + val wheelY = dropdown.y + (dropdown.height / 2).coerceAtLeast(1) - assertTrue(fixture.host.handleMouseWheel(wheelX, wheelY, -120)) + assertTrue(dispatchSystemMouseWheel(fixture, wheelX, wheelY, -120)) syncAndRender(fixture, wheelX, wheelY) - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isNotEmpty()) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) - var outsideX = contentRect.x + 4 - var outsideY = contentRect.y + 4 - if (dropdown.popupRect.contains(outsideX, outsideY) || triggerRect.contains(outsideX, outsideY)) { - val panelRect = fixture.inspector.debugPanelRect() ?: error("expected panel rect") - outsideX = panelRect.x + 8 - outsideY = panelRect.y + 8 - } + val panelRect = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") + val outsideX = (panelRect.x - 12).coerceAtLeast(1) + val outsideY = (panelRect.y - 12).coerceAtLeast(1) + assertFalse(dropdown.contains(outsideX, outsideY)) + assertFalse(triggerRect.contains(outsideX, outsideY)) - fixture.host.handleMouseDown(outsideX, outsideY, MouseButton.LEFT) - fixture.host.handleMouseUp(outsideX, outsideY, MouseButton.LEFT) + assertTrue(dispatchSystemMouseDown(fixture, outsideX, outsideY)) + assertTrue(dispatchSystemMouseUp(fixture, outsideX, outsideY)) syncAndRender(fixture, outsideX, outsideY) - - assertTrue(fixture.inspector.debugStyleEditorDropdowns().isEmpty()) } private fun openInspectorAndSelectTarget(withManyChildren: Boolean): Fixture { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) val root = inspectedRoot(withManyChildren) inspector.toggle() host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) host.paint(ctx) assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) assertTrue(host.handleMouseUp(984, 144, MouseButton.LEFT)) inspector.setPickMode(false) - val fixture = Fixture( - inspector = inspector, - host = host, - root = root, - revision = 2L, - viewportWidth = 1280, - viewportHeight = 720 - ) + val fixture = + Fixture( + inspector = inspector, + host = host, + root = root, + revision = 2L, + viewportWidth = 1280, + viewportHeight = 720, + ) syncAndRender(fixture, 984, 144) return fixture } @@ -203,14 +226,14 @@ class InspectorPointerAlignmentTests { inspectedLayoutRevision = fixture.revision++, cursorX = cursorX, cursorY = cursorY, - inspectorPointerCaptured = fixture.inspector.isPointerCaptured + inspectorPointerCaptured = fixture.inspector.isPointerCaptured, ) fixture.host.render(ctx, fixture.viewportWidth, fixture.viewportHeight) fixture.host.paint(ctx) } private fun dragInspectorPanel(fixture: Fixture, deltaX: Int, deltaY: Int) { - val panelRect = fixture.inspector.debugPanelRect() ?: error("expected panel rect") + val panelRect = fixture.inspector.floatingPanelRect() ?: error("expected panel rect") val downX = panelRect.x + 16 val downY = panelRect.y + 12 val moveX = downX + deltaX @@ -223,7 +246,7 @@ class InspectorPointerAlignmentTests { } private fun scrollInspectorBodyDown(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val wheelX = contentRect.x + 4 val wheelY = contentRect.y + 10 repeat(steps) { @@ -233,7 +256,7 @@ class InspectorPointerAlignmentTests { } private fun settleFrames(fixture: Fixture, steps: Int) { - val contentRect = fixture.inspector.debugContentRect() + val contentRect = fixture.inspector.portalContentRect() val cursorX = contentRect.x + 4 val cursorY = contentRect.y + 10 repeat(steps) { @@ -241,91 +264,111 @@ class InspectorPointerAlignmentTests { } } - private fun findVisibleSelectRowWithoutScrolling(fixture: Fixture): InspectorStyleEditorRowSnapshot { - val rows = fixture.inspector.debugStyleEditorRows().filter { row -> - row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect - } - val contentRect = fixture.inspector.debugContentRect() - val bodyScrollY = fixture.inspector.panelScrollOffsetY - return rows.firstOrNull { row -> - val rect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) - val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) - val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - } ?: error("expected visible inspector select row without scrolling") - } - private fun findOrScrollToVisibleSelectRow(fixture: Fixture): InspectorStyleEditorRowSnapshot { repeat(120) { - val rows = fixture.inspector.debugStyleEditorRows().filter { row -> - row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect - } - val contentRect = fixture.inspector.debugContentRect() + val rows = + fixture.inspector.portalStyleEditorRows().filter { row -> + row.editorKind == InspectorEditorKind.EnumSelect || row.editorKind == InspectorEditorKind.FontSelect + } + val contentRect = fixture.inspector.portalContentRect() val bodyScrollY = fixture.inspector.panelScrollOffsetY - val visible = rows.firstOrNull { row -> - val rect = Rect( - row.controlRect.x, - row.controlRect.y - bodyScrollY, - row.controlRect.width, - row.controlRect.height - ) - val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) - val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) - contentRect.contains(centerX, centerY) - } + val visible = + rows.firstOrNull { row -> + val rect = + Rect( + row.controlRect.x, + row.controlRect.y - bodyScrollY, + row.controlRect.width, + row.controlRect.height, + ) + val centerX = rect.x + (rect.width / 2).coerceAtLeast(1) + val centerY = rect.y + (rect.height / 2).coerceAtLeast(1) + contentRect.contains(centerX, centerY) + } if (visible != null) return visible scrollInspectorBodyDown(fixture, steps = 1) } error("expected visible inspector select row") } - private fun openDropdownFromVisibleSelectRow( - fixture: Fixture, - row: InspectorStyleEditorRowSnapshot - ): Pair { + private fun openDropdownFromVisibleSelectRow(fixture: Fixture, row: InspectorStyleEditorRowSnapshot): Rect { val triggerRect = visibleControlRect(fixture, row) val clickX = triggerRect.x + 2 val clickY = triggerRect.y + (triggerRect.height / 2).coerceAtLeast(1) fixture.host.handleMouseDown(clickX, clickY, MouseButton.LEFT) fixture.host.handleMouseUp(clickX, clickY, MouseButton.LEFT) syncAndRender(fixture, clickX, clickY) - val opened = fixture.inspector.debugStyleEditorDropdowns().firstOrNull() - assertNotNull(opened) - return triggerRect to opened + return triggerRect } - private fun findRowByProperty(fixture: Fixture, property: StyleProperty): InspectorStyleEditorRowSnapshot { - return fixture.inspector.debugStyleEditorRows().firstOrNull { it.property == property } - ?: error("expected row for property ${property.key}") + private fun selectPanelRect(ownerKey: String, fixture: Fixture): Rect { + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + return DomainPortalServices.systemSelectEngine.debugPanelRect(ownerKey) + ?: error("expected system select popup for owner=$ownerKey") } + private fun dispatchSystemMouseDown(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseDown(x, y, MouseButton.LEFT) || + fixture.host.handleMouseDown(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseUp(fixture: Fixture, x: Int, y: Int): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseUp(x, y, MouseButton.LEFT) || + fixture.host.handleMouseUp(x, y, MouseButton.LEFT) + + private fun dispatchSystemMouseWheel( + fixture: Fixture, + x: Int, + y: Int, + delta: Int, + ): Boolean = + DomainPortalServices.systemSelectEngine.handleMouseWheel(x, y, delta) || + fixture.host.handleMouseWheel(x, y, delta) + + private fun waitForSystemSelectClosed( + fixture: Fixture, + ownerKey: String, + cursorX: Int, + cursorY: Int, + ) { + repeat(30) { + if (!DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) return + Thread.sleep(5) + syncAndRender(fixture, cursorX, cursorY) + DomainPortalServices.systemSelectEngine.onFrame(ctx, fixture.viewportWidth, fixture.viewportHeight, 1f) + } + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(ownerKey)) + } + + private fun findRowByProperty(fixture: Fixture, property: StyleProperty): InspectorStyleEditorRowSnapshot = + fixture.inspector + .portalStyleEditorRows() + .firstOrNull { it.property == property } + ?: error("expected row for property ${property.key}") + private fun visibleControlRect(fixture: Fixture, row: InspectorStyleEditorRowSnapshot): Rect { val bodyScrollY = fixture.inspector.panelScrollOffsetY return Rect( row.controlRect.x, row.controlRect.y - bodyScrollY, row.controlRect.width, - row.controlRect.height + row.controlRect.height, ) } private fun inspectedRoot(withManyChildren: Boolean): ContainerNode { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 1280, 720) - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) if (withManyChildren) { repeat(24) { index -> - ContainerNode(key = "child-$index").apply { - bounds = Rect(980, 170 + index * 14, 120, 10) - }.applyParent(target) + ContainerNode(key = "child-$index") + .apply { + bounds = Rect(980, 170 + index * 14, 120, 10) + }.applyParent(target) } } StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() @@ -334,11 +377,10 @@ class InspectorPointerAlignmentTests { private data class Fixture( val inspector: InspectorController, - val host: SystemOverlayHost, + val host: SystemPortalHost, val root: ContainerNode, var revision: Long, var viewportWidth: Int, - var viewportHeight: Int + var viewportHeight: Int, ) } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorTextEditingDomMigrationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorTextEditingDomMigrationTests.kt similarity index 71% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorTextEditingDomMigrationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorTextEditingDomMigrationTests.kt index 270a0f5..4c028b1 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/InspectorTextEditingDomMigrationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/InspectorTextEditingDomMigrationTests.kt @@ -1,12 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.dreamfinity.dsgl.core.colorpicker.ColorPickerRuntime import org.dreamfinity.dsgl.core.dom.DOMNode import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode @@ -20,17 +13,27 @@ import org.dreamfinity.dsgl.core.event.MouseButton import org.dreamfinity.dsgl.core.input.ClipboardAccess import org.dreamfinity.dsgl.core.input.ClipboardBridge import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.OverlayOwnerScope +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleEngine import org.dreamfinity.dsgl.core.style.StyleProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class InspectorTextEditingDomMigrationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 9 - override fun measureText(text: String): Int = text.length * 6 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } private val clipboard = RecordingClipboardAccess() @@ -39,7 +42,7 @@ class InspectorTextEditingDomMigrationTests { FocusManager.clearFocus() KeyModifiers.sync(shift = false, control = false, meta = false) ClipboardBridge.install(null) - ColorPickerRuntime.engine.closeAll() + DomainPortalServices.applicationColorPickerEngine.closeAll() StyleEngine.clearAllInspectorOverrides() StyleEngine.clearCache() } @@ -47,11 +50,12 @@ class InspectorTextEditingDomMigrationTests { @Test fun `inspector live text editing is dom-first and controller edit session stays inactive`() { val fixture = openInspectorAndSelectTarget() - val stringInput = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-input-" - ) + val stringInput = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-input-", + ) val focused = focusInputByClick(fixture.host, stringInput) val nearEndX = (stringInput.bounds.x + stringInput.bounds.width - 3).coerceAtLeast(stringInput.bounds.x + 1) @@ -68,11 +72,12 @@ class InspectorTextEditingDomMigrationTests { ClipboardBridge.install(clipboard) val fixture = openInspectorAndSelectTarget() - val input = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-input-" - ) + val input = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-input-", + ) val focused = focusInputByClick(fixture.host, input) val y = focused.second @@ -108,11 +113,12 @@ class InspectorTextEditingDomMigrationTests { ClipboardBridge.install(clipboard) val fixture = openInspectorAndSelectTarget() - val numericInput = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-numeric-input-width" - ) + val numericInput = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-numeric-input-width", + ) focusInputByClick(fixture.host, numericInput) assertEquals(numericInput.key, FocusManager.focusedNode()?.key) @@ -142,11 +148,12 @@ class InspectorTextEditingDomMigrationTests { val fixture = openInspectorAndSelectTarget() var revision = fixture.nextRevision - val firstInput = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-numeric-input-width" - ) + val firstInput = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-numeric-input-width", + ) val inputKey = firstInput.key ?: error("expected keyed inspector input") val focused = focusInputByClick(fixture.host, firstInput) val focusX = focused.first @@ -163,11 +170,12 @@ class InspectorTextEditingDomMigrationTests { assertEquals(inputKey, FocusManager.focusedNode()?.key) } - val refreshed = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-numeric-input-width" - ) + val refreshed = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-numeric-input-width", + ) assertEquals(inputKey, refreshed.key) assertEquals("42", refreshed.text) @@ -178,11 +186,12 @@ class InspectorTextEditingDomMigrationTests { assertTrue(fixture.host.handleKeyDown(0, '7')) renderInspectorFrame(fixture, revision++, focusX, focusY) - val refreshedAgain = findVisibleInputNode( - host = fixture.host, - inspector = fixture.inspector, - keyPrefix = "dsgl-system-inspector-editor-numeric-input-width" - ) + val refreshedAgain = + findVisibleInputNode( + host = fixture.host, + inspector = fixture.inspector, + keyPrefix = "dsgl-system-inspector-editor-numeric-input-width", + ) assertEquals("37", refreshedAgain.text) assertNull(fixture.inspector.debugActiveEditBuffer()) } @@ -196,57 +205,77 @@ class InspectorTextEditingDomMigrationTests { renderInspectorFrame(fixture, fixture.nextRevision, anchor.x + 1, anchor.y + 1) assertTrue(fixture.host.isSystemColorPickerOpen()) - assertEquals(OverlayOwnerScope.System, fixture.host.debugSystemColorPickerPopupOwnerScope()) - assertNotNull(fixture.host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup)) + assertEquals(ScreenDomainId.System, fixture.host.debugSystemColorPickerPopupOwnerDomain()) + assertNotNull(fixture.host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup)) } private fun openInspectorAndSelectTarget(): Fixture { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - inspector.installColorPickerHost(host.systemInspectorColorPickerPopupHost()) + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) val (root, target) = inspectedRoot() inspector.toggle() host.onInputFrame(1280, 720) - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) host.handleMouseDown(984, 144, MouseButton.LEFT) host.handleMouseUp(984, 144, MouseButton.LEFT) inspector.setPickMode(false) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 984, cursorY = 144, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) host.render(ctx, 1280, 720) return Fixture(inspector, host, root, target, nextRevision = 3L) } - private fun renderInspectorFrame(fixture: Fixture, revision: Long, cursorX: Int, cursorY: Int) { + private fun renderInspectorFrame( + fixture: Fixture, + revision: Long, + cursorX: Int, + cursorY: Int, + ) { fixture.host.syncFrame( inspectedRoot = fixture.root, inspectedLayoutRevision = revision, cursorX = cursorX, cursorY = cursorY, - inspectorPointerCaptured = fixture.inspector.isPointerCaptured + inspectorPointerCaptured = fixture.inspector.isPointerCaptured, ) fixture.host.render(ctx, 1280, 720) } - private fun findVisibleInputNode(host: SystemOverlayHost, inspector: InspectorController, keyPrefix: String): TextInputNode { - val inspectorNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("inspector entry missing") - val contentRect = inspector.debugContentRect() - val candidates = collectNodes(inspectorNode) - .filterIsInstance() - .filter { (it.key?.toString() ?: "").startsWith(keyPrefix) } - - val visible = candidates.firstOrNull { node -> - val probeX = node.bounds.x + 2 - val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) - contentRect.contains(probeX, probeY) - } + private fun findVisibleInputNode(host: SystemPortalHost, inspector: InspectorController, keyPrefix: String): TextInputNode { + val inspectorNode = + host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector entry missing") + val contentRect = inspector.portalContentRect() + val candidates = + collectNodes(inspectorNode) + .filterIsInstance() + .filter { (it.key?.toString() ?: "").startsWith(keyPrefix) } + + val visible = + candidates.firstOrNull { node -> + val probeX = node.bounds.x + 2 + val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) + contentRect.contains(probeX, probeY) + } return visible ?: candidates.firstOrNull() ?: error("expected inspector input for prefix '$keyPrefix'") } - private fun focusInputByClick(host: SystemOverlayHost, input: TextInputNode): Pair { + private fun focusInputByClick(host: SystemPortalHost, input: TextInputNode): Pair { val y = input.bounds.y + (input.bounds.height / 2).coerceAtLeast(1) val left = (input.bounds.x + 2).coerceAtMost(input.bounds.x + input.bounds.width - 2) val center = input.bounds.x + (input.bounds.width / 2).coerceAtLeast(1) @@ -266,9 +295,10 @@ class InspectorTextEditingDomMigrationTests { private fun inspectedRoot(): Pair { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 1280, 720) - val target = ContainerNode(key = "target").apply { - bounds = Rect(980, 140, 120, 30) - } + val target = + ContainerNode(key = "target").apply { + bounds = Rect(980, 140, 120, 30) + } target.applyParent(root) StyleEngine.setInspectorOverrideLiteral(target, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() return root to target @@ -276,6 +306,7 @@ class InspectorTextEditingDomMigrationTests { private fun collectNodes(root: DOMNode): List { val out = ArrayList() + fun walk(node: DOMNode) { out += node node.children.forEach(::walk) @@ -286,10 +317,10 @@ class InspectorTextEditingDomMigrationTests { private data class Fixture( val inspector: InspectorController, - val host: SystemOverlayHost, + val host: SystemPortalHost, val root: ContainerNode, val target: ContainerNode, - val nextRevision: Long + val nextRevision: Long, ) private class RecordingClipboardAccess : ClipboardAccess { @@ -302,4 +333,3 @@ class InspectorTextEditingDomMigrationTests { } } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalColorPickerEntryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalColorPickerEntryTests.kt new file mode 100644 index 0000000..01d83b7 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalColorPickerEntryTests.kt @@ -0,0 +1,1124 @@ +package org.dreamfinity.dsgl.core.portal.system + +import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerPopupRequest +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState +import org.dreamfinity.dsgl.core.colorpicker.ColorPickerStyle +import org.dreamfinity.dsgl.core.colorpicker.ColorTextCodec +import org.dreamfinity.dsgl.core.colorpicker.RgbChannelOrder +import org.dreamfinity.dsgl.core.colorpicker.RgbaColor +import org.dreamfinity.dsgl.core.colorpicker.ScreenColorSampler +import org.dreamfinity.dsgl.core.colorpicker.ScreenColorSamplerBridge +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.KeyCodes +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SystemPortalColorPickerEntryTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @Test + fun `system picker popup lifecycle is entry owned and stable`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + assertFalse(host.isSystemColorPickerOpen()) + assertFalse(DomainPortalServices.applicationColorPickerEngine.isOpen()) + + pickerService.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(960, 720) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 44, cursorY = 48, inspectorPointerCaptured = false) + val firstNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val firstState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + assertTrue(firstState.active) + assertNotNull(firstState.panelState.currentRectOrNull()) + assertFalse(DomainPortalServices.applicationColorPickerEngine.isOpen()) + + host.onInputFrame(960, 720) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 50, cursorY = 56, inspectorPointerCaptured = false) + val secondNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val secondState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + assertSame(firstNode, secondNode) + assertSame(firstState, secondState) + + pickerService.close() + host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 50, cursorY = 56, inspectorPointerCaptured = false) + assertFalse(host.isSystemColorPickerOpen()) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerPopup)) + + pickerService.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(960, 720) + host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = 52, cursorY = 58, inspectorPointerCaptured = false) + val reopenedNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val reopenedState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + assertSame(firstNode, reopenedNode) + assertSame(firstState, reopenedState) + assertTrue(reopenedState.active) + } + + @Test + fun `system picker entry path stays independent from application runtime popup path`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val appOwner = Any() + + try { + DomainPortalServices.applicationColorPickerEngine.open( + ColorPickerPopupRequest( + owner = appOwner, + ownerDomain = ScreenDomainId.Application, + anchorRect = Rect(240, 210, 20, 18), + title = "App Popup", + state = popupState(), + ), + ) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + + pickerService.open(anchorRect = Rect(40, 42, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(960, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 44, + cursorY = 48, + inspectorPointerCaptured = false, + ) + + val node = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val styleTypes = collectStyleTypes(node) + assertTrue(styleTypes.contains("dsgl-floating-panel")) + assertTrue(styleTypes.contains("dsgl-system-color-picker-native-body")) + assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + assertTrue(host.isSystemColorPickerOpen()) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + + pickerService.close() + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = 44, + cursorY = 48, + inspectorPointerCaptured = false, + ) + assertFalse(host.isSystemColorPickerOpen()) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + } finally { + pickerService.close() + DomainPortalServices.applicationColorPickerEngine.close(appOwner) + } + } + + @Test + fun `system picker popup drag uses persistent entry drag session and keeps node stable`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val stableNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val stateBefore = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + val panelBefore = stateBefore.panelState.currentRectOrNull() ?: error("panel missing") + val header = host.debugSystemColorPickerHeaderRect() ?: error("header missing") + val startX = header.x + 8 + val startY = header.y + 8 + assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) + assertTrue(stateBefore.dragSession.active) + + host.handleMouseMove(startX + 50, startY + 30) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = startX + 50, + cursorY = startY + 30, + inspectorPointerCaptured = false, + ) + val midState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + val panelMid = midState.panelState.currentRectOrNull() ?: error("panel missing") + assertNotEquals(panelBefore.x, panelMid.x) + + host.handleMouseMove(startX + 90, startY + 60) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = startX + 90, + cursorY = startY + 60, + inspectorPointerCaptured = false, + ) + val movingNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val movingState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + val panelAfter = movingState.panelState.currentRectOrNull() ?: error("panel missing") + assertSame(stableNode, movingNode) + assertSame(stateBefore, movingState) + assertTrue(movingState.dragSession.active) + assertNotEquals(panelMid.x, panelAfter.x) + + assertTrue(host.handleMouseUp(startX + 90, startY + 60, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 4L, + cursorX = startX + 90, + cursorY = startY + 60, + inspectorPointerCaptured = false, + ) + val finalState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + val panelFinal = finalState.panelState.currentRectOrNull() ?: error("panel missing") + assertFalse(finalState.dragSession.active) + assertEquals(panelAfter.x, panelFinal.x) + assertEquals(panelAfter.y, panelFinal.y) + } + + @Test + fun `system picker popup survives routine sync updates without remount during drag`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(120, 100, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 126, + cursorY = 108, + inspectorPointerCaptured = false, + ) + + val initialNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val header = host.debugSystemColorPickerHeaderRect() ?: error("header missing") + val startX = header.x + 6 + val startY = header.y + 6 + assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) + + repeat(5) { step -> + val mx = startX + 20 + step * 10 + val my = startY + 15 + step * 7 + host.handleMouseMove(mx, my) + host.syncFrame( + inspectedRoot = root, + inspectedLayoutRevision = 2L + step, + cursorX = mx, + cursorY = my, + inspectorPointerCaptured = false, + ) + val node = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val state = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + assertSame(initialNode, node) + assertTrue(state.dragSession.active) + } + } + + @Test + fun `system picker popup close button closes entry through panel panel`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(80, 86, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 84, cursorY = 92, inspectorPointerCaptured = false) + val closeRect = host.debugSystemColorPickerCloseRect() ?: error("close rect missing") + assertTrue(host.handleMouseDown(closeRect.x + 1, closeRect.y + 1, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = closeRect.x + 1, + cursorY = closeRect.y + 1, + inspectorPointerCaptured = false, + ) + assertFalse(host.isSystemColorPickerOpen()) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerPopup)) + } + + @Test + fun `system picker keyboard-open path uses valid viewport after input frame sync`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val anchor = Rect(360, 220, 1, 1) + + pickerService.open(anchorRect = anchor, title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 364, + cursorY = 226, + inspectorPointerCaptured = false, + ) + val state = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("entry state missing") + val panel = state.panelState.currentRectOrNull() ?: error("panel missing") + + assertNotEquals(2, panel.x) + assertNotEquals(2, panel.y) + assertTrue(panel.x >= 8) + assertTrue(panel.y >= 8) + } + + @Test + fun `system picker entry mounts native body subtree without command bridge`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(60, 70, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 64, cursorY = 74, inspectorPointerCaptured = false) + + val node = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("entry node missing") + val styleTypes = collectStyleTypes(node) + assertTrue(styleTypes.contains("dsgl-floating-panel")) + assertTrue(styleTypes.contains("dsgl-system-color-picker-native-body")) + assertFalse(styleTypes.contains("dsgl-system-color-picker-command-bridge")) + assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) + } + + @Test + fun `system picker color field drag updates color continuously`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val previews = ArrayList() + + pickerService.open( + anchorRect = Rect(80, 90, 20, 18), + title = "Popup", + state = popupState(), + onPreview = { previews += it }, + ) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val field = layout.colorFieldRect + val startX = field.x + 2 + val startY = field.y + 2 + val midX = field.x + field.width / 2 + val midY = field.y + field.height / 2 + val endX = field.x + field.width - 2 + val endY = field.y + field.height - 2 + + assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) + host.handleMouseMove(midX, midY) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = midX, + cursorY = midY, + inspectorPointerCaptured = false, + ) + host.handleMouseMove(endX, endY) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = endX, + cursorY = endY, + inspectorPointerCaptured = false, + ) + assertTrue(host.handleMouseUp(endX, endY, MouseButton.LEFT)) + + val state = host.debugSystemColorPickerState() ?: error("state missing") + assertTrue(previews.size >= 2) + assertNotEquals(0.3f, state.color.r) + } + + @Test + fun `system picker current swatch click commits once without double apply`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + var commits = 0 + + pickerService.open( + anchorRect = Rect(80, 90, 20, 18), + title = "Popup", + state = + ColorPickerState( + color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), + previous = RgbaColor(0.1f, 0.2f, 0.3f, 1f), + mode = ColorFormatMode.RGB, + alphaEnabled = true, + closeOnSelect = false, + ), + onCommit = { commits += 1 }, + ) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val currentSwatch = host.debugSystemColorPickerBodyLayout()?.currentSwatchRect ?: error("swatch rect missing") + assertTrue(host.handleMouseDown(currentSwatch.x + 2, currentSwatch.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(currentSwatch.x + 2, currentSwatch.y + 2, MouseButton.LEFT)) + assertEquals(1, commits) + } + + @Test + fun `system picker recent swatch click previews once without double apply`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val initial = popupState() + val previews = ArrayList() + + pickerService.open( + anchorRect = Rect(80, 90, 20, 18), + title = "Popup", + state = initial, + onPreview = { previews += it }, + ) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val field = layout.colorFieldRect + val startX = field.x + 2 + val startY = field.y + 2 + val endX = field.x + field.width - 2 + val endY = field.y + field.height - 2 + + assertTrue(host.handleMouseDown(startX, startY, MouseButton.LEFT)) + host.handleMouseMove(endX, endY) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = endX, + cursorY = endY, + inspectorPointerCaptured = false, + ) + assertTrue(host.handleMouseUp(endX, endY, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = endX, + cursorY = endY, + inspectorPointerCaptured = false, + ) + + val stateAfterDrag = host.debugSystemColorPickerState() ?: error("state missing") + assertNotEquals(initial.color.toArgbInt(), stateAfterDrag.color.toArgbInt()) + val previewCountBeforeRecentClick = previews.size + + host.render(ctx, 1200, 800) + val recentRect = + host + .debugSystemColorPickerBodyLayout() + ?.recentRects + ?.getOrNull(1) + ?: error("recent swatch rect missing") + assertTrue(host.handleMouseDown(recentRect.x + 1, recentRect.y + 1, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(recentRect.x + 1, recentRect.y + 1, MouseButton.LEFT)) + assertEquals(previewCountBeforeRecentClick + 1, previews.size) + assertEquals( + initial.color.toArgbInt(), + host + .debugSystemColorPickerState() + ?.color + ?.toArgbInt(), + ) + } + + @Test + fun `system picker sync state updates current swatch without drag nudge`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val initial = popupState() + val updated = + initial.copy( + color = RgbaColor(0.92f, 0.16f, 0.24f, 1f), + previous = initial.color, + ) + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = initial) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val swatchRect = host.debugSystemColorPickerBodyLayout()?.currentSwatchRect ?: error("swatch rect missing") + host.render(ctx, 1200, 800) + val beforeColor = resolveRectFillColor(host.paint(ctx), swatchRect) ?: error("before swatch fill missing") + assertEquals(initial.color.toArgbInt(), beforeColor) + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = updated) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + host.render(ctx, 1200, 800) + val afterColor = resolveRectFillColor(host.paint(ctx), swatchRect) ?: error("after swatch fill missing") + assertEquals(updated.color.toArgbInt(), afterColor) + assertNotEquals(beforeColor, afterColor) + } + + @Test + fun `system picker sync state updates rgb order button selected visuals without drag nudge`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val style = ColorPickerStyle() + val initial = popupState().copy(mode = ColorFormatMode.RGB, rgbOrder = RgbChannelOrder.RGBA) + val updated = initial.copy(rgbOrder = RgbChannelOrder.ARGB) + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = initial) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 2, cursorY = 2, inspectorPointerCaptured = false) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val rgbaRect = initialLayout.rgbaOrderRect ?: error("rgba rect missing") + val argbRect = initialLayout.argbOrderRect ?: error("argb rect missing") + host.render(ctx, 1200, 800) + val rgbaBefore = resolveRectFillColor(host.paint(ctx), rgbaRect) ?: error("rgba fill missing") + val argbBefore = resolveRectFillColor(host.paint(ctx), argbRect) ?: error("argb fill missing") + assertEquals(style.buttonActiveColor, rgbaBefore) + assertEquals(style.buttonBackgroundColor, argbBefore) + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = updated) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 2, cursorY = 2, inspectorPointerCaptured = false) + + host.render(ctx, 1200, 800) + val rgbaAfter = resolveRectFillColor(host.paint(ctx), rgbaRect) ?: error("rgba fill missing after sync") + val argbAfter = resolveRectFillColor(host.paint(ctx), argbRect) ?: error("argb fill missing after sync") + assertEquals(style.buttonBackgroundColor, rgbaAfter) + assertEquals(style.buttonActiveColor, argbAfter) + assertNotEquals(rgbaBefore, rgbaAfter) + assertNotEquals(argbBefore, argbAfter) + } + + @Test + fun `system picker hue and alpha drag update state`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(80, 90, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 88, cursorY = 98, inspectorPointerCaptured = false) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val hue = layout.hueRect + val alpha = layout.alphaRect ?: error("alpha rect missing") + val initial = host.debugSystemColorPickerState() ?: error("state missing") + + val hueStartX = hue.x + 2 + val hueEndX = hue.x + hue.width - 2 + val hueY = hue.y + hue.height / 2 + assertTrue(host.handleMouseDown(hueStartX, hueY, MouseButton.LEFT)) + host.handleMouseMove(hueEndX, hueY) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = hueEndX, + cursorY = hueY, + inspectorPointerCaptured = false, + ) + assertTrue(host.handleMouseUp(hueEndX, hueY, MouseButton.LEFT)) + + val alphaStartX = alpha.x + alpha.width / 2 + val alphaEndX = alpha.x + 2 + val alphaY = alpha.y + alpha.height / 2 + assertTrue(host.handleMouseDown(alphaStartX, alphaY, MouseButton.LEFT)) + host.handleMouseMove(alphaEndX, alphaY) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = alphaEndX, + cursorY = alphaY, + inspectorPointerCaptured = false, + ) + assertTrue(host.handleMouseUp(alphaEndX, alphaY, MouseButton.LEFT)) + + val changed = host.debugSystemColorPickerState() ?: error("state missing") + assertNotEquals(initial.color.toArgbInt(), changed.color.toArgbInt()) + assertTrue(changed.color.a < initial.color.a) + } + + @Test + fun `system picker text input and mode controls stay synchronized`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val modeSelect = initialLayout.modeSelectRect + assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = modeSelect.x + 2, + cursorY = modeSelect.y + 2, + inspectorPointerCaptured = false, + ) + val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val hslOption = + expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") + assertTrue(host.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = hslOption.rect.x + 2, + cursorY = + hslOption.rect.y + 2, + inspectorPointerCaptured = false, + ) + + val modeChanged = host.debugSystemColorPickerState() ?: error("state missing") + assertEquals(ColorFormatMode.HSL, modeChanged.mode) + + val hslLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val saturationInput = hslLayout.inputSlots.firstOrNull { it.key == "s" } ?: error("s input missing") + assertTrue( + host.handleMouseDown(saturationInput.inputRect.x + 2, saturationInput.inputRect.y + 2, MouseButton.LEFT), + ) + assertTrue(host.handleKeyDown(KeyCodes.HOME, 0.toChar())) + repeat(4) { + assertTrue(host.handleKeyDown(KeyCodes.DELETE, 0.toChar())) + } + assertTrue(host.handleKeyDown(0, '0')) + assertTrue(host.handleKeyDown(KeyCodes.ENTER, '\n')) + + val updated = host.debugSystemColorPickerState() ?: error("state missing") + assertEquals(ColorFormatMode.HSL, updated.mode) + val updatedLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + assertEquals(listOf("h", "s", "l", "a"), updatedLayout.inputSlots.map { it.key }) + } + + @Test + fun `system picker input focus retargets by semantic key across rgb order switch`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val redInput = initialLayout.inputSlots.firstOrNull { it.key == "r" } ?: error("r input missing") + assertTrue(host.handleMouseDown(redInput.inputRect.x + 2, redInput.inputRect.y + 2, MouseButton.LEFT)) + + val argbButton = initialLayout.argbOrderRect ?: error("argb button missing") + assertTrue(host.handleMouseDown(argbButton.x + 2, argbButton.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = argbButton.x + 2, + cursorY = argbButton.y + 2, + inspectorPointerCaptured = false, + ) + assertEquals("dsgl-system-color-picker-input-value-1", FocusManager.focusedNode()?.key) + + assertTrue(host.handleKeyDown(KeyCodes.HOME, 0.toChar())) + repeat(4) { + assertTrue(host.handleKeyDown(KeyCodes.DELETE, 0.toChar())) + } + assertTrue(host.handleKeyDown(0, '0')) + assertTrue(host.handleKeyDown(KeyCodes.ENTER, '\n')) + + val updated = host.debugSystemColorPickerState() ?: error("state missing") + assertEquals(RgbChannelOrder.ARGB, updated.rgbOrder) + assertEquals(1f, updated.color.a) + assertEquals("dsgl-system-color-picker-input-value-1", FocusManager.focusedNode()?.key) + } + + @Test + fun `system picker rgb order buttons use dom semantic actions without double apply`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + var previews = 0 + var commits = 0 + + pickerService.open( + anchorRect = Rect(120, 120, 20, 18), + title = "Popup", + state = popupState().copy(mode = ColorFormatMode.RGB, rgbOrder = RgbChannelOrder.RGBA), + onPreview = { previews += 1 }, + onCommit = { commits += 1 }, + ) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val redInput = initialLayout.inputSlots.firstOrNull { it.key == "r" } ?: error("r input missing") + assertTrue(host.handleMouseDown(redInput.inputRect.x + 2, redInput.inputRect.y + 2, MouseButton.LEFT)) + host.render(ctx, 1200, 800) + + val argbButton = initialLayout.argbOrderRect ?: error("argb button missing") + assertTrue(host.handleMouseDown(argbButton.x + 2, argbButton.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(argbButton.x + 2, argbButton.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = argbButton.x + 2, + cursorY = argbButton.y + 2, + inspectorPointerCaptured = false, + ) + + val updated = host.debugSystemColorPickerState() ?: error("state missing") + assertEquals(RgbChannelOrder.ARGB, updated.rgbOrder) + assertEquals(ColorFormatMode.RGB, updated.mode) + assertEquals(0, previews) + assertEquals(0, commits) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + } + + @Test + fun `system picker mode trigger toggles dropdown through dom path without double apply`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val modeSelect = initialLayout.modeSelectRect + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + + assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = modeSelect.x + 2, + cursorY = modeSelect.y + 2, + inspectorPointerCaptured = false, + ) + + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + assertNotNull(host.debugSystemColorPickerBodyLayout()?.modeOptionsRect) + + assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = modeSelect.x + 2, + cursorY = modeSelect.y + 2, + inspectorPointerCaptured = false, + ) + + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + assertTrue(host.debugSystemColorPickerBodyLayout()?.modeOptionsRect == null) + } + + @Test + fun `system picker mode option click changes mode and closes dropdown via dom path`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + var previews = 0 + var commits = 0 + + pickerService.open( + anchorRect = Rect(120, 120, 20, 18), + title = "Popup", + state = popupState(), + onPreview = { previews += 1 }, + onCommit = { commits += 1 }, + ) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val modeSelect = initialLayout.modeSelectRect + assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = modeSelect.x + 2, + cursorY = modeSelect.y + 2, + inspectorPointerCaptured = false, + ) + + val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("expanded layout missing") + val hslOption = + expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") + assertTrue(host.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = hslOption.rect.x + 2, + cursorY = hslOption.rect.y + 2, + inspectorPointerCaptured = false, + ) + + assertEquals(ColorFormatMode.HSL, host.debugSystemColorPickerState()?.mode) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + assertEquals(0, previews) + assertEquals(0, commits) + } + + @Test + fun `system picker mode dropdown is mounted in transient lane and stays interactive`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(120, 120, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 128, + cursorY = 128, + inspectorPointerCaptured = false, + ) + + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val modeSelect = initialLayout.modeSelectRect + assertTrue(host.handleMouseDown(modeSelect.x + 2, modeSelect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = modeSelect.x + 2, + cursorY = modeSelect.y + 2, + inspectorPointerCaptured = false, + ) + + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + val transientNode = + host.debugEntryNode(SystemPortalEntryId.ColorPickerTransient) ?: error("transient node missing") + val transientStyleTypes = collectStyleTypes(transientNode) + assertTrue(transientStyleTypes.contains("dsgl-system-color-picker-native-mode-dropdown-portal")) + + val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("expanded layout missing") + val hslOption = + expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } ?: error("HSL option missing") + assertTrue(host.handleMouseDown(hslOption.rect.x + 2, hslOption.rect.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = hslOption.rect.x + 2, + cursorY = + hslOption.rect.y + 2, + inspectorPointerCaptured = false, + ) + + assertEquals(ColorFormatMode.HSL, host.debugSystemColorPickerState()?.mode) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + } + + @Test + fun `system picker pipette keeps system portal visible and uses transient lane`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(140, 140, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 146, + cursorY = 146, + inspectorPointerCaptured = false, + ) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val pipette = layout.pipetteRect + assertTrue(host.handleMouseDown(pipette.x + 2, pipette.y + 2, MouseButton.LEFT)) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = pipette.x + 2, + cursorY = pipette.y + 2, + inspectorPointerCaptured = false, + ) + + val mounted = host.debugMountedEntryIds() + assertTrue(mounted.contains(SystemPortalEntryId.ColorPickerPopup)) + assertTrue(mounted.contains(SystemPortalEntryId.ColorPickerTransient)) + assertTrue(host.isSystemColorPickerOpen()) + assertTrue(host.debugEntryState(SystemPortalEntryId.ColorPickerPopup)?.active == true) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + + val moveConsumed = host.handleMouseMove(pipette.x + 40, pipette.y + 40) + assertTrue(moveConsumed) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = pipette.x + 40, + cursorY = pipette.y + 40, + inspectorPointerCaptured = false, + ) + + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerPopup)) + } + + @Test + fun `system picker pipette transient entry emits visible tooltip commands`() { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + val gridColor = 0x7F4C93FF + val checkerLight = 0x7F0AA0A0 + val checkerDark = 0x7F104040 + + pickerService.open( + anchorRect = Rect(140, 140, 20, 18), + title = "Popup", + state = popupState(), + style = + ColorPickerStyle( + eyedropperGridSize = 5, + eyedropperCellSize = 3, + eyedropperGridEnabled = true, + eyedropperGridColor = gridColor, + checkerLightColor = checkerLight, + checkerDarkColor = checkerDark, + ), + ) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 146, + cursorY = 146, + inspectorPointerCaptured = false, + ) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val pipette = layout.pipetteRect + assertTrue(host.handleMouseDown(pipette.x + 2, pipette.y + 2, MouseButton.LEFT)) + host.handleMouseMove(pipette.x + 32, pipette.y + 28) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = pipette.x + 32, + cursorY = pipette.y + 28, + inspectorPointerCaptured = false, + ) + + host.render(ctx, 1200, 800) + val commands = host.paint(ctx) + assertTrue(commands.any { it is RenderCommand.CaptureScreenRegion }) + assertTrue(commands.any { it is RenderCommand.DrawCapturedScreenRegion }) + assertTrue( + commands.none { command -> + command is RenderCommand.DrawRect && command.width == 3 && command.height == 3 + }, + ) + assertTrue(commands.any { it is RenderCommand.DrawCheckerboard }) + assertTrue( + commands.none { command -> + command is RenderCommand.DrawRect && (command.color == checkerLight || command.color == checkerDark) + }, + ) + val capturedRegion = commands.filterIsInstance().single() + val grid = capturedRegion.grid ?: error("grid missing") + assertEquals(5, grid.columns) + assertEquals(5, grid.rows) + assertEquals(3, grid.magnification) + assertEquals(gridColor, grid.color) + assertTrue( + commands.none { command -> + command is RenderCommand.DrawRect && command.color == gridColor + }, + ) + assertTrue( + commands.any { command -> + command is RenderCommand.DrawText && command.text.startsWith("Mode:") + }, + ) + } + + @Test + fun `system picker pipette capture invalidates rendered sampled value`() { + ScreenColorSamplerBridge.install( + ScreenColorSampler { x, y -> + (0xFF shl 24) or ((x and 0xFF) shl 16) or ((y and 0xFF) shl 8) or 0x44 + }, + ) + try { + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() + val root = inspectedRoot() + + pickerService.open(anchorRect = Rect(140, 140, 20, 18), title = "Popup", state = popupState()) + host.onInputFrame(1200, 800) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 146, + cursorY = 146, + inspectorPointerCaptured = false, + ) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("layout missing") + val pipette = layout.pipetteRect + assertTrue(host.handleMouseDown(pipette.x + 2, pipette.y + 2, MouseButton.LEFT)) + + val firstX = pipette.x + 32 + val firstY = pipette.y + 28 + host.handleMouseMove(firstX, firstY) + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = firstX, + cursorY = firstY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1200, 800) + host.paint(ctx) + host.captureSystemColorPickerEyedropperSample() + host.render(ctx, 1200, 800) + val firstCommands = host.paint(ctx) + val firstColor = sampledColor(firstX, firstY) + assertEquals( + firstColor.toArgbInt(), + host + .debugSystemColorPickerState() + ?.color + ?.toArgbInt(), + ) + assertTrue(firstCommands.containsRenderedColorText(firstColor)) + + val secondX = pipette.x + 54 + val secondY = pipette.y + 43 + host.handleMouseMove(secondX, secondY) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = secondX, + cursorY = secondY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1200, 800) + host.paint(ctx) + host.captureSystemColorPickerEyedropperSample() + host.render(ctx, 1200, 800) + val secondCommands = host.paint(ctx) + val secondColor = sampledColor(secondX, secondY) + assertEquals( + secondColor.toArgbInt(), + host + .debugSystemColorPickerState() + ?.color + ?.toArgbInt(), + ) + assertTrue(secondCommands.containsRenderedColorText(secondColor)) + assertNotEquals(firstColor.toArgbInt(), secondColor.toArgbInt()) + } finally { + ScreenColorSamplerBridge.install(null) + } + } + + private fun collectStyleTypes(root: DOMNode): Set { + val out = LinkedHashSet() + + fun walk(node: DOMNode) { + out += node.styleType + node.children.forEach(::walk) + } + walk(root) + return out + } + + private fun popupState(): ColorPickerState = + ColorPickerState( + color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), + previous = RgbaColor(0.3f, 0.5f, 0.7f, 1f), + mode = ColorFormatMode.RGB, + alphaEnabled = true, + closeOnSelect = false, + ) + + private fun sampledColor(x: Int, y: Int): RgbaColor = RgbaColor.fromArgbInt((0xFF shl 24) or ((x and 0xFF) shl 16) or ((y and 0xFF) shl 8) or 0x44) + + private fun List.containsRenderedColorText(color: RgbaColor): Boolean { + val expected = + ColorTextCodec.format( + color, + ColorFormatMode.RGB, + includeAlpha = true, + rgbOrder = RgbChannelOrder.RGBA, + ) + return filterIsInstance().any { command -> command.text == expected } + } + + private fun inspectedRoot(): ContainerNode { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 1200, 800) + ContainerNode(key = "child") + .apply { + bounds = Rect(16, 18, 120, 30) + }.applyParent(root) + return root + } + + private fun resolveRectFillColor(commands: List, rect: Rect): Int? = + commands + .asReversed() + .asSequence() + .filterIsInstance() + .firstOrNull { command -> + command.x == rect.x && + command.y == rect.y && + command.width == rect.width && + command.height == rect.height + }?.color +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDomBridgeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDomBridgeTests.kt new file mode 100644 index 0000000..06d924f --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalDomBridgeTests.kt @@ -0,0 +1,167 @@ +package org.dreamfinity.dsgl.core.portal.system + +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.FocusManager +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorPortalNode +import org.dreamfinity.dsgl.core.portal.input.SurfaceDomInputRouter +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SystemPortalDomBridgeTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @Test + fun `renderer maps legacy commands to dom nodes`() { + val host = ContainerNode(stackLayout = true, key = "host") + val commands = + listOf( + RenderCommand.DrawRect(10, 12, 30, 14, 0xFF112233.toInt()), + RenderCommand.DrawText("Hello", 18, 20, 0xFFEEDDCC.toInt()), + ) + + SystemPortalCommandDslRenderer.rebuildInto(host, commands, "test") + + assertEquals(2, host.children.size) + assertTrue(host.children.all { it.styleType == "dsgl-system-raw-render-command" }) + } + + @Test + fun `renderer reuses raw nodes instead of recreating them`() { + val host = ContainerNode(stackLayout = true, key = "host") + val first = + listOf( + RenderCommand.DrawRect(2, 4, 12, 10, 0xFF223344.toInt()), + RenderCommand.DrawText("A", 6, 7, 0xFFFFFFFF.toInt()), + ) + val second = + listOf( + RenderCommand.DrawRect(2, 4, 12, 10, 0xFF556677.toInt()), + RenderCommand.DrawText("B", 6, 7, 0xFFFFFFFF.toInt()), + ) + + SystemPortalCommandDslRenderer.rebuildInto(host, first, "reuse") + val firstNode0 = host.children[0] + val firstNode1 = host.children[1] + + SystemPortalCommandDslRenderer.rebuildInto(host, second, "reuse") + assertSame(firstNode0, host.children[0]) + assertSame(firstNode1, host.children[1]) + } + + @Test + fun `system inspector portal creates native dom children from controller frame`() { + val controller = InspectorController() + controller.toggle() + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 420, 280) + } + ContainerNode(key = "child") + .apply { + bounds = Rect(16, 18, 120, 28) + }.applyParent(root) + + val portalNode = SystemInspectorPortalNode(controller) + portalNode.bindInspectedTree(root, layoutRevision = 1L) + portalNode.updateCursor(mouseX = 22, mouseY = 22, pointerCaptured = false) + portalNode.render(ctx, 0, 0, 420, 280) + + assertTrue(portalNode.children.isNotEmpty()) + assertTrue(portalNode.children.none { it.styleType == "dsgl-system-raw-render-command" }) + } + + @Test + fun `system inspector portal retains focused native input across frame rebuild`() { + val controller = InspectorController() + controller.toggle() + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 1280, 720) + } + ContainerNode(key = "target") + .apply { + bounds = Rect(980, 140, 120, 30) + }.applyParent(root) + + val portalNode = SystemInspectorPortalNode(controller) + controller.onLayoutCommitted(root, 1L) + controller.onCursorMoved(984, 144) + controller.handleMouseDown(984, 144, MouseButton.LEFT) + + portalNode.bindInspectedTree(root, layoutRevision = 2L) + portalNode.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) + portalNode.render(ctx, 0, 0, 1280, 720) + + fun findFirstInput(node: DOMNode): SingleLineInputNode? { + if (node is SingleLineInputNode) return node + node.children.forEach { child -> + val found = findFirstInput(child) + if (found != null) return found + } + return null + } + + val initialInput = findFirstInput(portalNode) + assertNotNull(initialInput) + val router = SurfaceDomInputRouter { portalNode } + val clickX = initialInput.bounds.x + 2 + val clickY = initialInput.bounds.y + initialInput.bounds.height / 2 + assertTrue(router.handleMouseDown(clickX, clickY, MouseButton.LEFT)) + assertTrue(router.handleMouseUp(clickX, clickY, MouseButton.LEFT)) + + val focusedAfterClick = FocusManager.focusedNode() + assertNotNull(focusedAfterClick) + val focusedKey = focusedAfterClick.key + assertEquals(initialInput.key, focusedKey) + + portalNode.bindInspectedTree(root, layoutRevision = 3L) + portalNode.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) + portalNode.render(ctx, 0, 0, 1280, 720) + + val focusedAfterRebuild = FocusManager.focusedNode() + assertTrue(focusedAfterRebuild is SingleLineInputNode) + assertEquals(focusedKey, focusedAfterRebuild.key) + assertTrue(focusedAfterRebuild !== initialInput) + } + + @Test + fun `system inspector portal mounts only while inspector is active`() { + val controller = InspectorController() + val root = + ContainerNode(key = "root").apply { + bounds = Rect(0, 0, 420, 280) + } + val portalNode = SystemInspectorPortalNode(controller) + + portalNode.bindInspectedTree(root, layoutRevision = 1L) + portalNode.render(ctx, 0, 0, 420, 280) + assertTrue(portalNode.children.isEmpty()) + + controller.toggle() + portalNode.render(ctx, 0, 0, 420, 280) + assertTrue(portalNode.children.isNotEmpty()) + assertTrue(portalNode.children.none { it.styleType == "dsgl-system-raw-render-command" }) + + controller.deactivate() + portalNode.render(ctx, 0, 0, 420, 280) + assertTrue(portalNode.children.isEmpty()) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntryInfrastructureTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntryInfrastructureTests.kt similarity index 51% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntryInfrastructureTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntryInfrastructureTests.kt index 171c603..dc6c6aa 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayEntryInfrastructureTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalEntryInfrastructureTests.kt @@ -1,12 +1,5 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNotSame -import kotlin.test.assertSame -import kotlin.test.assertTrue import org.dreamfinity.dsgl.core.colorpicker.ColorFormatMode import org.dreamfinity.dsgl.core.colorpicker.ColorPickerState import org.dreamfinity.dsgl.core.colorpicker.RgbaColor @@ -14,115 +7,160 @@ import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.inspector.InspectorController -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelDragSession -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelDragType -import org.dreamfinity.dsgl.core.overlay.panel.OverlayPanelState +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragSession +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelDragType +import org.dreamfinity.dsgl.core.portal.panel.FloatingPanelState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue -class SystemOverlayEntryInfrastructureTests { +class SystemPortalEntryInfrastructureTests { @Test - fun `system overlay host exposes explicit persistent entries`() { - val host = SystemOverlayHost(InspectorController()) + fun `system portal host exposes explicit persistent entries`() { + val host = SystemPortalHost(InspectorController()) assertEquals( - listOf(SystemOverlayEntryId.Inspector, SystemOverlayEntryId.ColorPickerPopup, SystemOverlayEntryId.ColorPickerTransient, SystemOverlayEntryId.PanelDemo), - host.debugRegisteredEntryIds() + listOf( + SystemPortalEntryId.Inspector, + SystemPortalEntryId.ColorPickerPopup, + SystemPortalEntryId.ColorPickerTransient, + ), + host.debugRegisteredEntryIds(), + ) + assertEquals( + listOf( + "system.Inspector", + "system.ColorPickerPopup", + "system.ColorPickerTransient", + ), + host.debugRegisteredPortalEntryIds(), ) } @Test fun `inspector entry keeps stable node and state while active`() { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) + val host = SystemPortalHost(inspector) val root = inspectedRoot() host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 10, cursorY = 10, inspectorPointerCaptured = false) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.Inspector)) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.Inspector)) inspector.toggle() host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 14, cursorY = 14, inspectorPointerCaptured = false) - val firstState = host.debugEntryState(SystemOverlayEntryId.Inspector) ?: error("state missing") - val firstNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("node missing") - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.Inspector)) + val firstState = host.debugEntryState(SystemPortalEntryId.Inspector) ?: error("state missing") + val firstNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("node missing") + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.Inspector)) assertTrue(firstState.active) + assertEquals(listOf("system.Inspector"), host.debugActivePortalEntryIds()) host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 22, cursorY = 20, inspectorPointerCaptured = false) - val secondState = host.debugEntryState(SystemOverlayEntryId.Inspector) ?: error("state missing") - val secondNode = host.debugEntryNode(SystemOverlayEntryId.Inspector) ?: error("node missing") + val secondState = host.debugEntryState(SystemPortalEntryId.Inspector) ?: error("state missing") + val secondNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("node missing") assertSame(firstState, secondState) assertSame(firstNode, secondNode) inspector.deactivate() host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = 22, cursorY = 20, inspectorPointerCaptured = false) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.Inspector)) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.Inspector)) + assertFalse(host.debugActivePortalEntryIds().contains("system.Inspector")) } @Test fun `color picker entry keeps panel state and identity stable across routine updates`() { - val host = SystemOverlayHost(InspectorController()) - val pickerHost = host.systemInspectorColorPickerPopupHost() + val host = SystemPortalHost(InspectorController()) + val pickerService = host.systemInspectorColorPickerService() val root = inspectedRoot() - pickerHost.open(anchorRect = Rect(36, 44, 20, 18), title = "Popup", state = popupState()) + pickerService.open(anchorRect = Rect(36, 44, 20, 18), title = "Popup", state = popupState()) try { - host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 42, inspectorPointerCaptured = false) - val firstState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("state missing") - val firstNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("node missing") + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 40, + cursorY = 42, + inspectorPointerCaptured = false, + ) + val firstState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("state missing") + val firstNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("node missing") val firstRect = firstState.panelState.currentRectOrNull() assertNotNull(firstRect) assertTrue(firstState.active) - host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 60, cursorY = 65, inspectorPointerCaptured = false) - val secondState = host.debugEntryState(SystemOverlayEntryId.ColorPickerPopup) ?: error("state missing") - val secondNode = host.debugEntryNode(SystemOverlayEntryId.ColorPickerPopup) ?: error("node missing") + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = 60, + cursorY = 65, + inspectorPointerCaptured = false, + ) + val secondState = host.debugEntryState(SystemPortalEntryId.ColorPickerPopup) ?: error("state missing") + val secondNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("node missing") val secondRect = secondState.panelState.currentRectOrNull() ?: error("panel rect missing") assertSame(firstState, secondState) assertSame(firstNode, secondNode) assertEquals(firstRect.x, secondRect.x) assertEquals(firstRect.y, secondRect.y) - assertTrue(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerPopup)) + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerPopup)) + assertTrue(host.debugActivePortalEntryIds().contains("system.ColorPickerPopup")) } finally { - pickerHost.close() + pickerService.close() } host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 60, cursorY = 65, inspectorPointerCaptured = false) - assertFalse(host.debugMountedEntryIds().contains(SystemOverlayEntryId.ColorPickerPopup)) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerPopup)) + assertFalse(host.debugActivePortalEntryIds().contains("system.ColorPickerPopup")) } @Test fun `entry ordering stays explicit when both system entries are active`() { val inspector = InspectorController() - val host = SystemOverlayHost(inspector) - val pickerHost = host.systemInspectorColorPickerPopupHost() + val host = SystemPortalHost(inspector) + val pickerService = host.systemInspectorColorPickerService() val root = inspectedRoot() inspector.toggle() - pickerHost.open(anchorRect = Rect(36, 44, 20, 18), title = "Popup", state = popupState()) + pickerService.open(anchorRect = Rect(36, 44, 20, 18), title = "Popup", state = popupState()) try { - host.syncFrame(root, inspectedLayoutRevision = 10L, cursorX = 20, cursorY = 18, inspectorPointerCaptured = false) + host.syncFrame( + root, + inspectedLayoutRevision = 10L, + cursorX = 20, + cursorY = 18, + inspectorPointerCaptured = false, + ) assertEquals( - listOf(SystemOverlayEntryId.Inspector, SystemOverlayEntryId.ColorPickerPopup), - host.debugMountedEntryIds() + listOf(SystemPortalEntryId.Inspector, SystemPortalEntryId.ColorPickerPopup), + host.debugMountedEntryIds(), + ) + assertEquals( + listOf("system.Inspector", "system.ColorPickerPopup"), + host.debugActivePortalEntryIds(), ) } finally { inspector.deactivate() - pickerHost.close() + pickerService.close() } } @Test fun `drag session captures start anchor updates and ends deterministically`() { - val panelState = OverlayPanelState() + val panelState = FloatingPanelState() panelState.updateFromRect(Rect(20, 24, 300, 200)) - val session = OverlayPanelDragSession() + val session = FloatingPanelDragSession() session.begin( - ownerId = SystemOverlayEntryId.ColorPickerPopup, - type = OverlayPanelDragType.PanelMove, + ownerId = SystemPortalEntryId.ColorPickerPopup, + type = FloatingPanelDragType.PanelMove, pointerX = 100, pointerY = 120, - panelState = panelState + panelState = panelState, ) assertTrue(session.active) - assertEquals(SystemOverlayEntryId.ColorPickerPopup, session.ownerId) - assertEquals(OverlayPanelDragType.PanelMove, session.type) + assertEquals(SystemPortalEntryId.ColorPickerPopup, session.ownerId) + assertEquals(FloatingPanelDragType.PanelMove, session.type) assertEquals(100, session.startPointerX) assertEquals(120, session.startPointerY) assertEquals(20, session.startPanelX) @@ -142,7 +180,7 @@ class SystemOverlayEntryInfrastructureTests { @Test fun `transient system ownership is owner session based and not cursor based`() { - val registry = SystemOverlayTransientOwnershipRegistry() + val registry = SystemPortalTransientOwnershipRegistry() val ownerA = Any() val ownerB = Any() @@ -152,12 +190,12 @@ class SystemOverlayEntryInfrastructureTests { assertSame(sessionA1, sessionA2) assertNotSame(sessionA1, sessionB) - assertEquals(SystemOverlayEntryId.TransientSession, sessionA1.entryState.id) + assertEquals(SystemPortalEntryId.TransientSession, sessionA1.entryState.id) } @Test fun `host transient session api preserves owner based stable sessions`() { - val host = SystemOverlayHost(InspectorController()) + val host = SystemPortalHost(InspectorController()) val owner = Any() val first = host.resolveTransientSession(owner, cursorX = 10, cursorY = 10) val second = host.resolveTransientSession(owner, cursorX = 400, cursorY = 220) @@ -167,22 +205,22 @@ class SystemOverlayEntryInfrastructureTests { assertEquals(0, host.debugTransientSessionCount()) } - private fun popupState(): ColorPickerState { - return ColorPickerState( + private fun popupState(): ColorPickerState = + ColorPickerState( color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), previous = RgbaColor(0.3f, 0.5f, 0.7f, 1f), mode = ColorFormatMode.RGB, alphaEnabled = true, - closeOnSelect = false + closeOnSelect = false, ) - } private fun inspectedRoot(): ContainerNode { val root = ContainerNode(key = "root") root.bounds = Rect(0, 0, 800, 600) - ContainerNode(key = "child").apply { - bounds = Rect(16, 18, 120, 30) - }.applyParent(root) + ContainerNode(key = "child") + .apply { + bounds = Rect(16, 18, 120, 30) + }.applyParent(root) return root } } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalInspectorNativeEntryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalInspectorNativeEntryTests.kt new file mode 100644 index 0000000..29f4205 --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalInspectorNativeEntryTests.kt @@ -0,0 +1,1644 @@ +package org.dreamfinity.dsgl.core.portal.system + +import org.dreamfinity.dsgl.core.colorpicker.* +import org.dreamfinity.dsgl.core.dom.DOMNode +import org.dreamfinity.dsgl.core.dom.applyParent +import org.dreamfinity.dsgl.core.dom.elements.ButtonNode +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.KeyModifiers +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.inspector.InspectorMode +import org.dreamfinity.dsgl.core.inspector.InspectorPanelState +import org.dreamfinity.dsgl.core.inspector.internal.SystemInspectorPortalNode +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.Overflow +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.core.style.StyleProperty +import java.io.File +import java.nio.file.Files +import kotlin.test.* + +class SystemPortalInspectorNativeEntryTests { + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun paint(commands: List) = Unit + } + + @AfterTest + fun cleanup() { + KeyModifiers.sync(shift = false, control = false, meta = false) + StyleEngine.setStylesDirectory(null) + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @Test + fun `inspector migration removes intermediate native portal model classes`() { + val loadResult = + runCatching { + Class.forName(removedLegacyNativeModelClassName()) + } + assertTrue(loadResult.isFailure) + } + + private fun removedLegacyNativeModelClassName(): String = "org.dreamfinity.dsgl.core.inspector.InspectorNative" + "Over" + "layModel" + + @Test + fun `inspector controller no longer exposes manual append portal commands path`() { + val methodNames = + InspectorController::class.java.methods + .map { it.name } + assertFalse(methodNames.contains("appendPortalCommands")) + } + + @Test + fun `inspector portal rebuild does not leak event bus registrations`() { + val controller = InspectorController() + val portalNode = SystemInspectorPortalNode(controller) + val root = inspectedRoot() + + controller.toggle() + portalNode.bindInspectedTree(root, layoutRevision = 1L) + portalNode.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) + portalNode.render(ctx, 0, 0, 1280, 720) + val firstSnapshot = EventBus.debugListenerSnapshot() + + repeat(24) { frame -> + portalNode.bindInspectedTree(root, layoutRevision = 2L + frame) + portalNode.updateCursor(mouseX = 984, mouseY = 144, pointerCaptured = false) + portalNode.render(ctx, 0, 0, 1280, 720) + } + val repeatedSnapshot = EventBus.debugListenerSnapshot() + + assertTrue(repeatedSnapshot.registeredNodes <= firstSnapshot.registeredNodes + 4) + assertTrue(repeatedSnapshot.registeredCallbacks <= firstSnapshot.registeredCallbacks + 24) + + controller.deactivate() + portalNode.render(ctx, 0, 0, 1280, 720) + val deactivatedSnapshot = EventBus.debugListenerSnapshot() + assertTrue(deactivatedSnapshot.registeredNodes <= firstSnapshot.registeredNodes) + assertTrue(deactivatedSnapshot.registeredCallbacks <= firstSnapshot.registeredCallbacks) + } + + @Test + fun `live inspector path is native system portal entry and anti-legacy guarded`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.Inspector)) + val node = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector entry missing") + val styleTypes = collectStyleTypes(node) + assertTrue(styleTypes.contains("dsgl-system-inspector")) + assertFalse(styleTypes.contains("dsgl-system-raw-render-command")) + assertFalse(styleTypes.contains("dsgl-system-inspector-command-bridge")) + } + + @Test + fun `expanded inspector paints occluder above full highlight geometry`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val initialRoot = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + initialRoot, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(984, 144, MouseButton.LEFT)) + + val movedRoot = inspectedRootMovedUnderPanel() + host.syncFrame( + movedRoot, + inspectedLayoutRevision = 2L, + cursorX = 84, + cursorY = 96, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + + val panelRect = inspector.floatingPanelRect() ?: error("panel rect missing") + val highlight = inspector.portalSelectedHighlight() ?: error("selected highlight missing") + assertTrue(intersects(highlight.contentRect, panelRect)) + + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val directChildren = inspectorNode.children.toList() + + val occluder = + directChildren.firstOrNull { it.key == "dsgl-system-inspector-panel-occluder" } + ?: error("occluder node missing") + val selectedContentFill = + directChildren.firstOrNull { it.key == "dsgl-system-inspector-selected-content-fill" } + ?: error("selected content fill node missing") + + assertEquals(panelRect, occluder.bounds) + assertEquals(highlight.contentRect, selectedContentFill.bounds) + assertTrue(directChildren.indexOf(occluder) > directChildren.indexOf(selectedContentFill)) + assertTrue(directChildren.none { (it.key?.toString() ?: "").contains("outside-occlusion") }) + } + + @Test + fun `inspector runtime interaction path supports selection controls and system-owned color edit`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 80, cursorY = 52, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val pickToggle = inspector.portalPickToggleBounds() ?: error("pick toggle missing") + assertTrue(host.handleMouseDown(pickToggle.x + 1, pickToggle.y + 1, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(pickToggle.x + 1, pickToggle.y + 1, MouseButton.LEFT)) + assertEquals(InspectorMode.Pick, inspector.mode) + + val colorAction = inspector.portalColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) + val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) + val openedByClick = + if (colorAction != null) { + host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && + host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) + } else { + false + } + if (!openedByClick) { + assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) + } + + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = colorAnchor.x + 1, + cursorY = colorAnchor.y + 1, + inspectorPointerCaptured = false, + ) + assertTrue(host.isSystemColorPickerOpen()) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + + val pickerNode = host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) ?: error("picker node missing") + val pickerStyles = collectStyleTypes(pickerNode) + assertTrue(pickerStyles.contains("dsgl-system-color-picker-native-body")) + assertFalse(pickerStyles.contains("dsgl-system-raw-render-command")) + } + + @Test + fun `pick selection resolves from latest synced tree before render`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + } + + @Test + fun `inspector minimize restore and close reopen remain stable`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val initialNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val minimizeRect = inspector.portalMinimizeBounds() ?: error("minimize bounds missing") + assertTrue(host.handleMouseDown(minimizeRect.x + 1, minimizeRect.y + 1, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(minimizeRect.x + 1, minimizeRect.y + 1, MouseButton.LEFT)) + assertEquals(InspectorPanelState.Minimized, inspector.panelState) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val (chipX, chipY) = inspector.panelPosition + assertTrue(host.handleMouseDown(chipX + 2, chipY + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(chipX + 2, chipY + 2, MouseButton.LEFT)) + assertEquals(InspectorPanelState.Expanded, inspector.panelState) + assertFalse(inspector.isPointerCaptured) + + inspector.deactivate() + host.syncFrame(root, inspectedLayoutRevision = 3L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.Inspector)) + + inspector.toggle() + host.syncFrame(root, inspectedLayoutRevision = 4L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + val reopenedNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + assertSame(initialNode, reopenedNode) + } + + @Test + fun `minimized inspector hides expanded panel host and keeps chip visible`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val minimizeRect = inspector.portalMinimizeBounds() ?: error("minimize bounds missing") + assertTrue(host.handleMouseDown(minimizeRect.x + 2, minimizeRect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(minimizeRect.x + 2, minimizeRect.y + 2, MouseButton.LEFT)) + assertEquals(InspectorPanelState.Minimized, inspector.panelState) + + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 48, cursorY = 36, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val panelHostNode = + collectNodes(inspectorNode).firstOrNull { node -> + (node.key?.toString() ?: "").startsWith("dsgl-floating-panel-") + } ?: error("panel host node missing") + val minimizedChipNode = + collectNodes(inspectorNode).firstOrNull { it.key == "dsgl-system-inspector-chip" } + ?: error("minimized chip node missing") + + assertEquals(0, panelHostNode.bounds.width) + assertEquals(0, panelHostNode.bounds.height) + assertTrue(minimizedChipNode.bounds.width > 0) + assertTrue(minimizedChipNode.bounds.height > 0) + } + + @Test + fun `minimized drag moves chip and releases pointer capture on mouse up`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame(root, inspectedLayoutRevision = 1L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val minimizeRect = inspector.portalMinimizeBounds() ?: error("minimize bounds missing") + assertTrue(host.handleMouseDown(minimizeRect.x + 2, minimizeRect.y + 2, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(minimizeRect.x + 2, minimizeRect.y + 2, MouseButton.LEFT)) + assertEquals(InspectorPanelState.Minimized, inspector.panelState) + + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 40, cursorY = 30, inspectorPointerCaptured = false) + host.render(ctx, 1280, 720) + + val (startX, startY) = inspector.panelPosition + val downX = startX + 6 + val downY = startY + 6 + val dragX = downX + 40 + val dragY = downY + 20 + + assertTrue(host.handleMouseDown(downX, downY, MouseButton.LEFT)) + assertTrue(host.handleMouseMove(dragX, dragY)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = dragX, + cursorY = dragY, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 1280, 720) + + assertEquals(InspectorPanelState.Minimized, inspector.panelState) + val (movedX, movedY) = inspector.panelPosition + assertTrue(movedX != startX || movedY != startY) + + assertTrue(host.handleMouseUp(dragX, dragY, MouseButton.LEFT)) + assertFalse(inspector.isPointerCaptured) + assertEquals(InspectorPanelState.Minimized, inspector.panelState) + } + + @Test + fun `inspector native path preserves scroll and scrollbar drag behavior`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val contentRect = inspector.portalContentRect() + val wheelX = contentRect.x + 4 + val wheelY = contentRect.y + 12 + assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) + + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + val afterWheel = inspector.panelScrollOffsetY + assertTrue(afterWheel > 0, "expected wheel scroll > 0, actual=$afterWheel") + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val thumbX = thumb.x + 1 + val thumbY = thumb.y + thumb.height / 2 + assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) + assertTrue(host.handleMouseMove(thumbX, thumbY + 70)) + assertTrue(host.handleMouseUp(thumbX, thumbY + 70, MouseButton.LEFT)) + assertTrue(inspector.panelScrollOffsetY >= afterWheel) + } + + @Test + fun `scrollbar drag release over control ends capture and does not trigger control click`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val pickToggle = inspector.portalPickToggleBounds() ?: error("pick toggle missing") + val modeBeforeRelease = inspector.mode + + val thumbX = thumb.x + thumb.width / 2 + val thumbY = thumb.y + thumb.height / 2 + assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) + + val releaseX = pickToggle.x + 2 + val releaseY = pickToggle.y + pickToggle.height / 2 + assertTrue(host.handleMouseMove(releaseX, releaseY)) + assertTrue(host.handleMouseUp(releaseX, releaseY, MouseButton.LEFT)) + + assertFalse(inspector.isPointerCaptured) + assertEquals(modeBeforeRelease, inspector.mode) + + val scrollAfterRelease = inspector.panelScrollOffsetY + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = releaseX, + cursorY = releaseY + 48, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + assertEquals(scrollAfterRelease, inspector.panelScrollOffsetY) + } + + @Test + fun `scrollbar drag release outside inspector consumes mouse up and stops capture`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val panelRect = inspector.floatingPanelRect() ?: error("panel rect missing") + val modeBeforeRelease = inspector.mode + + val candidatePoints = + listOf( + Pair((panelRect.x + panelRect.width + 4).coerceAtMost(419), panelRect.y + 6), + Pair((panelRect.x - 4).coerceAtLeast(0), panelRect.y + 6), + Pair(panelRect.x + 6, (panelRect.y - 4).coerceAtLeast(0)), + Pair(panelRect.x + 6, (panelRect.y + panelRect.height + 4).coerceAtMost(279)), + ) + val outsidePoint = + candidatePoints.firstOrNull { (x, y) -> !panelRect.contains(x, y) } + ?: error("failed to find outside release point") + val releaseX = outsidePoint.first + val releaseY = outsidePoint.second + + val thumbX = thumb.x + thumb.width / 2 + val thumbY = thumb.y + thumb.height / 2 + assertTrue(host.handleMouseDown(thumbX, thumbY, MouseButton.LEFT)) + + assertTrue(host.handleMouseMove(releaseX, releaseY)) + assertTrue(host.handleMouseUp(releaseX, releaseY, MouseButton.LEFT)) + assertFalse(inspector.isPointerCaptured) + assertEquals(modeBeforeRelease, inspector.mode) + + val scrollAfterRelease = inspector.panelScrollOffsetY + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = (releaseX + 32).coerceAtMost(419), + cursorY = (releaseY + 32).coerceAtMost(279), + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + assertEquals(scrollAfterRelease, inspector.panelScrollOffsetY) + } + + @Test + fun `inspector color edit ownership stays system-owned and independent from app runtime popup`() { + val appOwner = Any() + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + try { + DomainPortalServices.applicationColorPickerEngine.open( + ColorPickerPopupRequest( + owner = appOwner, + ownerDomain = ScreenDomainId.Application, + anchorRect = Rect(240, 210, 20, 18), + title = "App Popup", + state = popupState(), + ), + ) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.syncFrame( + root, + inspectedLayoutRevision = 2L, + cursorX = 80, + cursorY = 52, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + val colorAction = inspector.portalColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) + val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) + val openedByClick = + if (colorAction != null) { + host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && + host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) + } else { + false + } + if (!openedByClick) { + assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) + } + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = colorAnchor.x + 1, + cursorY = colorAnchor.y + 1, + inspectorPointerCaptured = false, + ) + + assertTrue(host.isSystemColorPickerOpen()) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + + host.systemInspectorColorPickerService().close() + host.syncFrame( + root, + inspectedLayoutRevision = 4L, + cursorX = colorAnchor.x + 1, + cursorY = colorAnchor.y + 1, + inspectorPointerCaptured = false, + ) + assertFalse(host.isSystemColorPickerOpen()) + assertTrue(DomainPortalServices.applicationColorPickerEngine.isOpenFor(appOwner)) + } finally { + host.systemInspectorColorPickerService().close() + DomainPortalServices.applicationColorPickerEngine.close(appOwner) + } + } + + @Test + fun `inspector-opened system color picker top controls expose hover feedback`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + fun sync(revision: Long, cursorX: Int, cursorY: Int) { + host.syncFrame( + root, + inspectedLayoutRevision = revision, + cursorX = cursorX, + cursorY = cursorY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + } + + inspector.toggle() + host.onInputFrame(1280, 720) + sync(revision = 1L, cursorX = 984, cursorY = 144) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + sync(revision = 2L, cursorX = 80, cursorY = 52) + val colorAction = inspector.portalColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) + val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) + val openedByClick = + if (colorAction != null) { + host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && + host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) + } else { + false + } + if (!openedByClick) { + assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) + } + sync(revision = 3L, cursorX = colorAnchor.x + 1, cursorY = colorAnchor.y + 1) + assertTrue(host.isSystemColorPickerOpen()) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + + val layout = host.debugSystemColorPickerBodyLayout() ?: error("color picker body layout missing") + val style = ColorPickerStyle() + val hoverTargets = + listOf( + "dsgl-system-color-picker-mode-select" to layout.modeSelectRect, + "dsgl-system-color-picker-order-argb" to (layout.argbOrderRect ?: error("argb order rect missing")), + "dsgl-system-color-picker-button-copy" to layout.copyRect, + "dsgl-system-color-picker-button-paste" to layout.pasteRect, + "dsgl-system-color-picker-button-pipette" to layout.pipetteRect, + ) + + var revision = 4L + hoverTargets.forEach { (key, rect) -> + val hoverX = rect.x + rect.width / 2 + val hoverY = rect.y + rect.height / 2 + assertTrue(host.handleMouseMove(hoverX, hoverY), "expected hover move to be consumed for $key") + sync(revision = revision++, cursorX = hoverX, cursorY = hoverY) + + val pickerNode = + host.debugEntryNode(SystemPortalEntryId.ColorPickerPopup) + ?: error("color picker entry missing") + val buttonNode = + collectNodes(pickerNode) + .firstOrNull { it.key?.toString() == key } as? ButtonNode + ?: error("button node missing for $key") + assertEquals(style.buttonHoverColor, buttonNode.backgroundColor, "expected hover color for $key") + } + } + + @Test + fun `inspector-opened system color picker mode dropdown options hover and click reliably`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + fun sync(revision: Long, cursorX: Int, cursorY: Int) { + host.syncFrame( + root, + inspectedLayoutRevision = revision, + cursorX = cursorX, + cursorY = cursorY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + } + + inspector.toggle() + host.onInputFrame(1280, 720) + sync(revision = 1L, cursorX = 984, cursorY = 144) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + val modeBeforeDropdown = inspector.mode + + sync(revision = 2L, cursorX = 80, cursorY = 52) + val colorAction = inspector.portalColorPickerActionBounds(StyleProperty.BACKGROUND_COLOR) + val colorAnchor = colorAction ?: Rect(80, 80, 20, 18) + val openedByClick = + if (colorAction != null) { + host.handleMouseDown(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) && + host.handleMouseUp(colorAction.x + 1, colorAction.y + 1, MouseButton.LEFT) + } else { + false + } + if (!openedByClick) { + assertTrue(inspector.debugOpenColorPickerForSelection(StyleProperty.BACKGROUND_COLOR, colorAnchor)) + } + sync(revision = 3L, cursorX = colorAnchor.x + 1, cursorY = colorAnchor.y + 1) + assertTrue(host.isSystemColorPickerOpen()) + assertEquals(ScreenDomainId.System, host.debugSystemColorPickerPopupOwnerDomain()) + + val initialLayout = host.debugSystemColorPickerBodyLayout() ?: error("color picker body layout missing") + assertTrue( + host.handleMouseDown( + initialLayout.modeSelectRect.x + 2, + initialLayout.modeSelectRect.y + 2, + MouseButton.LEFT, + ), + ) + sync( + revision = 4L, + cursorX = initialLayout.modeSelectRect.x + 2, + cursorY = initialLayout.modeSelectRect.y + 2, + ) + assertTrue(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + + val expandedLayout = host.debugSystemColorPickerBodyLayout() ?: error("expanded color picker layout missing") + val hslOption = + expandedLayout.modeOptions.firstOrNull { it.mode == ColorFormatMode.HSL } + ?: error("HSL mode option missing") + val optionHoverX = hslOption.rect.x + hslOption.rect.width / 2 + val optionHoverY = hslOption.rect.y + hslOption.rect.height / 2 + assertTrue(host.handleMouseMove(optionHoverX, optionHoverY)) + sync(revision = 5L, cursorX = optionHoverX, cursorY = optionHoverY) + + val style = ColorPickerStyle() + val transientNode = + host.debugEntryNode(SystemPortalEntryId.ColorPickerTransient) + ?: error("transient entry missing") + val optionNode = + collectNodes(transientNode) + .firstOrNull { it.key?.toString() == "dsgl-system-color-picker-mode-option-hsl" } as? ButtonNode + ?: error("HSL option node missing") + assertEquals(style.buttonHoverColor, optionNode.backgroundColor) + + assertTrue(host.handleMouseDown(optionHoverX, optionHoverY, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(optionHoverX, optionHoverY, MouseButton.LEFT)) + sync(revision = 6L, cursorX = optionHoverX, cursorY = optionHoverY) + + assertEquals(ColorFormatMode.HSL, host.debugSystemColorPickerState()?.mode) + assertFalse(host.debugMountedEntryIds().contains(SystemPortalEntryId.ColorPickerTransient)) + assertTrue(host.isSystemColorPickerOpen()) + assertEquals("target", inspector.selectedKey) + assertEquals(modeBeforeDropdown, inspector.mode) + } + + @Test + fun `inspector native body content remains clipped in narrow viewport`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(320, 220) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 320, 220) + + val bodyRect = inspector.portalContentRect() + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val bodyNode = + collectNodes(inspectorNode) + .firstOrNull { it.key?.toString() == "dsgl-system-inspector-body" } + ?: error("inspector body node missing") + assertEquals(Overflow.Hidden, bodyNode.overflowX) + assertEquals(Overflow.Auto, bodyNode.overflowY) + val bodyViewport = bodyNode.overflowViewportRect() ?: bodyRect + + val initialCommands = host.paint(ctx) + assertTrue( + initialCommands.any { command -> + command is RenderCommand.PushClip && + command.x == bodyViewport.x && + command.y == bodyViewport.y && + command.width == bodyViewport.width && + command.height == bodyViewport.height + }, + ) + + assertTrue(host.handleMouseWheel(bodyRect.x + 4, bodyRect.y + 12, -120)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = bodyRect.x + 4, + cursorY = bodyRect.y + 12, + inspectorPointerCaptured = false, + ) + host.render(ctx, 320, 220) + + val bodyLines = + collectNodes(inspectorNode).filter { node -> + if (node.display == Display.None) return@filter false + val key = node.key?.toString() ?: return@filter false + key.startsWith("dsgl-system-inspector-info-line-") || + key.startsWith("dsgl-system-inspector-style-line-") + } + + assertTrue(bodyLines.isNotEmpty()) + assertTrue( + bodyLines.any { node -> + node.bounds.y < bodyRect.y || node.bounds.y + node.bounds.height > bodyRect.y + bodyRect.height + }, + ) + + val edgeIntersecting = + bodyLines.filter { node -> + intersects(node.bounds, bodyRect) && !containsFully(bodyRect, node.bounds) + } + assertTrue(edgeIntersecting.isNotEmpty()) + assertTrue(edgeIntersecting.all { it.bounds.height >= 24 }) + + val scrolledCommands = host.paint(ctx) + assertTrue( + scrolledCommands.any { command -> + command is RenderCommand.PushClip && + command.x == bodyViewport.x && + command.y == bodyViewport.y && + command.width == bodyViewport.width && + command.height == bodyViewport.height + }, + ) + } + + @Test + fun `inspector expanded body renders baseline info text`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + val commands = host.paint(ctx) + + val bodyRect = inspector.portalContentRect() + assertTrue(bodyRect.width > 0 && bodyRect.height > 0) + val baselineInfoRendered = + commands.any { command -> + command is RenderCommand.DrawText && + command.text.contains("F12 toggle") && + command.x >= bodyRect.x && + command.x <= bodyRect.x + bodyRect.width && + command.y >= bodyRect.y && + command.y <= bodyRect.y + bodyRect.height + } + assertTrue(baselineInfoRendered) + } + + @Test + fun `inspector clipped body blocks hidden row input and accepts visible portion`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + host.onInputFrame(320, 213) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 320, 213) + + val bodyRect = inspector.portalContentRect() + val wheelX = bodyRect.x + 4 + val wheelY = bodyRect.y + 12 + + var revision = 3L + var edgeNode: DOMNode? = null + var hiddenNode: DOMNode? = null + var visibleNode: DOMNode? = null + var latestInteractiveNodes: List = emptyList() + repeat(24) { + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val interactiveNodes = + collectNodes(inspectorNode).filter { node -> + if (node.display == Display.None) return@filter false + val key = node.key?.toString() ?: return@filter false + isInteractiveInspectorControlKey(key) + } + latestInteractiveNodes = interactiveNodes + edgeNode = + interactiveNodes.firstOrNull { node -> + intersects(node.bounds, bodyRect) && + !containsFully( + bodyRect, + node.bounds, + ) + } + hiddenNode = interactiveNodes.firstOrNull { node -> !intersects(node.bounds, bodyRect) } + visibleNode = interactiveNodes.firstOrNull { node -> containsFully(bodyRect, node.bounds) } + if (edgeNode != null && visibleNode != null) return@repeat + assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) + host.syncFrame( + root, + inspectedLayoutRevision = revision, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 320, 213) + revision += 1L + } + + val hiddenTarget = + edgeNode ?: hiddenNode ?: latestInteractiveNodes.firstOrNull { node -> !intersects(node.bounds, bodyRect) } + ?: error("failed to find hidden interactive inspector control") + val visibleTarget = + visibleNode ?: latestInteractiveNodes.firstOrNull { node -> intersects(node.bounds, bodyRect) } ?: edgeNode + + val hiddenX = + if (edgeNode != null) { + maxOf(hiddenTarget.bounds.x, bodyRect.x) + 2 + } else { + hiddenTarget.bounds.x + (hiddenTarget.bounds.width / 2).coerceAtLeast(1) + } + val hiddenY = + if (edgeNode != null) { + if (hiddenTarget.bounds.y < bodyRect.y) { + hiddenTarget.bounds.y + 1 + } else { + hiddenTarget.bounds.y + hiddenTarget.bounds.height - 1 + } + } else { + hiddenTarget.bounds.y + (hiddenTarget.bounds.height / 2).coerceAtLeast(1) + } + + assertFalse(bodyRect.contains(hiddenX, hiddenY)) + assertTrue(hiddenTarget.bounds.contains(hiddenX, hiddenY)) + + assertFalse(host.handleMouseDown(hiddenX, hiddenY, MouseButton.LEFT)) + host.handleMouseUp(hiddenX, hiddenY, MouseButton.LEFT) + assertEquals("target", inspector.selectedKey) + + if (visibleTarget == null) return + + val visibleX = maxOf(visibleTarget.bounds.x, bodyRect.x) + 2 + val visibleY = maxOf(visibleTarget.bounds.y, bodyRect.y) + 1 + assertTrue(bodyRect.contains(visibleX, visibleY)) + assertTrue(visibleTarget.bounds.contains(visibleX, visibleY)) + + assertTrue(host.handleMouseDown(visibleX, visibleY, MouseButton.LEFT)) + assertTrue(host.handleMouseUp(visibleX, visibleY, MouseButton.LEFT)) + } + + @Test + fun `inspector body consumes generic scroll viewport and content state`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val bodyNode = + collectNodes(inspectorNode).firstOrNull { it.key == "dsgl-system-inspector-body" } + ?: error("inspector body node missing") + val scrollState = bodyNode.scrollContainerState() + + assertTrue(scrollState.axisY.scrollContainer) + assertTrue(scrollState.axisY.clipsToViewport) + assertTrue(scrollState.viewportRect.width > 0 && scrollState.viewportRect.height > 0) + assertTrue(scrollState.contentExtent.height >= scrollState.viewportRect.height) + assertTrue(!scrollState.axisX.scrollbarPresent) + assertEquals(0, scrollState.horizontalScrollbarGutter) + if (scrollState.axisY.scrollbarPresent) { + assertTrue(scrollState.verticalScrollbarGutter > 0) + assertTrue(scrollState.viewportRect.width < scrollState.baseViewportRect.width) + } else { + assertEquals(0, scrollState.verticalScrollbarGutter) + assertEquals(scrollState.baseViewportRect.width, scrollState.viewportRect.width) + } + assertEquals(scrollState.baseViewportRect.height, scrollState.viewportRect.height) + assertEquals(scrollState.viewportRect, bodyNode.overflowViewportRect()) + } + + @Test + fun `inspector wheel scrolling works when hovering interactive input`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + assertEquals("target", inspector.selectedKey) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val inspectorNode = host.debugEntryNode(SystemPortalEntryId.Inspector) ?: error("inspector node missing") + val bodyRect = inspector.portalContentRect() + val allNodes = collectNodes(inspectorNode) + val interactiveNode = + allNodes.firstOrNull { node -> + val key = node.key?.toString() ?: return@firstOrNull false + val interactiveControl = + key.startsWith("dsgl-system-inspector-editor-input-") || + key.startsWith("dsgl-system-inspector-editor-numeric-input-") || + key.startsWith("dsgl-system-inspector-editor-select-") || + key.startsWith("dsgl-system-inspector-editor-color-preview-") + if (!interactiveControl) return@firstOrNull false + val probeX = node.bounds.x + 2 + val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) + bodyRect.contains(probeX, probeY) + } + val wheelNode = + interactiveNode ?: allNodes.firstOrNull { node -> + val probeX = node.bounds.x + 2 + val probeY = node.bounds.y + (node.bounds.height / 2).coerceAtLeast(1) + bodyRect.contains(probeX, probeY) + } ?: error("visible inspector body content node missing") + + val wheelX = wheelNode.bounds.x + 2 + val wheelY = wheelNode.bounds.y + (wheelNode.bounds.height / 2).coerceAtLeast(1) + + val before = inspector.panelScrollOffsetY + assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + assertTrue(inspector.panelScrollOffsetY > before) + } + + @Test + fun `inspector shift wheel does not consume vertical wheel path`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + + val bodyRect = inspector.portalContentRect() + val wheelX = bodyRect.x + 4 + val wheelY = bodyRect.y + 12 + val before = inspector.panelScrollOffsetY + + KeyModifiers.sync(shift = true, control = false, meta = false) + host.handleMouseWheel(wheelX, wheelY, -120) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + assertEquals(before, inspector.panelScrollOffsetY) + KeyModifiers.sync(shift = false, control = false, meta = false) + } + + @Test + fun `inspector wheel scrolling remains symmetric across rebuilds`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + host.paint(ctx) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + host.paint(ctx) + + val contentRect = inspector.portalContentRect() + val wheelX = contentRect.x + 4 + val wheelY = contentRect.y + 12 + + repeat(4) { step -> + assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L + step, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + } + repeat(16) { settle -> + host.syncFrame( + root, + inspectedLayoutRevision = 20L + settle, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + } + val scrolledDown = inspector.panelScrollOffsetY + assertTrue(scrolledDown > 0, "expected downward wheel to increase scroll: down=$scrolledDown") + + var consumedUpWheel = false + repeat(8) { step -> + val consumed = host.handleMouseWheel(wheelX, wheelY, 120) + consumedUpWheel = consumedUpWheel || consumed + host.syncFrame( + root, + inspectedLayoutRevision = 40L + step, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + } + assertTrue(consumedUpWheel, "expected at least one upward wheel step to be consumed") + var scrolledUp = inspector.panelScrollOffsetY + repeat(24) { settle -> + if (scrolledUp < scrolledDown) return@repeat + host.syncFrame( + root, + inspectedLayoutRevision = 60L + settle, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + scrolledUp = inspector.panelScrollOffsetY + } + assertTrue( + scrolledUp < scrolledDown, + "expected upward wheel to reduce scroll: down=$scrolledDown up=$scrolledUp", + ) + } + + @Test + fun `inspector thumb drag remains active across rebuild without controller pointer capture`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + host.paint(ctx) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + host.paint(ctx) + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val dragX = thumb.x + thumb.width / 2 + val dragStartY = thumb.y + thumb.height / 2 + + assertTrue(host.handleMouseDown(dragX, dragStartY, MouseButton.LEFT)) + assertFalse(inspector.isPointerCaptured) + val beforeDrag = inspector.panelScrollOffsetY + + assertTrue(host.handleMouseMove(dragX, dragStartY + 18)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = dragX, + cursorY = dragStartY + 18, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + val afterFirstMove = inspector.panelScrollOffsetY + assertTrue(afterFirstMove > beforeDrag) + + assertTrue(host.handleMouseMove(dragX, dragStartY + 42)) + host.syncFrame( + root, + inspectedLayoutRevision = 4L, + cursorX = dragX, + cursorY = dragStartY + 42, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + val afterSecondMove = inspector.panelScrollOffsetY + assertTrue(afterSecondMove > afterFirstMove) + + assertTrue(host.handleMouseUp(dragX, dragStartY + 42, MouseButton.LEFT)) + } + + @Test + fun `inspector style boundary stays isolated from application stylesheet`() { + val stylesDir = + createTempStylesDir( + """ + text { color: #FF00FF00; } + div { background-color: #FFFF00FF; } + """.trimIndent(), + ) + StyleEngine.setStylesDirectory(stylesDir) + StyleEngine.forceReloadStylesheets() + + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRoot() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + val commands = host.paint(ctx) + val headerTexts = + commands + .filterIsInstance() + .filter { it.text.startsWith("Inspector") } + + assertTrue(headerTexts.isNotEmpty()) + assertTrue(headerTexts.none { it.color == 0xFF00FF00.toInt() }) + assertTrue(headerTexts.any { it.color == 0xFFE6EDF6.toInt() }) + } + + private fun inspectedRoot(): ContainerNode { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 1280, 720) + ContainerNode(key = "target") + .apply { + bounds = Rect(980, 140, 120, 30) + }.applyParent(root) + StyleEngine + .setInspectorOverrideLiteral(root.children.first(), StyleProperty.BACKGROUND_COLOR, "#FF112233") + .getOrThrow() + return root + } + + private fun inspectedRootWithManyChildren(): ContainerNode { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 1800, 1200) + val selected = + ContainerNode(key = "target") + .apply { + bounds = Rect(980, 140, 260, 180) + }.applyParent(root) + repeat(60) { index -> + ContainerNode(key = "child-$index") + .apply { + bounds = Rect(980, 180 + index * 12, 180, 10) + }.applyParent(selected) + } + StyleEngine.setInspectorOverrideLiteral(selected, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() + return root + } + + private fun inspectedRootMovedUnderPanel(): ContainerNode { + val root = ContainerNode(key = "root") + root.bounds = Rect(0, 0, 1280, 720) + val selected = + ContainerNode(key = "target") + .apply { + bounds = Rect(72, 84, 180, 80) + }.applyParent(root) + StyleEngine.setInspectorOverrideLiteral(selected, StyleProperty.BACKGROUND_COLOR, "#FF112233").getOrThrow() + return root + } + + private fun popupState(): ColorPickerState = + ColorPickerState( + color = RgbaColor(0.3f, 0.5f, 0.7f, 1f), + previous = RgbaColor(0.3f, 0.5f, 0.7f, 1f), + mode = ColorFormatMode.RGB, + alphaEnabled = true, + closeOnSelect = false, + ) + + private fun intersects(a: Rect, b: Rect): Boolean = + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + + private fun containsFully(outer: Rect, inner: Rect): Boolean = + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + + private fun isInteractiveInspectorControlKey(key: String): Boolean = + key == "dsgl-system-inspector-parent-row" || + key.startsWith("dsgl-system-inspector-child-row-") || + key.startsWith("dsgl-system-inspector-editor-reset-") || + key.startsWith("dsgl-system-inspector-editor-select-") || + key.startsWith("dsgl-system-inspector-editor-dec-") || + key.startsWith("dsgl-system-inspector-editor-inc-") || + key.startsWith("dsgl-system-inspector-editor-unit-") || + key.startsWith("dsgl-system-inspector-editor-color-preview-") || + key == "dsgl-system-inspector-reset-node" || + key == "dsgl-system-inspector-clear-all" + + private fun collectNodes(root: DOMNode): List { + val out = ArrayList() + + fun walk(node: DOMNode) { + out += node + node.children.forEach(::walk) + } + walk(root) + return out + } + + private fun collectStyleTypes(root: DOMNode): Set { + val out = LinkedHashSet() + + fun walk(node: DOMNode) { + out += node.styleType + node.children.forEach(::walk) + } + walk(root) + return out + } + + private fun createTempStylesDir(dss: String): File { + val root = Files.createTempDirectory("dsgl-system-inspector-style-").toFile() + root.resolve("test.dss").writeText(dss) + return root + } + + @Test + fun `inspector consumer scroll reacts on frame update without viewport resize`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + host.paint(ctx) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + host.paint(ctx) + + val contentRect = inspector.portalContentRect() + val wheelX = contentRect.x + 4 + val wheelY = contentRect.y + 14 + val before = inspector.panelScrollOffsetY + + assertTrue(host.handleMouseWheel(wheelX, wheelY, -120)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L, + cursorX = wheelX, + cursorY = wheelY, + inspectorPointerCaptured = false, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + + assertTrue(inspector.panelScrollOffsetY > before) + } + + @Test + fun `inspector consumer thumb drag remains smooth and stable on release`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + host.paint(ctx) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + host.paint(ctx) + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val dragX = thumb.x + thumb.width / 2 + val startY = thumb.y + thumb.height / 2 + + assertTrue(host.handleMouseDown(dragX, startY, MouseButton.LEFT)) + var previousScroll = inspector.panelScrollOffsetY + var previousThumbY = inspector.portalScrollbarThumbRect().y + + repeat(6) { step -> + val nextY = startY + (step + 1) * 9 + assertTrue(host.handleMouseMove(dragX, nextY)) + host.syncFrame( + root, + inspectedLayoutRevision = 3L + step, + cursorX = dragX, + cursorY = nextY, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + val currentScroll = inspector.panelScrollOffsetY + val currentThumbY = inspector.portalScrollbarThumbRect().y + assertTrue( + currentScroll >= previousScroll, + "scroll regressed: prev=$previousScroll current=$currentScroll step=$step", + ) + assertTrue( + currentThumbY >= previousThumbY, + "thumb regressed: prev=$previousThumbY current=$currentThumbY step=$step", + ) + previousScroll = currentScroll + previousThumbY = currentThumbY + } + + assertTrue(host.handleMouseUp(dragX, startY + 6 * 9, MouseButton.LEFT)) + val settledScroll = inspector.panelScrollOffsetY + val settledThumbY = inspector.portalScrollbarThumbRect().y + + repeat(6) { idx -> + host.syncFrame( + root, + inspectedLayoutRevision = 20L + idx, + cursorX = dragX, + cursorY = startY, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + assertEquals(settledScroll, inspector.panelScrollOffsetY) + assertEquals(settledThumbY, inspector.portalScrollbarThumbRect().y) + } + } + + @Test + fun `inspector consumer fast thumb drag to boundary stays stable`() { + val inspector = InspectorController() + val host = SystemPortalHost(inspector) + inspector.installColorPickerPortalService(host.systemInspectorColorPickerService()) + val root = inspectedRootWithManyChildren() + + inspector.toggle() + host.onInputFrame(1280, 720) + host.syncFrame( + root, + inspectedLayoutRevision = 1L, + cursorX = 984, + cursorY = 144, + inspectorPointerCaptured = false, + ) + host.render(ctx, 1280, 720) + host.paint(ctx) + assertTrue(host.handleMouseDown(984, 144, MouseButton.LEFT)) + + host.onInputFrame(420, 280) + host.syncFrame(root, inspectedLayoutRevision = 2L, cursorX = 90, cursorY = 90, inspectorPointerCaptured = false) + host.render(ctx, 420, 280) + host.paint(ctx) + + val thumb = inspector.portalScrollbarThumbRect() + assertTrue(thumb.width > 0 && thumb.height > 0) + val dragX = thumb.x + thumb.width / 2 + val startY = thumb.y + thumb.height / 2 + + assertTrue(host.handleMouseDown(dragX, startY, MouseButton.LEFT)) + var previousScroll = inspector.panelScrollOffsetY + var previousThumbY = inspector.portalScrollbarThumbRect().y + repeat(7) { step -> + val nextY = startY + (step + 1) * 120 + assertTrue(host.handleMouseMove(dragX, nextY)) + host.syncFrame( + root, + inspectedLayoutRevision = 20L + step, + cursorX = dragX, + cursorY = nextY, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + val currentScroll = inspector.panelScrollOffsetY + val currentThumbY = inspector.portalScrollbarThumbRect().y + assertTrue( + currentScroll >= previousScroll, + "scroll regressed: prev=$previousScroll current=$currentScroll step=$step", + ) + assertTrue( + currentThumbY >= previousThumbY, + "thumb regressed: prev=$previousThumbY current=$currentThumbY step=$step", + ) + previousScroll = currentScroll + previousThumbY = currentThumbY + } + + val settledScroll = inspector.panelScrollOffsetY + val settledThumbY = inspector.portalScrollbarThumbRect().y + repeat(8) { idx -> + val boundaryY = startY + 2000 + assertTrue(host.handleMouseMove(dragX, boundaryY)) + host.syncFrame( + root, + inspectedLayoutRevision = 40L + idx, + cursorX = dragX, + cursorY = boundaryY, + inspectorPointerCaptured = inspector.isPointerCaptured, + ) + host.render(ctx, 420, 280) + host.paint(ctx) + assertEquals(settledScroll, inspector.panelScrollOffsetY) + assertEquals(settledThumbY, inspector.portalScrollbarThumbRect().y) + } + + assertTrue(host.handleMouseUp(dragX, startY + 2000, MouseButton.LEFT)) + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayStyleIsolationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalStyleIsolationTests.kt similarity index 52% rename from core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayStyleIsolationTests.kt rename to core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalStyleIsolationTests.kt index 197a259..216d310 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/overlay/system/SystemOverlayStyleIsolationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/portal/system/SystemPortalStyleIsolationTests.kt @@ -1,4 +1,4 @@ -package org.dreamfinity.dsgl.core.overlay.system +package org.dreamfinity.dsgl.core.portal.system import org.dreamfinity.dsgl.core.DsglColors import org.dreamfinity.dsgl.core.dom.DOMNode @@ -7,7 +7,7 @@ import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.layout.Rect import org.dreamfinity.dsgl.core.dom.layout.Size import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext -import org.dreamfinity.dsgl.core.overlay.ApplicationOverlayRootNode +import org.dreamfinity.dsgl.core.portal.ApplicationPortalRootNode import org.dreamfinity.dsgl.core.render.RenderCommand import org.dreamfinity.dsgl.core.style.StyleApplicationScope import org.dreamfinity.dsgl.core.style.StyleEngine @@ -17,7 +17,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals -class SystemOverlayStyleIsolationTests { +class SystemPortalStyleIsolationTests { @AfterTest fun cleanup() { StyleEngine.setStylesDirectory(null) @@ -26,48 +26,74 @@ class SystemOverlayStyleIsolationTests { } @Test - fun `system overlay scope ignores user stylesheet rules`() { - val stylesDir = createTempStylesDir( - """ - * { color: #FF5500; } - probe { color: #00CCAA; } - .app probe { color: #1133DD; } - """.trimIndent() - ) + fun `system portal scope ignores user stylesheet rules`() { + val stylesDir = + createTempStylesDir( + """ + * { color: #FF5500; } + probe { color: #00CCAA; } + .app probe { color: #1133DD; } + """.trimIndent(), + ) StyleEngine.setStylesDirectory(stylesDir) StyleEngine.forceReloadStylesheets() - val appRoot = ContainerNode(key = "app-root").apply { - addClass("app") - } + val appRoot = + ContainerNode(key = "app-root").apply { + addClass("app") + } val appProbe = ProbeNode(key = "app-probe").applyParent(appRoot) - val appOverlayRoot = ApplicationOverlayRootNode() - val appOverlayProbe = ProbeNode(key = "app-overlay-probe").applyParent(appOverlayRoot) + val appPortalRoot = ApplicationPortalRootNode() + val appPortalProbe = ProbeNode(key = "app-portal-probe").applyParent(appPortalRoot) StyleEngine.applyStylesRecursively(appRoot, StyleApplicationScope.Application) - StyleEngine.applyStylesRecursively(appOverlayRoot, StyleApplicationScope.Application) + StyleEngine.applyStylesRecursively(appPortalRoot, StyleApplicationScope.Application) assertEquals(0xFF1133DD.toInt(), appProbe.appliedColor) - assertEquals(0xFF00CCAA.toInt(), appOverlayProbe.appliedColor) + assertEquals(0xFF00CCAA.toInt(), appPortalProbe.appliedColor) - val systemRoot = SystemOverlayRootNode() + val systemRoot = SystemPortalRootNode() val systemProbe = ProbeNode(key = "system-probe").applyParent(systemRoot) - StyleEngine.applyStylesRecursively(systemRoot, StyleApplicationScope.SystemOverlay) + StyleEngine.applyStylesRecursively(systemRoot, StyleApplicationScope.System) assertEquals(DsglColors.TEXT, systemProbe.appliedColor) stylesDir.resolve("test.dss").writeText( """ * { color: #33AA55; } probe { color: #AA22EE; } - """.trimIndent() + """.trimIndent(), ) StyleEngine.forceReloadStylesheets() StyleEngine.applyStylesRecursively(appRoot, StyleApplicationScope.Application) - StyleEngine.applyStylesRecursively(appOverlayRoot, StyleApplicationScope.Application) - StyleEngine.applyStylesRecursively(systemRoot, StyleApplicationScope.SystemOverlay) + StyleEngine.applyStylesRecursively(appPortalRoot, StyleApplicationScope.Application) + StyleEngine.applyStylesRecursively(systemRoot, StyleApplicationScope.System) assertEquals(0xFFAA22EE.toInt(), appProbe.appliedColor) - assertEquals(0xFFAA22EE.toInt(), appOverlayProbe.appliedColor) + assertEquals(0xFFAA22EE.toInt(), appPortalProbe.appliedColor) assertEquals(DsglColors.TEXT, systemProbe.appliedColor) } + @Test + fun `debug scope ignores user stylesheet rules`() { + val stylesDir = + createTempStylesDir( + """ + * { color: #FF5500; } + probe { color: #00CCAA; } + """.trimIndent(), + ) + StyleEngine.setStylesDirectory(stylesDir) + StyleEngine.forceReloadStylesheets() + + val appRoot = ContainerNode(key = "app-root") + val appProbe = ProbeNode(key = "app-probe").applyParent(appRoot) + val debugRoot = ContainerNode(key = "debug-root") + val debugProbe = ProbeNode(key = "debug-probe").applyParent(debugRoot) + + StyleEngine.applyStylesRecursively(appRoot, StyleApplicationScope.Application) + StyleEngine.applyStylesRecursively(debugRoot, StyleApplicationScope.Debug) + + assertEquals(0xFF00CCAA.toInt(), appProbe.appliedColor) + assertEquals(DsglColors.TEXT, debugProbe.appliedColor) + } + private fun createTempStylesDir(dss: String): File { val root = Files.createTempDirectory("dsgl-system-style-").toFile() root.resolve("test.dss").writeText(dss) @@ -75,7 +101,7 @@ class SystemOverlayStyleIsolationTests { } private class ProbeNode( - key: Any? + key: Any?, ) : DOMNode(key) { override val styleType: String = "probe" val defaultColor: Int = 0xFFABCDEF.toInt() @@ -83,14 +109,17 @@ class SystemOverlayStyleIsolationTests { override fun measure(ctx: UiMeasureContext): Size = Size(10, 10) - override fun render(ctx: UiMeasureContext, x: Int, y: Int, width: Int, height: Int) { + override fun render( + ctx: UiMeasureContext, + x: Int, + y: Int, + width: Int, + height: Int, + ) { bounds = Rect(x, y, width, height) } - override fun buildRenderCommands( - ctx: UiMeasureContext, - out: MutableList - ) = Unit + override fun buildRenderCommands(ctx: UiMeasureContext, out: MutableList) = Unit override fun defaultForegroundColor(): Int = defaultColor diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/ref/UseRefHookRuntimeTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/ref/UseRefHookRuntimeTests.kt index 29ff8cc..24dab34 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/ref/UseRefHookRuntimeTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/ref/UseRefHookRuntimeTests.kt @@ -63,9 +63,10 @@ class UseRefHookRuntimeTests { renderWithHookSession(window) window.useBetaBranch = true - val error = assertFailsWith { - renderWithHookSession(window) - } + val error = + assertFailsWith { + renderWithHookSession(window) + } assertTrue(error.message?.contains("Hook signature mismatch") == true) assertTrue(error.message?.contains("inputRef") == true) @@ -94,9 +95,10 @@ class UseRefHookRuntimeTests { window.beginRenderBuild() window.render() - val error = assertFailsWith { - window.endRenderBuild() - } + val error = + assertFailsWith { + window.endRenderBuild() + } assertEquals(error.message?.contains("Storage-backed hook 'useRef'"), true) assertEquals(error.message?.contains("delegated property syntax"), true) @@ -106,17 +108,15 @@ class UseRefHookRuntimeTests { fun `useRef outside active render fails loudly`() { val window = RefProbeWindow() - val error = assertFailsWith { - window.useRef() - } + val error = + assertFailsWith { + window.useRef() + } assertEquals(error.message?.contains("outside active component render"), true) } - private fun renderWithHookSession( - window: DsglWindow, - mode: HookRenderSessionMode = HookRenderSessionMode.Normal - ): DomTree { + private fun renderWithHookSession(window: DsglWindow, mode: HookRenderSessionMode = HookRenderSessionMode.Normal): DomTree { window.beginRenderBuild(mode) return try { window.render() @@ -185,7 +185,11 @@ class UseRefHookRuntimeTests { } } - private data class Alpha(val value: String) + private data class Alpha( + val value: String, + ) - private data class Beta(val value: String) + private data class Beta( + val value: String, + ) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/render/RenderCommandDrawTextTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/render/RenderCommandDrawTextTests.kt index 3892629..9128bb6 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/render/RenderCommandDrawTextTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/render/RenderCommandDrawTextTests.kt @@ -9,34 +9,36 @@ import kotlin.test.assertSame class RenderCommandDrawTextTests { @Test fun `withColor preserves all draw text fields except color`() { - val spans = listOf( - RenderCommand.TextStyleSpan( - start = 0, - end = 4, - color = 0xFF11AA33.toInt(), + val spans = + listOf( + RenderCommand.TextStyleSpan( + start = 0, + end = 4, + color = 0xFF11AA33.toInt(), + bold = true, + italic = true, + underline = true, + strikethrough = false, + obfuscated = true, + ), + ) + val original = + RenderCommand.DrawText( + text = "Demo", + x = 12, + y = 34, + color = 0xFF445566.toInt(), + fontId = "ubuntu", + fontSize = 16, + textFormatting = TextFormatting.Minecraft, bold = true, - italic = true, + italic = false, underline = true, - strikethrough = false, - obfuscated = true + strikethrough = true, + obfuscated = false, + textStyleSpans = spans, + sourceKey = "node.demo", ) - ) - val original = RenderCommand.DrawText( - text = "Demo", - x = 12, - y = 34, - color = 0xFF445566.toInt(), - fontId = "ubuntu", - fontSize = 16, - textFormatting = TextFormatting.Minecraft, - bold = true, - italic = false, - underline = true, - strikethrough = true, - obfuscated = false, - textStyleSpans = spans, - sourceKey = "node.demo" - ) val recolored = original.withColor(0xAA778899.toInt()) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/DomainPortalServicesOwnershipTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/DomainPortalServicesOwnershipTests.kt new file mode 100644 index 0000000..033508f --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/DomainPortalServicesOwnershipTests.kt @@ -0,0 +1,59 @@ +package org.dreamfinity.dsgl.core.select + +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.portal.DomainPortalServices +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DomainPortalServicesOwnershipTests { + @AfterTest + fun cleanup() { + DomainPortalServices.closeAllSelects() + } + + @Test + fun `application-scoped request opens application engine only`() { + val owner = Any() + DomainPortalServices.openSelect(request(owner, ScreenDomainId.Application)) + + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + assertTrue(DomainPortalServices.isSelectOpenFor(owner)) + } + + @Test + fun `system-scoped request opens system engine only`() { + val owner = Any() + DomainPortalServices.openSelect(request(owner, ScreenDomainId.System)) + + assertFalse(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + assertTrue(DomainPortalServices.isSelectOpenFor(owner)) + } + + @Test + fun `opening same owner in another scope switches engine ownership`() { + val owner = Any() + DomainPortalServices.openSelect(request(owner, ScreenDomainId.Application)) + assertTrue(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertFalse(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + + DomainPortalServices.openSelect(request(owner, ScreenDomainId.System)) + assertFalse(DomainPortalServices.applicationSelectEngine.isOpenFor(owner)) + assertTrue(DomainPortalServices.systemSelectEngine.isOpenFor(owner)) + } + + private fun request(owner: Any, scope: ScreenDomainId): SelectOpenRequest = + SelectOpenRequest( + owner = owner, + modelToken = 1L, + entries = listOf(SelectEntry.Option("a", labelProvider = { "Alpha" })), + selectedId = "a", + anchorRect = Rect(10, 10, 100, 20), + closeOnSelect = true, + ownerDomain = scope, + ) +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectEngineTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectEngineTests.kt index 1c735d5..7305ecf 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectEngineTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectEngineTests.kt @@ -8,27 +8,33 @@ import org.dreamfinity.dsgl.core.render.RenderCommand import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.test.assertNotEquals class SelectEngineTests { private class FakeClock( - var now: Long = 0L + var now: Long = 0L, ) : SelectClock { override fun nowMs(): Long = now + fun advance(ms: Long) { now += ms } } - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @Test fun `popup placement clamps and flips above when bottom space is not enough`() { @@ -39,14 +45,15 @@ class SelectEngineTests { SelectStyle( openDurationMs = 1L, closeDurationMs = 1L, - viewportPadding = 6 - ) + viewportPadding = 6, + ), ) - val model = selectModel { - repeat(16) { index -> - option(id = "id.$index", label = "Option $index") + val model = + selectModel { + repeat(16) { index -> + option(id = "id.$index", label = "Option $index") + } } - } val anchor = Rect(180, 96, 72, 18) engine.open( SelectOpenRequest( @@ -55,8 +62,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "id.0", anchorRect = anchor, - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, viewportWidth = 260, viewportHeight = 120, viewportScale = 1f) @@ -74,10 +81,11 @@ class SelectEngineTests { val owner = "select.state" val engine = SelectEngine(clock = clock) engine.setStyle(SelectStyle(openDurationMs = 1L, closeDurationMs = 10L)) - val model = selectModel { - option("a", "A") - option("b", "B") - } + val model = + selectModel { + option("a", "A") + option("b", "B") + } engine.open( SelectOpenRequest( owner = owner, @@ -85,8 +93,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "a", anchorRect = Rect(30, 30, 90, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 320, 180, 1f) clock.advance(2L) @@ -106,8 +114,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "a", anchorRect = Rect(30, 30, 90, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 320, 180, 1f) assertTrue(engine.handleKeyDown(KeyCodes.ESCAPE)) @@ -116,17 +124,62 @@ class SelectEngineTests { assertFalse(engine.isOpen()) } + @Test + fun `opening same owner while closing reverses close transition`() { + val clock = FakeClock() + val owner = "select.reopen" + val engine = SelectEngine(clock = clock) + engine.setStyle(SelectStyle(openDurationMs = 20L, closeDurationMs = 200L)) + val model = + selectModel { + option("a", "A") + option("b", "B") + } + val request = + SelectOpenRequest( + owner = owner, + modelToken = model.token, + entries = model.entries, + selectedId = "a", + anchorRect = Rect(30, 30, 90, 18), + closeOnSelect = true, + ) + + engine.open(request) + clock.advance(25L) + engine.onFrame(ctx, 320, 180, 1f) + val panel = requireNotNull(engine.debugPanelRect(owner)) + val style = engine.currentStyle() + val optionX = panel.x + style.panelPaddingX + style.rowPaddingX + 1 + val optionY = panel.y + style.panelPaddingY + 1 + + assertTrue(engine.handleMouseDown(optionX, optionY, MouseButton.LEFT)) + assertFalse(engine.isClosingFor(owner)) + assertTrue(engine.handleMouseUp(optionX, optionY, MouseButton.LEFT)) + assertTrue(engine.isOpen()) + assertTrue(engine.isClosingFor(owner)) + + engine.open(request) + assertTrue(engine.isOpen()) + assertFalse(engine.isClosingFor(owner)) + clock.advance(25L) + engine.onFrame(ctx, 320, 180, 1f) + + assertTrue(engine.isOpenFor(owner)) + } + @Test fun `keyboard navigation skips disabled options and enter selects`() { val clock = FakeClock() val owner = "select.keyboard" val engine = SelectEngine(clock = clock) engine.setStyle(SelectStyle(openDurationMs = 1L, closeDurationMs = 1L)) - val model = selectModel { - option("a", "Alpha") - option("b", "Beta") { enabled(false) } - option("c", "Charlie") - } + val model = + selectModel { + option("a", "Alpha") + option("b", "Beta") { enabled(false) } + option("c", "Charlie") + } var selected: String? = null engine.open( SelectOpenRequest( @@ -136,8 +189,8 @@ class SelectEngineTests { selectedId = "a", anchorRect = Rect(24, 24, 96, 18), closeOnSelect = true, - onSelect = { selected = it } - ) + onSelect = { selected = it }, + ), ) engine.onFrame(ctx, 320, 180, 1f) @@ -155,12 +208,13 @@ class SelectEngineTests { val owner = "select.typeahead" val engine = SelectEngine(clock = clock) engine.setStyle(SelectStyle(openDurationMs = 1L, closeDurationMs = 1L, typeAheadResetMs = 300L)) - val model = selectModel { - option("apple", "Apple") - option("banana", "Banana") - option("blueberry", "Blueberry") - option("cherry", "Cherry") - } + val model = + selectModel { + option("apple", "Apple") + option("banana", "Banana") + option("blueberry", "Blueberry") + option("cherry", "Cherry") + } var selected: String? = null engine.open( SelectOpenRequest( @@ -170,8 +224,8 @@ class SelectEngineTests { selectedId = null, anchorRect = Rect(10, 10, 90, 18), closeOnSelect = true, - onSelect = { selected = it } - ) + onSelect = { selected = it }, + ), ) engine.onFrame(ctx, 320, 180, 1f) @@ -186,10 +240,11 @@ class SelectEngineTests { val owner = "select.animation" val engine = SelectEngine(clock = clock) engine.setStyle(SelectStyle(openDurationMs = 100L, closeDurationMs = 80L)) - val model = selectModel { - option("one", "One") - option("two", "Two") - } + val model = + selectModel { + option("one", "One") + option("two", "Two") + } engine.open( SelectOpenRequest( owner = owner, @@ -197,8 +252,8 @@ class SelectEngineTests { entries = model.entries, selectedId = null, anchorRect = Rect(20, 20, 96, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 320, 180, 1f) assertTrue(engine.snapshot().animationProgress <= 0.01f) @@ -224,13 +279,14 @@ class SelectEngineTests { } @Test - fun `overlay consumes pointer before base dispatch when select is open`() { - val owner = "select.overlay.order" + fun `portal popup consumes pointer before base dispatch when select is open`() { + val owner = "select.portal.order" val engine = SelectEngine() - val model = selectModel { - option("x", "X") - option("y", "Y") - } + val model = + selectModel { + option("x", "X") + option("y", "Y") + } engine.open( SelectOpenRequest( owner = owner, @@ -238,8 +294,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "x", anchorRect = Rect(26, 26, 88, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 320, 180, 1f) val panel = engine.debugPanelRect(owner) @@ -253,11 +309,12 @@ class SelectEngineTests { val owner = "select.wheel.scroll" val engine = SelectEngine() engine.setStyle(SelectStyle(openDurationMs = 1L, closeDurationMs = 1L, wheelStepRows = 1)) - val model = selectModel { - repeat(40) { index -> - option("id-$index", "Item $index") + val model = + selectModel { + repeat(40) { index -> + option("id-$index", "Item $index") + } } - } engine.open( SelectOpenRequest( owner = owner, @@ -265,8 +322,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "id-0", anchorRect = Rect(20, 20, 90, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 320, 140, 1f) val panel = engine.debugPanelRect(owner) @@ -291,14 +348,15 @@ class SelectEngineTests { closeDurationMs = 1L, maxPanelHeightPadding = 8, scrollbarTrackColor = trackColor, - scrollbarThumbColor = thumbColor - ) + scrollbarThumbColor = thumbColor, + ), ) - val overflowModel = selectModel { - repeat(50) { index -> - option("id-$index", "Option $index") + val overflowModel = + selectModel { + repeat(50) { index -> + option("id-$index", "Option $index") + } } - } overflowEngine.open( SelectOpenRequest( owner = "select.scrollbar.overflow", @@ -306,11 +364,11 @@ class SelectEngineTests { entries = overflowModel.entries, selectedId = "id-0", anchorRect = Rect(18, 16, 100, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) val overflowCommands = mutableListOf() - overflowEngine.appendOverlayCommands(ctx, 240, 120, overflowCommands) + overflowEngine.appendPortalCommands(ctx, 240, 120, overflowCommands) assertTrue(overflowCommands.any { it is RenderCommand.DrawRect && it.color == trackColor }) assertTrue(overflowCommands.any { it is RenderCommand.DrawRect && it.color == thumbColor }) val pushClips = overflowCommands.count { it is RenderCommand.PushClip } @@ -324,14 +382,15 @@ class SelectEngineTests { closeDurationMs = 1L, maxPanelHeightPadding = 8, scrollbarTrackColor = trackColor, - scrollbarThumbColor = thumbColor - ) + scrollbarThumbColor = thumbColor, + ), ) - val noOverflowModel = selectModel { - option("a", "A") - option("b", "B") - option("c", "C") - } + val noOverflowModel = + selectModel { + option("a", "A") + option("b", "B") + option("c", "C") + } noOverflowEngine.open( SelectOpenRequest( owner = "select.scrollbar.fit", @@ -339,11 +398,11 @@ class SelectEngineTests { entries = noOverflowModel.entries, selectedId = "a", anchorRect = Rect(18, 16, 100, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) val noOverflowCommands = mutableListOf() - noOverflowEngine.appendOverlayCommands(ctx, 240, 200, noOverflowCommands) + noOverflowEngine.appendPortalCommands(ctx, 240, 200, noOverflowCommands) assertFalse(noOverflowCommands.any { it is RenderCommand.DrawRect && it.color == trackColor }) assertFalse(noOverflowCommands.any { it is RenderCommand.DrawRect && it.color == thumbColor }) val pushClipsNoOverflow = noOverflowCommands.count { it is RenderCommand.PushClip } @@ -360,14 +419,15 @@ class SelectEngineTests { openDurationMs = 1L, closeDurationMs = 1L, maxPanelHeightPadding = 8, - scrollbarWidth = 8 - ) + scrollbarWidth = 8, + ), ) - val model = selectModel { - repeat(64) { index -> - option("id-$index", "Option $index") + val model = + selectModel { + repeat(64) { index -> + option("id-$index", "Option $index") + } } - } engine.open( SelectOpenRequest( owner = owner, @@ -375,8 +435,8 @@ class SelectEngineTests { entries = model.entries, selectedId = "id-0", anchorRect = Rect(20, 20, 120, 18), - closeOnSelect = true - ) + closeOnSelect = true, + ), ) engine.onFrame(ctx, 300, 140, 1f) val panel = engine.debugPanelRect(owner) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCacheTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCacheTests.kt index 7495e6e..68a8979 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCacheTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectMeasurementCacheTests.kt @@ -7,41 +7,49 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class SelectMeasurementCacheTests { - private val ctx = object : UiMeasureContext { - override fun measureText(text: String): Int = text.length * 7 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 7 - override val fontHeight: Int = 9 - override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override fun measureText(text: String): Int = text.length * 7 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 7 + + override val fontHeight: Int = 9 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } @Test fun `measurement cache reuses computation when inputs unchanged`() { val cache = SelectMeasurementCache() - val model = selectModel { - option("a", "Alpha") - option("b", "Beta") - } + val model = + selectModel { + option("a", "Alpha") + option("b", "Beta") + } val style = SelectStyle() - val first = cache.measure( - modelToken = model.token, - entries = model.entries, - style = style, - ctx = ctx, - dpiScale = 1f, - fontId = null, - fontSize = null - ) - val second = cache.measure( - modelToken = model.token, - entries = model.entries, - style = style, - ctx = ctx, - dpiScale = 1f, - fontId = null, - fontSize = null - ) + val first = + cache.measure( + modelToken = model.token, + entries = model.entries, + style = style, + ctx = ctx, + dpiScale = 1f, + fontId = null, + fontSize = null, + ) + val second = + cache.measure( + modelToken = model.token, + entries = model.entries, + style = style, + ctx = ctx, + dpiScale = 1f, + fontId = null, + fontSize = null, + ) assertEquals(1L, cache.computeCount) assertEquals(first.panelWidth, second.panelWidth) @@ -50,15 +58,17 @@ class SelectMeasurementCacheTests { @Test fun `measurement cache invalidates when style or options change`() { val cache = SelectMeasurementCache() - val base = selectModel { - option("a", "Alpha") - option("b", "Beta") - } - val updated = selectModel { - option("a", "Alpha") - option("b", "Beta-Updated") - option("c", "Charlie") - } + val base = + selectModel { + option("a", "Alpha") + option("b", "Beta") + } + val updated = + selectModel { + option("a", "Alpha") + option("b", "Beta-Updated") + option("c", "Charlie") + } val styleA = SelectStyle(rowPaddingX = 6) val styleB = SelectStyle(rowPaddingX = 10) @@ -69,7 +79,7 @@ class SelectMeasurementCacheTests { ctx = ctx, dpiScale = 1f, fontId = null, - fontSize = null + fontSize = null, ) val afterBase = cache.computeCount cache.measure( @@ -79,7 +89,7 @@ class SelectMeasurementCacheTests { ctx = ctx, dpiScale = 1f, fontId = null, - fontSize = null + fontSize = null, ) val afterStyleChange = cache.computeCount cache.measure( @@ -89,7 +99,7 @@ class SelectMeasurementCacheTests { ctx = ctx, dpiScale = 1f, fontId = null, - fontSize = null + fontSize = null, ) val afterEntriesChange = cache.computeCount @@ -101,23 +111,25 @@ class SelectMeasurementCacheTests { @Test fun `measurement includes group indentation and marker column in panel width`() { val cache = SelectMeasurementCache() - val model = selectModel { - group("Citrus") { - option("orange", "Orange") - option("lemon", "Lemon") + val model = + selectModel { + group("Citrus") { + option("orange", "Orange") + option("lemon", "Lemon") + } } - } val style = SelectStyle(groupIndentX = 14, markerColumnWidth = 12, markerGap = 8) - val measurement = cache.measure( - modelToken = model.token, - entries = model.entries, - style = style, - ctx = ctx, - dpiScale = 1f, - fontId = null, - fontSize = null - ) + val measurement = + cache.measure( + modelToken = model.token, + entries = model.entries, + style = style, + ctx = ctx, + dpiScale = 1f, + fontId = null, + fontSize = null, + ) assertTrue(measurement.panelWidth >= style.minPanelWidth) assertTrue(measurement.panelWidth > 60) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalControllerTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalControllerTests.kt new file mode 100644 index 0000000..795ad8d --- /dev/null +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/select/SelectPortalControllerTests.kt @@ -0,0 +1,283 @@ +package org.dreamfinity.dsgl.core.select + +import org.dreamfinity.dsgl.core.dom.layout.Rect +import org.dreamfinity.dsgl.core.dom.layout.UiMeasureContext +import org.dreamfinity.dsgl.core.event.MouseButton +import org.dreamfinity.dsgl.core.portal.PortalDismissPolicy +import org.dreamfinity.dsgl.core.portal.PortalInputPolicy +import org.dreamfinity.dsgl.core.portal.PortalPointerRegion +import org.dreamfinity.dsgl.core.portal.ScreenDomainId +import org.dreamfinity.dsgl.core.render.RenderCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SelectPortalControllerTests { + private class FakeClock( + var now: Long = 0L, + ) : SelectClock { + override fun nowMs(): Long = now + + fun advance(ms: Long) { + now += ms + } + } + + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 9 + + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * 6 + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = 9 + + override fun paint(commands: List) = Unit + } + + @Test + fun `select portal entry exposes a DOM node with generic portal policies`() { + val fixture = openSelect() + val commands = ArrayList() + + fixture.controller.appendCommands(ctx, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, commands) + + val panel = fixture.engine.debugPanelRect(fixture.owner) + val debug = fixture.controller.debugPortalState(mouseX = 2, mouseY = 2) + assertNotNull(panel) + assertEquals( + panel, + debug.node.bounds, + ) + assertEquals( + PortalInputPolicy.DomOnly, + debug.state.inputPolicy, + ) + assertEquals( + PortalDismissPolicy.EscapeOrOutsidePointerDown, + debug.state.dismissPolicy, + ) + assertTrue(debug.state.active) + assertTrue(commands.isNotEmpty()) + } + + @Test + fun `inside option release is handled through the portal DOM node`() { + val fixture = openSelect() + val panel = requireNotNull(fixture.engine.debugPanelRect(fixture.owner)) + val style = fixture.engine.currentStyle() + val optionX = panel.x + style.panelPaddingX + style.rowPaddingX + 1 + val optionY = panel.y + style.panelPaddingY + 1 + + assertTrue(fixture.controller.handleMouseDown(optionX, optionY, MouseButton.LEFT)) + assertEquals(null, fixture.selected) + assertTrue(fixture.controller.handleMouseUp(optionX, optionY, MouseButton.LEFT)) + + assertEquals("a", fixture.selected) + } + + @Test + fun `option press released outside does not select`() { + val fixture = openSelect() + val panel = requireNotNull(fixture.engine.debugPanelRect(fixture.owner)) + val style = fixture.engine.currentStyle() + val optionX = panel.x + style.panelPaddingX + style.rowPaddingX + 1 + val optionY = panel.y + style.panelPaddingY + 1 + val outsideY = panel.y + panel.height + 8 + + assertTrue(fixture.controller.handleMouseDown(optionX, optionY, MouseButton.LEFT)) + fixture.controller.handleMouseMove(optionX, outsideY) + assertTrue(fixture.controller.handleMouseUp(optionX, outsideY, MouseButton.LEFT)) + + assertEquals(null, fixture.selected) + assertTrue(fixture.engine.isOpen()) + } + + @Test + fun `outside press uses generic portal policy closes and passes through`() { + val fixture = openSelect() + val outsideX = 2 + val outsideY = 2 + + val policy = + fixture.controller + .debugPortalState(outsideX, outsideY) + .outsidePointerPolicy + assertNotNull(policy) + assertEquals(PortalPointerRegion.OutsideEntry, policy.region) + assertTrue(policy.shouldClose) + assertTrue(policy.consumed) + assertFalse(fixture.controller.handleMouseDown(outsideX, outsideY, MouseButton.LEFT)) + + fixture.clock.advance(CLOSE_DURATION_MS + 1) + fixture.controller.onFrame(ctx, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, 1f) + + assertFalse(fixture.engine.isOpen()) + } + + @Test + fun `wheel inside select portal DOM updates scroll offset`() { + val fixture = openSelect(optionCount = 48) + val panel = requireNotNull(fixture.engine.debugPanelRect(fixture.owner)) + val style = fixture.engine.currentStyle() + val wheelX = panel.x + style.panelPaddingX + style.rowPaddingX + 1 + val wheelY = panel.y + style.panelPaddingY + 1 + val before = + fixture.engine + .snapshot() + .scrollOffset + + assertTrue(fixture.controller.handleMouseWheel(wheelX, wheelY, delta = -120)) + + assertTrue( + fixture.engine + .snapshot() + .scrollOffset > before, + ) + } + + @Test + fun `scrollbar drag is handled inside select portal DOM without outside dismiss`() { + val fixture = openSelect(optionCount = 48) + val track = requireNotNull(fixture.engine.debugScrollbarTrackRect(fixture.owner)) + val downX = track.x + track.width / 2 + val downY = track.y + track.height / 2 + val before = + fixture.engine + .snapshot() + .scrollOffset + + val policy = + fixture.controller + .debugPortalState(downX, downY) + .outsidePointerPolicy + assertNotNull(policy) + assertEquals(PortalPointerRegion.InsideEntry, policy.region) + assertFalse(policy.shouldClose) + assertTrue(policy.consumed) + assertTrue(fixture.controller.handleMouseDown(downX, downY, MouseButton.LEFT)) + assertTrue(fixture.engine.isOpen()) + + assertTrue(fixture.controller.handleMouseMove(downX, downY + 24)) + assertTrue( + fixture.engine + .snapshot() + .scrollOffset > before, + ) + assertTrue(fixture.controller.handleMouseUp(downX, downY + 24, MouseButton.LEFT)) + assertTrue(fixture.engine.isOpen()) + } + + @Test + fun `scrollbar drag remains captured when pointer leaves select portal bounds`() { + val fixture = openSelect(optionCount = 48) + val panel = requireNotNull(fixture.engine.debugPanelRect(fixture.owner)) + val track = requireNotNull(fixture.engine.debugScrollbarTrackRect(fixture.owner)) + val downX = track.x + track.width / 2 + val downY = track.y + 2 + val outsideY = panel.y + panel.height + 24 + val before = + fixture.engine + .snapshot() + .scrollOffset + + assertTrue(fixture.controller.handleMouseDown(downX, downY, MouseButton.LEFT)) + assertTrue(fixture.engine.isOpen()) + assertTrue(fixture.engine.isScrollbarDragging()) + + assertTrue(fixture.controller.handleMouseMove(downX, outsideY)) + assertTrue( + fixture.engine + .snapshot() + .scrollOffset > before, + ) + assertTrue(fixture.engine.isOpen()) + + assertTrue(fixture.controller.handleMouseUp(downX, outsideY, MouseButton.LEFT)) + assertTrue(fixture.engine.isOpen()) + assertFalse(fixture.engine.isScrollbarDragging()) + } + + @Test + fun `deactivating while scrollbar drag is captured does not recurse`() { + val fixture = openSelect(optionCount = 48) + val track = requireNotNull(fixture.engine.debugScrollbarTrackRect(fixture.owner)) + val downX = track.x + track.width / 2 + val downY = track.y + track.height / 2 + + assertTrue(fixture.controller.handleMouseDown(downX, downY, MouseButton.LEFT)) + assertTrue(fixture.engine.isScrollbarDragging()) + + fixture.engine.closeAll() + fixture.controller.onFrame(ctx, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, 1f) + + assertFalse(fixture.engine.isOpen()) + assertFalse( + fixture.controller + .debugPortalState(downX, downY) + .state + .active, + ) + } + + private fun openSelect(optionCount: Int = 2): SelectPortalFixture { + val clock = FakeClock() + val engine = SelectEngine(clock = clock) + engine.setStyle( + SelectStyle( + openDurationMs = OPEN_DURATION_MS, + closeDurationMs = CLOSE_DURATION_MS, + maxPanelHeightPadding = 10, + ), + ) + val controller = + SelectPortalController( + engine = engine, + ownerDomain = ScreenDomainId.Application, + entryId = "test.select", + ) + val model = + selectModel { + repeat(optionCount) { index -> + option(('a'.code + index).toChar().toString(), "Option $index") + } + } + var selected: String? = null + val owner = "select.portal.test" + engine.open( + SelectOpenRequest( + owner = owner, + modelToken = model.token, + entries = model.entries, + selectedId = null, + anchorRect = Rect(30, 30, 90, 18), + closeOnSelect = true, + onSelect = { selected = it }, + ), + ) + clock.advance(OPEN_DURATION_MS + 1) + controller.onFrame(ctx, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, 1f) + return SelectPortalFixture(clock, engine, controller, owner) { selected } + } + + private data class SelectPortalFixture( + val clock: FakeClock, + val engine: SelectEngine, + val controller: SelectPortalController, + val owner: Any, + val selectedProvider: () -> String?, + ) { + val selected: String? + get() = selectedProvider() + } + + private companion object { + const val VIEWPORT_WIDTH = 320 + const val VIEWPORT_HEIGHT = 180 + const val OPEN_DURATION_MS = 1L + const val CLOSE_DURATION_MS = 1L + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/CssLengthTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/CssLengthTests.kt index 7cd058f..8365161 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/CssLengthTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/CssLengthTests.kt @@ -19,17 +19,19 @@ class CssLengthTests { @Test fun `unitless zero is accepted but non-zero is rejected`() { assertEquals(CssLength.ZERO_PX, parseCssLength("0")) - val error = assertFailsWith { - parseCssLength("12") - } + val error = + assertFailsWith { + parseCssLength("12") + } assertTrue(error.message?.contains("Expected explicit unit") == true) } @Test fun `unknown units are rejected`() { - val error = assertFailsWith { - parseCssLength("1q") - } + val error = + assertFailsWith { + parseCssLength("1q") + } assertTrue(error.message?.contains("Unknown length unit") == true) } @@ -42,10 +44,11 @@ class CssLengthTests { @Test fun `parses spacing shorthand with mixed units`() { - val insets = parseSpacingLengthShorthand( - raw = "1em 8px 2vh 5%", - allowNegative = false - ) + val insets = + parseSpacingLengthShorthand( + raw = "1em 8px 2vh 5%", + allowNegative = false, + ) assertEquals(CssUnit.Em, insets.top.unit) assertEquals(CssUnit.Px, insets.right.unit) @@ -55,19 +58,22 @@ class CssLengthTests { @Test fun `resolves percent against horizontal and vertical axes`() { - val context = LengthResolveContext( - viewportWidthPx = 400f, - viewportHeightPx = 200f, - containingBlockWidthPx = 240f, - containingBlockHeightPx = 80f, - currentFontSizePx = 10f, - inheritedFontSizePx = 12f - ) - - val horizontal = CssLength(50f, CssUnit.Percent) - .resolvePx(context, LengthPercentBase.ContainerWidth) - val vertical = CssLength(50f, CssUnit.Percent) - .resolvePx(context, LengthPercentBase.ContainerHeight) + val context = + LengthResolveContext( + viewportWidthPx = 400f, + viewportHeightPx = 200f, + containingBlockWidthPx = 240f, + containingBlockHeightPx = 80f, + currentFontSizePx = 10f, + inheritedFontSizePx = 12f, + ) + + val horizontal = + CssLength(50f, CssUnit.Percent) + .resolvePx(context, LengthPercentBase.ContainerWidth) + val vertical = + CssLength(50f, CssUnit.Percent) + .resolvePx(context, LengthPercentBase.ContainerHeight) assertEquals(120f, horizontal) assertEquals(40f, vertical) @@ -75,10 +81,11 @@ class CssLengthTests { @Test fun `resolves vw and vh against viewport`() { - val context = LengthResolveContext( - viewportWidthPx = 320f, - viewportHeightPx = 180f - ) + val context = + LengthResolveContext( + viewportWidthPx = 320f, + viewportHeightPx = 180f, + ) val vw = CssLength(10f, CssUnit.Vw).resolvePx(context, LengthPercentBase.ContainerWidth) val vh = CssLength(20f, CssUnit.Vh).resolvePx(context, LengthPercentBase.ContainerHeight) @@ -89,22 +96,26 @@ class CssLengthTests { @Test fun `em uses current font size and inherited base for font-size percent`() { - val context = LengthResolveContext( - viewportWidthPx = 0f, - viewportHeightPx = 0f, - containingBlockWidthPx = 0f, - containingBlockHeightPx = 0f, - rootFontSizePx = 24f, - currentFontSizePx = 12f, - inheritedFontSizePx = 20f - ) - - val rem = CssLength(0.5f, CssUnit.Rem) - .resolvePx(context, LengthPercentBase.ContainerWidth) - val paddingEm = CssLength(1.5f, CssUnit.Em) - .resolvePx(context, LengthPercentBase.ContainerWidth) - val fontPercent = CssLength(125f, CssUnit.Percent) - .resolvePx(context, LengthPercentBase.InheritedFontSize) + val context = + LengthResolveContext( + viewportWidthPx = 0f, + viewportHeightPx = 0f, + containingBlockWidthPx = 0f, + containingBlockHeightPx = 0f, + rootFontSizePx = 24f, + currentFontSizePx = 12f, + inheritedFontSizePx = 20f, + ) + + val rem = + CssLength(0.5f, CssUnit.Rem) + .resolvePx(context, LengthPercentBase.ContainerWidth) + val paddingEm = + CssLength(1.5f, CssUnit.Em) + .resolvePx(context, LengthPercentBase.ContainerWidth) + val fontPercent = + CssLength(125f, CssUnit.Percent) + .resolvePx(context, LengthPercentBase.InheritedFontSize) assertEquals(12f, rem) assertEquals(18f, paddingEm) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/DssParserTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/DssParserTests.kt index e5236bd..38d30ec 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/DssParserTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/DssParserTests.kt @@ -6,16 +6,17 @@ import kotlin.test.* class DssParserTests { @Test fun parsesSelectorsAndDeclarations() { - val data = DssParser.parse( - """ - :root { --primary: #3E6B9E; --fg: #FFFFFF; } - button { padding: 6px 10px; background-color: #222222; } - .accent { border-width: 1px; } - button.primary:hover { color: #FFF; } - #dangerAction:disabled { background-color: #A34343; } - """.trimIndent(), - "selectors.dss" - ) + val data = + DssParser.parse( + """ + :root { --primary: #3E6B9E; --fg: #FFFFFF; } + button { padding: 6px 10px; background-color: #222222; } + .accent { border-width: 1px; } + button.primary:hover { color: #FFF; } + #dangerAction:disabled { background-color: #A34343; } + """.trimIndent(), + "selectors.dss", + ) assertEquals(4, data.rules.size, "Expected 4 non-root rules.") assertEquals("#3E6B9E", data.rootVariables["--primary"]) @@ -44,29 +45,35 @@ class DssParserTests { @Test fun parsesVariableReferences() { - val data = DssParser.parse( - """ - :root { --primary: #3E6B9E; } - button.primary { background-color: var(--primary); } - """.trimIndent(), - "vars.dss" - ) + val data = + DssParser.parse( + """ + :root { --primary: #3E6B9E; } + button.primary { background-color: var(--primary); } + """.trimIndent(), + "vars.dss", + ) - val expr = data.rules.single().declarations.get(StyleProperty.BACKGROUND_COLOR) + val expr = + data.rules + .single() + .declarations + .get(StyleProperty.BACKGROUND_COLOR) val variableRef = assertIs(expr) assertEquals("--primary", variableRef.name) } @Test fun assignsStableSourceOrder() { - val data = DssParser.parse( - """ - button { width: 10px; } - .accent { width: 11px; } - #idOne { width: 12px; } - """.trimIndent(), - "order.dss" - ) + val data = + DssParser.parse( + """ + button { width: 10px; } + .accent { width: 11px; } + #idOne { width: 12px; } + """.trimIndent(), + "order.dss", + ) assertEquals(0, data.rules[0].sourceOrder) assertEquals(1, data.rules[1].sourceOrder) @@ -75,45 +82,52 @@ class DssParserTests { @Test fun ignoresBlockComments() { - val data = DssParser.parse( - """ - /* comment before rules */ - .accent { width: 10px; } /* inline comment */ - """.trimIndent(), - "comments.dss" - ) + val data = + DssParser.parse( + """ + /* comment before rules */ + .accent { width: 10px; } /* inline comment */ + """.trimIndent(), + "comments.dss", + ) assertEquals(1, data.rules.size) - assertEquals("accent", data.rules[0].selector.className) + assertEquals( + "accent", + data.rules[0] + .selector.className, + ) } @Test fun rejectsVariableOutsideRoot() { - val error = assertFailsWith { - DssParser.parse( - """ - .card { - --primary: #123456; - } - """.trimIndent(), - "bad-vars.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .card { + --primary: #123456; + } + """.trimIndent(), + "bad-vars.dss", + ) + } assertTrue(error.message?.contains("only supported inside :root") == true) } @Test fun rejectsUnsupportedPropertyWithLocation() { - val error = assertFailsWith { - DssParser.parse( - """ - .card { - unsupportedProp: 10; - } - """.trimIndent(), - "bad-prop.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .card { + unsupportedProp: 10; + } + """.trimIndent(), + "bad-prop.dss", + ) + } assertEquals("bad-prop.dss", error.path) assertTrue(error.line >= 2) assertTrue(error.column >= 1) @@ -121,16 +135,17 @@ class DssParserTests { @Test fun rejectsInvalidSpacingShorthand() { - val error = assertFailsWith { - DssParser.parse( - """ - button { - padding: 1px 2px 3px 4px 5px; - } - """.trimIndent(), - "bad-spacing.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + button { + padding: 1px 2px 3px 4px 5px; + } + """.trimIndent(), + "bad-spacing.dss", + ) + } assertTrue(error.message?.contains("supports 1, 2, 3, or 4 values") == true) } @@ -142,7 +157,11 @@ class DssParserTests { file.writeText("button { height: 20px; }") val data = DssParser.parse(file) assertEquals(1, data.rules.size) - assertEquals("button", data.rules[0].selector.typeName) + assertEquals( + "button", + data.rules[0] + .selector.typeName, + ) assertEquals(file.path, data.source) } finally { tempDir.deleteRecursively() @@ -151,24 +170,25 @@ class DssParserTests { @Test fun parsesDisplayFlexAndGridProperties() { - val data = DssParser.parse( - """ - .layout { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - gap: 6px; - text-wrap: nowrap; - } - .grid { - display: grid; - grid-columns: 4; - grid-column-span: 2; - } - """.trimIndent(), - "display.dss" - ) + val data = + DssParser.parse( + """ + .layout { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 6px; + text-wrap: nowrap; + } + .grid { + display: grid; + grid-columns: 4; + grid-column-span: 2; + } + """.trimIndent(), + "display.dss", + ) val layoutRule = data.rules.first { it.selector.className == "layout" } assertIs(layoutRule.declarations.get(StyleProperty.DISPLAY)) @@ -186,61 +206,106 @@ class DssParserTests { @Test fun parsesCombinatorsAndSpecificity() { - val data = DssParser.parse( - """ - .panel .item { color: #FFFFFF; } - .panel > .item { color: #EEEEEE; } - .item + .item { color: #DDDDDD; } - .item ~ .item { color: #CCCCCC; } - #root.toolbar .btn:hover { color: #DDDDDD; } - """.trimIndent(), - "combinators.dss" - ) + val data = + DssParser.parse( + """ + .panel .item { color: #FFFFFF; } + .panel > .item { color: #EEEEEE; } + .item + .item { color: #DDDDDD; } + .item ~ .item { color: #CCCCCC; } + #root.toolbar .btn:hover { color: #DDDDDD; } + """.trimIndent(), + "combinators.dss", + ) assertEquals(5, data.rules.size) - assertTrue(data.rules[0].selector.hasCombinators) - assertEquals(StyleCombinator.Descendant, data.rules[0].selector.steps[1].combinatorToLeft) - assertEquals(StyleCombinator.Child, data.rules[1].selector.steps[1].combinatorToLeft) - assertEquals(StyleCombinator.AdjacentSibling, data.rules[2].selector.steps[1].combinatorToLeft) - assertEquals(StyleCombinator.GeneralSibling, data.rules[3].selector.steps[1].combinatorToLeft) - assertEquals(1, data.rules[4].selector.specificity.idCount) - assertEquals(3, data.rules[4].selector.specificity.classLikeCount) + assertTrue( + data.rules[0] + .selector.hasCombinators, + ) + assertEquals( + StyleCombinator.Descendant, + data.rules[0] + .selector.steps[1] + .combinatorToLeft, + ) + assertEquals( + StyleCombinator.Child, + data.rules[1] + .selector.steps[1] + .combinatorToLeft, + ) + assertEquals( + StyleCombinator.AdjacentSibling, + data.rules[2] + .selector.steps[1] + .combinatorToLeft, + ) + assertEquals( + StyleCombinator.GeneralSibling, + data.rules[3] + .selector.steps[1] + .combinatorToLeft, + ) + assertEquals( + 1, + data.rules[4] + .selector.specificity.idCount, + ) + assertEquals( + 3, + data.rules[4] + .selector.specificity.classLikeCount, + ) } @Test fun `parses universal selector`() { - val data = DssParser.parse( - """ - * { padding: 2px; } - """.trimIndent(), - "universal.dss" - ) + val data = + DssParser.parse( + """ + * { padding: 2px; } + """.trimIndent(), + "universal.dss", + ) assertEquals(1, data.rules.size) - assertTrue(data.rules[0].selector.steps.single().part.universal) + assertTrue( + data.rules[0] + .selector.steps + .single() + .part.universal, + ) } @Test fun `parses open pseudo state`() { - val data = DssParser.parse( - """ - select:open { border-color: #7CB6FF; } - """.trimIndent(), - "open-pseudo.dss" - ) + val data = + DssParser.parse( + """ + select:open { border-color: #7CB6FF; } + """.trimIndent(), + "open-pseudo.dss", + ) assertEquals(1, data.rules.size) - assertEquals(StylePseudoState.OPEN, data.rules.single().selector.pseudoState) + assertEquals( + StylePseudoState.OPEN, + data.rules + .single() + .selector.pseudoState, + ) } @Test fun parsesImportantDeclarationFlag() { - val data = DssParser.parse( - """ - .btn { color: #111111 !important; } - """.trimIndent(), - "important.dss" - ) + val data = + DssParser.parse( + """ + .btn { color: #111111 !important; } + """.trimIndent(), + "important.dss", + ) val rule = data.rules.single() assertTrue(rule.declarations.isImportant(StyleProperty.FOREGROUND_COLOR)) @@ -248,96 +313,123 @@ class DssParserTests { @Test fun `foreground-color alias is accepted and warns once`() { - val data = DssParser.parse( - """ - .card { foreground-color: #ff0000; } - .other { foreground-color: #00ff00; } - """.trimIndent(), - "alias.dss" - ) + val data = + DssParser.parse( + """ + .card { foreground-color: #ff0000; } + .other { foreground-color: #00ff00; } + """.trimIndent(), + "alias.dss", + ) assertEquals(2, data.rules.size) - assertIs(data.rules[0].declarations.get(StyleProperty.FOREGROUND_COLOR)) + assertIs( + data.rules[0] + .declarations + .get(StyleProperty.FOREGROUND_COLOR), + ) assertEquals(1, data.warnings.size) - assertTrue(data.warnings.single().contains("foreground-color")) + assertTrue( + data.warnings + .single() + .contains("foreground-color"), + ) } @Test fun `root block supports variables and root selector declarations`() { - val data = DssParser.parse( - """ - :root { --base: #222222; color: #abcdef; } - """.trimIndent(), - "root-rule.dss" - ) + val data = + DssParser.parse( + """ + :root { --base: #222222; color: #abcdef; } + """.trimIndent(), + "root-rule.dss", + ) assertEquals("#222222", data.rootVariables["--base"]) assertEquals(1, data.rules.size) - assertEquals("dsgl-root", data.rules.single().selector.typeName) + assertEquals( + "dsgl-root", + data.rules + .single() + .selector.typeName, + ) } - @Test fun `parses z-index as unitless integer`() { - val data = DssParser.parse( - """ - .panel { z-index: 5; } - """.trimIndent(), - "z-index.dss" - ) + val data = + DssParser.parse( + """ + .panel { z-index: 5; } + """.trimIndent(), + "z-index.dss", + ) - val expression = data.rules.single().declarations.get(StyleProperty.Z_INDEX) + val expression = + data.rules + .single() + .declarations + .get(StyleProperty.Z_INDEX) val literal = assertIs(expression) assertEquals("5", literal.value) } @Test fun `rejects unitized z-index literal`() { - val error = assertFailsWith { - DssParser.parse( - """ - .panel { z-index: 5px; } - """.trimIndent(), - "z-index-bad.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .panel { z-index: 5px; } + """.trimIndent(), + "z-index-bad.dss", + ) + } assertTrue(error.message?.contains("Expected number") == true) } @Test fun `parses position enum values`() { - val data = DssParser.parse( - """ - .a { position: static; } - .b { position: relative; } - .c { position: absolute; } - .d { position: fixed; } - .e { position: sticky; } - """.trimIndent(), - "position.dss" - ) + val data = + DssParser.parse( + """ + .a { position: static; } + .b { position: relative; } + .c { position: absolute; } + .d { position: fixed; } + .e { position: sticky; } + """.trimIndent(), + "position.dss", + ) assertEquals(5, data.rules.size) - val values = data.rules.mapNotNull { rule -> - (rule.declarations.get(StyleProperty.POSITION) as? StyleExpression.Literal)?.value - } + val values = + data.rules.mapNotNull { rule -> + (rule.declarations.get(StyleProperty.POSITION) as? StyleExpression.Literal)?.value + } assertEquals(listOf("static", "relative", "absolute", "fixed", "sticky"), values) } + @Test fun `parses offset properties through length-like grammar`() { - val data = DssParser.parse( - """ - .panel { - left: 10px; - top: 1.5em; - right: auto; - bottom: 0; - } - """.trimIndent(), - "offsets.dss" - ) + val data = + DssParser.parse( + """ + .panel { + left: 10px; + top: 1.5em; + right: auto; + bottom: 0; + } + """.trimIndent(), + "offsets.dss", + ) - val declarations = data.rules.single().declarations + val declarations = + data.rules + .single() + .declarations assertEquals("10px", (declarations.get(StyleProperty.LEFT) as? StyleExpression.Literal)?.value) assertEquals("1.5em", (declarations.get(StyleProperty.TOP) as? StyleExpression.Literal)?.value) assertEquals("auto", (declarations.get(StyleProperty.RIGHT) as? StyleExpression.Literal)?.value) @@ -346,57 +438,62 @@ class DssParserTests { @Test fun `rejects invalid offset literal`() { - val error = assertFailsWith { - DssParser.parse( - """ - .panel { left: nope; } - """.trimIndent(), - "offsets-bad.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .panel { left: nope; } + """.trimIndent(), + "offsets-bad.dss", + ) + } assertTrue(error.message?.contains("Expected CSS length") == true) } + @Test fun `unitless non-zero length literal is rejected`() { - val error = assertFailsWith { - DssParser.parse( - """ - .panel { margin: 12; padding: 4px; } - """.trimIndent(), - "unitless-length.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .panel { margin: 12; padding: 4px; } + """.trimIndent(), + "unitless-length.dss", + ) + } assertTrue(error.message?.contains("Expected explicit unit") == true) } @Test fun `parses line-height grammar values`() { - val data = DssParser.parse( - """ - .text-a { line-height: normal; } - .text-b { line-height: 24px; } - .text-c { line-height: 1.5em; } - """.trimIndent(), - "line-height.dss" - ) + val data = + DssParser.parse( + """ + .text-a { line-height: normal; } + .text-b { line-height: 24px; } + .text-c { line-height: 1.5em; } + """.trimIndent(), + "line-height.dss", + ) - val values = data.rules.mapNotNull { rule -> - (rule.declarations.get(StyleProperty.LINE_HEIGHT) as? StyleExpression.Literal)?.value - } + val values = + data.rules.mapNotNull { rule -> + (rule.declarations.get(StyleProperty.LINE_HEIGHT) as? StyleExpression.Literal)?.value + } assertEquals(listOf("normal", "24px", "1.5em"), values) } @Test fun `rejects invalid line-height literal`() { - val error = assertFailsWith { - DssParser.parse( - """ - .text { line-height: nope; } - """.trimIndent(), - "line-height-bad.dss" - ) - } + val error = + assertFailsWith { + DssParser.parse( + """ + .text { line-height: nope; } + """.trimIndent(), + "line-height-bad.dss", + ) + } assertTrue(error.message?.contains("Expected CSS length") == true) - }} - - + } +} diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/LineHeightStyleContractTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/LineHeightStyleContractTests.kt index 0a36d95..22d5005 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/LineHeightStyleContractTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/LineHeightStyleContractTests.kt @@ -27,9 +27,10 @@ class LineHeightStyleContractTests { @Test fun `line-height reaches computed style from inline declarations`() { val node = ContainerNode(key = "line-height-node") - node.inlineStyleDeclarations = StyleDeclarations().apply { - set(StyleProperty.LINE_HEIGHT, StyleExpression.Literal("22px")) - } + node.inlineStyleDeclarations = + StyleDeclarations().apply { + set(StyleProperty.LINE_HEIGHT, StyleExpression.Literal("22px")) + } StyleEngine.clearCache() StyleEngine.applyStylesRecursively(node) @@ -43,9 +44,10 @@ class LineHeightStyleContractTests { @Test fun `line-height inherits through computed style`() { val root = ContainerNode(key = "root") - root.inlineStyleDeclarations = StyleDeclarations().apply { - set(StyleProperty.LINE_HEIGHT, StyleExpression.Literal("24px")) - } + root.inlineStyleDeclarations = + StyleDeclarations().apply { + set(StyleProperty.LINE_HEIGHT, StyleExpression.Literal("24px")) + } val child = TextNode(TextSource.Static("child"), key = "child").applyParent(root) StyleEngine.clearCache() diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/OverflowSizeStyleContractTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/OverflowSizeStyleContractTests.kt index 7b810c4..592a607 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/OverflowSizeStyleContractTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/OverflowSizeStyleContractTests.kt @@ -37,15 +37,16 @@ class OverflowSizeStyleContractTests { @Test fun `overflow axis properties override shorthand-derived values`() { - val target = styledTarget( - """ - .target { - overflow: hidden auto; - overflow-x: visible; - overflow-y: scroll; - } - """.trimIndent() - ) + val target = + styledTarget( + """ + .target { + overflow: hidden auto; + overflow-x: visible; + overflow-y: scroll; + } + """.trimIndent(), + ) val style = target.appliedComputedStyleSnapshot() ?: error("Missing computed style") assertEquals(Overflow.Visible, style.overflowX) @@ -54,16 +55,17 @@ class OverflowSizeStyleContractTests { @Test fun `min and max size properties parse and persist in computed style`() { - val target = styledTarget( - """ - .target { - min-width: 120px; - min-height: 10vh; - max-width: 75%; - max-height: auto; - } - """.trimIndent() - ) + val target = + styledTarget( + """ + .target { + min-width: 120px; + min-height: 10vh; + max-width: 75%; + max-height: auto; + } + """.trimIndent(), + ) val style = target.appliedComputedStyleSnapshot() ?: error("Missing computed style") assertEquals(CssLength(120f, CssUnit.Px), style.minWidth) @@ -115,4 +117,3 @@ class OverflowSizeStyleContractTests { assertEquals(expectedY, actual.overflowY) } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/PositionedLayoutStyleContractTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/PositionedLayoutStyleContractTests.kt index 103ce17..e7e8497 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/PositionedLayoutStyleContractTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/PositionedLayoutStyleContractTests.kt @@ -1,7 +1,7 @@ package org.dreamfinity.dsgl.core.style -import org.dreamfinity.dsgl.core.dsl.StyleScope import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dsl.StyleScope import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -38,23 +38,25 @@ class PositionedLayoutStyleContractTests { @Test fun `position z-index and offsets reach computed style`() { val node = ContainerNode(key = "positioned-style-node") - val expectations = listOf( - "static" to PositionMode.Static, - "relative" to PositionMode.Relative, - "absolute" to PositionMode.Absolute, - "fixed" to PositionMode.Fixed, - "sticky" to PositionMode.Sticky - ) + val expectations = + listOf( + "static" to PositionMode.Static, + "relative" to PositionMode.Relative, + "absolute" to PositionMode.Absolute, + "fixed" to PositionMode.Fixed, + "sticky" to PositionMode.Sticky, + ) expectations.forEach { (literal, expectedPosition) -> - node.inlineStyleDeclarations = StyleDeclarations().apply { - set(StyleProperty.POSITION, StyleExpression.Literal(literal)) - set(StyleProperty.LEFT, StyleExpression.Literal("10px")) - set(StyleProperty.TOP, StyleExpression.Literal("2em")) - set(StyleProperty.RIGHT, StyleExpression.Literal("auto")) - set(StyleProperty.BOTTOM, StyleExpression.Literal("0")) - set(StyleProperty.Z_INDEX, StyleExpression.Literal("13")) - } + node.inlineStyleDeclarations = + StyleDeclarations().apply { + set(StyleProperty.POSITION, StyleExpression.Literal(literal)) + set(StyleProperty.LEFT, StyleExpression.Literal("10px")) + set(StyleProperty.TOP, StyleExpression.Literal("2em")) + set(StyleProperty.RIGHT, StyleExpression.Literal("auto")) + set(StyleProperty.BOTTOM, StyleExpression.Literal("0")) + set(StyleProperty.Z_INDEX, StyleExpression.Literal("13")) + } StyleEngine.clearCache() StyleEngine.applyStylesRecursively(node) val computed = node.appliedComputedStyleSnapshot() @@ -79,4 +81,3 @@ class PositionedLayoutStyleContractTests { assertEquals("sticky", literal) } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleCascadeCombinatorTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleCascadeCombinatorTests.kt index a6c5f0b..26d6c8d 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleCascadeCombinatorTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleCascadeCombinatorTests.kt @@ -25,7 +25,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .panel .item { color: #00AA00; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -48,7 +48,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .panel > .item { color: #3355AA; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -72,7 +72,7 @@ class StyleCascadeCombinatorTests { text { color: #111111; } .btn { color: #222222; } #target { color: #333333; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -90,7 +90,7 @@ class StyleCascadeCombinatorTests { """ .btn { color: #111111; } .btn { color: #222222; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -107,7 +107,7 @@ class StyleCascadeCombinatorTests { """ .btn { color: #111111 !important; } .btn { color: #222222; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -123,7 +123,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .parent { color: #ABCDEF; font-size: 14px; padding: 7px; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -142,7 +142,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .panel:hover .item { color: #00AAFF; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -163,7 +163,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .a + .b { color: #FF22AA; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -189,7 +189,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ .a ~ .b { color: #22AAFF; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -215,7 +215,7 @@ class StyleCascadeCombinatorTests { """ .a + .b .c { color: #5566FF; } .a ~ .b > .c { font-size: 14px; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -238,7 +238,7 @@ class StyleCascadeCombinatorTests { """ .a ~ .b { color: #2255AA; } .b { color: #992222 !important; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -257,7 +257,7 @@ class StyleCascadeCombinatorTests { """ .alias { foreground-color: #113355; } .canonical { color: #113355; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -276,7 +276,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ * { align: end; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -294,7 +294,7 @@ class StyleCascadeCombinatorTests { installStylesheet( """ :root { align: end; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleDeclarationsHashTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleDeclarationsHashTests.kt index 3a3c2e2..c91cdc2 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleDeclarationsHashTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleDeclarationsHashTests.kt @@ -7,26 +7,29 @@ import kotlin.test.assertNotEquals class StyleDeclarationsHashTests { @Test fun `stable hash is independent from insertion order`() { - val first = StyleDeclarations().apply { - set(StyleProperty.WIDTH, StyleExpression.Literal("120")) - set(StyleProperty.DISPLAY, StyleExpression.Literal("flex")) - set(StyleProperty.GAP, StyleExpression.Literal("4")) - } - val second = StyleDeclarations().apply { - set(StyleProperty.GAP, StyleExpression.Literal("4")) - set(StyleProperty.WIDTH, StyleExpression.Literal("120")) - set(StyleProperty.DISPLAY, StyleExpression.Literal("flex")) - } + val first = + StyleDeclarations().apply { + set(StyleProperty.WIDTH, StyleExpression.Literal("120")) + set(StyleProperty.DISPLAY, StyleExpression.Literal("flex")) + set(StyleProperty.GAP, StyleExpression.Literal("4")) + } + val second = + StyleDeclarations().apply { + set(StyleProperty.GAP, StyleExpression.Literal("4")) + set(StyleProperty.WIDTH, StyleExpression.Literal("120")) + set(StyleProperty.DISPLAY, StyleExpression.Literal("flex")) + } assertEquals(first.toStableHash(), second.toStableHash()) } @Test fun `stable hash changes when declaration value changes`() { - val declarations = StyleDeclarations().apply { - set(StyleProperty.WIDTH, StyleExpression.Literal("120")) - set(StyleProperty.DISPLAY, StyleExpression.Literal("block")) - } + val declarations = + StyleDeclarations().apply { + set(StyleProperty.WIDTH, StyleExpression.Literal("120")) + set(StyleProperty.DISPLAY, StyleExpression.Literal("block")) + } val before = declarations.toStableHash() declarations.set(StyleProperty.DISPLAY, StyleExpression.Literal("flex")) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineIncrementalTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineIncrementalTests.kt index 544dcbd..2c02b7f 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineIncrementalTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineIncrementalTests.kt @@ -66,7 +66,7 @@ class StyleEngineIncrementalTests { installStylesheet( """ .a ~ .b { color: #33AA66; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -92,7 +92,7 @@ class StyleEngineIncrementalTests { installStylesheet( """ .target { color: #2288FF; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -113,7 +113,7 @@ class StyleEngineIncrementalTests { installStylesheet( """ .node { color: #FFFFFF; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -132,6 +132,31 @@ class StyleEngineIncrementalTests { assertEquals(0, report.recomputedNodes) } + @Test + fun `changed node state invalidates style cache entry`() { + installStylesheet( + """ + .marked { color: #AA3344; } + """.trimIndent(), + ) + + val root = ContainerNode(key = "root") + val leaf = TextNode(TextSource.Static("leaf"), key = "leaf").applyParent(root) + StyleEngine.applyStylesRecursivelyDetailed(root) + + val unchanged = StyleEngine.applyStylesRecursivelyDetailed(root) + assertEquals(0, unchanged.recomputedNodes, "Unchanged nodes must be served from the style cache.") + + leaf.setClassNames("marked") + val afterClassChange = StyleEngine.applyStylesRecursivelyDetailed(root) + assertTrue(afterClassChange.recomputedNodes >= 1, "Class change must invalidate the cached style.") + assertEquals(0xFFAA3344.toInt(), leaf.color) + + leaf.setHoveredState(true) + val afterHoverChange = StyleEngine.applyStylesRecursivelyDetailed(root) + assertTrue(afterHoverChange.recomputedNodes >= 1, "Pseudo-state change must invalidate the cached style.") + } + private fun installStylesheet(contents: String) { val dir = Files.createTempDirectory("dsgl-style-incremental-test").toFile() dir.resolve("test.dss").writeText(contents) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineInspectionTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineInspectionTests.kt index 5f1ef7c..c8ab5f7 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineInspectionTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleEngineInspectionTests.kt @@ -1,15 +1,15 @@ package org.dreamfinity.dsgl.core.style -import java.io.File -import java.nio.file.Files -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals import org.dreamfinity.dsgl.core.DsglColors import org.dreamfinity.dsgl.core.dom.applyParent import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dom.elements.TextNode import org.dreamfinity.dsgl.core.dom.elements.TextSource +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals class StyleEngineInspectionTests { @AfterTest @@ -21,12 +21,13 @@ class StyleEngineInspectionTests { @Test fun `inspect reports selector and inline provenance`() { - val stylesDir = createTempStylesDir( - """ - text { color: #112233; } - #sample { color: #445566; } - """.trimIndent() - ) + val stylesDir = + createTempStylesDir( + """ + text { color: #112233; } + #sample { color: #445566; } + """.trimIndent(), + ) StyleEngine.setStylesDirectory(stylesDir) StyleEngine.forceReloadStylesheets() @@ -40,7 +41,7 @@ class StyleEngineInspectionTests { node.inlineStyleDeclarations.set( StyleProperty.FOREGROUND_COLOR, - StyleExpression.Literal("#ABCDEF") + StyleExpression.Literal("#ABCDEF"), ) val inlineInspection = StyleEngine.inspect(node) val inlineSource = inlineInspection.propertySources[StyleProperty.FOREGROUND_COLOR] @@ -50,12 +51,13 @@ class StyleEngineInspectionTests { @Test fun `inspector override has highest precedence`() { - val stylesDir = createTempStylesDir( - """ - text { color: #111111; } - #sample { color: #222222; } - """.trimIndent() - ) + val stylesDir = + createTempStylesDir( + """ + text { color: #111111; } + #sample { color: #222222; } + """.trimIndent(), + ) StyleEngine.setStylesDirectory(stylesDir) StyleEngine.forceReloadStylesheets() @@ -63,7 +65,7 @@ class StyleEngineInspectionTests { node.styleId = "sample" node.inlineStyleDeclarations.set( StyleProperty.FOREGROUND_COLOR, - StyleExpression.Literal("#333333") + StyleExpression.Literal("#333333"), ) StyleEngine.setInspectorOverrideLiteral("sample", StyleProperty.FOREGROUND_COLOR, "#444444").getOrThrow() diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleLengthUnitsIntegrationTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleLengthUnitsIntegrationTests.kt index 602cbff..fac536f 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleLengthUnitsIntegrationTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleLengthUnitsIntegrationTests.kt @@ -11,13 +11,18 @@ import kotlin.test.Test import kotlin.test.assertEquals class StyleLengthUnitsIntegrationTests { - private val ctx = object : UiMeasureContext { - override val fontHeight: Int = 10 - override fun measureText(text: String): Int = text.length * 6 - override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * ((fontSize ?: 10) / 2).coerceAtLeast(1) - override fun fontHeight(fontId: String?, fontSize: Int?): Int = fontSize ?: 10 - override fun paint(commands: List) = Unit - } + private val ctx = + object : UiMeasureContext { + override val fontHeight: Int = 10 + + override fun measureText(text: String): Int = text.length * 6 + + override fun measureText(text: String, fontId: String?, fontSize: Int?): Int = text.length * ((fontSize ?: 10) / 2).coerceAtLeast(1) + + override fun fontHeight(fontId: String?, fontSize: Int?): Int = fontSize ?: 10 + + override fun paint(commands: List) = Unit + } @AfterTest fun cleanup() { @@ -32,7 +37,7 @@ class StyleLengthUnitsIntegrationTests { """ .parent { font-size: 20px; } .child { font-size: 1.2em; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -53,7 +58,7 @@ class StyleLengthUnitsIntegrationTests { """ .parent { font-size: 20px; } .child { font-size: 10px; padding: 1.5em; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -79,7 +84,7 @@ class StyleLengthUnitsIntegrationTests { height: 25%; margin: 10% 5%; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -105,7 +110,7 @@ class StyleLengthUnitsIntegrationTests { height: 20vh; padding: 1vh 1vw; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") @@ -130,7 +135,7 @@ class StyleLengthUnitsIntegrationTests { font-size: 0.5rem; padding: 1rem; } - """.trimIndent() + """.trimIndent(), ) val root = ContainerNode(key = "root") diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistryTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistryTests.kt index adf1ac4..b23abfc 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistryTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StylePropertyRegistryTests.kt @@ -7,7 +7,10 @@ import kotlin.test.assertTrue class StylePropertyRegistryTests { @Test fun `registry defines descriptor for every style property`() { - val registered = StylePropertyRegistry.all.map { it.property }.toSet() + val registered = + StylePropertyRegistry.all + .map { it.property } + .toSet() assertEquals(StyleProperty.entries.toSet(), registered) assertEquals(StyleProperty.entries.size, StylePropertyRegistry.all.size) } @@ -51,11 +54,11 @@ class StylePropertyRegistryTests { assertEquals( LineHeightValue.Normal, - StylePropertyRegistry.parseLineHeightLiteral(StyleProperty.LINE_HEIGHT, "normal") + StylePropertyRegistry.parseLineHeightLiteral(StyleProperty.LINE_HEIGHT, "normal"), ) assertEquals( LineHeightValue.Length(CssLength(24f, CssUnit.Px)), - StylePropertyRegistry.parseLineHeightLiteral(StyleProperty.LINE_HEIGHT, "24px") + StylePropertyRegistry.parseLineHeightLiteral(StyleProperty.LINE_HEIGHT, "24px"), ) } @@ -65,7 +68,7 @@ class StylePropertyRegistryTests { StyleProperty.LEFT, StyleProperty.TOP, StyleProperty.RIGHT, - StyleProperty.BOTTOM + StyleProperty.BOTTOM, ).forEach { property -> val descriptor = StylePropertyRegistry.descriptor(property) assertEquals(StyleValueGrammarKind.LengthLike, descriptor.grammarKind) @@ -76,7 +79,10 @@ class StylePropertyRegistryTests { @Test fun `registry includes text style editable properties`() { - val properties = StylePropertyRegistry.all.map { it.property }.toSet() + val properties = + StylePropertyRegistry.all + .map { it.property } + .toSet() assertTrue(StyleProperty.FOREGROUND_COLOR in properties) assertTrue(StyleProperty.FONT_SIZE in properties) assertTrue(StyleProperty.LINE_HEIGHT in properties) diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleScopeComplexDslContractTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleScopeComplexDslContractTests.kt index 8734e15..8226bfd 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleScopeComplexDslContractTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleScopeComplexDslContractTests.kt @@ -1,8 +1,8 @@ package org.dreamfinity.dsgl.core.style +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import org.dreamfinity.dsgl.core.dsl.StyleBorder import org.dreamfinity.dsgl.core.dsl.StyleScope -import org.dreamfinity.dsgl.core.dom.elements.ContainerNode import kotlin.test.Test import kotlin.test.assertEquals @@ -165,11 +165,7 @@ class StyleScopeComplexDslContractTests { assertEquals(null, bottomValue) } - private fun assertLiteral( - node: ContainerNode, - property: StyleProperty, - expected: String - ) { + private fun assertLiteral(node: ContainerNode, property: StyleProperty, expected: String) { val value = (node.inlineStyleDeclarations.get(property) as? StyleExpression.Literal)?.value assertEquals(expected, value) } diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsingTransformTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsingTransformTests.kt index fb655b0..3a8b334 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsingTransformTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/style/StyleValueParsingTransformTests.kt @@ -37,4 +37,3 @@ class StyleValueParsingTransformTests { } } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParserTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParserTests.kt index a765260..7aec3ae 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParserTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/MinecraftFormattingParserTests.kt @@ -1,22 +1,23 @@ package org.dreamfinity.dsgl.core.text -import org.dreamfinity.dsgl.core.font.MsdfFontMetaParser import org.dreamfinity.dsgl.core.dom.elements.support.TextLayoutEngine +import org.dreamfinity.dsgl.core.font.MsdfFontMetaParser import org.dreamfinity.dsgl.core.style.TextFormatting import org.dreamfinity.dsgl.core.style.TextWrap import kotlin.test.Test -import kotlin.test.assertFalse import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue class MinecraftFormattingParserTests { @Test fun `parses legacy colors and reset into plain text with spans`() { - val parsed = MinecraftFormattingParser.parse( - text = "\u00A7aHi \u00A7bWorld\u00A7r!", - mode = TextFormatting.Minecraft - ) + val parsed = + MinecraftFormattingParser.parse( + text = "\u00A7aHi \u00A7bWorld\u00A7r!", + mode = TextFormatting.Minecraft, + ) assertEquals("Hi World!", parsed.plainText) val spans = MinecraftFormattingParser.resolveColorSpans(parsed, 0xFF336699.toInt()) @@ -67,10 +68,11 @@ class MinecraftFormattingParserTests { @Test fun `modern hex sequence is parsed when complete`() { - val parsed = MinecraftFormattingParser.parse( - text = "\u00A7x\u00A7F\u00A7F\u00A70\u00A70\u00A70\u00A70R", - mode = TextFormatting.Minecraft - ) + val parsed = + MinecraftFormattingParser.parse( + text = "\u00A7x\u00A7F\u00A7F\u00A70\u00A70\u00A70\u00A70R", + mode = TextFormatting.Minecraft, + ) assertEquals("R", parsed.plainText) val spans = MinecraftFormattingParser.resolveColorSpans(parsed, 0xAA000000.toInt()) assertEquals(1, spans.size) @@ -79,24 +81,27 @@ class MinecraftFormattingParserTests { @Test fun `format flags toggle and reset to base style`() { - val parsed = MinecraftFormattingParser.parse( - text = "\u00A7lA\u00A7oB\u00A7nC\u00A7mD\u00A7kE\u00A7rF", - mode = TextFormatting.Minecraft - ) + val parsed = + MinecraftFormattingParser.parse( + text = "\u00A7lA\u00A7oB\u00A7nC\u00A7mD\u00A7kE\u00A7rF", + mode = TextFormatting.Minecraft, + ) assertEquals("ABCDEF", parsed.plainText) - val baseFlags = TextStyleFlags( - bold = false, - italic = true, - underline = false, - strikethrough = false, - obfuscated = false - ) - val spans = MinecraftFormattingParser.resolveStyleSpans( - parsed = parsed, - baseColor = 0xFF445566.toInt(), - baseFlags = baseFlags - ) + val baseFlags = + TextStyleFlags( + bold = false, + italic = true, + underline = false, + strikethrough = false, + obfuscated = false, + ) + val spans = + MinecraftFormattingParser.resolveStyleSpans( + parsed = parsed, + baseColor = 0xFF445566.toInt(), + baseFlags = baseFlags, + ) assertTrue(spans.isNotEmpty()) assertEquals(true, spans[0].flags.bold) assertEquals(true, spans[1].flags.bold) @@ -104,8 +109,20 @@ class MinecraftFormattingParserTests { assertEquals(true, spans[2].flags.underline) assertEquals(true, spans[3].flags.strikethrough) assertEquals(true, spans[4].flags.obfuscated) - assertEquals(true, spans.last().flags.italic, "§r should reset to base italic=true") - assertEquals(false, spans.last().flags.bold, "§r should reset bold to base bold=false") + assertEquals( + true, + spans + .last() + .flags.italic, + "§r should reset to base italic=true", + ) + assertEquals( + false, + spans + .last() + .flags.bold, + "§r should reset bold to base bold=false", + ) } @Test @@ -117,64 +134,70 @@ class MinecraftFormattingParserTests { assertEquals("ABC", plain) val baseWidth = meta.measureTextWidth(plain, 12) - val extra = TextStyleMetrics.boldExtraPxForRange( - plainText = plain, - spans = parsed.spans, - baseFlags = TextStyleFlags.NONE - ) + val extra = + TextStyleMetrics.boldExtraPxForRange( + plainText = plain, + spans = parsed.spans, + baseFlags = TextStyleFlags.NONE, + ) assertEquals(BOLD_ADVANCE_EXTRA_PX, extra) assertEquals(baseWidth + BOLD_ADVANCE_EXTRA_PX, baseWidth + extra) } @Test fun `obfuscation selector is deterministic and time-varying`() { - val fixedA = ObfuscationTextSelector.selectCandidateIndex( - sourceKey = "node.key", - lineIndex = 1, - glyphIndexInLine = 4, - timeSlice = 10L, - originalCodepoint = 'A'.code, - candidateCount = 16 - ) - val fixedB = ObfuscationTextSelector.selectCandidateIndex( - sourceKey = "node.key", - lineIndex = 1, - glyphIndexInLine = 4, - timeSlice = 10L, - originalCodepoint = 'A'.code, - candidateCount = 16 - ) - val changed = ObfuscationTextSelector.selectCandidateIndex( - sourceKey = "node.key", - lineIndex = 1, - glyphIndexInLine = 4, - timeSlice = 11L, - originalCodepoint = 'A'.code, - candidateCount = 16 - ) - assertEquals(fixedA, fixedB) - assertTrue(fixedA in 0 until 16) - assertTrue(changed in 0 until 16) - val rowA = (0 until 16).map { index -> + val fixedA = ObfuscationTextSelector.selectCandidateIndex( sourceKey = "node.key", - lineIndex = 0, - glyphIndexInLine = index, - timeSlice = 25L, - originalCodepoint = 'X'.code, - candidateCount = 32 + lineIndex = 1, + glyphIndexInLine = 4, + timeSlice = 10L, + originalCodepoint = 'A'.code, + candidateCount = 16, ) - } - val rowB = (0 until 16).map { index -> + val fixedB = ObfuscationTextSelector.selectCandidateIndex( sourceKey = "node.key", - lineIndex = 0, - glyphIndexInLine = index, - timeSlice = 26L, - originalCodepoint = 'X'.code, - candidateCount = 32 + lineIndex = 1, + glyphIndexInLine = 4, + timeSlice = 10L, + originalCodepoint = 'A'.code, + candidateCount = 16, ) - } + val changed = + ObfuscationTextSelector.selectCandidateIndex( + sourceKey = "node.key", + lineIndex = 1, + glyphIndexInLine = 4, + timeSlice = 11L, + originalCodepoint = 'A'.code, + candidateCount = 16, + ) + assertEquals(fixedA, fixedB) + assertTrue(fixedA in 0 until 16) + assertTrue(changed in 0 until 16) + val rowA = + (0 until 16).map { index -> + ObfuscationTextSelector.selectCandidateIndex( + sourceKey = "node.key", + lineIndex = 0, + glyphIndexInLine = index, + timeSlice = 25L, + originalCodepoint = 'X'.code, + candidateCount = 32, + ) + } + val rowB = + (0 until 16).map { index -> + ObfuscationTextSelector.selectCandidateIndex( + sourceKey = "node.key", + lineIndex = 0, + glyphIndexInLine = index, + timeSlice = 26L, + originalCodepoint = 'X'.code, + candidateCount = 32, + ) + } assertTrue(rowA.toSet().size >= 8, "Obfuscated row should produce varied symbols") val changedSlots = rowA.indices.count { rowA[it] != rowB[it] } assertTrue(changedSlots >= 8, "Obfuscated symbols should refresh across time slices") @@ -186,28 +209,31 @@ class MinecraftFormattingParserTests { fun `decoration spans can be split per wrapped line`() { val rawMeta = loadResource("fonts/minecraft/MinecraftDefault-Regular-meta.json") val meta = MsdfFontMetaParser.parse(rawMeta) - val parsed = MinecraftFormattingParser.parse( - text = "\u00A7nUnderline decoration wraps across multiple lines in a narrow panel", - mode = TextFormatting.Minecraft - ) + val parsed = + MinecraftFormattingParser.parse( + text = "\u00A7nUnderline decoration wraps across multiple lines in a narrow panel", + mode = TextFormatting.Minecraft, + ) val fontSize = 12 val lineHeight = meta.lineHeightPx(fontSize) - val layout = TextLayoutEngine.layout( - text = parsed.plainText, - maxWidth = 72, - wrap = TextWrap.Wrap, - fontHeight = lineHeight, - measureText = { value -> meta.measureTextWidth(value, fontSize) } - ) + val layout = + TextLayoutEngine.layout( + text = parsed.plainText, + maxWidth = 72, + wrap = TextWrap.Wrap, + fontHeight = lineHeight, + measureText = { value -> meta.measureTextWidth(value, fontSize) }, + ) assertTrue(layout.lines.size >= 2) layout.lines.forEach { line -> - val lineSpans = MinecraftFormattingParser.resolveStyleSpans( - parsed = parsed, - baseColor = 0xFFFFFFFF.toInt(), - baseFlags = TextStyleFlags.NONE, - rangeStart = line.startIndex, - rangeEnd = line.endIndexExclusive - ) + val lineSpans = + MinecraftFormattingParser.resolveStyleSpans( + parsed = parsed, + baseColor = 0xFFFFFFFF.toInt(), + baseFlags = TextStyleFlags.NONE, + rangeStart = line.startIndex, + rangeEnd = line.endIndexExclusive, + ) assertTrue(lineSpans.all { it.start >= 0 && it.end <= line.text.length }) if (line.text.isNotEmpty()) { assertTrue(lineSpans.any { it.flags.underline }) @@ -221,4 +247,3 @@ class MinecraftFormattingParserTests { return stream.bufferedReader(Charsets.UTF_8).use { it.readText() } } } - diff --git a/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayoutTests.kt b/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayoutTests.kt index 48cd5a5..5f5e0b8 100644 --- a/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayoutTests.kt +++ b/core/src/test/kotlin/org/dreamfinity/dsgl/core/text/TextDecorationLayoutTests.kt @@ -9,16 +9,18 @@ class TextDecorationLayoutTests { fun `converts y-up font metrics to y-down screen coordinates`() { val fontPx = 16 val scalePx = TextDecorationLayout.scalePx(fontPx = fontPx, emSize = 1f) - val baseline = TextDecorationLayout.baselineY( - lineTopY = 10f, - ascenderEm = 0.932f, - scalePx = scalePx - ) - val underlineY = TextDecorationLayout.screenYFromYUpMetric( - baselineY = baseline, - metricYUpEm = -0.162f, - scalePx = scalePx - ) + val baseline = + TextDecorationLayout.baselineY( + lineTopY = 10f, + ascenderEm = 0.932f, + scalePx = scalePx, + ) + val underlineY = + TextDecorationLayout.screenYFromYUpMetric( + baselineY = baseline, + metricYUpEm = -0.162f, + scalePx = scalePx, + ) assertEquals(24.912f, baseline, 0.0001f) assertTrue(underlineY > baseline) @@ -26,32 +28,36 @@ class TextDecorationLayoutTests { @Test fun `uses fallback thickness and position for tiny underline metrics`() { - val line = TextVisualLine( - lineIndex = 0, - lineTopY = 0f, - baselineY = TextDecorationLayout.baselineY( + val line = + TextVisualLine( + lineIndex = 0, lineTopY = 0f, + baselineY = + TextDecorationLayout.baselineY( + lineTopY = 0f, + ascenderEm = 0.694f, + scalePx = TextDecorationLayout.scalePx(fontPx = 14, emSize = 1f), + ), + lineHeightPx = 11f, + glyphStartIndex = 0, + glyphEndIndexExclusive = 1, + ) + val metrics = + DecorationFontMetrics( + emSize = 1f, + lineHeightEm = 0.746f, ascenderEm = 0.694f, - scalePx = TextDecorationLayout.scalePx(fontPx = 14, emSize = 1f) - ), - lineHeightPx = 11f, - glyphStartIndex = 0, - glyphEndIndexExclusive = 1 - ) - val metrics = DecorationFontMetrics( - emSize = 1f, - lineHeightEm = 0.746f, - ascenderEm = 0.694f, - descenderEm = 0f, - underlineYEm = -0.0015f, - underlineThicknessEm = 0.0015f - ) + descenderEm = 0f, + underlineYEm = -0.0015f, + underlineThicknessEm = 0.0015f, + ) - val resolved = TextDecorationLayout.resolveLineMetrics( - line = line, - fontMetrics = metrics, - fontPx = 14 - ) + val resolved = + TextDecorationLayout.resolveLineMetrics( + line = line, + fontMetrics = metrics, + fontPx = 14, + ) assertTrue(resolved.underlineThickness >= 1f) assertTrue(resolved.underlineY > line.baselineY) @@ -59,45 +65,50 @@ class TextDecorationLayoutTests { @Test fun `splits decoration segments per visual line`() { - val metrics = DecorationFontMetrics( - emSize = 1f, - lineHeightEm = 1f, - ascenderEm = 0.8f, - descenderEm = -0.2f, - underlineYEm = -0.1f, - underlineThicknessEm = 0.05f - ) - val lines = listOf( - TextVisualLine( - lineIndex = 0, - lineTopY = 0f, - baselineY = 12f, - lineHeightPx = 16f, - glyphStartIndex = 0, - glyphEndIndexExclusive = 2 - ), - TextVisualLine( - lineIndex = 1, - lineTopY = 16f, - baselineY = 28f, - lineHeightPx = 16f, - glyphStartIndex = 2, - glyphEndIndexExclusive = 4 + val metrics = + DecorationFontMetrics( + emSize = 1f, + lineHeightEm = 1f, + ascenderEm = 0.8f, + descenderEm = -0.2f, + underlineYEm = -0.1f, + underlineThicknessEm = 0.05f, + ) + val lines = + listOf( + TextVisualLine( + lineIndex = 0, + lineTopY = 0f, + baselineY = 12f, + lineHeightPx = 16f, + glyphStartIndex = 0, + glyphEndIndexExclusive = 2, + ), + TextVisualLine( + lineIndex = 1, + lineTopY = 16f, + baselineY = 28f, + lineHeightPx = 16f, + glyphStartIndex = 2, + glyphEndIndexExclusive = 4, + ), + ) + val glyphs = + listOf( + GlyphDecorationSample(0, 0, 0f, 8f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), + GlyphDecorationSample(0, 1, 8f, 16f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), + GlyphDecorationSample(1, 2, 0f, 7f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), + GlyphDecorationSample(1, 3, 7f, 14f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), ) - ) - val glyphs = listOf( - GlyphDecorationSample(0, 0, 0f, 8f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), - GlyphDecorationSample(0, 1, 8f, 16f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), - GlyphDecorationSample(1, 2, 0f, 7f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false), - GlyphDecorationSample(1, 3, 7f, 14f, 0xFFFFFFFF.toInt(), underline = true, strikethrough = false) - ) - val quads = TextDecorationLayout.buildDecorationQuads( - lines = lines, - glyphs = glyphs, - fontMetrics = metrics, - fontPx = 16 - ).filter { it.type == DecorationType.Underline } + val quads = + TextDecorationLayout + .buildDecorationQuads( + lines = lines, + glyphs = glyphs, + fontMetrics = metrics, + fontPx = 16, + ).filter { it.type == DecorationType.Underline } assertEquals(2, quads.size) assertEquals(0, quads[0].lineIndex) @@ -106,38 +117,42 @@ class TextDecorationLayoutTests { @Test fun `splits segments when style color changes mid-line`() { - val metrics = DecorationFontMetrics( - emSize = 1f, - lineHeightEm = 1f, - ascenderEm = 0.8f, - descenderEm = -0.2f, - underlineYEm = -0.1f, - underlineThicknessEm = 0.05f - ) - val lines = listOf( - TextVisualLine( - lineIndex = 0, - lineTopY = 0f, - baselineY = 12f, - lineHeightPx = 16f, - glyphStartIndex = 0, - glyphEndIndexExclusive = 3 + val metrics = + DecorationFontMetrics( + emSize = 1f, + lineHeightEm = 1f, + ascenderEm = 0.8f, + descenderEm = -0.2f, + underlineYEm = -0.1f, + underlineThicknessEm = 0.05f, + ) + val lines = + listOf( + TextVisualLine( + lineIndex = 0, + lineTopY = 0f, + baselineY = 12f, + lineHeightPx = 16f, + glyphStartIndex = 0, + glyphEndIndexExclusive = 3, + ), ) - ) val colorA = 0xFFFF0000.toInt() val colorB = 0xFF00FF00.toInt() - val glyphs = listOf( - GlyphDecorationSample(0, 0, 0f, 6f, colorA, underline = true, strikethrough = false), - GlyphDecorationSample(0, 1, 6f, 12f, colorB, underline = true, strikethrough = false), - GlyphDecorationSample(0, 2, 12f, 18f, colorB, underline = true, strikethrough = true) - ) + val glyphs = + listOf( + GlyphDecorationSample(0, 0, 0f, 6f, colorA, underline = true, strikethrough = false), + GlyphDecorationSample(0, 1, 6f, 12f, colorB, underline = true, strikethrough = false), + GlyphDecorationSample(0, 2, 12f, 18f, colorB, underline = true, strikethrough = true), + ) - val quads = TextDecorationLayout.buildDecorationQuads( - lines = lines, - glyphs = glyphs, - fontMetrics = metrics, - fontPx = 16 - ) + val quads = + TextDecorationLayout.buildDecorationQuads( + lines = lines, + glyphs = glyphs, + fontMetrics = metrics, + fontPx = 16, + ) val underline = quads.filter { it.type == DecorationType.Underline } val strike = quads.filter { it.type == DecorationType.Strikethrough } @@ -147,4 +162,3 @@ class TextDecorationLayoutTests { assertEquals(1, strike.size) } } - diff --git a/docs/architecture.md b/docs/architecture.md index 1056311..1c58948 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,8 +51,9 @@ require it. Style scope is explicit: -- application/root/app-overlay trees use application scope -- system overlay uses system scope +- Application root and Application portal trees use application scope +- System root and System portal trees use system scope +- Debug root and Debug portal trees use debug scope ## Layout and paint responsibilities @@ -85,19 +86,165 @@ This is the basis for DSGL effect semantics (commit-bound effects, discard-safe When hot-reload mode is active, hook signature mismatches can request subtree remount/reset instead of crashing the entire rebuild. -## Overlay model +## Screen domain model -DSGL composes layers, not separate rendering universes: +DSGL screens are composed from domain surfaces. The authoritative surface contract is `ScreenDomainSurfaces`. -- application root -- application overlay -- system overlay -- debug layer +The screen domains are: -Paint order and input priority are explicit in `OverlayLayerContracts`. +- Application +- System +- Debug -You usually treat overlays as host-managed runtime surfaces. Public helper APIs (modal/select/context-menu/color picker, -etc.) sit on top of this model; low-level overlay internals are not a stable app extension API. +Every domain has the same pair of surfaces: + +- root +- portal + +Empty roots and empty portals are valid. System root and Debug portal do not require special mechanics just because some +screens currently have no content there. The current Debug pane is mounted as Debug root DOM, and Debug has a real portal +host even when no Debug portal entries are active. + +## Domain surface ordering contract + +`ScreenDomainSurfaces` defines the render and input ordering: + +- paint order: `Application root -> Application portal -> System root -> System portal -> Debug root -> Debug portal` +- input priority: `Debug portal -> Debug root -> System portal -> System root -> Application portal -> Application root` + +Any future refactor must preserve these orders unless a deliberate architecture change is explicitly approved. + +## Domain surface ownership contract + +### `DsglScreenHost` owns + +- adapter/backend lifecycle integration +- frame orchestration across domain surfaces +- rebuild/layout/paint guard rails and fallback behaviour +- top-level domain-surface composition and contract-ordered input routing +- global cleanup across host-managed portal services on close + +`DsglScreenHost` is the current screen-level orchestrator. + +### Domain surface hosts own + +- a specific `ScreenDomainSurface` +- viewport-local lifecycle for that surface +- render/paint/input forwarding for that surface +- cleanup of state mounted through that surface + +Screen/runtime ownership classes now use domain/portal terminology, and their runtime contract is domain-surface-first +through `DomainSurfaceHost`. + +### Portal hosts own + +- portal entry registration +- active entry paint/input order +- entry lifecycle cleanup +- validation that entries are mounted into their owning domain portal surface +- generic portal-entry policy metadata and evaluation for dismiss, backdrop consumption, focus intent, lifecycle, + placement, and protected inside regions + +Portal hosts are physical mount points for floating UI in their owning domain. They are not separate domains or hidden +widget runtimes. + +Portal entry policies are generic. Component-specific behavior remains in the mounted portal DOM subtree or in temporary +helper engines during migration. Outside-pointer policy is evaluated topmost-first against active portal entries. Inside +entry interaction is determined from the entry DOM, entry bounds, or explicit protected bounds; it must not depend on +widget type. Once a portal DOM tree is selected for input, events bubble inside that physical portal tree only, not into +the owning domain root DOM or another domain. + +## Shared domain mechanics + +The following mechanics should converge into common expectations across all domains: + +- explicit per-frame phases (`input frame prep -> sync -> render -> paint -> clear refs`) +- explicit surface input enable/disable gating +- predictable DOM and policy-based input routing for eligible surface content +- screen/domain-aware keyboard targeting: key-down and key-up target the one focused node only when that node belongs to + the selected physical domain surface tree +- shared DOM-node pointer capture bookkeeping for root and portal DOM dispatch (`PointerCaptureSession`): captured + nodes continue receiving move/release/cancel until capture ends, and keyed captures can restore across + retained-DOM reconciliation +- stable portal-entry ownership (`id`, active/open state, placement/drag session ownership where applicable) +- explicit viewport/bounds/coordinate handling with no hidden fallback geometry assumptions + +## Domain-specific ownership + +- Application root and Application portal share application styling semantics. +- System root and System portal are isolated from application styling. +- Debug root and Debug portal are isolated from application styling. +- Domain-specific behaviour stays in its owning domain or portal service. + +## Intentional distinctions vs accidental gaps + +Intentional distinctions: + +- split between Application, System, and Debug ownership +- split between each domain root and portal surface +- scope-driven ownership for transient portal routing +- explicit Debug domain ownership for debug-only diagnostics and controls + +Remaining explicit limits: + +- `DsglScreenHost` still owns top-level pointer, hover, and input routing between domain surfaces. DOM-node pointer + capture bookkeeping is shared between root and portal DOM dispatch. Application-owned DnD drag ghosts now paint through + an Application portal entry while preserving the existing DnD session model. +- implementation names that describe screen/runtime ownership now use domain/portal terminology. +- system Inspector/color-picker entries still use system-specific manual dispatch plus DOM fallback where text editing + and panel dragging require it. +- modal focus/session state is still modal-specific through `ModalPortalSessionStore`, but modal focus requests are + scoped to the mounted modal portal root where available. + +## Non-regression invariants for domain refactors + +- preserve domain-surface ordering for both paint and input +- preserve ownership separation between Application, System, and Debug domains +- preserve application/system/debug scope separation and style isolation boundaries +- preserve inspector drag behaviour and pointer-capture semantics +- preserve transient portal ownership as owner-token/session based, not cursor-derived ownership +- preserve anti-click-through behaviour: once a higher-priority surface consumes input, lower surfaces do not receive it +- preserve pointer-sequence ownership: when a higher-priority surface consumes pointer-down, the matching pointer-up + remains owned by that higher surface sequence and must not synthesize an Application root click if the portal state + changes before release +- preserve central pointer-capture cleanup: captured DOM nodes receive move/release/cancel until capture ends, consumed + pointer events do not arm lower-root DnD, and active modal portals must not leak Application-root DnD ghosts +- preserve DnD ghost portal ownership: Application-owned drag ghosts paint through Application portal entries without + adding another root/portal/widget capture bookkeeping path + +Public helper APIs such as modal/select/context-menu/color picker sit on top of this model; low-level domain/portal +internals are not a stable app extension API. + +Keyboard events follow DOM-like targeting. A screen still has one active focused element. The focused element belongs to +a domain through its mounted DOM tree, and key-down/key-up dispatch selects a domain surface before posting the event to +that focused node. Bubbling then stays inside that node's physical DOM tree. Screen/global shortcuts such as debug +toggles and style reloads remain explicit screen-host dispatcher behavior. + +Select popup ownership is scope-aware: application-owned selects route through the application portal service, and +system-owned selects route through the system portal service. The select popup is mounted as a portal entry with a real +portal DOM node. Pointer and wheel input for the popup routes through that portal DOM tree first; outside pointer +dismissal uses generic portal-entry policy evaluation before lower-priority surfaces can receive the same pointer +sequence. `SelectEngine` remains a temporary helper for select state, measurement, placement, animation, keyboard +navigation, and paint primitives rather than an independent screen-level runtime owner. + +Modal presentation is mounted as application portal DOM while `modalPortal` keeps regular content in the application root. +Modal pointer-down containment, backdrop dismissal, consume-only static backdrop behavior, and anti-click-through use +generic portal-entry policies. The screen coordinator also preserves higher-surface pointer sequence ownership so a +portal-consumed backdrop press cannot release into an Application root click after modal state changes. +`ModalPortalSessionStore` owns the modal-specific stack/focus/trap/restore session state as a bounded implementation +detail, with focus restore/trap requests scoped to the modal portal DOM root where available. + +Inspector and system color picker UI are system portal entries. They keep system style isolation and may still use +system-domain manual dispatch plus DOM fallback internally where native Inspector/color-picker DOM editing requires it. + +DnD drag visuals are floating UI. Application-owned drag ghosts are represented as Application portal entries and are +painted as part of the Application portal surface, after Application root content and before later higher-domain +surfaces. The DnD engine still owns drag session state, smoothing, source hiding, drop target tracking, and preview +command production. Portal ownership only decides where the visual ghost is mounted and staged (`ApplicationDndGhostPortalController` +in `ApplicationPortalHost.kt` implements this entry). While an Application +modal portal is active, lower Application-root DnD ghost output remains suppressed so modal-owned pointer sequences do +not leak stale root ghosts. System/Debug-owned drag visuals are reserved for the owning domain portal when such drag +sources exist. ## Where to inspect next @@ -106,3 +253,5 @@ etc.) sit on top of this model; low-level overlay internals are not a stable app - Style runtime: `core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StyleEngine.kt` - Stylesheet loading: `core/src/main/kotlin/org/dreamfinity/dsgl/core/style/StylesheetManager.kt` - Hook lifecycle runtime: `core/src/main/kotlin/org/dreamfinity/dsgl/core/hooks/ComponentHookRuntime.kt` +- Portal input capture: `core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/input/PointerCaptureSession.kt` +- Application portal host + DnD ghost controller: `core/src/main/kotlin/org/dreamfinity/dsgl/core/portal/ApplicationPortalHost.kt` diff --git a/docs/cookbook.md b/docs/cookbook.md index c1f2039..ad01a37 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,11 +1,11 @@ # Cookbook A set of practical DSGL patterns that are already used in this repository (mostly in `adapters/mc-forge-1-7-10/demo`). -It is not a generic UI cookbook; every recipe here maps to existing runtime / demo behaviour. +It is not a generic UI cookbook; every recipe here maps to existing portal/runtime and demo behaviour. ## Recipe 1: State-driven modal stack -Use a window/component state list of `ModalSpec`, and render it through `modalHost`. +Use a window/component state list of `ModalSpec`, and render it through `modalPortal`. Topmost modal is the last item in the list. ```kotlin { .kotlin .copy .select } @@ -29,7 +29,7 @@ private fun UiScope.modalStackRecipe() { modals = modals.filterNot { it.key == key } } - modalHost(modals = modals, modalKey = "recipe.modal.host") { + modalPortal(modals = modals, key = "recipe.modal.portal") { button("Open modal", { onMouseClick = { modals = modals + ModalSpec( @@ -63,7 +63,7 @@ Why this pattern: ## Recipe 2: Attach a context menu to a node Use `DOMNode.onContextMenu { ... }` and build a model with `contextMenu { ... }`. -The current runtime opens it from the right mouse down and consumes that event path. +The application portal service opens it from the right mouse down and consumes that event path. ```kotlin { .kotlin .copy .select } import org.dreamfinity.dsgl.core.DsglWindow diff --git a/docs/custom-components.md b/docs/custom-components.md index c99de95..bcac8c0 100644 --- a/docs/custom-components.md +++ b/docs/custom-components.md @@ -151,7 +151,7 @@ Avoid building app components around internal plumbing: - `UiScope.mount(...)` (internal) - `DsglWindow.hookRuntime()` and `ComponentHookRuntime.withComponentInstance(...)` (internal runtime plumbing) -- overlay/system internals and devtool internals (`overlay.system.*`, inspector internals, `HotReloadBridge`) +- portal/system internals and devtool internals (`core.portal.system.*`, inspector internals, `HotReloadBridge`) These exist for framework/runtime internals, not as stable public extension contracts. @@ -162,6 +162,6 @@ These exist for framework/runtime internals, not as stable public extension cont - Add slot lambdas for extensibility. - Keep the hook state local only when it is truly local. - Use stable keys for interactive or reorderable component instances. -- Treat internal runtime/overlay APIs as non-public unless docs explicitly promote them. +- Treat internal runtime/portal APIs as non-public unless docs explicitly promote them. Next: [Hot-reload setup](hot-reload-agent.md). diff --git a/docs/elements-overview.md b/docs/elements-overview.md index 7cb9bf3..f6e2a56 100644 --- a/docs/elements-overview.md +++ b/docs/elements-overview.md @@ -75,15 +75,17 @@ div({ } ``` -### `overlay(...)` +### `div({ overlapChildren = true })` -Container for overlapping children (stack-like behaviour). +Container mode for overlapping direct children. This is still a normal `div`; the flag makes direct children share one +content area instead of flowing one after another. Builder shape: ```kotlin { .kotlin .copy .select } -overlay({ - key = "overlay.layer" +div({ + key = "overlap.example" + overlapChildren = true style = { width = 100.percent } }) { div({ style = { margin(8.px, 0.px, 0.px, 8.px) } }) { text("Floating badge") } @@ -226,6 +228,7 @@ var currentValue by useState(null) select({ key = "example.select" value = currentValue + ownerDomain = ScreenDomainId.Application onValueChange = { event -> currentValue = event.value } }) { placeholder("Choose one") @@ -240,7 +243,8 @@ select({ Caveats: -- popup behaviour is provided by select runtime/overlay internals; this API is the supported convenience entrypoint. +- popup behaviour is provided by domain portal services; this API is the supported convenience entrypoint. +- use `ownerDomain = ScreenDomainId.System` when a select is hosted by system-owned UI (for example inspector/system tools). - keyboard and wheel behaviour are implemented and covered by `SelectEngineTests`. ### `colorPicker(...)` and `colorPickerPopup(...)` @@ -273,14 +277,14 @@ colorPickerPopup({ ## Higher-level helper DSLs -These are exposed conveniences for common UI workflows. They are not documented as a generalized public overlay +These are exposed conveniences for common UI workflows. They are not documented as a generalized public portal/runtime framework contract. ### Modal helpers Public helper set: -- `modalHost(modals, modalKey) { ... }` +- `modalPortal(modals, key) { ... }` - `ModalSpec(...)` - `modalFrame`, `modalDialog`, `modalHeader`, `modalTitle`, `modalBody`, `modalFooter` - `alertModal`, `confirmModal`, `promptModal` @@ -291,7 +295,7 @@ Small example: fun UiScope.modalSample() { var modals by useState(emptyList()) - modalHost(modals = modals, modalKey = "example.modal.host") { + modalPortal(modals = modals, key = "example.modal.portal") { button("Open modal", { onMouseClick = { modals = listOf( @@ -311,7 +315,7 @@ fun UiScope.modalSample() { Caveat: -- modal focus restore/trap/topmost handling is runtime-managed and tested (`ModalRuntimeTests`). +- modal focus restore/trap/topmost handling is portal-session-managed and tested (`ModalPortalSessionStoreTests`). ### Context menu helpers diff --git a/docs/hooks.md b/docs/hooks.md index 0f83ac1..bd6a076 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -533,6 +533,6 @@ Persistence across rebuilds: - For repeated unkeyed sibling component calls, expect ordinal fallback semantics and position-based rebinding on reorder. - Use `by` for storage-backed hooks. -- Treat DnD hooks as advanced descriptor APIs, not as a generic public overlay framework. +- Treat DnD hooks as advanced descriptor APIs, not as a generic public portal/runtime framework. - For runtime behaviour details around rebuild triggers, see [State and reactivity](state-reactivity.md). diff --git a/docs/index.md b/docs/index.md index 73b10a9..d55378d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,8 @@ Reference: - **screen host**: a host implementation (for example `DsglScreenHost`) that drives lifecycle, input, and painting - **DOM tree**: the retained tree of DSGL nodes (`DomTree` + `DOMNode` hierarchy) - **hook runtime**: the per-window hook state/effect system managed by `ComponentHookRuntime` -- **overlay**: layered UI surfaces above the application root (application/system/debug layers) +- **domain surface**: one physical screen target owned by a domain, either root DOM or portal host +- **portal**: a domain-local mount point for floating UI such as modals, dropdowns, context menus, drag ghosts, and panels - **stylesheet**: DSS files parsed and applied by the style engine - **element**: a concrete node in the DOM tree (for example, text, button, input, container) - **component**: a reusable UI composition built with the DSL diff --git a/docs/layout-model.md b/docs/layout-model.md index b5a03af..e16b15b 100644 --- a/docs/layout-model.md +++ b/docs/layout-model.md @@ -57,9 +57,10 @@ This is intentionally narrower than browser CSS grid. Node is excluded from layout/render traversal and state like hover/active/focus/open is reset for that node. -### Overlay stack container (DSL helper) +### Overlapping children -`overlay { ... }` uses a `ContainerNode` with `stackLayout=true`. +`div({ overlapChildren = true }) { ... }` uses a `ContainerNode` mode where direct children share one content area +instead of flowing as block/flex/grid/inline items. - children share the same content area - child `align` controls placement inside that area @@ -68,8 +69,9 @@ Node is excluded from layout/render traversal and state like hover/active/focus/ Example: ```kotlin { .kotlin .copy .select } -overlay({ - key = "hud.layer" +div({ + key = "hud.overlap" + overlapChildren = true style = { width = 100.percent height = 100.percent diff --git a/docs/project-structure.md b/docs/project-structure.md index 729b0fc..4acd35d 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -34,14 +34,14 @@ - `src/main/kotlin/org/dreamfinity/dsgl/core/dom/` DOM node implementations, layout models, reconcile helpers, overflow/positioning behaviour. -- `src/main/kotlin/org/dreamfinity/dsgl/core/overlay/` - Overlay layering/contracts and host-layer plumbing. +- `src/main/kotlin/org/dreamfinity/dsgl/core/portal/` + Domain-surface and portal runtime contracts, portal hosts, and floating UI helpers. - `src/main/kotlin/org/dreamfinity/dsgl/core/components/`, `select/`, `contextmenu/`, `colorpicker/`, `dnd/` Higher-level public helpers and advanced interaction systems. - `src/test/kotlin/...` - Main regression coverage (hooks, layout, style, overlay/input, helper systems). + Main regression coverage (hooks, layout, style, domain/portal input, helper systems). ## `adapters/mc-forge-1-7-10/` structure @@ -106,5 +106,5 @@ - DSL/API behaviour: start in `core/.../Dsl.kt` and relevant `core/hooks` or component package. - Runtime frame behaviour: start in `adapters/mc-forge-1-7-10/.../DsglScreenHost.kt` and `core/DomTree.kt`. - Style parsing/application: start in `core/style/`. -- Overlay routing/ordering: start in `core/overlay/` plus host integration in `DsglScreenHost`. +- Domain/portal routing and ordering: start in `core/portal/` plus host integration in `DsglScreenHost`. - Demo regressions/examples: `adapters/mc-forge-1-7-10/demo/src/main/kotlin/.../demo/sections`. diff --git a/docs/quickstart.md b/docs/quickstart.md index fee0998..bb949e7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -144,10 +144,10 @@ object DsglClientHotkeys { ![img](images/quickstart-basic-screen.png) -!!! note "Small overlay panel" +!!! note "Debug domain panel" - You might mention a small overlay panel in the left bottom edge. It has debug-only functions and some - information about the current window (overlays settings and immediate+sliding window FPS counter). + You might notice a small debug panel in the left bottom edge. It has debug-only functions and some + information about the current window (domain-surface settings and immediate+sliding window FPS counter). It will be removed in the release (or at least hidden by default). ## 5) Compose reusable components diff --git a/docs/status-limitations.md b/docs/status-limitations.md index 6602090..5c3f384 100644 --- a/docs/status-limitations.md +++ b/docs/status-limitations.md @@ -12,14 +12,17 @@ These areas are used across core + demo and have broad test coverage: - stylesheet loading and style application pipeline (`.dss` + inline style) - primary layout modes in current model (`block`, `inline`, `flex`, `grid`, `none`) and positioning (`static`, `relative`, `absolute`, `fixed`, `sticky`) -- overlay layer ordering contract used by host/runtime ( - `application root -> application overlay -> system overlay -> debug`) +- domain surface ordering contract used by host/runtime ( + `Application root -> Application portal -> System root -> System portal -> Debug root -> Debug portal`) ## Experimental -- drag-and-drop/sortable ecosystem (`useDraggable`, `useDroppable`, `useSortable`, monitor callbacks) -- color picker flows (especially popup/eyedropper interactions across overlay ownership) +- drag-and-drop/sortable ecosystem (`useDraggable`, `useDroppable`, `useSortable`, monitor callbacks); Application-owned + drag ghosts render through Application portal ownership, while System/Debug drag visuals remain future domain-owned + cases +- color picker flows (especially popup/eyedropper interactions across domain portal ownership) - hot-reload integration path (stable enough for local development, but dependent on external agent/build workflow) +- Debug-owned select/dropdown portal support is intentionally unsupported until Debug UI needs select/dropdown components These areas are public and tested, but still advanced and more likely to evolve than baseline DSL/state/style/layout surfaces. @@ -28,10 +31,10 @@ surfaces. Do not treat these as extension contracts: -- `org.dreamfinity.dsgl.core.*.internal.*` packages (modal internals, inspector internals, overlay internals, +- `org.dreamfinity.dsgl.core.*.internal.*` packages (modal internals, inspector internals, portal/runtime internals, color-picker internals) -- runtime plumbing classes such as `HotReloadBridge`, low-level overlay hosts, internal engine state holders -- system/debug overlay internals and inspector implementation nodes +- runtime plumbing classes such as `HotReloadBridge`, low-level portal hosts, internal engine state holders +- system/debug domain internals and inspector implementation nodes If you rely on these directly, expect breakage across normal development changes. @@ -51,7 +54,7 @@ If you rely on these directly, expect breakage across normal development changes - direct runtime mounting/reconcile internals as a custom-component authoring mechanism - low-level JVMTI/Rust hot-reload internals as a stable contract -- generalized overlay framework internals behind modal/select/context-menu convenience DSLs +- generalized portal/runtime internals behind modal/select/context-menu convenience DSLs Use documented DSL composition patterns as the supported extension model: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b1a0233..9d2f952 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -104,17 +104,20 @@ If rules still do not affect node: - remember inline style has higher precedence than stylesheet rules - confirm pseudo-state (`:hover`, `:active`, etc.) is actually active at runtime -## Overlay / popup input surprises +## Portal / popup input surprises -Input routing is layered, not flat: +Input routing follows domain-surface priority, not a flat DOM tree: -1. system overlay -2. application overlay -3. application DOM tree +1. Debug portal +2. Debug root +3. System portal +4. System root +5. Application portal +6. Application root Practical effect: -- when a select/context menu/modal consumes pointer input, underlying app element will not receive that event +- when a select/context menu/modal consumes pointer input from its owning portal, lower-priority surfaces will not receive that event - this is expected behaviour, not click-through failure Context menu caveat in current API: diff --git a/gradle.properties b/gradle.properties index 50da364..73e1cfe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ group=org.dreamfinity version=0.0.1 -startParameter.offline=true org.gradle.jvmargs=-Xmx4g +kotlinVersion=2.3.21 # adapters turn on / off for development enableMinecraftForge1710=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e69de29..3bb84c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -0,0 +1,7 @@ +[versions] +ktlint-plugin = "14.0.1" +detekt-plugin = "1.23.8" + +[plugins] +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt-plugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b9f101..79e3998 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,13 @@ pluginManagement { fun isAdapterEnabled(name: String) = providers.gradleProperty("enable$name").orNull?.toBoolean() ?: false + val kotlinVersion: String by settings + + plugins { + kotlin("jvm") version kotlinVersion + id("org.jetbrains.kotlin.plugin.serialization") version kotlinVersion + } + repositories { maven(url = "https://maven.minecraftforge.net") gradlePluginPortal()