diff --git a/adapters/mc-forge-1-12-2/adapter-build-logic/build.gradle.kts b/adapters/mc-forge-1-12-2/adapter-build-logic/build.gradle.kts new file mode 100644 index 0000000..22622e0 --- /dev/null +++ b/adapters/mc-forge-1-12-2/adapter-build-logic/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() + mavenCentral() + maven { + // RetroFuturaGradle + name = "GTNH Maven" + setUrl("https://nexus.gtnewhorizons.com/repository/public/") + mavenContent { + includeGroupByRegex("com\\.gtnewhorizons\\..+") + includeGroup("com.gtnewhorizons") + } + } +} + +dependencies { + implementation("com.gtnewhorizons:retrofuturagradle:1.4.9") +} diff --git a/adapters/mc-forge-1-12-2/adapter-build-logic/settings.gradle.kts b/adapters/mc-forge-1-12-2/adapter-build-logic/settings.gradle.kts new file mode 100644 index 0000000..7f753ac --- /dev/null +++ b/adapters/mc-forge-1-12-2/adapter-build-logic/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { + includeBuild("../../../build-logic") + repositories { + gradlePluginPortal() + mavenCentral() + } +} diff --git a/adapters/mc-forge-1-12-2/adapter-build-logic/src/main/kotlin/dsgl-mc-forge-1-12-2.conventions.gradle.kts b/adapters/mc-forge-1-12-2/adapter-build-logic/src/main/kotlin/dsgl-mc-forge-1-12-2.conventions.gradle.kts new file mode 100644 index 0000000..ba20d24 --- /dev/null +++ b/adapters/mc-forge-1-12-2/adapter-build-logic/src/main/kotlin/dsgl-mc-forge-1-12-2.conventions.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("com.gtnewhorizons.retrofuturagradle") +} + +val gameVersion: String by project +val forgeVersion: String by project + +repositories { + gradlePluginPortal() + mavenCentral() +} + +minecraft { + mcVersion.set("1.12.2") + username.set("Developer") +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + diff --git a/adapters/mc-forge-1-12-2/build.gradle.kts b/adapters/mc-forge-1-12-2/build.gradle.kts new file mode 100644 index 0000000..8abf3a5 --- /dev/null +++ b/adapters/mc-forge-1-12-2/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("dsgl-mc-adapter.conventions") + id("dsgl-mc-forge-1-12-2.conventions") + id("dsgl-releaseable-module.conventions") +} + +dsglRelease { + syncKeys.add("modVersion") +} + +dependencies { + 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-12-2/demo/build.gradle.kts b/adapters/mc-forge-1-12-2/demo/build.gradle.kts new file mode 100644 index 0000000..1a15180 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/build.gradle.kts @@ -0,0 +1,200 @@ +plugins { + id("dsgl-mc-adapter.conventions") + id("dsgl-mc-forge-1-12-2.conventions") +} + +val modId: String by project +val modGroup: String by project +val modName: String by project +val modVersion: String by project +val modAuthor: String by project +val modDescription: String by project +val modCredits: String by project +val modIcon: String by project +val gameVersion: String by project + +val hotReload: String by project +val msdfDebug: String by project +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 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 +) + +fun currentModVersion(): String { + val dynamic = (findProperty("modVersion") as? String)?.trim() + if (!dynamic.isNullOrEmpty()) return dynamic + if (modVersion.isNotBlank()) return modVersion + throw GradleException("Missing required property 'modVersion' for mc-forge-1-7-10-demo module.") +} + +fun currentModMetadataTokens(): Map { + return 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." + ) + } + + return project.rootDir.resolve("dsgl-hot-reload-agent/target/release/$libraryName") +} + +val generatedModMetadataDir: Provider = layout.buildDirectory.dir("generated/sources/modMetadata/kotlin") + +val generateModMetadata by tasks.registering { + description = "Generate mod metadata" + inputs.properties(currentModMetadataTokens()) + outputs.dir(generatedModMetadataDir) + + doLast { + val tokens = currentModMetadataTokens() + val outputDir = generatedModMetadataDir.get().asFile + val packagePath = File(outputDir, "org/dreamfinity/dsgl/mcForge1122") + packagePath.mkdirs() + val outputFile = File(packagePath, "DsglMc1122DemoGeneratedMetadata.kt") + + outputFile.writeText( + """ + package org.dreamfinity.dsgl.mcForge1122 + + /** + * Generated from Gradle properties to keep @Mod metadata consistent. + */ + object DsglMc1122DemoGeneratedMetadata { + const val MOD_ID: String = "${tokens["modId"]}" + const val MOD_NAME: String = "${tokens["modName"]}" + const val MOD_VERSION: String = "${tokens["modVersion"]}" + const val MC_VERSION_RANGE: String = "[${tokens["gameVersion"]}]" + const val MOD_AUTHOR: String = "${tokens["modAuthor"]}" + const val MOD_DESCRIPTION: String = "${tokens["modDescription"]}" + const val MOD_CREDITS: String = "${tokens["modCredits"]}" + const val MOD_ICON: String = "${tokens["modIcon"]}" + } + """.trimIndent() + ) + } +} + +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", + ) + + if (hotReload.toBoolean()) { + jvmArgs = jvmArgs + listOf("-agentpath:${hotReloadAgentLibraryFile().absolutePath}") + } + + jvmArgs(jvmArgs) + + if (project.hasProperty("clientRunArgs")) { + println("clientRunArgs: ${project.property("clientRunArgs")}") + args(project.property("clientRunArgs")) + } + } + + runServer { + if (project.hasProperty("serverRunArgs")) { + println("serverRunArgs: ${project.property("serverRunArgs")}") + args(project.property("serverRunArgs")) + } + } +} + +kotlin { + sourceSets.getByName("main").kotlin.srcDir(generatedModMetadataDir) +} + +tasks.named("compileKotlin") { + dependsOn(generateModMetadata) +} + +tasks.named("sourcesJar") { + dependsOn(generateModMetadata) +} + +tasks.named("devSourcesJar") { + dependsOn(generateModMetadata) +} + +tasks.named("dokkaGeneratePublicationHtml") { + dependsOn(generateModMetadata) +} + +tasks.named("processResources") { + inputs.properties(baseModMetadataTokens) + inputs.property("modVersion", providers.provider { currentModVersion() }) + + filesMatching(listOf("mcmod.info", "META-INF/MANIFEST.MF")) { + expand(currentModMetadataTokens()) + } +} + +tasks.named("jar") { + dependsOn(tasks.named("processResources")) + val processedManifest = layout.buildDirectory.file("resources/main/META-INF/MANIFEST.MF") + manifest.from(processedManifest) + exclude("META-INF/MANIFEST.MF") +} + +tasks.named("reobfJar") { + dependsOn(":adapters:mc-forge-1-12-2:reobfJar") +} + +tasks.withType().configureEach { + enabled = false +} +tasks.withType().configureEach { + enabled = false +} +tasks.named("publish") { + enabled = false +} +tasks.named("publishToMavenLocal") { + enabled = false +} + +repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/Dreamfinity/DSGL") + } +} + +dependencies { + implementation(project(":core")) + implementation(project(":adapters:mc-forge-1-12-2")) + testImplementation(kotlin("test-junit")) + testImplementation(kotlin("test")) +} diff --git a/adapters/mc-forge-1-12-2/demo/gradle.properties b/adapters/mc-forge-1-12-2/demo/gradle.properties new file mode 100644 index 0000000..3c5d6d8 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/gradle.properties @@ -0,0 +1,35 @@ +publishEnabled=false + +# Minecraft and Forge params +gameVersion=1.12.2 +forgeVersion=14.23.5.2864 + +# Mod params +modGroup=org.dreamfinity +modId=dsgl-demo +modName=dsgl-demo +modArchivesName=dsgl-demo +modAuthor=Veritaris +modIcon= +modDescription=Dreamfinity Simple GUI Library Demo +modCredits=Veritaris +buildVersion=1 +modVersion=0.0.1 + +# Mod dev params +isClientBuild=false +clientRunArgs="--username" "Dreamfinity" +serverRunArgs="--no-gui" +hotReload=true +msdfDebug=false +msdfDebugDecorations=false +msdfDebugPerformance=false +rebuildTrace=false +perfDebug=false +dsglOverlayDebug=true +dsglOverlayControls=true + +startParameter.offline=true + +# Publishing params +publishProjectDepsOnly=true diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglClientHotkeys.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglClientHotkeys.kt new file mode 100644 index 0000000..207651a --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglClientHotkeys.kt @@ -0,0 +1,39 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.client.Minecraft +import net.minecraft.client.settings.KeyBinding +import net.minecraftforge.common.MinecraftForge +import net.minecraftforge.fml.client.registry.ClientRegistry +import net.minecraftforge.fml.common.FMLCommonHandler +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent +import net.minecraftforge.fml.common.gameevent.InputEvent +import org.dreamfinity.dsgl.mcForge1122.demo.ShowcaseWindow +import org.lwjgl.input.Keyboard + +/** + * Client-only hotkeys for DSGL. + */ +object DsglClientHotkeys { + 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) + MinecraftForge.EVENT_BUS.register(this) + registered = true + } + + @SubscribeEvent + fun onKeyInput(event: InputEvent.KeyInputEvent) { + when { + openShowcaseKey.isPressed -> Minecraft + .getMinecraft() + .displayGuiScreen(object : DsglScreenHost({ ShowcaseWindow() }) {}) + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglMc1122ModContainer.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglMc1122ModContainer.kt new file mode 100644 index 0000000..ec8ce89 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglMc1122ModContainer.kt @@ -0,0 +1,26 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.client.Minecraft +import net.minecraftforge.fml.common.FMLCommonHandler +import net.minecraftforge.fml.common.Mod +import net.minecraftforge.fml.common.event.FMLInitializationEvent + +/** + * Minimal Forge mod container so FML can discover and load the DSGL MC 1.7.10 adapter module. + */ +@Mod( + modid = DsglMc1122DemoGeneratedMetadata.MOD_ID, + name = DsglMc1122DemoGeneratedMetadata.MOD_NAME, + version = DsglMc1122DemoGeneratedMetadata.MOD_VERSION, + acceptedMinecraftVersions = DsglMc1122DemoGeneratedMetadata.MC_VERSION_RANGE, + useMetadata = true +) +class DsglMc1122ModContainer { + @Mod.EventHandler + fun onInit(event: FMLInitializationEvent) { + if (FMLCommonHandler.instance().side.isClient) { + DsglFonts.ensureInitialized(Minecraft.getMinecraft().gameDir, javaClass.classLoader) + DsglClientHotkeys.register() + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/ShowcaseWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/ShowcaseWindow.kt new file mode 100644 index 0000000..aff636e --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/ShowcaseWindow.kt @@ -0,0 +1,955 @@ +package org.dreamfinity.dsgl.mcForge1122.demo + +import net.minecraft.client.Minecraft +import net.minecraft.init.Blocks +import net.minecraft.init.Items +import net.minecraft.item.Item +import net.minecraft.item.ItemStack +import org.dreamfinity.dsgl.core.DomTree +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.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.mcForge1122.McItemStackRef +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.mcForge1122.demo.sections.* +import org.dreamfinity.dsgl.mcForge1122.demo.support.* +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 + get() = viewportWidth + internal val viewportHeightPx: Int + get() = viewportHeight + + internal var selectedSection by state(DemoSection.OVERVIEW) + internal var checklistPage by state(0) + internal val checklistPageSize: Int = 8 + + internal val maxEventLogs: Int = 24 + internal val visibleEventLines: Int = 7 + internal var eventLogs by state(emptyList()) + private var logSequence: Int = 0 + + internal var renderPasses: Int = 0 + internal var demoModals by state(emptyList()) + internal var mediaReady by state(false) + + internal val resourceImageSource: String = "minecraft:textures/gui/options_background.png" + internal val fileImageSource: String = "file://demo/local_showcase.png" + internal val httpImageSource: String = "https://demo.local/assets/showcase_http.png" + internal val flatItemRef = McItemStackRef(ItemStack(Items.DIAMOND_SWORD)) + internal val blockItemRef = McItemStackRef(ItemStack(Item.getItemFromBlock(Blocks.STONE))) + internal var clippingScrollDemoText by state(buildClippingScrollDemoText()) + + internal val implementedCapabilities: Set + get() = CapabilityChecklistCatalog.implementedByAllSections() + + override val rebuildOnResize: Boolean + get() = true + + override fun onOpen() { + prepareDemoMedia() + prepareDemoStylesheet() + prepareCascadeStylesheet() + registerAnimationKeyframes() + appendInfo("Showcase opened") + } + + override fun onResize(width: Int, height: Int) { + viewportWidth = width + viewportHeight = height + } + + override fun render(): DomTree { + renderPasses += 1 + + return ui { + modalHost(modals = demoModals, modalKey = "showcase.modalHost") { + div({ + key = "showcase.root" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 100.vw + height = 100.vh + padding = 4.px + gap = 4.px + backgroundColor = DEMO_BG + } + }) { + text("DSGL Showcase Window", { style = { color = DsglColors.WHITE } }) + text( + "renderPasses=$renderPasses section=${selectedSection.title} viewport=${viewportWidth}x$viewportHeight", + { + style = { + color = DEMO_MUTED + padding = 4.px + } + } + ) + + div({ + key = "showcase.body" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + justifyContent = JustifyContent.SpaceBetween + } + }) { + div({ + key = "showcase.nav" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 4.px + backgroundColor = DEMO_SURFACE + color = DsglColors.TEXT + border { width = 1.px; color = DsglColors.BORDER } + } + + }) { + text("Sections", { style = { color = DsglColors.WHITE } }) + DemoSection.entries.forEach { section -> + button(section.title, { + key = "nav.${section.name.lowercase()}" + style = { + backgroundColor = + if (selectedSection == section) DEMO_ACCENT else DsglColors.BUTTON + } + onMouseClick = { selectSection(section) } + }) + } + } + + div({ + key = "showcase.content" + style = { + display = Display.Flex + flexGrow = 2.0f + flexDirection = FlexDirection.Column + gap = 4.px + backgroundColor = DEMO_SURFACE + color = DsglColors.TEXT + 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.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 = McFeaturesShellProps( + 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) } + ) + ) + } + } + + div({ + key = "showcase.side" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + width = 15.vw + } + }) { + renderEventInspectorPanel( + eventLogs = eventLogs, + maxEventLogs = maxEventLogs, + visibleEventLines = visibleEventLines, + onClearLogs = ::clearEventLogs + ) + renderChecklistPanel( + implementedCapabilities = implementedCapabilities, + checklistPage = checklistPage, + checklistPageSize = checklistPageSize, + onSetChecklistPage = { checklistPage = it }, + onMoveChecklistPage = ::moveChecklistPage + ) + } + } + } + } + } + } + + internal fun clearEventLogs() { + eventLogs = emptyList() + } + + internal fun pushModal(spec: ModalSpec) { + if (demoModals.any { it.key == spec.key }) return + demoModals = demoModals + spec + appendInfo("Modal pushed: ${spec.key}") + } + + internal fun removeModal(key: String) { + val before = demoModals.size + demoModals = demoModals.filterNot { it.key == key } + if (demoModals.size != before) { + appendInfo("Modal removed: $key") + } + } + + internal fun popTopModal() { + if (demoModals.isEmpty()) return + val removed = demoModals.last() + demoModals = demoModals.dropLast(1) + appendInfo("Modal popped: ${removed.key}") + } + + internal fun moveChecklistPage(delta: Int) { + val required = CapabilityChecklistCatalog.required.size + val pageCount = ((required + checklistPageSize - 1) / checklistPageSize).coerceAtLeast(1) + checklistPage = (checklistPage + delta).coerceIn(0, pageCount - 1) + } + + internal fun requestManualInvalidate(reason: String) { + invalidate() + } + + internal fun logHook(hookName: String, event: Event, note: String? = null, color: Int = DsglColors.TEXT) { + val line = formatEventLine(hookName, event, note) + appendLog(line, color) + } + + internal fun appendInfo(message: String) { + appendLog(message, DEMO_OK) + } + + internal fun currentGuiScale(): Int { + return Minecraft.getMinecraft().gameSettings.guiScale.coerceIn(0, 4) + } + + internal fun guiScaleLabel(value: Int = currentGuiScale()): String { + return when (value.coerceIn(0, 4)) { + 0 -> "Auto" + 1 -> "1x" + 2 -> "2x" + 3 -> "3x" + else -> "4x" + } + } + + internal fun setGuiScale(value: Int) { + val mc = Minecraft.getMinecraft() + val normalized = value.coerceIn(0, 4) + if (mc.gameSettings.guiScale == normalized) return + mc.gameSettings.guiScale = normalized + mc.gameSettings.saveOptions() + appendInfo("guiScale -> ${guiScaleLabel(normalized)}") + requestManualInvalidate("guiScale change") + } + + internal fun cycleGuiScale(step: Int) { + val current = currentGuiScale() + val next = (current + step).coerceIn(0, 4) + setGuiScale(next) + } + + internal fun reloadStylesheetsProgrammatically(source: String) { + StyleEngine.forceReloadStylesheets() + requestManualInvalidate("stylesheets reload") + appendInfo("Stylesheets reloaded by $source") + } + + internal fun loadStylesheetEditorFromFile(source: String): String { + try { + val file = demoStylesheetFile() + if (!file.exists()) { + prepareDemoStylesheet() + } + val content = file.readText() + appendInfo("Stylesheet loaded by $source") + return content + } catch (ex: Exception) { + appendLog("Stylesheet load failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) + throw ex + } + } + + internal fun saveStylesheetEditorToFile(content: String, source: String) { + try { + val file = demoStylesheetFile() + file.parentFile?.mkdirs() + file.writeText(content) + appendInfo("Stylesheet saved by $source") + } catch (ex: Exception) { + appendLog("Stylesheet save failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) + throw ex + } + } + + private fun selectSection(section: DemoSection) { + if (selectedSection == section) return + selectedSection = section + appendInfo("Section: ${section.title}") + } + + private fun appendLog(line: String, color: Int) { + logSequence += 1 + val entry = EventLogEntry(logSequence, line, color) + eventLogs = (listOf(entry) + eventLogs).take(maxEventLogs) + } + + private fun prepareDemoMedia() { + try { + val dataDir = Minecraft.getMinecraft().gameDir + writeDemoImage( + File(dataDir, "dsgl/demo/local_showcase.png"), + 0xFF3B71A5.toInt(), + 0xFFF7B25B.toInt() + ) + writeDemoImage( + File(dataDir, "dsgl/cache/downloads/demo.local/assets/showcase_http.png"), + 0xFF2D8757.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) { + mediaReady = false + appendLog("Media prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) + } + } + + private fun prepareDemoStylesheet() { + try { + val stylesheetFile = demoStylesheetFile() + stylesheetFile.parentFile?.mkdirs() + var created = false + if (!stylesheetFile.exists()) { + stylesheetFile.writeText( + """ + :root { + --primary: #3E6B9E; + --accent: #7CB6FF; + --danger: #A34343; + --fg: #E9F1FF; + } + + button { + border-width: 1px; + border-color: #000000; + padding: 3px 6px; + } + + select { + background-color: #2B3744; + border-color: #5A6D80; + border-width: 1px; + color: #EAF2FD; + padding: 3px 6px; + } + + select:hover { + background-color: #324456; + } + + select:focus { + border-color: #7CB6FF; + } + + select:open { + border-color: #9BD3FF; + background-color: #35506B; + } + + select:disabled { + background-color: #2B2B2B; + border-color: #555555; + color: #8E8E8E; + } + + .style-card { + margin: 2px 0px 0px 0px; + background-color: #2A3440; + border-color: #5E6A77; + 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); + } + + .units-demo { + padding: 8px; + gap: 6px; + background-color: #1E2C3A; + border-color: #5E6A77; + border-width: 1px; + } + + .units-vw-chip { + width: 20vw; + background-color: #355674; + border-color: #7CB6FF; + border-width: 1px; + padding: 2px 4px; + } + + .units-playground { + width: 100%; + background-color: #17222D; + border-color: #4E5E6E; + border-width: 1px; + padding: 4px; + } + + .units-percent-box { + width: 50%; + height: 40%; + margin: 8% 5%; + background-color: #3D6B52; + border-color: #8DD0A6; + border-width: 1px; + padding: 1em; + } + + .units-em-text { + font-size: 1.25em; + margin: 1em 0; + color: #E6F0FF; + } + + .units-vh-bar { + width: 40%; + height: 8vh; + background-color: #5A3F77; + border-color: #9B83C5; + border-width: 1px; + padding: 2px 4px; + } + """.trimIndent() + ) + appendInfo("Created demo stylesheet: ${stylesheetFile.name}") + created = true + } else { + val existing = stylesheetFile.readText() + if (!existing.contains(".units-demo")) { + stylesheetFile.appendText( + """ + + .units-demo { + padding: 8px; + gap: 6px; + background-color: #1E2C3A; + border-color: #5E6A77; + border-width: 1px; + } + + .units-vw-chip { + width: 20vw; + background-color: #355674; + border-color: #7CB6FF; + border-width: 1px; + padding: 2px 4px; + } + + .units-playground { + width: 100%; + background-color: #17222D; + border-color: #4E5E6E; + border-width: 1px; + padding: 4px; + } + + .units-percent-box { + width: 50%; + height: 40%; + margin: 8% 5%; + background-color: #3D6B52; + border-color: #8DD0A6; + border-width: 1px; + padding: 1em; + } + + .units-em-text { + font-size: 1.25em; + margin: 1em 0; + color: #E6F0FF; + } + + .units-vh-bar { + width: 40%; + height: 8vh; + background-color: #5A3F77; + border-color: #9B83C5; + border-width: 1px; + padding: 2px 4px; + } + """.trimIndent() + ) + appendInfo("Patched demo stylesheet with CSS units section") + created = true + } + if (!existing.contains("select:open")) { + stylesheetFile.appendText( + """ + + select { + background-color: #2B3744; + border-color: #5A6D80; + border-width: 1px; + color: #EAF2FD; + padding: 3px 6px; + } + + select:hover { + background-color: #324456; + } + + select:focus { + border-color: #7CB6FF; + } + + select:open { + border-color: #9BD3FF; + background-color: #35506B; + } + + select:disabled { + background-color: #2B2B2B; + border-color: #555555; + color: #8E8E8E; + } + """.trimIndent() + ) + appendInfo("Patched demo stylesheet with select styles") + created = true + } + } + if (created) { + StyleEngine.forceReloadStylesheets() + } + } catch (ex: Exception) { + appendLog("Stylesheet prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) + } + } + + private fun registerAnimationKeyframes() { + keyframes("showcase.spinFade") { + at(0f) { + transform { rotate(0f) } + opacity = 0.35f + color = 0xFFFF6B6B.toInt() + } + at(50f) { + transform { rotate(180f); scale(1.08f) } + opacity = 1f + color = 0xFF6BCB77.toInt() + } + at(100f) { + transform { rotate(360f) } + opacity = 0.35f + color = 0xFF4D96FF.toInt() + } + } + } + + private fun prepareCascadeStylesheet() { + try { + val file = cascadeStylesheetFile() + file.parentFile?.mkdirs() + file.writeText( + """ + .cascade-demo-root { + background-color: #25303B; + border-color: #5A6877; + 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; + } + + .cascade-sibling-adj { + background-color: #1E2731; + border-color: #45576B; + border-width: 1px; + padding: 3px; + } + + .cascade-sibling-adj .adj-item { + background-color: #2D3A47; + border-color: #53667A; + border-width: 1px; + padding: 2px 4px; + } + + .cascade-sibling-adj .adj-source { + color: #FFDE9E; + } + + .cascade-sibling-adj .adj-source + .adj-target { + color: #7DFFB0; + border-color: #7DFFB0; + } + + .cascade-sibling-general { + background-color: #1B2530; + border-color: #495D73; + border-width: 1px; + padding: 3px; + } + + .cascade-sibling-general .warning { + color: #FF9B9B; + } + + .cascade-sibling-general .warning ~ .gen-target { + color: #6EC8FF; + } + + .cascade-mixed { + background-color: #202A34; + border-color: #516679; + border-width: 1px; + padding: 3px; + } + + .cascade-mixed > .header { + color: #D8E6F5; + } + + .cascade-mixed > .header + .body .title { + color: #F6D66F; + } + """.trimIndent() + ) + StyleEngine.forceReloadStylesheets() + } catch (ex: Exception) { + appendLog("Cascade stylesheet prep failed: ${ex.javaClass.simpleName}", 0xFFFF9A66.toInt()) + } + } + + private fun demoStylesheetFile(): File { + val dataDir = Minecraft.getMinecraft().gameDir + return File(dataDir, "dsgl/styles/showcase_styles.dss") + } + + private fun cascadeStylesheetFile(): File { + val dataDir = Minecraft.getMinecraft().gameDir + return File(dataDir, "dsgl/styles/showcase_cascade.dss") + } + + private fun writeDemoImage(file: File, colorA: Int, colorB: Int) { + file.parentFile?.mkdirs() + val image = BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val useA = ((x / 5) + (y / 5)) % 2 == 0 + image.setRGB(x, y, if (useA) colorA else colorB) + } + } + ImageIO.write(image, "png", file) + } + + private fun writeDemoFolderIcon(file: File) { + file.parentFile?.mkdirs() + val image = BufferedImage(18, 18, BufferedImage.TYPE_INT_ARGB) + val bg = 0xFFE0B25A.toInt() + val fg = 0xFFC7923D.toInt() + for (y in 0 until image.height) { + for (x in 0 until image.width) { + image.setRGB(x, y, 0x00000000) + } + } + for (y in 5 until 15) { + for (x in 1 until 17) { + image.setRGB(x, y, bg) + } + } + for (y in 3 until 7) { + for (x in 3 until 9) { + image.setRGB(x, y, fg) + } + } + for (x in 1 until 17) { + image.setRGB(x, 5, fg) + image.setRGB(x, 14, fg) + } + for (y in 5 until 15) { + image.setRGB(1, y, fg) + image.setRGB(16, y, fg) + } + ImageIO.write(image, "png", file) + } + + private fun writeDemoDocumentIcon(file: File) { + file.parentFile?.mkdirs() + val image = BufferedImage(18, 18, BufferedImage.TYPE_INT_ARGB) + val bg = 0xFFD7E6F8.toInt() + val fg = 0xFF94AAC4.toInt() + for (y in 0 until image.height) { + for (x in 0 until image.width) { + image.setRGB(x, y, 0x00000000) + } + } + for (y in 2 until 16) { + for (x in 3 until 14) { + image.setRGB(x, y, bg) + } + } + for (x in 3 until 14) { + image.setRGB(x, 2, fg) + image.setRGB(x, 15, fg) + } + for (y in 2 until 16) { + image.setRGB(3, y, fg) + image.setRGB(13, y, fg) + } + for (line in 0..4) { + val y = 5 + line * 2 + for (x in 5 until 12) { + image.setRGB(x, y, fg) + } + } + ImageIO.write(image, "png", file) + } + + private fun buildClippingScrollDemoText(): String { + val out = StringBuilder() + for (line in 1..100) { + out.append("Line ") + out.append(line) + out.append(" :: clipping+scroll demo") + if (line < 100) out.append('\n') + } + return out.toString() + } +} + + + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/containers/centeredFlexWrapper.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/containers/centeredFlexWrapper.kt new file mode 100644 index 0000000..080b0ad --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/containers/centeredFlexWrapper.kt @@ -0,0 +1,23 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.containers + +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.div +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 + +internal fun UiScope.centeredFlexWrapper(direction: FlexDirection = FlexDirection.Column, content: UiScope.() -> Unit) = + div({ + style { + width = 100.percent + height = 100.percent + display = Display.Flex + flexDirection = direction + alignItems = AlignItems.Center + justifyContent = JustifyContent.Center + backgroundColor = 0xbb1A2230.toInt() + } + }) { + content() + } \ No newline at end of file diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ContextMenuWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ContextMenuWindow.kt new file mode 100644 index 0000000..cb1a9e5 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ContextMenuWindow.kt @@ -0,0 +1,41 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.cookbook + +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.contextmenu.contextMenu +import org.dreamfinity.dsgl.core.dom.onContextMenu +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class ContextMenuWindow : DsglWindow() { + override fun render() = ui { + centeredFlexWrapper { + contextMenuRecipe() + } + } +} + +fun UiScope.contextMenuRecipe() { + var lastAction by useState("none") + + div({ + key = "recipe.file.tile" + style = { display = Display.Flex } + }) { + text("Right-click this tile") + }.onContextMenu { + openMenu( + contextMenu(id = "recipe.file.menu") { + item("Open") { onClick { lastAction = "open" } } + item("Rename") { onClick { lastAction = "rename" } } + separator() + item("Delete") { onClick { lastAction = "delete" } } + } + ) + } + + text("lastAction=$lastAction") +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/DragNDropWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/DragNDropWindow.kt new file mode 100644 index 0000000..678c539 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/DragNDropWindow.kt @@ -0,0 +1,86 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.cookbook + +import net.minecraft.item.ItemStack +import net.minecraft.util.ResourceLocation +import net.minecraftforge.fml.common.registry.ForgeRegistries +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.dnd.applyDraggable +import org.dreamfinity.dsgl.core.dnd.applyDroppable +import org.dreamfinity.dsgl.core.dnd.useDraggable +import org.dreamfinity.dsgl.core.dnd.useDroppable +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.div +import org.dreamfinity.dsgl.core.dsl.itemStack +import org.dreamfinity.dsgl.core.dsl.text +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.mcForge1122.McItemStackRef +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class DragNDropWindow : DsglWindow() { + override fun render() = ui { + centeredFlexWrapper { + dragBucketRecipe() + } + } +} + + +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 + } + ) + + div({ + key = "recipe.done.bucket" + style = { + display = Display.Flex + alignItems = AlignItems.Center + } + applyDroppable(doneDrop) + }) { + div({ style = { display = Display.Flex; alignItems = AlignItems.Center } }) { + text("Done (${done.size})") + } + done.forEach { card -> + div({ style = { display = Display.Flex; alignItems = AlignItems.Center } }) { + ForgeRegistries.ITEMS.getValue(ResourceLocation(card.id))?.let { item -> + itemStack(McItemStackRef(ItemStack(item, 1, 0)), { size = 32 }) + } ?: text("?") + text(card.label) + } + } + } + + lane.forEach { card -> + val drag = useDraggable(id = card.id, nodeKey = "lane.card.${card.id}") + div({ + key = "lane.card.${card.id}" + style = { + display = Display.Flex + alignItems = AlignItems.Center + } + applyDraggable(drag) + }) { + ForgeRegistries.ITEMS.getValue(ResourceLocation(card.id))?.let { item -> + itemStack(McItemStackRef(ItemStack(item, 1, 0)), { size = 32 }) + } ?: text("?") + text(card.label) + } + } +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ModalStackWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ModalStackWindow.kt new file mode 100644 index 0000000..8826642 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/ModalStackWindow.kt @@ -0,0 +1,49 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.cookbook + +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.components.modal.* +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class ModalStackWindow : DsglWindow() { + override fun render() = ui { + centeredFlexWrapper { + modalStackRecipe() + } + } +} + +private fun UiScope.modalStackRecipe() { + var modals by useState(emptyList()) + + fun removeModal(key: String) { + modals = modals.filterNot { it.key == key } + } + + modalHost(modals = modals, modalKey = "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() } }) + } + } + } + }) + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/RefUsageWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/RefUsageWindow.kt new file mode 100644 index 0000000..8da064f --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/cookbook/RefUsageWindow.kt @@ -0,0 +1,27 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.cookbook + +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.dom.elements.InputType +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.input +import org.dreamfinity.dsgl.core.hooks.ref.ElementHandle +import org.dreamfinity.dsgl.core.hooks.ref.useRef +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class RefUsageWindow : DsglWindow() { + override fun render() = ui { + centeredFlexWrapper { + focusRecipe() + } + } +} + +fun UiScope.focusRecipe() { + val inputRef by useRef() + + input(InputType.Text(value = "", placeholder = "Focusable input"), ref = inputRef) + button("Focus input", { + onMouseClick = { inputRef.current?.requestFocus() } + }) +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindow.kt new file mode 100644 index 0000000..77ff52b --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindow.kt @@ -0,0 +1,25 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.quickstart + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.event.Event +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class HelloWindow : DsglWindow() { + private var clicks by state(0) + + 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-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindowWithComponents.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindowWithComponents.kt new file mode 100644 index 0000000..6b852a0 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/quickstart/HelloWindowWithComponents.kt @@ -0,0 +1,38 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.quickstart + +import org.dreamfinity.dsgl.core.DsglWindow +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.hooks.useState +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class HelloWindowWithComponents : DsglWindow() { + override fun render() = ui { + centeredFlexWrapper(direction = FlexDirection.Row) { + counterCard("Left panel") + counterCard("Right panel") + } + } +} + +private fun UiScope.counterCard(title: String) { + var count by useState(0) + + div({ + key = "counter.$title" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 3.px + padding = 4.px + } + }) { + text(title) + text("Count: $count") + button("Increment", { onMouseClick = { count += 1 } }) + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/stateAndReactivity/GlobalStateWindow.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/stateAndReactivity/GlobalStateWindow.kt new file mode 100644 index 0000000..67f3938 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/examples/stateAndReactivity/GlobalStateWindow.kt @@ -0,0 +1,23 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.examples.stateAndReactivity + +import org.dreamfinity.dsgl.core.DsglWindow +import org.dreamfinity.dsgl.core.dsl.UiScope +import org.dreamfinity.dsgl.core.dsl.button +import org.dreamfinity.dsgl.core.dsl.text +import org.dreamfinity.dsgl.core.event.Event +import org.dreamfinity.dsgl.mcForge1122.demo.examples.containers.centeredFlexWrapper + +class GlobalStateWindow : DsglWindow() { + private var counter by state(0) + + 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-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/AnimationsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/AnimationsSection.kt new file mode 100644 index 0000000..5cfd663 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/AnimationsSection.kt @@ -0,0 +1,330 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +import org.dreamfinity.dsgl.core.animation.* +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.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.mcForge1122.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 directionOptions: List = listOf( + AnimationDirection.Normal, + AnimationDirection.Reverse, + AnimationDirection.Alternate, + AnimationDirection.AlternateReverse +) + +private val fillModeOptions: List = listOf( + AnimationFillMode.None, + AnimationFillMode.Forwards, + AnimationFillMode.Backwards, + AnimationFillMode.Both +) + +fun UiScope.animationsSection(onInfo: (String) -> Unit) { + var animationsToggle by useState(false) + var animationsHover by useState(false) + var animationsPaused by useState(false) + var animationsDurationMs by useState(1400L) + var animationsUseInfinite by useState(true) + var animationsEasingIndex by useState(0) + var animationsDirectionIndex by useState(0) + var animationsFillModeIndex by useState(0) + var animationsBezierX1 by useState(17L) + var animationsBezierY1 by useState(67L) + var animationsBezierX2 by useState(83L) + 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 easingIndex = animationsEasingIndex.coerceIn(0, dynamicEasingOptions.lastIndex) + val directionIndex = animationsDirectionIndex.coerceIn(0, directionOptions.lastIndex) + val fillIndex = animationsFillModeIndex.coerceIn(0, fillModeOptions.lastIndex) + val easing = dynamicEasingOptions[easingIndex].second + val easingName = dynamicEasingOptions[easingIndex].first + val direction = directionOptions[directionIndex] + val fillMode = fillModeOptions[fillIndex] + val playState = if (animationsPaused) AnimationPlayState.Paused else AnimationPlayState.Running + val iterations = if (animationsUseInfinite) IterationCount.Infinite else IterationCount.Count(3) + + div({ + key = "section.animations" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Transforms + Transitions + Keyframes") + text( + "Transforms are layout-neutral; hit testing follows transformed geometry.", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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 + } + }) + button(if (animationsUseInfinite) "Iterations: inf" else "Iterations: 3", { + onMouseClick = { + animationsUseInfinite = !animationsUseInfinite + } + }) + button("Easing: $easingName", { + onMouseClick = { + animationsEasingIndex = (animationsEasingIndex + 1) % dynamicEasingOptions.size + } + }) + button("Dir: ${direction.name.lowercase()}", { + onMouseClick = { + animationsDirectionIndex = (animationsDirectionIndex + 1) % directionOptions.size + } + }) + button("Fill: ${fillMode.name.lowercase()}", { + onMouseClick = { + animationsFillModeIndex = (animationsFillModeIndex + 1) % fillModeOptions.size + } + }) + } + + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("duration=$animationsDurationMs ms", { style = { color = DEMO_MUTED } }) + input( + InputType.Range( + value = duration.toLong(), + min = 200, + max = 6000, + step = 50 + ), + { + key = "animations.duration.slider" + style = { width = 100.percent } + onInput = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: duration.toLong() + animationsDurationMs = next.coerceIn(200, 6000) + } + } + ) + } + + bezierSliderRow("Bezier x1=$animationsBezierX1", "animations.bezier.x1", animationsBezierX1) { next -> + animationsBezierX1 = next + } + bezierSliderRow("Bezier y1=$animationsBezierY1", "animations.bezier.y1", animationsBezierY1) { next -> + animationsBezierY1 = next + } + bezierSliderRow("Bezier x2=$animationsBezierX2", "animations.bezier.x2", animationsBezierX2) { next -> + animationsBezierX2 = next + } + bezierSliderRow("Bezier y2=$animationsBezierY2", "animations.bezier.y2", animationsBezierY2) { next -> + animationsBezierY2 = next + } + + div({ + key = "animations.cards" + style = { + width = 100.percent + padding = 4.px + gap = 6.px + backgroundColor = 0xFF222B37.toInt() + display = Display.Flex + flexDirection = FlexDirection.Row + alignItems = AlignItems.Center + justifyContent = JustifyContent.Start + border { width = 1.px; color = 0xFF3F4D5E.toInt() } + } + }) { + div({ + key = "animations.transition.card" + onMouseEnter = { animationsHover = true } + onMouseLeave = { animationsHover = false } + style = { + width = 120.px + height = 52.px + backgroundColor = 0xFF2E3C4F.toInt() + display = Display.Flex + flexDirection = FlexDirection.Column + transition { + property(StyleAnimProps.transform, 220, easing = Easings.EASE_IN_OUT) + property(StyleAnimProps.opacity, 200, easing = Easings.EASE_OUT) + property(StyleAnimProps.color, 260, easing = Easings.EASE_IN) + } + val tx = if (animationsToggle) 20f else 0f + val lift = if (animationsHover) -8f else 0f + val scale = if (animationsToggle) 1.08f else 1f + transform { + translate(tx, lift) + scale(scale) + rotate(if (animationsToggle) 8f else 0f) + } + 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() } + padding { all(4.px) } + } + }) { + text("Transition card") + text("hover + toggle", { style = { color = DEMO_MUTED } }) + } + + div({ + key = "animations.keyframes.card" + style = { + width = 120.px + height = 52.px + backgroundColor = 0xFF31313C.toInt() + display = Display.Flex + flexDirection = FlexDirection.Column + animation { + animation( + name = "showcase.spinFade", + durationMs = duration, + easing = easing, + iterationCount = iterations, + direction = direction, + fillMode = fillMode, + playState = playState + ) + } + transformOrigin { x = 0.5f; y = 0.5f } + border { width = 1.px; color = 0xFF5F5F72.toInt() } + padding { all(4.px) } + } + }) { + text("Keyframes card") + text("spin + fade + color", { style = { color = DEMO_MUTED } }) + } + + div({ + key = "animations.nested.parent" + style = { + width = 110.px + height = 52.px + backgroundColor = 0xFF2A3442.toInt() + padding = 4.px + transform { + rotate(if (animationsToggle) 12f else 0f) + } + transformOrigin { x = 0.5f; y = 0.5f } + transition { + property(StyleAnimProps.transform, 260, easing = Easings.EASE_IN_OUT) + } + border { width = 1.px; color = 0xFF4C6077.toInt() } + } + }) { + div({ + key = "animations.nested.child" + style = { + width = 64.px + height = 22.px + backgroundColor = 0xFF3F5571.toInt() + transform { + translate( + if (animationsToggle) 10f else 0f, + if (animationsToggle) 4f else 0f + ) + } + transition { + property(StyleAnimProps.transform, 220, easing = Easings.EASE_OUT) + } + border { width = 1.px; color = 0xFF7593B8.toInt() } + } + }) { + text("Nested", { style = { color = 0xFFEAF3FF.toInt() } }) + } + } + } + + 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}" + } ?: "-" + "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}" + + style = { color = DEMO_MUTED } + }) + } +} + +private fun UiScope.bezierSliderRow( + label: String, + key: String, + value: Long, + onChange: (Long) -> Unit +) { + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text(label, { style = { color = DEMO_MUTED } }) + input( + InputType.Range( + value = value, + min = 0, + max = 100, + step = 1 + ), + { + this.key = key + style = { width = 100.percent } + onInput = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: value + onChange(next.coerceIn(0, 100)) + } + } + ) + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ColorPickerSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ColorPickerSection.kt new file mode 100644 index 0000000..a2b78fd --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ColorPickerSection.kt @@ -0,0 +1,202 @@ +package org.dreamfinity.dsgl.mcForge1122.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.hooks.useEffect +import org.dreamfinity.dsgl.core.hooks.useMemo +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +fun UiScope.colorPickerSection() { + var colorInlineValue by useState(RgbaColor(0.28f, 0.52f, 0.88f, 1f)) + var colorInlineMode by useState(ColorFormatMode.HEX) + var colorPopupValue by useState(RgbaColor(0.82f, 0.31f, 0.41f, 0.9f)) + var colorPopupSecondValue by useState(RgbaColor(0.29f, 0.73f, 0.46f, 1f)) + var colorSharedA by useState(RgbaColor(0.91f, 0.73f, 0.19f, 1f)) + var colorSharedB by useState(RgbaColor(0.45f, 0.41f, 0.96f, 0.8f)) + var colorPickerLastCommit by useState("none") + var colorPickerAlphaEnabled by useState(true) + val sharedColorPickerManager by useMemo { ColorPickerPopupManager() } + + useEffect(sharedColorPickerManager) { + onDispose { sharedColorPickerManager.close() } + } + + fun openSharedColorPicker(target: String, mouseX: Int, mouseY: Int) { + val current = if (target == "A") colorSharedA else colorSharedB + sharedColorPickerManager.open( + anchorRect = Rect(mouseX, mouseY, 1, 1), + title = "Shared Picker [$target]", + state = ColorPickerState( + color = current, + previous = current, + mode = colorInlineMode, + alphaEnabled = colorPickerAlphaEnabled, + closeOnSelect = false + ), + closeOnOutsideClick = false, + onPreview = { color -> + if (target == "A") { + colorSharedA = color + } else { + colorSharedB = color + } + }, + onChange = { color -> + if (target == "A") { + colorSharedA = color + } else { + colorSharedB = color + } + }, + onCommit = { color -> + if (target == "A") { + colorSharedA = color + } else { + colorSharedB = color + } + colorPickerLastCommit = colorLabel(color) + } + ) + } + + div({ + key = "section.color-picker" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + text( + "Inline picker follows app styling. Inspector picker (F8) is rendered in isolated system overlay styles.", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button(if (colorPickerAlphaEnabled) "Alpha ON" else "Alpha OFF", { + onMouseClick = { + colorPickerAlphaEnabled = !colorPickerAlphaEnabled + } + }) + button("HEX", { + style = { backgroundColor = if (colorInlineMode == ColorFormatMode.HEX) 0xFF3E5877.toInt() else null } + onMouseClick = { colorInlineMode = ColorFormatMode.HEX } + }) + button("RGB", { + style = { backgroundColor = if (colorInlineMode == ColorFormatMode.RGB) 0xFF3E5877.toInt() else null } + onMouseClick = { colorInlineMode = ColorFormatMode.RGB } + }) + button("HSL", { + style = { backgroundColor = if (colorInlineMode == ColorFormatMode.HSL) 0xFF3E5877.toInt() else null } + onMouseClick = { colorInlineMode = ColorFormatMode.HSL } + }) + button("HSB", { + style = { backgroundColor = if (colorInlineMode == ColorFormatMode.HSB) 0xFF3E5877.toInt() else null } + onMouseClick = { colorInlineMode = ColorFormatMode.HSB } + }) + } + + div({ + style = { + gap = 6.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + div({ style = { display = Display.Inline } }) { + colorPicker({ + key = "demo.color.inline" + value = colorInlineValue + mode = colorInlineMode + alphaEnabled = colorPickerAlphaEnabled + closeOnSelect = false + style = {} + onPreviewColor = { colorInlineValue = it } + onChangeColor = { colorInlineValue = it } + onCommitColor = { + colorInlineValue = it + colorPickerLastCommit = colorLabel(it) + } + }) + } + + div({ + style = { + gap = 4.px + flexGrow = 1f + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Popup wrapper fields") + colorPickerPopup({ + key = "demo.color.popup.primary" + value = colorPopupValue + mode = ColorFormatMode.HEX + alphaEnabled = colorPickerAlphaEnabled + popupCloseOnOutsideClick = false + closeOnSelect = false + onPreviewColor = { colorPopupValue = it } + onChangeColor = { colorPopupValue = it } + onCommitColor = { + colorPopupValue = it + colorPickerLastCommit = colorLabel(it) + } + style = { width = 100.percent } + }) + colorPickerPopup({ + key = "demo.color.popup.secondary" + value = colorPopupSecondValue + mode = ColorFormatMode.RGB + alphaEnabled = colorPickerAlphaEnabled + popupCloseOnOutsideClick = false + closeOnSelect = false + onPreviewColor = { colorPopupSecondValue = it } + onChangeColor = { colorPopupSecondValue = it } + onCommitColor = { + colorPopupSecondValue = it + colorPickerLastCommit = colorLabel(it) + } + style = { width = 100.percent } + }) + + text("Shared manager retarget demo") + button("Edit A (${colorLabel(colorSharedA)})", { + style = { width = 100.percent } + onMouseDown = { event -> + openSharedColorPicker("A", event.mouseX, event.mouseY) + } + }) + button("Edit B (${colorLabel(colorSharedB)})", { + style = { width = 100.percent } + onMouseDown = { event -> + openSharedColorPicker("B", event.mouseX, event.mouseY) + } + }) + + text("Last commit: $colorPickerLastCommit", { style = { color = DEMO_MUTED } }) + text("A=${colorLabel(colorSharedA)}", { style = { color = DEMO_MUTED } }) + text("B=${colorLabel(colorSharedB)}", { style = { color = DEMO_MUTED } }) + } + } + } +} + +private fun colorLabel(color: RgbaColor): String { + return ColorTextCodec.format(color, ColorFormatMode.HEX, includeAlpha = true) +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ContextMenuSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ContextMenuSection.kt new file mode 100644 index 0000000..0dfb317 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ContextMenuSection.kt @@ -0,0 +1,1065 @@ + +package org.dreamfinity.dsgl.mcForge1122.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.event.KeyCodes +import org.dreamfinity.dsgl.core.event.MouseButton +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.mcForge1122.demo.support.DEMO_MUTED +import java.util.ArrayDeque + +private const val TILE_WIDTH = 86 +private const val TILE_ICON_SIZE = 30 +private const val TILE_GHOST_SIZE = 30 +private const val ICON_FOLDER = "file://demo/folder.png" +private const val ICON_DOCUMENT = "file://demo/document.png" +private const val CONTEXT_MENU_ROOT_ID = "fs.root" +private const val CONTEXT_MENU_DOUBLE_CLICK_MS = 320L + +private data class ContextMenuDemoFile( + val id: String, + val parentId: String?, + val name: String, + val sizeKb: Int, + val isDirectory: Boolean, + val locked: Boolean, + val updatedAtOrder: Long +) + +private data class ContextMenuBreadcrumb( + val id: String, + val label: String +) + +private data class ContextMenuSectionState( + val lastAction: String = "none", + val lastTarget: String = "none", + val actionCount: Int = 0, + val openAnchored: Boolean = false, + val showCursorDebug: Boolean = false, + val clipboardHasData: Boolean = false, + val clipboardEntryName: String = "clipboard.txt", + val clipboardEntryId: String? = null, + val sortMode: String = "Name", + val cursorX: Int = -1, + val cursorY: Int = -1, + val cursorOwner: String = "none", + val cursorLocalX: Int = 0, + val cursorLocalY: Int = 0, + val pinned: Boolean = false, + val fileSelection: String = "README.md", + val currentDirectoryId: String = CONTEXT_MENU_ROOT_ID, + val backHistory: List = emptyList(), + val forwardHistory: List = emptyList(), + val renameTargetId: String? = null, + val renameDraft: String = "", + val dragHoverDirectoryId: String? = null, + val files: List = defaultContextMenuFiles(), + val fileSequence: Long = 100L, + val lastClickEntryId: String? = null, + 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 + ) + onInfo("Context menu [$target]: $action") + } + + fun recordContextMenuCursor( + owner: String, + mouseX: Int, + mouseY: Int, + localX: Int, + localY: Int + ) { + state = state.copy( + cursorOwner = owner, + cursorX = mouseX, + cursorY = mouseY, + cursorLocalX = localX, + cursorLocalY = localY + ) + } + + fun contextMenuEntryById(entryId: String?): ContextMenuDemoFile? { + if (entryId == null) return null + return state.files.firstOrNull { it.id == entryId } + } + + fun contextMenuCurrentPath(): String { + val byId = state.files.associateBy { it.id } + val path = ArrayDeque() + var currentId: String? = state.currentDirectoryId + while (currentId != null) { + val entry = byId[currentId] ?: break + if (entry.id != CONTEXT_MENU_ROOT_ID) { + path.addFirst(entry.name) + } + currentId = entry.parentId + } + return if (path.isEmpty()) "/" else "/" + path.joinToString("/") + } + + fun contextMenuBreadcrumbs(): List { + val byId = state.files.associateBy { it.id } + val breadcrumbs = ArrayDeque() + var currentId: String? = state.currentDirectoryId + while (currentId != null) { + val entry = byId[currentId] ?: break + val label = if (entry.id == CONTEXT_MENU_ROOT_ID) "Workspace" else entry.name + breadcrumbs.addFirst(ContextMenuBreadcrumb(entry.id, label)) + currentId = entry.parentId + } + return breadcrumbs.toList() + } + + fun contextMenuCanGoBack(): Boolean = state.backHistory.isNotEmpty() + + fun contextMenuCanGoForward(): Boolean = state.forwardHistory.isNotEmpty() + + fun contextMenuCanGoUp(): Boolean { + val current = contextMenuEntryById(state.currentDirectoryId) ?: return false + return current.parentId != null + } + + fun contextMenuOpenDirectory(directoryId: String, pushHistory: Boolean) { + val currentState = state + val directory = currentState.files.firstOrNull { it.id == directoryId && it.isDirectory } ?: return + var backHistory = currentState.backHistory + var forwardHistory = currentState.forwardHistory + if (pushHistory && directory.id != currentState.currentDirectoryId) { + backHistory = backHistory + currentState.currentDirectoryId + forwardHistory = emptyList() + } + 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 + ) + 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 + ) + recordContextMenuAction("navigator", "forward to ${contextMenuCurrentPath()}") + } + + fun contextMenuNavigateUp() { + val current = contextMenuEntryById(state.currentDirectoryId) ?: return + 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( + files = state.files, + directoryId = state.currentDirectoryId, + 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 + ) + if (file.isDirectory && isDoubleClick) { + contextMenuOpenDirectory(file.id, pushHistory = true) + } + } + + fun contextMenuCreateFolder(parentId: String = state.currentDirectoryId) { + val currentState = state + 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 + ) + recordContextMenuAction("background", "new folder ${created.name}") + } + + fun contextMenuCreateFile(parentId: String = state.currentDirectoryId) { + val currentState = state + val parent = currentState.files.firstOrNull { it.id == parentId && it.isDirectory } ?: return + 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 + ) + } + + 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 + ) + recordContextMenuAction("background", "new file $name") + } + + fun contextMenuOpenFile(file: ContextMenuDemoFile) { + if (file.isDirectory) { + contextMenuOpenDirectory(file.id, pushHistory = true) + return + } + state = state.copy(fileSelection = file.name) + recordContextMenuAction(file.name, "open") + } + + fun contextMenuBeginRename(file: ContextMenuDemoFile) { + if (file.locked) return + state = state.copy( + renameTargetId = file.id, + renameDraft = file.name, + fileSelection = file.name + ) + } + + fun contextMenuCancelRename() { + state = state.copy(renameTargetId = null, renameDraft = "") + } + + fun contextMenuApplyRename() { + val targetId = state.renameTargetId ?: return + val target = contextMenuEntryById(targetId) ?: run { + state = state.copy(renameTargetId = null) + return + } + if (target.locked) { + state = state.copy(renameTargetId = null) + return + } + 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 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 + ) + recordContextMenuAction(target.name, "rename to $resolved") + } + + fun contextMenuCopyFile(file: ContextMenuDemoFile) { + 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 + ) { + val currentState = state + val targetParent = currentState.files.firstOrNull { it.id == targetParentId && it.isDirectory } ?: return + val byId = currentState.files.associateBy { it.id } + val descendants = collectSubtree(file.id, byId) + if (descendants.isEmpty()) return + + val idRemap = linkedMapOf() + var sequence = currentState.fileSequence + fun nextId(): String { + sequence += 1L + return "fs.$sequence" + } + + fun nextOrder(): Long { + sequence += 1L + return sequence + } + + val rootCopyId = nextId() + 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() + ) + 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() + ) + } + + 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 + if (!destination.isDirectory) return false + if (entry.id == destination.id) return false + if (entry.parentId == destination.id) return false + if (isDescendantDirectory(state.files, ancestorId = entry.id, candidateId = destination.id)) return false + return true + } + + fun contextMenuMoveFile(file: ContextMenuDemoFile, destinationDirectoryId: String) { + if (!contextMenuCanDropIntoDirectory(file.id, destinationDirectoryId)) return + 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 + ) + recordContextMenuAction(file.name, "move to ${destination.name}") + } + + fun contextMenuDeleteFile(file: ContextMenuDemoFile) { + if (file.locked) return + val subtreeIds = collectSubtreeIds(state.files, file.id) + var nextCurrentDirectory = state.currentDirectoryId + var nextBackHistory = state.backHistory + var nextForwardHistory = state.forwardHistory + if (nextCurrentDirectory == file.id || nextCurrentDirectory in subtreeIds) { + nextCurrentDirectory = CONTEXT_MENU_ROOT_ID + nextBackHistory = emptyList() + nextForwardHistory = emptyList() + } else { + nextBackHistory = nextBackHistory.filterNot { subtreeIds.contains(it) } + nextForwardHistory = nextForwardHistory.filterNot { subtreeIds.contains(it) } + } + + var nextClipboardHasData = state.clipboardHasData + var nextClipboardEntryId = state.clipboardEntryId + if (nextClipboardEntryId in subtreeIds) { + nextClipboardHasData = false + nextClipboardEntryId = null + } + + 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( + files = nextFiles, + directoryId = nextCurrentDirectory, + sortMode = state.sortMode + ).none { it.name == nextSelection } + ) { + nextSelection = sortedChildrenForDirectory( + files = nextFiles, + directoryId = nextCurrentDirectory, + 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 + ) + recordContextMenuAction(file.name, "delete") + } + + fun contextMenuPasteIntoWorkspace() { + if (!state.clipboardHasData) return + val sourceId = state.clipboardEntryId ?: return + val source = contextMenuEntryById(sourceId) ?: return + contextMenuDuplicateFile(source, targetParentId = state.currentDirectoryId) + recordContextMenuAction("background", "paste ${source.name}") + } + + fun contextMenuRefreshWorkspace() { + 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() } + } + } + + 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("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") + + 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) } + } + item("Directory", id = "entry.createInside.dir") { + icon("FD") + onClick { contextMenuCreateFolder(file.id) } + } + } + separator("entry.sep.open") + } + + item("Duplicate", id = "entry.duplicate") { + icon("CP") + onClick { contextMenuDuplicateFile(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) } + } + + separator("entry.sep.copy") + + 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}", + 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() + } + 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() + } + } + } + ) + } 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 + recordContextMenuCursor( + owner = "file:${file.id}", + mouseX = mouseX, + mouseY = mouseY, + localX = mouseX - anchorX, + localY = mouseY - anchorY + ) + openMenu(buildEntryMenu(file)) + } + } + + val entries = contextMenuVisibleFiles() + ContextMenuRuntime.engine.setStyle( + ContextMenuStyle( + panelPaddingX = 4, + panelPaddingY = 4, + rowPaddingX = 6, + rowPaddingY = 2, + rowGap = 1, + iconColumnMinWidth = 13, + minPanelWidth = 158, + panelBackgroundColor = 0xFF212833.toInt(), + panelBorderColor = 0xFF5F7387.toInt(), + panelShadowColor = 0x7C0E1520, + itemHoverBackgroundColor = 0xFF33506B.toInt(), + itemSelectedBackgroundColor = 0xFF2A4155.toInt(), + itemTextColor = 0xFFF1F6FC.toInt(), + disabledTextColor = 0xFF8D98A4.toInt(), + hintTextColor = 0xFFC5D2E1.toInt(), + separatorColor = 0xFF4C6074.toInt(), + checkMarkColor = 0xFF8BD59D.toInt(), + submenuArrowColor = 0xFFC9D7E6.toInt() + ) + ) + + div({ + key = "section.contextMenu" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Pseudo filesystem: tile view + context menu + drag/drop") + text( + "path=${contextMenuCurrentPath()} sort=${state.sortMode} selected=${state.fileSelection}", + { style = { color = DEMO_MUTED } } + ) + text( + "lastAction=${state.lastAction} target=${state.lastTarget} actions=${state.actionCount}", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("New File", { + onMouseClick = { contextMenuCreateFile() } + }) + button("New Folder", { + onMouseClick = { contextMenuCreateFolder() } + }) + button("Up", { + onMouseClick = { contextMenuNavigateUp() } + disabled = !contextMenuCanGoUp() + }) + } + + div({ + key = "section.contextMenu.window" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + flexGrow = 1f + minHeight = 150.px + padding = 3.px + gap = 2.px + backgroundColor = 0xFF2A313B.toInt() + border { width = 1.px; color = 0xFF5B6A7A.toInt() } + } + }) { + div({ + key = "section.contextMenu.pathbar" + style = { + padding = 2.px + gap = 2.px + backgroundColor = 0xFF25303A.toInt() + display = Display.Flex + flexDirection = FlexDirection.Row + border { width = 1.px; color = 0xFF4F6175.toInt() } + } + }) { + button("<", { + key = "section.contextMenu.path.back" + onMouseClick = { contextMenuNavigateBack() } + disabled = !contextMenuCanGoBack() + }) + button(">", { + key = "section.contextMenu.path.forward" + onMouseClick = { contextMenuNavigateForward() } + disabled = !contextMenuCanGoForward() + }) + val breadcrumbs = contextMenuBreadcrumbs() + breadcrumbs.forEachIndexed { index, breadcrumb -> + if (index > 0) { + 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) + 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 + }, + 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, { + key = breadcrumbKey + onMouseClick = { + contextMenuOpenDirectory(breadcrumb.id, pushHistory = true) + } + style = { + backgroundColor = when { + isDropHover -> 0xFF40617F.toInt() + isCurrent -> 0xFF364A5E.toInt() + else -> 0xFF2B3A4A.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 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 } }) + } + } else { + entries.forEach { file -> + contextMenuEntryTile(file) + } + } + } + listNode.onContextMenu { + val anchorX = anchorRect?.x ?: mouseX + val anchorY = anchorRect?.y ?: mouseY + recordContextMenuCursor( + owner = "background", + mouseX = mouseX, + mouseY = mouseY, + localX = mouseX - anchorX, + localY = mouseY - anchorY + ) + openMenu(buildBackgroundMenu()) + } + } + } +} + +private fun iconFor(file: ContextMenuDemoFile): String { + return if (file.isDirectory) ICON_FOLDER else ICON_DOCUMENT +} + +private fun uniqueContextMenuName( + files: List, + parentId: String, + baseName: String, + variant: String = "new" +): String { + 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) { + "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) + while (existing.contains(next)) { + index += 1 + next = candidate(index) + } + return next +} + +private fun splitContextMenuName(name: String): Pair { + val dotIndex = name.lastIndexOf('.') + return if (dotIndex > 0 && dotIndex < name.length - 1) { + name.substring(0, dotIndex) to name.substring(dotIndex) + } else { + name to "" + } +} +private fun sortedChildrenForDirectory( + files: List, + directoryId: 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() } + } + return children.sortedWith(compareBy { !it.isDirectory }.then(comparator)) +} + +private fun collectSubtree( + rootId: String, + byId: Map +): List { + val root = byId[rootId] ?: return emptyList() + val queue = ArrayDeque() + val orderedIds = ArrayList() + queue += root.id + while (queue.isNotEmpty()) { + val currentId = queue.removeFirst() + orderedIds += currentId + byId.values + .filter { it.parentId == currentId } + .sortedBy { it.name.lowercase() } + .forEach { queue += it.id } + } + 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 isDescendantDirectory( + files: List, + ancestorId: String, + candidateId: String +): Boolean { + if (ancestorId == candidateId) return true + val byId = files.associateBy { it.id } + var current = byId[candidateId] + while (current != null) { + if (current.parentId == ancestorId) return true + current = current.parentId?.let { byId[it] } + } + return false +} + +private fun defaultContextMenuFiles(): List { + return 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), + ContextMenuDemoFile("fs.projects", CONTEXT_MENU_ROOT_ID, "Projects", 0, true, false, 4L), + ContextMenuDemoFile("fs.readme", CONTEXT_MENU_ROOT_ID, "README.md", 4, false, true, 5L), + ContextMenuDemoFile("fs.build", CONTEXT_MENU_ROOT_ID, "build.gradle.kts", 3, false, false, 6L), + ContextMenuDemoFile("fs.mods", CONTEXT_MENU_ROOT_ID, "mods.toml", 1, false, false, 7L), + ContextMenuDemoFile("fs.atlas", CONTEXT_MENU_ROOT_ID, "TexturesAtlas.kt", 19, false, false, 8L), + ContextMenuDemoFile("fs.notes", CONTEXT_MENU_ROOT_ID, "notes.txt", 2, false, false, 9L), + ContextMenuDemoFile("fs.roadmap", CONTEXT_MENU_ROOT_ID, "roadmap.md", 6, false, false, 10L), + ContextMenuDemoFile("fs.docs.spec", "fs.docs", "spec.md", 5, false, false, 11L), + ContextMenuDemoFile("fs.docs.todos", "fs.docs", "todos.txt", 2, false, false, 12L), + ContextMenuDemoFile("fs.projects.clientA", "fs.projects", "Client A", 0, true, false, 13L), + ContextMenuDemoFile("fs.projects.archive", "fs.projects", "Archive", 0, true, false, 14L), + 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) + ) +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/CssCascadeSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/CssCascadeSection.kt new file mode 100644 index 0000000..7221743 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/CssCascadeSection.kt @@ -0,0 +1,326 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +fun UiScope.cssCascadeCombinatorsSection(onLogHook: (String, Event, String?) -> Unit) { + var cascadeParentDark by useState(false) + var cascadeRuleAEnabled by useState(true) + var cascadeAdjacentSourceEnabled by useState(true) + var cascadeAdjacentSwapOrder by useState(false) + var cascadeGeneralWarningIndex by useState(1L) + var cascadeGeneralInsertExtra by useState(false) + var cascadeMixedSpacerEnabled by useState(false) + + 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") + } + 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" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + + div({ + key = "section.cssCascade.controls" + style = { + gap = 4.px + display = Display.Flex + 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 (cascadeRuleAEnabled) "Rule block: A" else "Rule block: B", { + key = "section.cssCascade.toggleRuleBlock" + onMouseClick = { event -> + cascadeRuleAEnabled = !cascadeRuleAEnabled + onLogHook("css.cascade.toggle.ruleBlock", event, "ruleA=$cascadeRuleAEnabled") + } + }) + } + + div({ + key = "section.cssCascade.demoRoot" + className = "cascade-demo-root $parentThemeClass $ruleBlockClass" + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + + div({ + key = "section.cssCascade.panel" + className = "panel" + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("panel > .item (direct)", { + key = "section.cssCascade.directItem" + className = "item direct-item" + }) + div({ + key = "section.cssCascade.nestedWrap" + style = { + gap = 1.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("panel .item (nested descendant)", { + key = "section.cssCascade.nestedItem" + className = "item nested-item" + }) + } + } + + text("outside .panel item (inherit only)", { + key = "section.cssCascade.outsideItem" + className = "item outside-item" + }) + + button("Specificity target #primary.btn", { + key = "section.cssCascade.primaryBtn" + id = "primary" + className = "btn" + onMouseClick = { event -> + onLogHook("css.cascade.specificity.target", event, null) + } + }) + + text("Source order target: later '.order-target' rule should win (green).", { + key = "section.cssCascade.sourceOrder" + className = "order-target" + }) + text("Important target: !important should win (orange).", { + key = "section.cssCascade.important" + className = "important-target" + }) + text("Rule block target: toggles between A/B classes on parent.", { + key = "section.cssCascade.blockToggle" + className = "toggle-target" + }) + } + + div({ + key = "section.cssCascade.siblings" + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text( + "Adjacent sibling (+): only immediate .adj-target after .adj-source should change.", + { style = { color = DEMO_MUTED } } + ) + div({ + key = "section.cssCascade.adj.controls" + style = { + gap = 4.px + display = Display.Flex + 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 (cascadeAdjacentSwapOrder) "Order: swapped" else "Order: default", { + key = "section.cssCascade.adj.swap" + onMouseClick = { event -> + cascadeAdjacentSwapOrder = !cascadeAdjacentSwapOrder + onLogHook("css.cascade.adj.swap", event, "swap=$cascadeAdjacentSwapOrder") + } + }) + } + div({ + key = "section.cssCascade.adj.demo" + className = "cascade-sibling-adj" + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + adjacentOrder.forEach { item -> + val classNames = buildString { + append("adj-item ") + when (item) { + "adj-source" -> { + if (cascadeAdjacentSourceEnabled) append("adj-source") + else append("adj-neutral") + } + + else -> append("adj-target") + } + } + text(item, { + key = "section.cssCascade.$item" + className = classNames + }) + } + } + + text( + "General sibling (~): all .gen-target after .warning should change.", + { style = { color = DEMO_MUTED } } + ) + div({ + key = "section.cssCascade.gen.controls" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Move warning", { + key = "section.cssCascade.gen.moveWarning" + onMouseClick = { event -> + val size = generalItems.size.coerceAtLeast(1) + cascadeGeneralWarningIndex = (cascadeGeneralWarningIndex + 1L) % size + onLogHook( + "css.cascade.gen.moveWarning", + event, + "index=$cascadeGeneralWarningIndex" + ) + } + }) + button(if (cascadeGeneralInsertExtra) "Extra sibling: ON" else "Extra sibling: OFF", { + key = "section.cssCascade.gen.toggleExtra" + onMouseClick = { event -> + cascadeGeneralInsertExtra = !cascadeGeneralInsertExtra + onLogHook( + "css.cascade.gen.toggleExtra", + event, + "extra=$cascadeGeneralInsertExtra" + ) + } + }) + } + div({ + key = "section.cssCascade.gen.demo" + className = "cascade-sibling-general" + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + generalItems.forEachIndexed { index, keySuffix -> + val classNames = if (index == effectiveWarningIndex) "warning gen-item" else "gen-target gen-item" + text(keySuffix, { + key = "section.cssCascade.$keySuffix" + className = classNames + }) + } + } + + text( + "Mixed chain: .cascade-mixed > .header + .body .title", + { style = { color = DEMO_MUTED } } + ) + button(if (cascadeMixedSpacerEnabled) "Spacer: ON (break +)" else "Spacer: OFF (adjacent)", { + key = "section.cssCascade.mixed.toggleSpacer" + onMouseClick = { event -> + cascadeMixedSpacerEnabled = !cascadeMixedSpacerEnabled + onLogHook( + "css.cascade.mixed.toggleSpacer", + event, + "spacer=$cascadeMixedSpacerEnabled" + ) + } + }) + div({ + key = "section.cssCascade.mixed.demo" + className = "cascade-mixed" + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("header", { + key = "section.cssCascade.mixed.header" + className = "header" + }) + if (cascadeMixedSpacerEnabled) { + text("spacer", { + key = "section.cssCascade.mixed.spacer" + className = "spacer" + }) + } + div({ + key = "section.cssCascade.mixed.body" + className = "body" + style = { + gap = 1.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("title", { + key = "section.cssCascade.mixed.title" + className = "title" + }) + } + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DisplaySection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DisplaySection.kt new file mode 100644 index 0000000..e78eb3c --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DisplaySection.kt @@ -0,0 +1,429 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.* +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.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 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 +) { + var displayBlockLargeGap by useState(false) + var displayInlineWidth by useState(132L) + var displayShowHidden by useState(true) + var displayFlexJustifyIndex by useState(0) + var displayFlexAlignIndex by useState(0) + var displayGridColumns by useState(3L) + var displayGridLargeGap by useState(false) + var displayNoneClicks by useState(0) + + val inlineMinWidth = 96 + val inlineMaxWidth = 320 + val inlineWidth = displayInlineWidth.toInt().coerceIn(inlineMinWidth, inlineMaxWidth) + val gridColumns = displayGridColumns.toInt().coerceIn(2, 6) + val justifyIndex = displayFlexJustifyIndex.mod(JUSTIFY_OPTIONS.size) + val alignIndex = displayFlexAlignIndex.mod(ALIGN_OPTIONS.size) + val justify = JUSTIFY_OPTIONS[justifyIndex] + val align = ALIGN_OPTIONS[alignIndex] + + div({ + key = "section.display" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Display showcase: block / inline / none / flex / grid") + text("This section is self-checking: each panel demonstrates one display mode.", { + style = { color = DEMO_MUTED } + }) + + text("Block flow (vertical stacking)") + button(if (displayBlockLargeGap) "Block gap: large" else "Block gap: compact", { + onMouseClick = { + displayBlockLargeGap = !displayBlockLargeGap + onInfo("Display.block gap=${if (displayBlockLargeGap) "large" else "compact"}") + } + }) + div({ + key = "display.block.container" + style = { + width = 100.percent + padding = 3.px + backgroundColor = 0xFF2B3542.toInt() + display = Display.Block + gap = (if (displayBlockLargeGap) 6 else 2).px + border { width = 1.px; color = 0xFF657688.toInt() } + } + }) { + repeat(3) { index -> + div({ + key = "display.block.item.$index" + style = { + padding = 2.px + backgroundColor = (0xFF3A4B60 + index * 0x000A0A00).toInt() + border { width = 1.px; color = 0xFF8095AA.toInt() } + } + }) { + text("Block item ${index + 1}") + } + } + } + + text("Inline flow (chips with wrap, incl. nested flex/block items)") + input( + InputType.Range( + value = inlineWidth.toLong(), + min = inlineMinWidth.toLong(), + max = inlineMaxWidth.toLong(), + step = 4 + ), + { + key = "display.inline.width" + style = { width = 100.percent } + onInput = { event -> + 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 } }) + div({ + key = "display.inline.container" + style = { + width = inlineWidth.px + padding = 3.px + backgroundColor = 0xFF2E3946.toInt() + display = Display.Inline + border { width = 1.px; color = 0xFF607181.toInt() } + gap = 2.px + } + }) { + listOf("alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta").forEachIndexed { index, label -> + div({ + key = "display.inline.chip.$index" + style = { + 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() } + } + }) { + text(label) + } + } + div({ + key = "display.inline.flex.item" + style = { + 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() } + } + }) { + text("flex") + div({ + style = { + gap = 1.px + padding = 1.px + backgroundColor = 0xFF2E4739.toInt() + display = Display.Flex + flexDirection = FlexDirection.Row + border { width = 1.px; color = 0xFF5B8D73.toInt() } + } + }) { + div({ + style = { + width = 8.px + height = 4.px + backgroundColor = 0xFF6EAE8B.toInt() + } + }) + div({ + style = { + width = 6.px + height = 4.px + backgroundColor = 0xFF4E7A62.toInt() + } + }) + } + } + div({ + key = "display.inline.block.item" + style = { + 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() } + } + }) { + div({ + style = { + padding = 1.px + backgroundColor = 0xFF4B3B30.toInt() + display = Display.Block + border { width = 1.px; color = 0xFF8B6A51.toInt() } + gap = 1.px + } + }) { + text("block") + text("A") + text("B") + } + } + } + + text("Display none (layout + hit-test removal)") + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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 } } + ) + } + div({ + key = "display.none.container" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + width = 100.percent + padding = 3.px + backgroundColor = 0xFF303A46.toInt() + gap = 2.px + border { width = 1.px; color = 0xFF64788B.toInt() } + } + }) { + div({ + key = "display.none.target" + onMouseClick = { + displayNoneClicks += 1 + onLogHook("display.none.onMouseClick", it, null) + } + style = { + padding = 2.px + backgroundColor = 0xFF5A3E3E.toInt() + display = if (displayShowHidden) Display.Block else Display.None + border { width = 1.px; color = 0xFFB07B7B.toInt() } + } + }) { + text("Toggle target (click me)") + } + text("Sibling stays and reflows when target is hidden.", { style = { color = DEMO_MUTED } }) + } + + text("Flex layout (row + column)") + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("justify=${justify.first}", { + onMouseClick = { + displayFlexJustifyIndex = (justifyIndex + 1) % JUSTIFY_OPTIONS.size + } + }) + button("align=${align.first}", { + onMouseClick = { + displayFlexAlignIndex = (alignIndex + 1) % ALIGN_OPTIONS.size + } + }) + 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 } + }) + div({ + key = "display.flex.justify.playground" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + width = 100.percent + padding = 1.px + backgroundColor = 0xFF24303A.toInt() + justifyContent = justify.second + alignItems = AlignItems.Center + gap = 0.px + border { width = 1.px; color = 0xFF7E93A8.toInt() } + } + }) { + dot("left", "A", 0xFFB3D6FF.toInt(), 0xFFDEEFFF.toInt()) + dot("mid", "B", 0xFF9FE3B5.toInt(), 0xFFD6FFE4.toInt()) + dot("right", "C", 0xFFFFC7A3.toInt(), 0xFFFFE5D3.toInt()) + } + text("Top strip isolates justify (A/B/C). Large row below combines justify + align + gap.", { + style = { color = DEMO_MUTED } + }) + div({ + key = "display.flex.row" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + width = 100.percent + padding = 2.px + backgroundColor = 0xFF2A343F.toInt() + justifyContent = justify.second + alignItems = align.second + gap = (if (displayGridLargeGap) 8 else 2).px + border { width = 1.px; color = 0xFF6C7E90.toInt() } + } + }) { + flexRowCell("0", "1", 14, 1, 0xFF46627C.toInt()) + flexRowCell("1", "2", 20, 2, 0xFF4E7A5A.toInt()) + flexRowCell("2", "3", 26, 3, 0xFF7A5B4A.toInt()) + flexRowCell("3", "4", 16, 1, 0xFF6A4B78.toInt()) + } + div({ + key = "display.flex.column" + style = { + width = 100.percent + padding = 2.px + backgroundColor = 0xFF2A3340.toInt() + gap = 2.px + border { width = 1.px; color = 0xFF6B7E92.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + div({ + style = { + display = Display.Inline + backgroundColor = 0xFF4B5C70.toInt() + } + }) { text("header") } + div({ + style = { + display = Display.Inline + backgroundColor = 0xFF3E6A54.toInt() + flexGrow = 1f + } + }) { + text("content grow") + } + div({ + style = { + display = Display.Inline + backgroundColor = 0xFF66503D.toInt() + } + }) { text("footer") } + } + + text("Grid layout (repeat(columns, 1fr))") + input( + InputType.Range( + value = gridColumns.toLong(), + min = 2, + max = 6, + step = 1 + ), + { + key = "display.grid.columns" + style = { width = 100.percent } + onInput = { event -> + 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 } } + ) + div({ + key = "display.grid.container" + style = { + width = 100.percent + padding = 3.px + backgroundColor = 0xFF2B3540.toInt() + display = Display.Grid + this.gridColumns = gridColumns + gap = (if (displayGridLargeGap) 4 else 2).px + alignItems = align.second + justifyItems = JustifyItems.Stretch + border { width = 1.px; color = 0xFF70849A.toInt() } + } + }) { + repeat(10) { index -> + div({ + key = "display.grid.item.$index" + style = { + padding = 1.px + backgroundColor = (0xFF3D5873 + index * 0x00040401).toInt() + if (index == 0) { + gridColumnSpan = 2 + } + border { width = 1.px; color = 0xFF93AACC.toInt() } + } + }) { + text("Cell ${index + 1}") + } + } + } + } +} + +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 } + } + }) { text(label) } +} + +private fun UiScope.flexRowCell(keyPart: String, label: String, widthPx: Int, paddingPx: Int, color: Int) { + div({ + key = "display.flex.row.item.$keyPart" + style = { + display = Display.Inline + backgroundColor = color + } + }) { text(label) } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DragDropSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DragDropSection.kt new file mode 100644 index 0000000..c217162 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/DragDropSection.kt @@ -0,0 +1,971 @@ +package org.dreamfinity.dsgl.mcForge1122.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.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.JustifyItems +import org.dreamfinity.dsgl.core.hooks.useEffect +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.McItemStackRef +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +private const val HIGHLIGHT_DELTA = 22 + +private data class DndDemoItem( + val id: String, + val label: String, + val stack: McItemStackRef +) + +private enum class DndLaneIndicator { + NONE, + BEFORE, + AFTER +} + +private data class DndSectionState( + val items: List = defaultDndItems(), + val hoverZone: String = "none", + val lastAction: String = "none", + val transferTypes: String = "-", + val dropEffect: String = "none", + val activeItem: String = "none", + val dragTickCount: Int = 0, + val ghostEnabled: Boolean = true, + val hideSourceWhileDragging: Boolean = false, + val smoothFactor: Double = 26.0, + val reorderHoverTargetId: String? = null, + val reorderHoverInsertAfter: Boolean = false, + val reorderHoverLaneAppend: Boolean = false, + val debugOverId: String = "none", + val debugOverContainerId: String = "none", + 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() + ) +) + +private data class LaneHoverIntent( + val targetId: String?, + val insertAfter: Boolean, + val append: Boolean +) + +fun UiScope.dragNDropSection( + onInfo: (String) -> Unit, + onClearLogs: () -> 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 + ) + } + + fun resetDndItems(source: String) { + state = DndSectionState( + items = defaultDndItems(), + smoothFactor = state.smoothFactor + ) + onInfo("DnD demo list reset by $source") + } + + fun updateDndSmoothing(delta: Double) { + val next = (state.smoothFactor + delta).coerceIn(0.0, 96.0) + state = state.copy(smoothFactor = next) + DndSystem.setSmoothingFactor(next) + onInfo("DnD smoothing k=${"%.1f".format(next)}") + } + + 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 laneIndex = lane.indexOfFirst { it.id == cardId } + if (laneIndex >= 0) { + val card = lane.removeAt(laneIndex) + return Triple(card, lane, boxes) + } + boxes.forEach { (_, list) -> + val boxIndex = list.indexOfFirst { it.id == cardId } + if (boxIndex >= 0) { + val card = list.removeAt(boxIndex) + return Triple(card, lane, boxes) + } + } + return null + } + + fun wouldLaneReorderChange( + draggedId: String, + targetId: String?, + insertAfter: Boolean?, + dropOnLane: Boolean + ): Boolean { + 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) + } + laneIds.add(destinationIndex, removed) + return laneIds != state.items.map { it.id } + } + + fun commitLaneReorderDrop( + draggedId: String, + targetId: String?, + insertAfter: 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) + } + 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() } + ) + return true + } + + fun moveCardToBox(cardId: String, boxId: String): Boolean { + val extracted = extractCard(cardId) ?: return false + val lane = extracted.second.toMutableList() + 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() } + ) + 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 + } + event.dataTransfer.setData("text/plain", item.label) + event.dataTransfer.setData("application/x-dsgl-item-id", item.id) + event.dataTransfer.effectAllowed = EffectAllowed.COPY_MOVE + event.dataTransfer.dropEffect = DropEffect.MOVE + if (!state.ghostEnabled) { + event.dataTransfer.hideGhost() + } + 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" + 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() + ) + if (tick % 5 == 0) { + logHook("dnd.onDrag", event, "tick=$tick") + } + } + + fun laneCards(laneNode: DOMNode?, excludedCardId: String?): List> { + if (laneNode == null) return emptyList() + return laneNode.children + .mapNotNull { child -> + val id = extractCardIdFromDragKey(child.key) ?: return@mapNotNull null + if (excludedCardId != null && id == excludedCardId) return@mapNotNull null + id to child + } + .sortedBy { (_, node) -> node.bounds.y } + } + + 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 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 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() + ) + event.acceptDrop(DropEffect.MOVE) + } + + fun handleDndLaneDrop(event: DropEvent) { + 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 + ) + if (moved) { + onInfo( + "Lane drop: drag=$draggedId target=${intent.targetId ?: "lane"} pos=${ + 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" + ) + logHook( + "dnd.reorder.lane.onDrop", + event, + "dragged=$draggedId target=${intent.targetId ?: "lane"} append=${intent.append}" + ) + } + + fun handleDndCardReorderOver(targetCardId: String, insertAfter: Boolean, event: DragOverEvent) { + 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() + ) + event.acceptDrop(DropEffect.MOVE) + event.cancelled = true + } + + fun handleDndCardReorderDrop(targetCardId: String, insertAfter: Boolean, event: DropEvent) { + val draggedId = event.dataTransfer.getData("application/x-dsgl-item-id") ?: return + if (draggedId == targetCardId) return + val moved = commitLaneReorderDrop(draggedId, targetCardId, insertAfter, dropOnLane = false) + if (moved) { + 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() + ) + event.cancelled = true + logHook( + "dnd.reorder.card.onDrop", + event, + "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() + ) + event.acceptDrop(DropEffect.MOVE) + } + + fun handleDndBoxDrop(boxId: String, event: DropEvent) { + val draggedId = event.dataTransfer.getData("application/x-dsgl-item-id") ?: return + val moved = moveCardToBox(draggedId, boxId) + if (moved) { + state = state.copy(hoverZone = boxId, lastAction = "moved $draggedId to $boxId") + } + 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" + ) + logHook("dnd.onDragEnd", event, "drop=${event.didDrop}") + } + + fun laneIndicatorForCard(cardId: String, sourceKey: Any?): DndLaneIndicator { + 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 + ) + if (!wouldChange) return DndLaneIndicator.NONE + return if (state.reorderHoverInsertAfter) DndLaneIndicator.AFTER else DndLaneIndicator.BEFORE + } + + fun shouldShowLaneAppendGap(sourceKey: Any?): Boolean { + if (!state.reorderHoverLaneAppend) return false + val draggedId = extractCardIdFromDragKey(sourceKey) ?: return true + if (!wouldLaneReorderChange(draggedId, targetId = null, insertAfter = null, dropOnLane = true)) return false + val index = state.items.indexOfFirst { it.id == draggedId } + if (index < 0) return true + return index != state.items.lastIndex + } + + useEffect(state.smoothFactor) { + DndSystem.setSmoothingFactor(state.smoothFactor) + } + + 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" + ) + }, + onDragOver = { active, over -> + 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" + ) + }, + onDragCancel = { + state = state.copy( + debugOverId = "none", + debugOverContainerId = "none" + ) + } + ) + ) + + val monitor = DndSystem.monitor() + + div({ + key = "section.dragDrop" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Drag preview modes: ORIGINAL (detached source) and GHOST (overlay preview).") + text( + "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 } } + ) + text( + "candidates=${state.debugCandidatesCount} insert=${state.debugInsertPosition} excludeActive=${state.debugExcludesActiveCard}", + { style = { color = DEMO_MUTED } } + ) + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button(if (state.ghostEnabled) "Ghost ON" else "Ghost OFF", { + onMouseClick = { + val next = !state.ghostEnabled + state = state.copy(ghostEnabled = next) + onInfo("DnD ghost=$next") + } + }) + button(if (state.hideSourceWhileDragging) "Hide ON" else "Hide OFF", { + onMouseClick = { + val next = !state.hideSourceWhileDragging + state = state.copy(hideSourceWhileDragging = next) + onInfo("DnD hideSource=$next") + } + }) + button("Reset state", { onMouseClick = { resetDndItems("toolbar") } }) + button("Reset logs", { onMouseClick = { onClearLogs() } }) + } + + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("k-", { onMouseClick = { updateDndSmoothing(-4.0) } }) + button("k+", { onMouseClick = { updateDndSmoothing(4.0) } }) + text("smoothing k=${"%.1f".format(state.smoothFactor)}", { style = { color = DEMO_MUTED } }) + } + + div({ + key = "dnd.main" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 6.px + alignItems = AlignItems.Stretch + } + }) { + originalModeReorder( + state = state, + sourceKey = monitor.sourceKey, + onStart = ::handleDndStart, + onDrag = ::handleDndDrag, + onEnd = ::handleDndEnd, + onLaneOver = ::handleDndLaneOver, + onLaneLeave = ::clearLaneReorderHover, + onLaneDrop = ::handleDndLaneDrop, + onCardOver = ::handleDndCardReorderOver, + onCardDrop = ::handleDndCardReorderDrop, + laneIndicatorForCard = ::laneIndicatorForCard, + shouldShowLaneAppendGap = ::shouldShowLaneAppendGap + ) + renderGhostModeBoxes( + state = state, + onStart = ::handleDndStart, + onDrag = ::handleDndDrag, + onEnd = ::handleDndEnd, + onBoxOver = ::handleDndBoxOver, + onBoxDrop = ::handleDndBoxDrop, + onHoverZone = { boxId -> state = state.copy(hoverZone = boxId) }, + onLogHook = ::logHook, + onReset = { resetDndItems("button") } + ) + } + } +} + +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 +) { + val draggedId = extractCardIdFromDragKey(sourceKey) + + div({ + key = "dnd.original.panel" + style = { + minWidth = 200.px + gap = 3.px + padding = 3.px + backgroundColor = 0xFF2D333B.toInt() + border { width = 1.px; color = 0xFF6B7785.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Column + flexGrow = 1.0f + } + }) { + 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) } + ) + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + justifyItems = JustifyItems.Center + alignItems = AlignItems.Center + gap = 4.px + } + }) { + div({ + key = "dnd.lane.column" + style = { + gap = 6.px + backgroundColor = if (state.reorderHoverLaneAppend) 0x2A9EC4E3 else 0x00000000 + border { width = 1.px; color = if (state.reorderHoverLaneAppend) 0xFF9EC4E3.toInt() else 0x44405058 } + display = Display.Flex + flexDirection = FlexDirection.Column + } + applyDroppable(laneDroppable) + }) { + if (state.items.isEmpty()) { + text("No items available - drop here", { style = { color = DEMO_MUTED } }) + } + + 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 + ) + + cardWithItem( + item = item, + cardKey = "dnd.lane.card.${item.id}", + sortable = sortable, + 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) + } + ) + ) + } + + if (shouldShowLaneAppendGap(sourceKey)) { + div({ + key = "dnd.lane.append.gap" + style = { + backgroundColor = 0x2A9EC4E3 + border { width = 1.px; color = 0xFF9EC4E3.toInt() } + borderRadius = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + alignItems = AlignItems.Center + } + }) { + text("APPEND", { style = { color = 0xFFD3E8FB.toInt() } }) + } + } + } + } + } +} + +private fun UiScope.renderGhostModeBoxes( + state: DndSectionState, + 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, + onReset: () -> Unit +) { + div({ + key = "dnd.ghost.panel" + style = { + minWidth = 200.px + flexGrow = 1.0f + gap = 4.px + padding = 3.px + backgroundColor = 0xFF2D333B.toInt() + border { width = 1.px; color = 0xFF6B7785.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Buckets: drop card to move it into a box") + text("Ghost toggle applies to drag previews in this panel.", { style = { color = DEMO_MUTED } }) + + dropBox( + state = state, + boxKey = "dnd.box.a", + title = "Box A", + boxId = "box-a", + color = 0xFF314B3A.toInt(), + cards = state.boxes["box-a"].orEmpty(), + onStart = onStart, + onDrag = onDrag, + onEnd = onEnd, + onBoxOver = onBoxOver, + onBoxDrop = onBoxDrop, + onHoverZone = onHoverZone, + onLogHook = onLogHook + ) + dropBox( + state = state, + boxKey = "dnd.box.b", + title = "Box B", + boxId = "box-b", + color = 0xFF5A3434.toInt(), + cards = state.boxes["box-b"].orEmpty(), + onStart = onStart, + onDrag = onDrag, + onEnd = onEnd, + onBoxOver = onBoxOver, + onBoxDrop = onBoxDrop, + onHoverZone = onHoverZone, + onLogHook = onLogHook + ) + dropBox( + state = state, + boxKey = "dnd.box.c", + title = "Box C", + boxId = "box-c", + color = 0xFF354A5A.toInt(), + cards = state.boxes["box-c"].orEmpty(), + onStart = onStart, + onDrag = onDrag, + onEnd = onEnd, + onBoxOver = onBoxOver, + onBoxDrop = onBoxDrop, + onHoverZone = onHoverZone, + onLogHook = onLogHook + ) + + button("Reset DnD", { onMouseClick = { onReset() } }) + } +} + +private fun UiScope.cardWithItem( + item: DndDemoItem, + cardKey: Any, + draggable: Draggable? = null, + sortable: Sortable? = null, + draggableEnabled: Boolean = true, + highlighted: Boolean, + insertionIndicator: DndLaneIndicator = DndLaneIndicator.NONE, + extraListeners: DndListeners = DndListeners() +) { + val draggingThis = sortable?.isDragging ?: draggable?.isDragging ?: DndSystem.monitor(cardKey).isDragging + val accent = itemAccentColor(item.id) + val base = itemBaseColor(item.id) + val insertionGap = 24 + div({ + key = cardKey + dragPlaceholder = { + fillColor = 0x44333F4D + borderColor = accent + borderWidth = 1 + } + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + alignItems = AlignItems.Center + padding = 2.px + gap = 1.px + backgroundColor = when { + draggingThis -> lighten(base, HIGHLIGHT_DELTA + 8) + highlighted -> lighten(base, HIGHLIGHT_DELTA) + else -> base + } + 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.NONE -> Unit + } + } + when { + sortable != null -> applySortable(sortable) + draggable != null -> applyDraggable(draggable) + else -> this.draggable = draggableEnabled + } + if (!draggableEnabled) { + this.draggable = false + } + applyDndListeners(extraListeners) + }) { + div({ + key = "$cardKey.accent" + style = { + backgroundColor = lighten(accent, 12) + borderRadius = 2.px + } + }) + itemStack(item.stack, { + key = "dnd.stack.${item.id}" + style = { + border { width = 1.px; color = 0x553A4452 } + backgroundColor = 0x2219222B + } + }) + text(item.label, { + style = { color = if (draggingThis) 0xFFFFFFFF.toInt() else 0xFFEAF2FD.toInt() } + }) + } +} + +private fun UiScope.dropBox( + state: DndSectionState, + boxKey: Any, + title: String, + boxId: String, + color: Int, + cards: List, + 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 +) { + 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) } + ) + 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() } + borderRadius = 3.px + } + applyDroppable(dropDescriptor) + }) { + text("$title (${cards.size})") + if (cards.isEmpty()) { + text("Drop here", { style = { this.color = DEMO_MUTED } }) + } else { + div({ + style = { + key = "$boxKey.cards" + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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) } + ) + cardWithItem( + item = item, + cardKey = "dnd.box.$boxId.card.${item.id}", + draggable = draggable, + highlighted = false + ) + } + if (cards.size > 5) { + text("+${cards.size - 5}", { style = { this.color = DEMO_MUTED } }) + } + } + } + } +} + +private fun extractCardIdFromDragKey(sourceKey: Any?): String? { + val key = sourceKey as? String ?: return null + val marker = ".card." + val markerIndex = key.indexOf(marker) + if (markerIndex < 0) return null + return key.substring(markerIndex + marker.length).takeIf { it.isNotBlank() } +} + +private fun itemBaseColor(itemId: String): Int { + return 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) { + "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 + val r = ((color ushr 16) and 0xFF) + delta + val g = ((color ushr 8) and 0xFF) + delta + val b = (color and 0xFF) + delta + return (a shl 24) or ((r.coerceAtMost(255)) shl 16) or ((g.coerceAtMost(255)) shl 8) or b.coerceAtMost(255) +} + +private fun resolveInsertAfter(event: DragOverEvent): Boolean { + val target = event.target ?: return false + val splitY = target.bounds.y + (target.bounds.height / 2) + return event.mouseY >= splitY +} + +private fun resolveInsertAfter(event: DropEvent): Boolean { + val target = event.target ?: return false + val splitY = target.bounds.y + (target.bounds.height / 2) + return event.mouseY >= splitY +} + +private fun defaultDndItems(): List = listOf( + DndDemoItem( + id = "apple", + label = "Apple", + stack = McItemStackRef(ItemStack(Items.APPLE)) + ), + DndDemoItem( + id = "bread", + label = "Bread", + stack = McItemStackRef(ItemStack(Items.BREAD)) + ), + DndDemoItem( + id = "carrot", + label = "Carrot", + stack = McItemStackRef(ItemStack(Items.CARROT)) + ), + DndDemoItem( + id = "diamond", + label = "Diamond", + stack = McItemStackRef(ItemStack(Items.DIAMOND)) + ) +) diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/FocusRebuildSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/FocusRebuildSection.kt new file mode 100644 index 0000000..a3ea9b0 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/FocusRebuildSection.kt @@ -0,0 +1,159 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.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.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +fun UiScope.focusRebuildSection( + renderPasses: Int, + onManualInvalidate: (String) -> Unit, + onInfo: (String) -> Unit, + onLogHook: (String, Event, String?) -> Unit +) { + var focusStableValue by useState("") + var focusUnstableValue by useState("") + var focusStableEnterRebuilds by useState(0) + var focusKeyVersion by useState(0) + var autoRebuildCounter by useState(0) + var manualInvalidateCount by useState(0) + var lastManualReason by useState("none") + + fun requestLocalManualInvalidate(reason: String) { + manualInvalidateCount += 1 + lastManualReason = reason + onManualInvalidate(reason) + } + + div({ + key = "section.focusRebuild" + style = { + gap = 4.px + 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 } + }) + + text( + "renderPasses=$renderPasses autoState=$autoRebuildCounter manualInvalidates=$manualInvalidateCount", + { style = { color = DEMO_MUTED } } + ) + text( + "stableEnterRebuilds=$focusStableEnterRebuilds unstableKeyVersion=$focusKeyVersion", + { style = { color = DEMO_MUTED } } + ) + + input( + InputType.Text( + value = focusStableValue, + placeholder = "Stable key input (press Enter to rebuild)" + ), + { + key = "focus.stable.input" + style = { width = 100.percent } + onKeyDown = { event -> + if (event.keyCode == KeyCodes.ENTER) { + focusStableEnterRebuilds += 1 + requestLocalManualInvalidate("stable input Enter") + onLogHook("focus.stable.onKeyDown", event, "manual rebuild") + } else { + focusStableValue = applyTextMutation(focusStableValue, event, maxLength = 28) + onLogHook("focus.stable.onKeyDown", event, null) + } + } + onKeyUp = { event -> + onLogHook("focus.stable.onKeyUp", event, null) + } + } + ) + + input( + InputType.Text( + value = focusUnstableValue, + placeholder = "Unstable key input" + ), + { + key = "focus.unstable.input.$focusKeyVersion" + style = { width = 100.percent } + onKeyDown = { event -> + focusUnstableValue = applyTextMutation(focusUnstableValue, event, maxLength = 28) + onLogHook("focus.unstable.onKeyDown", event, null) + } + } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Auto state +1", { + onMouseClick = { + autoRebuildCounter += 1 + onInfo("Focus/Rebuild: state counter increment") + } + }) + button("Manual invalidate", { + onMouseClick = { + requestLocalManualInvalidate("focus section button") + onInfo("Focus/Rebuild: manual invalidate button") + } + }) + } + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Bump unstable key", { + onMouseClick = { + focusKeyVersion += 1 + requestLocalManualInvalidate("unstable key version changed") + onInfo("Focus/Rebuild: unstable key version=$focusKeyVersion") + } + }) + text( + "lastManualReason=$lastManualReason", + { style = { color = DEMO_MUTED } } + ) + } + } +} + +private fun applyTextMutation( + current: String, + event: KeyboardKeyDownEvent, + allowedChars: String? = null, + maxLength: Int? = null +): String { + if (event.keyCode == KeyCodes.BACKSPACE) { + if (current.isEmpty()) return current + return current.dropLast(1) + } + + var ch = event.keyChar + if (ch < ' ' || ch.code == 127) return current + ch = KeyInput.applyShift(ch, KeyModifiers.shiftDown) + if (allowedChars != null && !allowedChars.contains(ch)) return current + val next = current + ch + if (maxLength != null && next.length > maxLength) return current + return next +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/HooksSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/HooksSection.kt new file mode 100644 index 0000000..fb4aba8 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/HooksSection.kt @@ -0,0 +1,506 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.hooks.createContext +import org.dreamfinity.dsgl.core.hooks.provideContext +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 +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_SURFACE_ALT + +private val hooksThemeContext = createContext(defaultValue = "System", name = "HooksTheme") + +fun UiScope.hooksSection( + onInfo: (String) -> Unit, + onLogHook: (String, Event, String?) -> Unit +) { + div({ + key = "section.hooks" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Hooks showcase: useRef, useState, useMemo, useCallback, useReducer, useContext, useEffect.") + text("Keep blocks small and behavior-focused; use this section for manual hook verification.", { + style = { color = DEMO_MUTED } + }) + + overviewUseRef(onInfo = onInfo, onLogHook = onLogHook) + overviewUseState() + overviewUseMemo() + overviewUseCallback() + overviewUseReducer() + overviewUseContext() + overviewUseEffect() + } +} + +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) + var refsCallbackAttachCount by useState(0) + var refsCallbackDetachCount by useState(0) + var refsCallbackLast by useState("none") + + val inputRef by useRef() + val panelRef by useRef() + val refsCallbackRef by useMemo { + RefTarget { handle -> + if (handle == null) { + refsCallbackDetachCount += 1 + refsCallbackLast = "detach" + onInfo("Hooks/useRef callback detached") + return@RefTarget + } + refsCallbackAttachCount += 1 + refsCallbackLast = "attach key=${handle.key}" + onInfo("Hooks/useRef callback attached key=${handle.key}") + } + } + + hookCard("useRef", "Object ref + callback ref + imperative handle checks") { + input( + InputType.Text( + value = refsInputValue, + placeholder = "Focusable input with stable key" + ), + { + key = "refs.input.primary" + onInput = { event -> + refsInputValue = event.value + onLogHook("refs.input.onInput", event, "value=${event.value}") + } + }, + ref = inputRef + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Focus via ref", { + onMouseClick = { + inputRef.current?.requestFocus() + onInfo("Hooks/useRef: requestFocus() via object ref") + } + }) + button("Rebuild", { + onMouseClick = { + refsRebuildCount += 1 + } + }) + button(if (refsCallbackMounted) "Unmount callback target" else "Mount callback target", { + onMouseClick = { + refsCallbackMounted = !refsCallbackMounted + onInfo("Hooks/useRef callback target mounted=$refsCallbackMounted") + } + }) + } + + text({ + val hasRef = inputRef.current != null + value = "objectRef.current set=$hasRef rebuilds=$refsRebuildCount" + style = { color = DEMO_MUTED } + }) + + div( + { + key = "refs.bounds.panel" + style = { + padding = 4.px + backgroundColor = 0xFF313844.toInt() + } + }, + 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}" + } + style = { color = DEMO_MUTED } + }) + + if (refsCallbackMounted) { + div( + { + key = "refs.callback.target" + style = { + backgroundColor = 0xFF2F3C2F.toInt() + padding = 4.px + } + }, + ref = refsCallbackRef + ) { + text("Callback ref target", { style = { color = 0xFFC5E8C5.toInt() } }) + } + } + + text( + "callback attaches=$refsCallbackAttachCount detaches=$refsCallbackDetachCount last=$refsCallbackLast", + { style = { color = DEMO_MUTED } } + ) + } +} + +private fun UiScope.overviewUseState() { + var hooksStateDemoMounted by useState(true) + hookCard("useState", "Local state + disappearance/reappearance reset") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button(if (hooksStateDemoMounted) "Hide local state sample" else "Show local state sample", { + onMouseClick = { hooksStateDemoMounted = !hooksStateDemoMounted } + }) + } + + if (hooksStateDemoMounted) { + var hooksStateLocalCount by useState(0) + var hooksStateLocalText by useState("fresh") + button("Increment local count ($hooksStateLocalCount)", { + onMouseClick = { hooksStateLocalCount += 1 } + }) + input( + InputType.Text( + value = hooksStateLocalText, + placeholder = "Local useState text" + ), + { + key = "hooks.useState.localText" + onInput = { event -> hooksStateLocalText = event.value } + } + ) + text("mounted state: count=$hooksStateLocalCount text=$hooksStateLocalText", { + style = { color = DEMO_MUTED } + }) + } else { + text("Local state sample is hidden. Show it again: values should reset to initial.", { + style = { color = DEMO_MUTED } + }) + } + } +} + +private fun UiScope.overviewUseMemo() { + var hooksMemoBase by useState(2) + var hooksMemoMultiplier by useState(3) + var hooksMemoNoise by useState(0) + var hooksMemoRecomputeCount by useState(0) + val hooksMemoDerived by useMemo(hooksMemoBase, hooksMemoMultiplier) { + expensiveDerivedValue(hooksMemoBase, hooksMemoMultiplier) + } + useEffect(hooksMemoBase, hooksMemoMultiplier) { + hooksMemoRecomputeCount += 1 + } + + hookCard("useMemo", "Derived value recomputes only when deps change") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("base +1 ($hooksMemoBase)", { + onMouseClick = { hooksMemoBase += 1 } + }) + button("mult +1 ($hooksMemoMultiplier)", { + onMouseClick = { hooksMemoMultiplier += 1 } + }) + button("rerender only ($hooksMemoNoise)", { + onMouseClick = { hooksMemoNoise += 1 } + }) + } + text("derived=$hooksMemoDerived recomputeCount=$hooksMemoRecomputeCount", { + style = { color = DEMO_MUTED } + }) + text("Expected: 'rerender only' changes noise but not recomputeCount.", { + style = { color = DEMO_MUTED } + }) + } +} + +private fun UiScope.overviewUseCallback() { + var hooksCallbackDep by useState(0) + var hooksCallbackNoise by useState(0) + var hooksCallbackInvokeCount by useState(0) + var hooksCallbackLastInvoke by useState("none") + val hooksCallback by useCallback(hooksCallbackDep) { + val capturedDep = hooksCallbackDep + { + hooksCallbackInvokeCount += 1 + hooksCallbackLastInvoke = "invoke dep=$capturedDep" + } + } + 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" + } + hooksCallbackIdentityRef.current = hooksCallbackIdentity + hookCard("useCallback", "Function identity is stable until dependency changes") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Invoke callback", { + onMouseClick = { hooksCallback() } + }) + button("rerender only ($hooksCallbackNoise)", { + onMouseClick = { hooksCallbackNoise += 1 } + }) + button("dep +1 ($hooksCallbackDep)", { + onMouseClick = { hooksCallbackDep += 1 } + }) + } + text("callback identity=$hooksCallbackIdentity ($hooksCallbackIdentityStatus)", { + style = { color = DEMO_MUTED } + }) + text("invokeCount=$hooksCallbackInvokeCount last=$hooksCallbackLastInvoke", { + style = { color = DEMO_MUTED } + }) + text("Expected: rerender-only keeps identity stable; dep change marks identity changed.", { + style = { color = DEMO_MUTED } + }) + } +} + +private fun UiScope.overviewUseReducer() { + var hooksReducerMounted by useState(true) + var hooksReducerNoise by useState(0) + hookCard("useReducer", "Reducer-driven local state + dispatch behavior") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button(if (hooksReducerMounted) "Hide reducer sample" else "Show reducer sample", { + onMouseClick = { hooksReducerMounted = !hooksReducerMounted } + }) + button("rerender only ($hooksReducerNoise)", { + onMouseClick = { hooksReducerNoise += 1 } + }) + } + + if (hooksReducerMounted) { + val (hooksReducerCount, dispatchReducer) = useReducer( + initialState = 0, + reducer = { old: Int, action: Int -> old + action } + ) + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("dispatch +1", { + onMouseClick = { dispatchReducer(1) } + }) + button("dispatch +5", { + onMouseClick = { dispatchReducer(5) } + }) + button("dispatch -1", { + onMouseClick = { dispatchReducer(-1) } + }) + } + text("state=$hooksReducerCount noise=$hooksReducerNoise", { + style = { color = DEMO_MUTED } + }) + text("Expected: dispatch changes reducer state; rerender-only keeps state unchanged.", { + style = { color = DEMO_MUTED } + }) + } else { + text("Reducer sample is hidden. Show it again: reducer state should reinitialize to 0.", { + style = { color = DEMO_MUTED } + }) + } + } +} + +private fun UiScope.overviewUseContext() { + var hooksContextProviderMounted by useState(true) + var hooksContextNestedOverride by useState(false) + var hooksContextValue by useState("Light") + hookCard("useContext", "Nearest provider wins + nested override + default fallback") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("provider=$hooksContextValue", { + onMouseClick = { + hooksContextValue = if (hooksContextValue == "Light") "Dark" else "Light" + } + }) + button(if (hooksContextNestedOverride) "Disable nested override" else "Enable nested override", { + onMouseClick = { hooksContextNestedOverride = !hooksContextNestedOverride } + }) + button(if (hooksContextProviderMounted) "Hide provider" else "Show provider", { + onMouseClick = { hooksContextProviderMounted = !hooksContextProviderMounted } + }) + } + + if (hooksContextProviderMounted) { + provideContext(hooksThemeContext, hooksContextValue) { + val outerSeen = useContext(hooksThemeContext) + text("outer consumer sees=$outerSeen", { style = { color = DEMO_MUTED } }) + + if (hooksContextNestedOverride) { + provideContext(hooksThemeContext, "Nested") { + val nestedSeen = useContext(hooksThemeContext) + text("nested consumer sees=$nestedSeen", { style = { color = DEMO_MUTED } }) + } + val afterNestedSeen = useContext(hooksThemeContext) + text("after nested block sees=$afterNestedSeen", { style = { color = DEMO_MUTED } }) + } else { + text("nested override disabled", { style = { color = DEMO_MUTED } }) + } + } + } else { + val fallbackSeen = useContext(hooksThemeContext) + text("provider hidden -> default fallback=$fallbackSeen", { style = { color = DEMO_MUTED } }) + } + } +} + +private fun UiScope.overviewUseEffect() { + var hooksEffectMounted by useState(true) + var hooksEffectDep by useState(0) + 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 + hooksEffectLogRevision += 1 + } + if (hooksEffectMounted) { + useEffect(hooksEffectDep) { + val runDep = hooksEffectDep + appendEffectLog("run dep=$runDep") + onDispose { + appendEffectLog("cleanup dep=$runDep") + } + } + } + + hookCard("useEffect", "Post-commit run/cleanup log + hide/show cleanup behavior") { + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("dep +1 ($hooksEffectDep)", { + onMouseClick = { hooksEffectDep += 1 } + }) + button(if (hooksEffectMounted) "Hide effect scope" else "Show effect scope", { + onMouseClick = { hooksEffectMounted = !hooksEffectMounted } + }) + button("rerender only ($hooksEffectNoise)", { + onMouseClick = { hooksEffectNoise += 1 } + }) + button("Clear log", { + onMouseClick = { + hooksEffectLogBuffer.current?.clear() + hooksEffectLogRevision += 1 + } + }) + } + + text("mounted=$hooksEffectMounted dep=$hooksEffectDep logRevision=$hooksEffectLogRevision", { + style = { color = DEMO_MUTED } + }) + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + overflowY = Overflow.Auto + maxHeight = 8.em + } + }) { + val logLines: List = hooksEffectLogBuffer.current?.toList() ?: emptyList() + if (logLines.isEmpty()) { + text("log: ", { style = { color = DEMO_MUTED } }) + } else { + logLines.asReversed().forEach { line -> + text("log: $line", { style = { color = DEMO_MUTED } }) + } + } + } + } +} + +private fun UiScope.hookCard( + title: String, + subtitle: String, + content: UiScope.() -> Unit +) { + div({ + key = "hooks.card.$title" + style = { + backgroundColor = DEMO_SURFACE_ALT + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + padding = 4.px + } + }) { + text(title) + text(subtitle, { style = { color = DEMO_MUTED } }) + content() + } +} + +private fun expensiveDerivedValue(base: Int, multiplier: Int): Int { + var acc = 0 + val iterations = (base.coerceAtLeast(1) * multiplier.coerceAtLeast(1)).coerceAtMost(5000) + for (index in 1..iterations) { + acc = (acc + (base * multiplier) + index) % 10007 + } + return acc +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputEventsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputEventsSection.kt new file mode 100644 index 0000000..b5d0465 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputEventsSection.kt @@ -0,0 +1,281 @@ +package org.dreamfinity.dsgl.mcForge1122.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.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.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.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.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 inputEventRadioOptions = listOf( + InputOption("north", "North"), + InputOption("center", "Center"), + InputOption("south", "South") +) + +private val inputEventTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + +fun UiScope.inputEventsSection(onLogHook: (String, Event, String?) -> Unit) { + var inputEventTextValue by useState("") + var inputEventTextareaValue by useState("Multiline event sample") + var inputEventCheckboxValue by useState(setOf("alpha")) + var inputEventRadioValue by useState("center") + var inputEventRangeValue by useState(35L) + var inputEventLogEntries by useState(emptyList()) + + 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) + onLogHook("inputEvents.$control.$phase", event, "value=$value") + } + + fun parseCheckboxSelection(parsedValue: Any?): Set { + val parsedSet = parsedValue as? Set<*> + if (parsedSet != null) { + return parsedSet.mapNotNull { it as? String }.toSet() + } + val parsedString = parsedValue as? String + if (!parsedString.isNullOrBlank()) { + return parsedString + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + } + return emptySet() + } + + fun checkboxValueString(): String = inputEventCheckboxValue.toList().sorted().joinToString(",") + + div({ + key = "section.inputEvents" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + + div({ + style = { + gap = 6.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "inputEvents.left" + style = { + flexGrow = 1f + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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) + } + } + ) + + text("Textarea") + textarea({ + placeholder = "Multiline event sample" + key = "inputEvents.textarea" + style = { + width = 100.percent + height = 46.px + } + value = inputEventTextareaValue + onFocusGain = { event: FocusGainEvent -> + appendInputEvent("textarea", "focus", inputEventTextareaValue, event) + } + onFocusLose = { event: FocusLoseEvent -> + appendInputEvent("textarea", "blur", inputEventTextareaValue, event) + } + onInput = { event: InputEvent -> + inputEventTextareaValue = event.value + appendInputEvent("textarea", "input", event.value.replace("\n", "\\n"), event) + } + onValueChange = { event: ValueChangedEvent -> + inputEventTextareaValue = event.value + appendInputEvent("textarea", "change", event.value.replace("\n", "\\n"), event) + } + }) + } + + div({ + key = "inputEvents.right" + style = { + flexGrow = 1f + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Checkbox") + input( + InputType.Checkbox( + variants = inputEventCheckboxOptions, + selected = inputEventCheckboxValue, + minSelected = 0, + maxSelected = 3 + ), + { + key = "inputEvents.checkbox" + style = { width = 100.percent } + onFocusGain = { event: FocusGainEvent -> + appendInputEvent("checkbox", "focus", checkboxValueString(), event) + } + onFocusLose = { event: FocusLoseEvent -> + appendInputEvent("checkbox", "blur", checkboxValueString(), event) + } + onInput = { event: InputEvent -> + inputEventCheckboxValue = parseCheckboxSelection(event.parsedValue) + appendInputEvent("checkbox", "input", event.value, event) + } + onValueChange = { event: ValueChangedEvent -> + inputEventCheckboxValue = parseCheckboxSelection(event.parsedValue) + appendInputEvent("checkbox", "change", event.value, event) + } + } + ) + + text("Radio") + input( + InputType.Radio( + variants = inputEventRadioOptions, + selected = inputEventRadioValue + ), + { + key = "inputEvents.radio" + style = { width = 100.percent } + onFocusGain = { event: FocusGainEvent -> + appendInputEvent("radio", "focus", inputEventRadioValue ?: "", event) + } + onFocusLose = { event: FocusLoseEvent -> + appendInputEvent("radio", "blur", inputEventRadioValue ?: "", event) + } + onInput = { event: InputEvent -> + inputEventRadioValue = event.parsedValue as? String + appendInputEvent("radio", "input", event.value, event) + } + onValueChange = { event: ValueChangedEvent -> + inputEventRadioValue = event.parsedValue as? String + appendInputEvent("radio", "change", event.value, event) + } + } + ) + + text("Range") + input( + InputType.Range( + value = inputEventRangeValue, + min = 0, + max = 100, + step = 1 + ), + { + key = "inputEvents.range" + style = { width = 100.percent } + onFocusGain = { event: FocusGainEvent -> + appendInputEvent("range", "focus", inputEventRangeValue.toString(), event) + } + onFocusLose = { event: FocusLoseEvent -> + appendInputEvent("range", "blur", inputEventRangeValue.toString(), event) + } + onInput = { event: InputEvent -> + inputEventRangeValue = (event.parsedValue as? Long) ?: inputEventRangeValue + appendInputEvent("range", "input", event.value, event) + } + onValueChange = { event: ValueChangedEvent -> + inputEventRangeValue = (event.parsedValue as? Long) ?: inputEventRangeValue + appendInputEvent("range", "change", event.value, event) + } + } + ) + text( + "Range value=$inputEventRangeValue", + { style = { color = DEMO_MUTED } } + ) + } + } + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Clear Log", { + onMouseClick = { inputEventLogEntries = emptyList() } + }) + text( + "Entries=${inputEventLogEntries.size}", + { style = { color = DEMO_MUTED } } + ) + } + + div({ + key = "inputEvents.logPanel" + style = { + width = 100.percent + maxHeight = 25.em + display = Display.Flex + flexDirection = FlexDirection.Column + overflowY = Overflow.Auto + backgroundColor = DEMO_SURFACE_ALT + padding = 3.px + border { width = 1.px; color = 0xFF6A7785.toInt() } + } + }) { + if (inputEventLogEntries.isEmpty()) { + text("No input events yet.", { style = { color = DEMO_MUTED } }) + } else { + inputEventLogEntries.forEach { line -> + text(line) + } + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputsGallerySection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputsGallerySection.kt new file mode 100644 index 0000000..3003d28 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InputsGallerySection.kt @@ -0,0 +1,474 @@ +package org.dreamfinity.dsgl.mcForge1122.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.event.FocusManager +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.core.select.SelectRuntime +import org.dreamfinity.dsgl.core.select.SelectStyle +import org.dreamfinity.dsgl.core.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.mcForge1122.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 inputsRadioOptions = listOf( + InputOption("north", "North"), + InputOption("center", "Center"), + InputOption("south", "South") +) + +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) + var inputCheckboxValue by useState(setOf("alpha")) + var inputRadioValue by useState("center") + var selectBasicValue by useState(null) + var selectManyValue by useState("item-05") + var selectDisabledValue by useState("locked") + var selectDynamicValue by useState("alpha") + var selectDynamicAlt by useState(false) + var toggleBasicValue by useState(false) + var toggleSecondaryValue by useState(true) + + fun parseCheckboxSelection(parsedValue: Any?): Set { + val parsedSet = parsedValue as? Set<*> + if (parsedSet != null) { + return parsedSet.mapNotNull { it as? String }.toSet() + } + val parsedString = parsedValue as? String + if (!parsedString.isNullOrBlank()) { + return parsedString + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + } + return emptySet() + } + + fun checkboxValueString(): String = inputCheckboxValue.toList().sorted().joinToString(",") + + SelectRuntime.engine.setStyle( + SelectStyle( + panelBackgroundColor = 0xFF202A35.toInt(), + panelBorderColor = 0xFF607286.toInt(), + panelShadowColor = 0x70101926, + optionHoverBackgroundColor = 0xFF33506B.toInt(), + optionSelectedBackgroundColor = 0xFF2A4258.toInt(), + groupTextColor = 0xFFB7C6D6.toInt(), + openDurationMs = 120L, + closeDurationMs = 90L + ) + ) + + div({ + key = "section.inputs" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("All InputType variants are interactive below.") + text( + "Validation examples: allowed chars, min/max, step, date format.", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 6.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "inputs.left" + style = { + flexGrow = 1f + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Text (A-F/0-9, max 8)") + input( + InputType.Text( + value = "A1", + placeholder = "hex", + allowedChars = "0123456789ABCDEF", + maxLength = 8 + ), + { + key = "input.text" + style = { width = 100.percent } + } + ) + + text("Password (max 12)") + input( + InputType.Password( + value = "", + placeholder = "secret", + maxLength = 12 + ), + { + key = "input.password" + style = { width = 100.percent } + } + ) + + text("Number (10..20, wheel when focused)") + input( + InputType.Number( + value = 15, + placeholder = "10..20", + min = 10, + max = 20 + ), + { + key = "input.number.basic" + style = { width = 100.percent } + } + ) + + text("Number 0..100 wired with slider below") + input( + InputType.Number( + value = sharedRangeValue, + placeholder = "0..100", + min = 0, + max = 100 + ), + { + key = "input.number.shared" + style = { width = 100.percent } + onInput = { event -> + sharedRangeValue = (event.parsedValue as? Long) ?: sharedRangeValue + } + onValueChange = { event -> + sharedRangeValue = event.value.toLongOrNull() ?: sharedRangeValue + } + } + ) + + text("Range (step 5, value=$sharedRangeValue)") + input( + InputType.Range( + value = sharedRangeValue, + min = 0, + max = 100, + step = 5 + ), + { + key = "input.range" + style = { width = 100.percent } + onInput = { event -> + sharedRangeValue = (event.parsedValue as? Long) ?: sharedRangeValue + } + onValueChange = { event -> + sharedRangeValue = event.value.toLongOrNull() ?: sharedRangeValue + } + } + ) + } + + div({ + key = "inputs.right" + style = { + flexGrow = 1f + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Checkbox (min 1, max 2)") + input( + InputType.Checkbox( + variants = inputsCheckboxOptions, + selected = inputCheckboxValue, + minSelected = 1, + maxSelected = 2 + ), + { + key = "input.checkbox" + style = { width = 100.percent } + onInput = { event -> + inputCheckboxValue = parseCheckboxSelection(event.parsedValue) + } + onValueChange = { event -> + inputCheckboxValue = parseCheckboxSelection(event.parsedValue) + } + } + ) + text("Selected: ${checkboxValueString()}", { style = { color = DEMO_MUTED } }) + + text("Radio") + input( + InputType.Radio( + variants = inputsRadioOptions, + selected = inputRadioValue + ), + { + key = "input.radio" + style = { width = 100.percent } + onInput = { event -> + inputRadioValue = event.parsedValue as? String + } + onValueChange = { event -> + inputRadioValue = event.parsedValue as? String + } + } + ) + text("Selected: ${inputRadioValue ?: "-"}", { style = { color = DEMO_MUTED } }) + + text("Toggle (iOS-like)") + div({ + key = "inputs.toggle.row" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 6.px + } + }) { + toggle({ + key = "input.toggle.basic" + checked = toggleBasicValue + onValueChange = { event -> + toggleBasicValue = event.parsedValue as? Boolean ?: false + } + }) + text(if (toggleBasicValue) "On" else "Off", { style = { color = DEMO_MUTED } }) + } + + text("Disabled toggle") + toggle({ + key = "input.toggle.disabled" + checked = toggleSecondaryValue + disabled = true + }) + + text("Date (dd.MM.yyyy HH:mm)") + input( + InputType.Date( + value = openedAt, + zoneId = timeZoneId + ), + { + key = "input.date" + style = { width = 100.percent } + } + ) + + text("Opened: $openedAt", { style = { color = DEMO_MUTED } }) + } + } + + text("Textarea (multiline input)") + textarea({ + key = "input.textarea" + placeholder = "Type multiple lines" + style = { + width = 100.percent + height = 40.px + } + }) + + text("Select (overlay popup + keyboard + disabled options)") + text( + "Use Enter/Space/ArrowDown when focused. Esc closes popup. Wheel scrolls long list.", + { style = { color = DEMO_MUTED } } + ) + + div({ + key = "inputs.select.row" + style = { + gap = 6.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "inputs.select.left" + style = { + flexGrow = 1f + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Basic") + select({ + key = "input.select.basic" + value = selectBasicValue + style = { width = 100.percent } + onValueChange = { event -> + selectBasicValue = event.value + } + }) { + placeholder("Choose a fruit") + option("apple", "Apple") + option("banana", "Banana") + separator("sep-1") + group("Citrus") { + option("orange", "Orange") + option("lemon", "Lemon") + option("pomelo", "Pomelo") { enabled(false) } + } + } + + text("Many options (scroll)") + select({ + key = "input.select.many" + value = selectManyValue + style = { + maxHeight = 15.em + width = 100.percent + } + onValueChange = { event -> + selectManyValue = event.value + } + }) { + placeholder("Pick one") + repeat(100) { index -> + val id = "item-${index.toString().padStart(2, '0')}" + option(id, "Item ${index.toString().padStart(2, '0')}") { + enabled(index % 9 != 0) + } + } + } + } + + div({ + key = "inputs.select.right" + style = { + flexGrow = 1f + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Disabled select") + select({ + key = "input.select.disabled" + value = selectDisabledValue + disabled = true + style = { width = 100.percent } + onValueChange = { event -> + selectDisabledValue = event.value + } + }) { + option("locked", "Locked value") + option("alt", "Alternative") + } + + button( + if (selectDynamicAlt) "Use option set A" else "Use option set B", + { + onMouseClick = { + selectDynamicAlt = !selectDynamicAlt + } + } + ) + text("Dynamic options") + select({ + key = "input.select.dynamic" + value = selectDynamicValue + style = { width = 100.percent } + onValueChange = { event -> + selectDynamicValue = event.value + } + }) { + placeholder("Dynamic set") + if (selectDynamicAlt) { + option("alpha", "Alpha") + option("beta", "Beta") + option("gamma", "Gamma") + } else { + option("delta", "Delta") + option("epsilon", "Epsilon") + option("theta", "Theta") + } + } + } + } + text( + "Select state: basic=${selectBasicValue ?: "-"} many=${selectManyValue ?: "-"} dynamic=${selectDynamicValue ?: "-"}", + { style = { color = DEMO_MUTED } } + ) + + text("Clipping + internal scrolling demo (100 lines prefilled)", { style = { color = DEMO_MUTED } }) + div({ + key = "input.textarea.clip.demo.controls" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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 } } + ) + } + div({ + key = "input.textarea.clip.demo.row" + style = { + gap = 4.px + height = 8.em + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "input.textarea.clip.left" + style = { + width = 42.px + height = 100.percent + backgroundColor = 0xFF5A3434.toInt() + padding = 2.px + } + }) { + text("L", { style = { color = 0xFFFFD0D0.toInt() } }) + } + textarea({ + placeholder = "Scroll with wheel / PgUp / PgDn" + key = "input.textarea.clip" + style = { + flexGrow = 1f + } + value = clippingScrollDemoText + onInput = { event -> + onClippingScrollDemoTextChange(event.value) + } + onValueChange = { event -> + onClippingScrollDemoTextChange(event.value) + } + }) + div({ + key = "input.textarea.clip.right" + style = { + width = 42.px + height = 100.percent + backgroundColor = 0xFF345A34.toInt() + padding = 2.px + } + }) { + text("R", { style = { color = 0xFFD0FFD0.toInt() } }) + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InspectorSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InspectorSection.kt new file mode 100644 index 0000000..009dbaf --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InspectorSection.kt @@ -0,0 +1,107 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.hooks.useState + +private const val INSPECTOR_MUTED_TEXT: Int = 0xFFB0B7C1.toInt() + +fun UiScope.inspectorSection(onInfo: (String) -> Unit) { + var inspectorBehindClickCounter by useState(0) + var inspectorInputValue by useState("") + + div({ + key = "section.inspector" + style = { + gap = 4.px + + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("In-game Inspector is global (works on every DSGL screen).") + text("F8: toggle inspector overlay", { 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("Style editor now uses typed controls: dropdowns, text inputs, numeric input + units.", { + style = { color = INSPECTOR_MUTED_TEXT } + }) + text("Hover var(--token) values in style overrides to preview resolved values.", { + style = { color = INSPECTOR_MUTED_TEXT } + }) + text("Pick mode captures clicks for selection; Locked mode blocks input in inspector rect.", { + style = { color = INSPECTOR_MUTED_TEXT } + }) + text("Click-through check: clicking inspector must NOT increment the counter below.", { + style = { color = INSPECTOR_MUTED_TEXT } + }) + + div({ + key = "inspector.sample.panel" + style = { + gap = 4.px + padding = 4.px + backgroundColor = 0x3338424F + 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", { + style = { color = 0xFFB6D7A8.toInt() } + }) + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Behind button (+1)", { + onMouseClick = { + inspectorBehindClickCounter += 1 + onInfo("Inspector sample: behind counter=$inspectorBehindClickCounter") + } + }) + input( + InputType.Text(inspectorInputValue, "Focusable input"), + { + style = { flexGrow = 1f } + key = "inspector.sample.input" + onInput = { event -> + inspectorInputValue = event.value + } + } + ) + } + div({ + key = "inspector.sample.grid" + style = { + gap = 3.px + display = Display.Grid + gridColumns = 3 + } + }) { + repeat(6) { index -> + div({ + key = "inspector.sample.cell.$index" + style = { + padding = 2.px + backgroundColor = 0x22496699 + border { width = 1.px; color = 0x665A9CE0 } + } + + }) { + text("cell-$index") + } + } + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InteractionsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InteractionsSection.kt new file mode 100644 index 0000000..397c7d5 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/InteractionsSection.kt @@ -0,0 +1,247 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.event.KeyCodes +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.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_SURFACE_ALT + +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) + var mouseMoveCount by useState(0) + var mouseDownCount by useState(0) + var mouseUpCount by useState(0) + var mouseClickCount by useState(0) + var mouseDragCount by useState(0) + var mouseWheelCount by useState(0) + var keyDownCount by useState(0) + var keyUpCount by useState(0) + var keyPressedCount by useState(0) + var keyReleasedCount by useState(0) + var enterActionCount by useState(0) + var cancellationEnabled by useState(true) + var cancellationParentHits by useState(0) + var cancellationChildHits by useState(0) + + val mouseOverSamplesRef by useRef(0) + val mouseMoveSamplesRef by useRef(0) + val interactionZoneInsideRef by useRef(false) + + fun sampledMouseOverEvent(): Boolean { + val next = (mouseOverSamplesRef.current ?: 0) + 1 + mouseOverSamplesRef.current = next + return next % 6 == 0 + } + + fun sampledMouseMoveEvent(): Boolean { + val next = (mouseMoveSamplesRef.current ?: 0) + 1 + mouseMoveSamplesRef.current = next + return next % 8 == 0 + } + + fun markInteractionZoneEntered(): Boolean { + val inside = interactionZoneInsideRef.current ?: false + if (inside) return false + interactionZoneInsideRef.current = true + return true + } + + fun markInteractionZoneLeft(): Boolean { + val inside = interactionZoneInsideRef.current ?: false + if (!inside) return false + interactionZoneInsideRef.current = false + return true + } + + div({ + key = "section.interactions" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Mouse zone wires all mouse hooks. Key fields wire key hooks.") + text("Event Inspector will show bubbling and cancellation details.", { + style = { color = DEMO_MUTED } + }) + + div({ + key = "interactions.mouse.zone" + onMouseEnter = { event -> + if (markInteractionZoneEntered()) { + mouseEnterCount += 1 + onLogHook("onMouseEnter", event, null) + } + } + onMouseLeave = { event -> + if (markInteractionZoneLeft()) { + mouseLeaveCount += 1 + onLogHook("onMouseLeave", event, null) + } + } + onMouseOver = { event -> + mouseOverCount += 1 + if (sampledMouseOverEvent()) { + onLogHook("onMouseOver", event, "sampled") + } + } + onMouseMove = { event -> + mouseMoveCount += 1 + if (sampledMouseMoveEvent()) { + onLogHook("onMouseMove", event, "sampled") + } + } + onMouseDown = { event -> + mouseDownCount += 1 + onLogHook("onMouseDown", event, null) + } + onMouseUp = { event -> + mouseUpCount += 1 + onLogHook("onMouseUp", event, null) + } + onMouseClick = { event -> + mouseClickCount += 1 + onLogHook("onMouseClick", event, null) + } + onMouseDrag = { event -> + mouseDragCount += 1 + onLogHook("onMouseDrag", event, null) + } + onMouseWheel = { event -> + mouseWheelCount += 1 + onLogHook("onMouseWheel", event, null) + } + style = { + width = 100.percent + height = 52.px + padding = 4.px + backgroundColor = DEMO_SURFACE_ALT + 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 } } + ) + } + + div({ + style = { + gap = 4.px + display = Display.Flex + 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 = "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 } } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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 } } + ) + } + + div({ + key = "interactions.bubble.parent" + onMouseClick = { event -> + cancellationParentHits += 1 + onLogHook("parent.onMouseClick", event, null) + } + style = { + width = 100.percent + padding = 3.px + backgroundColor = 0xFF353D46.toInt() + border { width = 1.px; color = 0xFF708090.toInt() } + } + }) { + text("Parent click area") + div({ + key = "interactions.bubble.child" + onMouseClick = { event -> + cancellationChildHits += 1 + if (cancellationEnabled) { + event.cancelled = true + } + onLogHook( + "child.onMouseClick", + event, + if (cancellationEnabled) "cancelled=true" else "cancelled=false" + ) + } + style = { + padding = 3.px + backgroundColor = 0xFF4D5560.toInt() + border { width = 1.px; color = 0xFF9AA5B1.toInt() } + } + }) { + text("Child area") + } + } + } +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutDebugSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutDebugSection.kt new file mode 100644 index 0000000..b04c532 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutDebugSection.kt @@ -0,0 +1,122 @@ +package org.dreamfinity.dsgl.mcForge1122.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.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.mcForge1122.demo.support.DEMO_MUTED + +private const val WRAP_DEBUG_TEXT_A = + "Wrapped text A: if line-height or measured-height is wrong, this line stack will collide with text B." +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 +) { + val minWidth = 96 + val maxWidth = 320 + var layoutDebugStrict by useState(LayoutDebug.strictBounds) + var layoutDebugDraw by useState(LayoutDebug.drawBounds) + var layoutDebugWrapWidth by useState(148L) + val wrapWidth = layoutDebugWrapWidth.toInt().coerceIn(minWidth, maxWidth) + + div({ + key = "section.layoutDebug" + style = { + gap = 4.px + + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Layout validator") + text("Checks containment, invalid sizes, and wrapped text line-stack invariants.", { + style = { color = DEMO_MUTED } + }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + 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("clear logs", { + onMouseClick = { onClearLogs() } + }) + } + text( + "validatorViolations=${LayoutDebug.lastViolationCount} strict=${LayoutDebug.strictBounds} draw=${LayoutDebug.drawBounds}", + { style = { color = DEMO_MUTED } } + ) + + input( + InputType.Range( + value = wrapWidth.toLong(), + min = minWidth.toLong(), + max = maxWidth.toLong(), + step = 2 + ), + { + key = "layoutDebug.wrapWidth" + style = { width = 100.percent } + onInput = { event -> + 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 } }) + + div({ + key = "layoutDebug.wrapCase" + style = { + width = wrapWidth.px + padding = 3.px + gap = 2.px + backgroundColor = 0xFF2D3745.toInt() + display = Display.Flex + flexDirection = FlexDirection.Column + 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 } }) + text(WRAP_DEBUG_TEXT_B, { style = { textWrap = TextWrap.Wrap } }) + button("button label wraps too: long_unbroken_word_to_force_hard_break_123456789", { + style = { + width = 100.percent + textWrap = TextWrap.Wrap + } + }) + } + } +} + + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutStyleSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutStyleSection.kt new file mode 100644 index 0000000..1f7addf --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/LayoutStyleSection.kt @@ -0,0 +1,310 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.dom.DOMNode +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.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_SURFACE_ALT +import kotlin.math.abs + +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) + + val overlayDragAnchorXRef by useRef(0) + val overlayDragAnchorYRef by useRef(0) + val overlayDragMovedRef by useRef(false) + + val demoGap = if (styleLargeGap) 10 else 3 + val fixedSize = if (styleFixedSize) 24 else null + val overlayWidth = 148 + val overlayHeight = 26 + + overlay({ + key = "section.layoutStyle.stack" + style = { + width = 100.percent + gap = 0.px + } + }) { + div({ + key = "section.layoutStyle" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text( + "Toggle values and click boxes to verify row/column behavior.", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button( + if (styleLargeGap) "Gap: Large" else "Gap: Compact", + { + onMouseClick = { + 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") + } + } + ) + } + + div({ + style = { + key = "layout.row.demo" + gap = demoGap.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + repeat(3) { index -> + div({ + key = "layout.row.box.$index" + onMouseClick = { event -> + onLogHook("layout.row.onMouseClick", event, "box=$index") + } + style = { + width = fixedSize?.px + height = fixedSize?.px + padding = 2.px + backgroundColor = 0xFF3A4A5A.toInt() + border { width = 1.px; color = 0xFF5E89B5.toInt() } + } + }) { + text("R${index + 1}") + } + } + } + + div({ + style = { + key = "layout.column.demo" + gap = demoGap.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + repeat(3) { index -> + div({ + key = "layout.column.box.$index" + onMouseClick = { event -> + onLogHook("layout.column.onMouseClick", event, "box=$index") + } + style = { + width = if (styleFixedSize) 72.px else null + padding = 2.px + backgroundColor = 0xFF43404F.toInt() + border { width = 1.px; color = 0xFF786AA6.toInt() } + } + }) { + text("Column box ${index + 1}") + } + } + } + + div({ + style = { + gap = 4.px + display = Display.Flex + 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 } + } + ) + } + + div({ + key = "layout.style.target" + onMouseClick = { event -> + onLogHook("layout.style.onMouseClick", event, null) + } + style = { + width = 100.percent + backgroundColor = DEMO_SURFACE_ALT + 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() } + } + }) { + text("Style target (margin/padding/border)") + text( + "margin=$styleUseMargin padding=$styleUsePadding border=$styleUseBorder", + { style = { color = DEMO_MUTED } } + ) + } + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button( + if (stackOverlayEnabled) "Stack Overlay ON" else "Stack Overlay OFF", + { + onMouseClick = { + stackOverlayEnabled = !stackOverlayEnabled + onInfo("Layout: stackOverlay=$stackOverlayEnabled") + } + } + ) + button("Reset Overlay", { + onMouseClick = { + layoutOverlayX = 8 + layoutOverlayY = 92 + layoutOverlayDragging = false + overlayDragMovedRef.current = false + onInfo("Layout: overlay reset") + } + }) + text( + "Overlay: $layoutOverlayX,$layoutOverlayY clicks=$overlayClicks", + { style = { color = DEMO_MUTED } } + ) + } + } + + if (stackOverlayEnabled) { + div({ + key = "layout.stack.overlay" + 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 + } + onMouseDrag = { event -> + updateOverlayDrag( + event = event, + overlayWidth = overlayWidth, + overlayHeight = overlayHeight, + isDragging = layoutOverlayDragging, + currentX = layoutOverlayX, + currentY = layoutOverlayY, + anchorX = overlayDragAnchorXRef.current ?: 0, + anchorY = overlayDragAnchorYRef.current ?: 0 + ) { nextX, nextY, moved -> + if (moved) { + overlayDragMovedRef.current = true + } + if (nextX != layoutOverlayX) { + layoutOverlayX = nextX + } + if (nextY != layoutOverlayY) { + layoutOverlayY = nextY + } + } + } + onMouseUp = onMouseUp@{ event -> + if (!layoutOverlayDragging) return@onMouseUp + if (event.mouseButton == MouseButton.LEFT && !(overlayDragMovedRef.current ?: false)) { + overlayClicks += 1 + onLogHook("overlay.onMouseClick", event, "overlayClicks=$overlayClicks") + } + layoutOverlayDragging = false + overlayDragMovedRef.current = false + } + style = { + width = overlayWidth.px + height = overlayHeight.px + backgroundColor = 0xCC5A3131.toInt() + margin { top = layoutOverlayY.px; right = 0.px; bottom = 0.px; left = layoutOverlayX.px } + padding { all(4.px) } + border { width = 1.px; color = 0xFF8D4848.toInt() } + } + }) { + text( + if (layoutOverlayDragging) "Overlay (dragging...)" else "Overlay (drag me)", + { style = { color = 0xFFF5F7FA.toInt() } } + ) + } + } + } +} + +private fun updateOverlayDrag( + event: MouseDragEvent, + overlayWidth: Int, + overlayHeight: Int, + isDragging: Boolean, + currentX: Int, + currentY: Int, + anchorX: Int, + anchorY: Int, + 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 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 + onUpdate(nextX, nextY, moved) +} + +private fun findNodeInPath(start: DOMNode?, key: Any): DOMNode? { + var current = start + while (current != null) { + if (current.key == key) return current + current = current.parent + } + return null +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/McFeaturesSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/McFeaturesSection.kt new file mode 100644 index 0000000..be2c8ef --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/McFeaturesSection.kt @@ -0,0 +1,440 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +import org.dreamfinity.dsgl.core.DsglColors +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.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.McItemStackRef +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import kotlin.math.roundToLong + +data class McFeaturesShellProps( + val viewportWidthPx: Int, + val viewportHeightPx: Int, + val mediaReady: Boolean, + val resourceImageSource: String, + val fileImageSource: String, + val httpImageSource: String, + val flatItemRef: McItemStackRef, + val blockItemRef: McItemStackRef, + val clippingScrollDemoText: String, + val onClippingScrollDemoTextChange: (String) -> Unit, + val currentGuiScale: () -> Int, + val guiScaleLabel: (Int) -> String, + val setGuiScale: (Int) -> Unit, + val cycleGuiScale: (Int) -> Unit, + val onLogHook: (String, Event, String?) -> Unit +) + +fun UiScope.mcFeaturesSection(props: McFeaturesShellProps) { + 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 + itemRotY = normalized + itemRotX = (itemRotX + deltaX).coerceIn(-89.0, 89.0) + } + + val guiScaleValue = props.currentGuiScale() + + div({ + key = "section.mcFeatures" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text( + "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 } } + ) + + div({ + key = "mc.guiScale.controls" + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Auto", { + style { + backgroundColor = if (guiScaleValue == 0) 0xFF2F556E.toInt() else DsglColors.BUTTON + } + onMouseClick = { props.setGuiScale(0) } + }) + button("1x", { + style { + backgroundColor = if (guiScaleValue == 1) 0xFF2F556E.toInt() else DsglColors.BUTTON + } + onMouseClick = { props.setGuiScale(1) } + }) + button("2x", { + style { + backgroundColor = if (guiScaleValue == 2) 0xFF2F556E.toInt() else DsglColors.BUTTON + } + onMouseClick = { props.setGuiScale(2) } + }) + button("3x", { + style { + backgroundColor = if (guiScaleValue == 3) 0xFF2F556E.toInt() else DsglColors.BUTTON + } + onMouseClick = { props.setGuiScale(3) } + }) + button("4x", { + style { + backgroundColor = if (guiScaleValue == 4) 0xFF2F556E.toInt() else DsglColors.BUTTON + } + onMouseClick = { props.setGuiScale(4) } + }) + button("-", { + onMouseClick = { props.cycleGuiScale(-1) } + }) + button("+", { + onMouseClick = { props.cycleGuiScale(1) } + }) + } + + text("Pixel board (1px borders) + nested layout + clipping + ItemStack positioning") + div({ + key = "mc.pixel.board" + style = { + width = 100.percent + maxWidth = 360.px + padding = 4.px + border { width = 1.px; color = 0xFF5D6A76.toInt() } + backgroundColor = 0xFF1A222A.toInt() + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + for (row in 0 until 4) { + div({ + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + for (col in 0 until 8) { + div({ + 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() + } + }) {} + } + } + } + + div({ + key = "mc.pixel.board.content" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "mc.pixel.board.nested" + style = { + width = 160.px + height = 102.px + padding = 4.px + border { width = 1.px; color = 0xFF6A7784.toInt() } + backgroundColor = 0xFF111922.toInt() + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + justifyContent = JustifyContent.SpaceBetween + } + }) { + itemStack(props.flatItemRef, { + key = "mc.pixel.item.topLeft" + size = 16 + style = { width = 18.px } + }) + itemStack(props.blockItemRef, { + key = "mc.pixel.item.topRight" + size = 16 + rotYDeg = itemRotY + rotXDeg = itemRotX + style = { width = 18.px } + }) + } + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + justifyContent = JustifyContent.Center + alignItems = AlignItems.Center + } + }) { + itemStack(props.blockItemRef, { + key = "mc.pixel.item.center" + size = 20 + rotYDeg = itemRotY + rotXDeg = itemRotX + style = { + width = 28.px + transform { + rotate(5f) + } + } + }) + } + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + justifyContent = JustifyContent.SpaceBetween + } + }) { + itemStack(props.flatItemRef, { + key = "mc.pixel.item.bottomLeft" + size = 16 + style = { width = 18.px } + }) + itemStack(props.flatItemRef, { + key = "mc.pixel.item.bottomRight" + size = 16 + style = { width = 18.px } + }) + } + } + textarea({ + key = "mc.pixel.clip.textarea" + placeholder = "Clipped/scrollable viewport check" + value = props.clippingScrollDemoText + style = { + flexGrow = 1f + minWidth = 96.px + height = 102.px + } + onInput = { event -> + props.onClippingScrollDemoTextChange(event.value) + } + onValueChange = { event -> + props.onClippingScrollDemoTextChange(event.value) + } + }) + } + } + + text("Image sources: resource + file:// + http(s):// cached path.") + text( + "mediaReady=${props.mediaReady} file=${if (props.mediaReady) "prepared" else "failed"}", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + gap = 3.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + div({ + key = "mc.image.resource.col" + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Resource", { style = { color = DEMO_MUTED } }) + img(props.resourceImageSource, { + key = "mc.image.resource" + style = { + width = 36.px + height = 36.px + border { width = 1.px; color = 0xFF66737F.toInt() } + } + }) + } + div({ + key = "mc.image.file.col" + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("file://", { style = { color = DEMO_MUTED } }) + img(props.fileImageSource, { + key = "mc.image.file" + style = { + width = 36.px + height = 36.px + border { width = 1.px; color = 0xFF66737F.toInt() } + } + }) + } + div({ + key = "mc.image.http.col" + style = { + gap = 2.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("http://", { style = { color = DEMO_MUTED } }) + img(props.httpImageSource, { + key = "mc.image.http" + style = { + width = 36.px + height = 36.px + border { width = 1.px; color = 0xFF66737F.toInt() } + } + }) + } + } + + text("Item stack render modes (2D item + 3D block)") + div({ + key = "mc.items.row" + style = { + gap = 10.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + itemStack(props.flatItemRef, { + size = 18 + key = "mc.item.2d" + style = { + width = 64.px + border { width = 1.px; color = 0xFF586A7A.toInt() } + } + }) + itemStack(props.blockItemRef, { + size = 20 + rotYDeg = itemRotY + rotXDeg = itemRotX + key = "mc.item.3d" + style = { + width = 70.px + border { width = 1.px; color = 0xFF586A7A.toInt() } + } + }) + } + + 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 } } + ) + input( + InputType.Range( + value = itemRotYLong(), + min = 0, + max = 360, + step = 5 + ), + { + key = "mc.rotation.slider.yaw" + style = { width = 100.percent } + onMouseDown = { event -> + props.onLogHook("mc.rotY.onMouseDown", event, "capture-start") + } + onMouseUp = { event -> + props.onLogHook("mc.rotY.onMouseUp", event, "capture-end rotY=${itemRotYLong()}") + } + onInput = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: itemRotYLong() + itemRotY = next.toDouble() + props.onLogHook("mc.rotY.onInput", event, "rotY=${itemRotYLong()}") + } + onValueChange = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: itemRotYLong() + itemRotY = next.toDouble() + props.onLogHook("mc.rotY.onChange", event, "rotY=${itemRotYLong()}") + } + } + ) + + input( + InputType.Range( + value = itemRotXLong(), + min = -89, + max = 89, + step = 1 + ), + { + key = "mc.rotation.slider.pitch" + style = { width = 100.percent } + onMouseDown = { event -> + props.onLogHook("mc.rotX.onMouseDown", event, "capture-start") + } + onMouseUp = { event -> + props.onLogHook("mc.rotX.onMouseUp", event, "capture-end rotX=${itemRotXLong()}") + } + onInput = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: itemRotXLong() + itemRotX = next.toDouble() + props.onLogHook("mc.rotX.onInput", event, "rotX=${itemRotXLong()}") + } + onValueChange = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: itemRotXLong() + itemRotX = next.toDouble() + props.onLogHook("mc.rotX.onChange", event, "rotX=${itemRotXLong()}") + } + } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Y-15", { + onMouseClick = { adjustItemRotation(deltaY = -15.0) } + }) + button("Y+15", { + onMouseClick = { adjustItemRotation(deltaY = 15.0) } + }) + button("X-10", { + onMouseClick = { adjustItemRotation(deltaX = -10.0) } + }) + button("X+10", { + onMouseClick = { adjustItemRotation(deltaX = 10.0) } + }) + button("Reset", { + onMouseClick = { + itemRotY = 160.0 + itemRotX = -11.0 + } + }) + } + + text( + "rotY=${itemRotYLong()} rotX=${itemRotXLong()}", + { style = { color = DEMO_MUTED } } + ) + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ModalsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ModalsSection.kt new file mode 100644 index 0000000..4a821b1 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/ModalsSection.kt @@ -0,0 +1,203 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +import org.dreamfinity.dsgl.core.dsl.* +import org.dreamfinity.dsgl.core.components.modal.* +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.mcForge1122.demo.support.DEMO_MUTED + +fun UiScope.modalsSection( + modals: List, + onPushModal: (ModalSpec) -> Unit, + onRemoveModal: (String) -> Unit, + onPopTopModal: () -> Unit, + onClearModals: () -> Unit, + onInfo: (String) -> Unit +) { + var modalBackgroundCounter by useState(0) + + div({ + key = "section.modals" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Declarative modal stack (state-driven list order).") + text("Last modal in list is topmost. Background button proves input blocking.", { + style = { color = DEMO_MUTED } + }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Open basic", { + onMouseClick = { onPushModal(basicModal(onRemoveModal)) } + }) + button("Open static", { + onMouseClick = { onPushModal(staticModal(onRemoveModal)) } + }) + button("Open lg centered", { + onMouseClick = { onPushModal(largeCenteredModal(onRemoveModal)) } + }) + button("Open flow step 1", { + onMouseClick = { onPushModal(flowStep1Modal(onPushModal, onRemoveModal)) } + }) + } + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Background +1", { + onMouseClick = { + modalBackgroundCounter += 1 + onInfo("Background counter incremented") + } + }) + button("Pop top", { + onMouseClick = { onPopTopModal() } + }) + button("Clear modals", { + onMouseClick = { + onClearModals() + onInfo("Modal stack cleared") + } + }) + } + + text({ + 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 } } + ) + } +} + +private fun basicModal(onRemoveModal: (String) -> Unit): ModalSpec { + return ModalSpec( + key = "modal.basic", + backdrop = BackdropMode.True, + keyboard = true, + onHide = { onRemoveModal("modal.basic") } + ) { scope -> + modalHeader(closeButton = true, onHide = scope.dismiss) { + modalTitle("Basic Modal") + } + modalBody { + text("This modal closes by backdrop click, ESC, close button, or footer button.") + } + modalFooter { + button("Close", { + onMouseClick = { scope.dismiss?.invoke() } + }) + } + } +} + +private fun staticModal(onRemoveModal: (String) -> Unit): ModalSpec { + return ModalSpec( + key = "modal.static", + backdrop = BackdropMode.Static, + keyboard = false, + onHide = { onRemoveModal("modal.static") } + ) { scope -> + modalHeader(closeButton = true, onHide = scope.dismiss) { + modalTitle("Static Backdrop") + } + modalBody { + text("Backdrop clicks and ESC do not dismiss this modal.") + text("Use close button or footer action.", { style = { color = DEMO_MUTED } }) + } + modalFooter { + button("Close", { + onMouseClick = { scope.dismiss?.invoke() } + }) + } + } +} + +private fun largeCenteredModal(onRemoveModal: (String) -> Unit): ModalSpec { + return ModalSpec( + key = "modal.large", + size = ModalSize.Lg, + centered = true, + 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 } }) + } + modalFooter { + button("Done", { + onMouseClick = { scope.dismiss?.invoke() } + }) + } + } +} + +private fun flowStep1Modal( + onPushModal: (ModalSpec) -> Unit, + onRemoveModal: (String) -> Unit +): ModalSpec { + return 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(flowStep2Modal(onRemoveModal)) + } + }) + } + } +} + +private fun flowStep2Modal(onRemoveModal: (String) -> Unit): ModalSpec { + return 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() } + }) + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/MsdfFontsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/MsdfFontsSection.kt new file mode 100644 index 0000000..20b2597 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/MsdfFontsSection.kt @@ -0,0 +1,358 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.text.MsdfRuntimeDebugSettings + +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." +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" +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" +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." +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." + +private const val SAMPLE_SPACES_B = "A A A" +private const val SAMPLE_MC_COLORS = "\u00A7aGreen \u00A7bBlue \u00A7cRed \u00A7rBackToDefault" +private const val SAMPLE_MC_FLAGS = + "\u00A7lBold \u00A7oItalic \u00A7nUnderline \u00A7mStrike \u00A7kMagic\u00A7r Normal" + +fun UiScope.msdfFontsSection(onInfo: (String) -> Unit) { + var msdfOpacityPercent by useState(100L) + var msdfFontSizePx by useState(9L) + var msdfWrapWidthPercent by useState(15L) + var msdfColorIndex by useState(0) + var msdfParseMinecraftFormatting by useState(true) + var msdfShowBaselineGuides by useState(MsdfRuntimeDebugSettings.decorationGuidesEnabled) + val selectableFonts = FontRegistry.registeredFonts() + var selectedFont by useState(selectableFonts.first()) + + val panelWidthPercent = msdfWrapWidthPercent.coerceIn(0L, 100L) + val textOpacity = (msdfOpacityPercent.toFloat() / 100f).coerceIn(0f, 1f) + val fontSize = msdfFontSizePx.toInt().coerceIn(6, 48) + + val textColor = COLOR_PRESETS[msdfColorIndex.coerceIn(0, COLOR_PRESETS.lastIndex)] + val formattingMode = if (msdfParseMinecraftFormatting) TextFormatting.Minecraft else TextFormatting.None + + div({ + key = "section.msdfFonts" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + text("DREAMFINITY", { style = { fontId = "telegrafico" } }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + select({ + value = selectedFont.fontId + onValueChange = { event -> + selectedFont = selectableFonts.first { it.fontId == event.value } + } + }) { + selectableFonts.forEach { font -> + option(font.fontId, font.fontId) + } + } + + button("Color", { + onMouseClick = { + msdfColorIndex = (msdfColorIndex + 1) % COLOR_PRESETS.size + } + }) + button( + if (msdfParseMinecraftFormatting) { + "Formatting ON" + } else { + "Formatting OFF" + }, + { + onMouseClick = { + msdfParseMinecraftFormatting = !msdfParseMinecraftFormatting + onInfo("MSDF formatting=$msdfParseMinecraftFormatting") + } + } + ) + button( + if (msdfShowBaselineGuides) { + "Guides ON" + } else { + "Guides OFF" + }, + { + onMouseClick = { + msdfShowBaselineGuides = !msdfShowBaselineGuides + MsdfRuntimeDebugSettings.decorationGuidesEnabled = msdfShowBaselineGuides + System.setProperty( + "dsgl.msdf.debug.decorations", + msdfShowBaselineGuides.toString() + ) + onInfo("MSDF guides=$msdfShowBaselineGuides") + } + } + ) + } + + input( + InputType.Range( + value = msdfOpacityPercent, + min = 0, + max = 100, + step = 1 + ), + { + key = "msdf.opacity" + style = { width = 100.percent } + onInput = { event -> + 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 + ), + { + key = "msdf.fontSize" + style = { width = 100.percent } + onInput = { event -> + 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 + ), + { + key = "msdf.wrapWidth" + style = { width = 100.percent } + onInput = { event -> + 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 } } + ) + text( + "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" + } + + style = { + color = DEMO_MUTED + textWrap = TextWrap.Wrap + } + }) + + div({ + key = "msdf.panel" + style = { + width = panelWidthPercent.percent + padding = 4.px + gap = 2.px + backgroundColor = 0xFF233040.toInt() + display = Display.Flex + flexDirection = FlexDirection.Column + border { width = 1.px; color = 0xFF5F7288.toInt() } + } + }) { + text("Header text", { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text("Style only: bold + italic", { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + fontWeight = FontWeight.Bold + fontStyle = FontStyle.Italic + } + }) + text("Style only: underline + strikethrough", { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textDecoration = TextDecoration.UnderlineStrikethrough + } + }) + text("Style only: obfuscated text sample 12345", { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + obfuscated = true + } + }) + text(LONG_CH_SENTENCE, { + style = { + fontId = "Noto_Sans_SC/NotoSansSC" + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + } + }) + text(LONG_CH_SENTENCE, { + style = { + fontId = "Noto_Sans_TC/NotoSansTC" + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + } + }) + text(LONG_JP_SENTENCE, { + style = { + fontId = "Noto_Sans_JP/NotoSansJP" + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + } + }) + text(LONG_KR_SENTENCE, { + style = { + fontId = "Noto_Sans_KR/NotoSansKR" + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + } + }) + text(SAMPLE_PARAGRAPH, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text(SAMPLE_WORD, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text(SAMPLE_SPACES_A, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text(SAMPLE_SPACES_B, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text("Minecraft Color Codes", { + style = { color = DEMO_MUTED } + }) + text(SAMPLE_MC_COLORS, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + text(SAMPLE_MC_FLAGS, { + style = { + fontId = selectedFont.fontId + this.fontSize = fontSize.px + foregroundColor = textColor + this.opacity = textOpacity + textWrap = TextWrap.Wrap + textFormatting = formattingMode + } + }) + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverflowScrollSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverflowScrollSection.kt new file mode 100644 index 0000000..80519b3 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverflowScrollSection.kt @@ -0,0 +1,318 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.Overflow +import org.dreamfinity.dsgl.core.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +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 nextOverflow(current: Overflow): Overflow { + val idx = OVERFLOW_MODES.indexOf(current) + if (idx < 0) return OVERFLOW_MODES.first() + return OVERFLOW_MODES[(idx + 1) % OVERFLOW_MODES.size] +} + +fun UiScope.overflowScrollSection(onInfo: (String) -> Unit) { + var overflowDemoOverflowX by useState(Overflow.Auto) + var overflowDemoOverflowY by useState(Overflow.Auto) + var overflowDemoViewportWidth by useState(118L) + var overflowDemoViewportHeight by useState(76L) + var overflowDemoContentWidth by useState(132L) + var overflowDemoContentHeight by useState(126L) + var overflowDemoVisibleClicks by useState(0) + var overflowDemoEdgeClicks by useState(0) + + val viewportMinWidth = 88 + val viewportMaxWidth = 260 + val viewportMinHeight = 56 + val viewportMaxHeight = 180 + val contentMinWidth = 60 + val contentMaxWidth = 420 + val contentMinHeight = 48 + val contentMaxHeight = 420 + + val viewportWidth = overflowDemoViewportWidth.toInt().coerceIn(viewportMinWidth, viewportMaxWidth) + val viewportHeight = overflowDemoViewportHeight.toInt().coerceIn(viewportMinHeight, viewportMaxHeight) + val demoContentWidth = overflowDemoContentWidth.toInt().coerceIn(contentMinWidth, contentMaxWidth) + val demoContentHeight = overflowDemoContentHeight.toInt().coerceIn(contentMinHeight, contentMaxHeight) + + div({ + key = "section.overflowScroll" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + overflowY = Overflow.Auto + } + }) { + text("Overflow/scroll viewport playground") + text( + "Scrollbar presence is state-only for now, but gutters already reduce viewport size.", + { style = { color = DEMO_MUTED } } + ) + + div({ + key = "section.overflowScroll.controls" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 4.px + } + }) { + button("overflow-x: ${overflowDemoOverflowX.label()}", { + onMouseClick = { + overflowDemoOverflowX = nextOverflow(overflowDemoOverflowX) + onInfo("Overflow demo x=${overflowDemoOverflowX.label()}") + } + }) + button("overflow-y: ${overflowDemoOverflowY.label()}", { + onMouseClick = { + overflowDemoOverflowY = nextOverflow(overflowDemoOverflowY) + onInfo("Overflow demo y=${overflowDemoOverflowY.label()}") + } + }) + button("Reset", { + onMouseClick = { + overflowDemoOverflowX = Overflow.Auto + overflowDemoOverflowY = Overflow.Auto + overflowDemoViewportWidth = 118L + overflowDemoViewportHeight = 76L + overflowDemoContentWidth = 132L + overflowDemoContentHeight = 126L + overflowDemoVisibleClicks = 0 + overflowDemoEdgeClicks = 0 + onInfo("Overflow demo reset") + } + }) + } + + input( + InputType.Range( + value = viewportWidth.toLong(), + min = viewportMinWidth.toLong(), + max = viewportMaxWidth.toLong(), + step = 2 + ), + { + key = "section.overflowScroll.viewportWidth" + style = { width = 100.percent } + onInput = { event -> + 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 } }) + + input( + InputType.Range( + value = viewportHeight.toLong(), + min = viewportMinHeight.toLong(), + max = viewportMaxHeight.toLong(), + step = 2 + ), + { + key = "section.overflowScroll.viewportHeight" + style = { width = 100.percent } + onInput = { event -> + 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 } }) + + input( + InputType.Range( + value = demoContentWidth.toLong(), + min = contentMinWidth.toLong(), + max = contentMaxWidth.toLong(), + step = 2 + ), + { + key = "section.overflowScroll.contentWidth" + style = { width = 100.percent } + onInput = { event -> + 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 } }) + + input( + InputType.Range( + value = demoContentHeight.toLong(), + min = contentMinHeight.toLong(), + max = contentMaxHeight.toLong(), + step = 2 + ), + { + key = "section.overflowScroll.contentHeight" + style = { width = 100.percent } + onInput = { event -> + 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 } } + ) + + overflowDemoCard( + title = "Interactive lab", + note = "Switch overflow-x/y and sizes; verify clipping and gutter-driven viewport changes.", + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + contentWidth = demoContentWidth, + contentHeight = demoContentHeight, + overflowX = overflowDemoOverflowX, + overflowY = overflowDemoOverflowY, + keyPrefix = "section.overflowScroll.lab", + onVisibleClick = { + overflowDemoVisibleClicks += 1 + onInfo("Overflow demo visible click") + }, + onEdgeClick = { + overflowDemoEdgeClicks += 1 + onInfo("Overflow demo edge click") + } + ) + + overflowDemoCard( + title = "Preset: overflow=scroll", + note = "Both axes reserve gutter even if content mostly fits.", + viewportWidth = 118, + viewportHeight = 76, + contentWidth = 110, + contentHeight = 68, + overflowX = Overflow.Scroll, + overflowY = Overflow.Scroll, + keyPrefix = "section.overflowScroll.preset.scroll", + onVisibleClick = {}, + onEdgeClick = {} + ) + + overflowDemoCard( + title = "Preset: cross-axis forcing (auto/auto)", + note = "Tall content triggers vertical gutter, reduced width can then trigger horizontal overflow.", + viewportWidth = 118, + viewportHeight = 76, + contentWidth = 102, + contentHeight = 156, + overflowX = Overflow.Auto, + overflowY = Overflow.Auto, + keyPrefix = "section.overflowScroll.preset.cross", + onVisibleClick = {}, + onEdgeClick = {} + ) + } +} + +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 +) { + div({ + key = "$keyPrefix.card" + style = { + width = 100.percent + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + padding = 2.px + backgroundColor = 0xFF2B3440.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 } } + ) + + div({ + key = "$keyPrefix.viewport" + style = { + width = viewportWidth.px + height = viewportHeight.px + this.overflowX = overflowX + this.overflowY = overflowY + border { width = 1.px; color = 0xFF8AA0B5.toInt() } + backgroundColor = 0xFF23303D.toInt() + padding = 2.px + } + }) { + div({ + key = "$keyPrefix.content" + style = { + width = contentWidth.px + height = contentHeight.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + border { width = 1.px; color = 0xFF7992AA.toInt() } + backgroundColor = 0xFF384C60.toInt() + padding = 2.px + } + }) { + text("content top") + div({ + key = "$keyPrefix.row" + style = { + width = 100.percent + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 2.px + } + }) { + button("visible", { + key = "$keyPrefix.visible" + onMouseClick = { onVisibleClick() } + }) + div({ + key = "$keyPrefix.spacer" + style = { flexGrow = 1f } + }) {} + button("edge", { + key = "$keyPrefix.edge" + onMouseClick = { onEdgeClick() } + }) + } + repeat(8) { index -> + text("line ${index + 1} -> clip boundary check") + } + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverviewSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverviewSection.kt new file mode 100644 index 0000000..6b23fd4 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/OverviewSection.kt @@ -0,0 +1,90 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +import org.dreamfinity.dsgl.core.dsl.* +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.mcForge1122.demo.support.CapabilityId +import org.dreamfinity.dsgl.mcForge1122.demo.support.CapabilityChecklistCatalog +import org.dreamfinity.dsgl.mcForge1122.demo.support.CapabilityGroup +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_OK + +fun UiScope.overviewSection( + implementedCapabilities: Set, + onManualInvalidate: (String) -> Unit, + onInfo: (String) -> Unit +) { + var manualInvalidateCount by useState(0) + var lastManualReason by useState("none") + var autoRebuildCounter by useState(0) + + div({ + key = "section.overview" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("Use left navigation to open each capability group.") + text("Event Inspector and Checklist stay visible while switching sections.", { + style = { color = DEMO_MUTED } + }) + text("Stylesheets: /dsgl/styles/*.dss (manual reload).", { + style = { color = DEMO_MUTED } + }) + text("Open the Stylesheets section for full selector/pseudo-state/variables showcase.", { + style = { color = DEMO_MUTED } + }) + 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).", { + style = { color = DEMO_MUTED } + }) + + text("Manual invalidates: $manualInvalidateCount (last=$lastManualReason)", { + style = { color = DEMO_MUTED } + }) + text("Auto state rebuild counter: $autoRebuildCounter", { + style = { color = DEMO_MUTED } + }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Auto state +1", { + onMouseClick = { + autoRebuildCounter += 1 + onInfo("Overview: state-driven rebuild") + } + }) + button("Manual invalidate", { + onMouseClick = { + val reason = "overview button" + manualInvalidateCount += 1 + lastManualReason = reason + onManualInvalidate(reason) + onInfo("Overview: manual invalidate requested") + } + }) + } + + text("Checklist groups", { style = { color = DEMO_OK } }) + CapabilityGroup.entries.forEach { group -> + 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() } } + ) + } + } +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/PositionedLayoutSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/PositionedLayoutSection.kt new file mode 100644 index 0000000..fdee10d --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/PositionedLayoutSection.kt @@ -0,0 +1,1325 @@ +package org.dreamfinity.dsgl.mcForge1122.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.hooks.useState +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_MUTED + +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 +private const val Z_MIN = -8 +private const val Z_MAX = 12 + +private const val CARD_BLUE = 0xFF355E91.toInt() +private const val CARD_GREEN = 0xFF3E7A56.toInt() +private const val CARD_RED = 0xFF8A4A44.toInt() + +fun UiScope.positionedLayoutSection(viewportWidthPx: Int) { + var positionedDemoModeIndex by useState(1L) + var positionedDemoUseLeft by useState(true) + var positionedDemoUseTop by useState(true) + var positionedDemoLeft by useState(24L) + var positionedDemoTop by useState(14L) + var positionedDemoRight by useState(26L) + var positionedDemoBottom by useState(18L) + var positionedDemoZBlue by useState(1L) + var positionedDemoZGreen by useState(4L) + var positionedDemoZRed by useState(2L) + var positionedDemoTieSwap by useState(false) + var positionedDemoLastHover by useState("none") + var positionedDemoLastClick by useState("none") + var positionedDemoBlueClicks by useState(0) + var positionedDemoGreenClicks by useState(0) + var positionedDemoRedClicks by useState(0) + var positionedDemoTieFirstClicks by useState(0) + var positionedDemoTieSecondClicks by useState(0) + var positionedDemoMixedStaticClicks by useState(0) + var positionedDemoMixedPositionedClicks by useState(0) + var positionedDemoScrollClicks by useState(0) + var positionedDemoStickyTopClicks by useState(0) + var positionedDemoStickyCombinedClicks by useState(0) + + val modeIndex = positionedDemoModeIndex.toInt().coerceIn(0, POSITION_MODE_OPTIONS.lastIndex) + val demoMode = POSITION_MODE_OPTIONS[modeIndex] + + val leftOffset = positionedDemoLeft.toInt().coerceIn(OFFSET_MIN, OFFSET_MAX) + val topOffset = positionedDemoTop.toInt().coerceIn(OFFSET_MIN, OFFSET_MAX) + val rightOffset = positionedDemoRight.toInt().coerceIn(0, OFFSET_MAX) + val bottomOffset = positionedDemoBottom.toInt().coerceIn(0, OFFSET_MAX) + val zBlue = positionedDemoZBlue.toInt().coerceIn(Z_MIN, Z_MAX) + val zGreen = positionedDemoZGreen.toInt().coerceIn(Z_MIN, Z_MAX) + val zRed = positionedDemoZRed.toInt().coerceIn(Z_MIN, Z_MAX) + + val rootAnchoredLeft = (viewportWidthPx / 2).coerceAtLeast(112) + val rootAnchoredTop = 58 + val fixedBaseLeft = (viewportWidthPx - 236).coerceAtLeast(104) + val fixedBaseTop = 72 + + + div({ + key = "section.positionedLayout" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + maxHeight = 90.vh + gap = 4.px + overflowY = Overflow.Auto + overflowX = Overflow.Scroll + } + }) { + 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 } } + ) + + controls( + ControlsProps( + modeIndex = modeIndex, + demoMode = demoMode, + useLeft = positionedDemoUseLeft, + useTop = positionedDemoUseTop, + leftOffset = leftOffset, + rightOffset = rightOffset, + topOffset = topOffset, + bottomOffset = bottomOffset, + zBlue = zBlue, + zGreen = zGreen, + zRed = zRed, + lastHover = positionedDemoLastHover, + lastClick = positionedDemoLastClick, + onModeCycle = { positionedDemoModeIndex = ((modeIndex + 1) % POSITION_MODE_OPTIONS.size).toLong() }, + onToggleUseLeft = { positionedDemoUseLeft = !positionedDemoUseLeft }, + onToggleUseTop = { positionedDemoUseTop = !positionedDemoUseTop }, + onSetLeft = { positionedDemoLeft = it }, + onSetRight = { positionedDemoRight = it }, + onSetTop = { positionedDemoTop = it }, + onSetBottom = { positionedDemoBottom = it }, + onSetZBlue = { positionedDemoZBlue = it }, + onSetZGreen = { positionedDemoZGreen = it }, + onSetZRed = { positionedDemoZRed = it }, + onReset = { + positionedDemoModeIndex = 1L + positionedDemoUseLeft = true + positionedDemoUseTop = true + positionedDemoLeft = 24L + positionedDemoTop = 14L + positionedDemoRight = 26L + positionedDemoBottom = 18L + positionedDemoZBlue = 1L + positionedDemoZGreen = 4L + positionedDemoZRed = 2L + positionedDemoTieSwap = false + positionedDemoLastHover = "none" + positionedDemoLastClick = "none" + positionedDemoBlueClicks = 0 + positionedDemoGreenClicks = 0 + positionedDemoRedClicks = 0 + positionedDemoTieFirstClicks = 0 + positionedDemoTieSecondClicks = 0 + positionedDemoMixedStaticClicks = 0 + positionedDemoMixedPositionedClicks = 0 + positionedDemoScrollClicks = 0 + positionedDemoStickyTopClicks = 0 + positionedDemoStickyCombinedClicks = 0 + } + ) + ) + + div({ + style = { + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + padding = 5.px + } + }) { + text("Mode playground: change position mode with the same offsets to compare runtime behavior quickly.") + div({ + key = "positioned.mode.playground" + style = { + position = PositionMode.Relative + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + backgroundColor = 0xFF2A3440.toInt() + padding = 5.px + } + }) { + div({ + key = "positioned.mode.target" + onMouseEnter = { positionedDemoLastHover = "mode-$demoMode" } + onMouseClick = { positionedDemoLastClick = "mode-$demoMode" } + style = { + position = demoMode + left = if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } + right = rightOffset.px + 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() } + } + }) { + text("mode=$demoMode") + } + text("same offsets + z are reused; static should stay neutral", { style = { color = DEMO_MUTED } }) + } + } + + div({ + style = { + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + padding = 5.px + } + }) { + text("A. Static baseline: offsets exist in style state but static visible geometry ignores them.") + div({ + key = "positioned.static.sample" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + padding = 5.px + border { width = 1.px; color = 0xFF667A8D.toInt() } + backgroundColor = 0xFF2A313A.toInt() + } + }) { + div({ + style = { + padding = 5.px + backgroundColor = 0xFF364352.toInt() + border { width = 1.px; color = 0xFF7E97AD.toInt() } + } + }) { text("Flow item before") } + div({ + key = "positioned.static.target" + onMouseEnter = { positionedDemoLastHover = "static-target" } + onMouseClick = { + positionedDemoLastClick = "static-target" + } + style = { + position = PositionMode.Static + left = leftOffset.px + right = rightOffset.px + top = topOffset.px + bottom = bottomOffset.px + padding = 5.px + backgroundColor = 0xFF3F5A73.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() } + } + }) { text("Flow item after") } + } + } + div({ + style = { + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + padding = 5.px + } + }) { + text("B. Relative: visual offset changes, but the original flow slot stays reserved.") + div({ + key = "positioned.relative.sample" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 3.px + padding = 5.px + border { width = 1.px; color = 0xFF6D7C8A.toInt() } + backgroundColor = 0xFF29323D.toInt() + } + }) { + div({ + style = { + padding = 5.px + backgroundColor = 0xFF455B70.toInt() + border { width = 1.px; color = 0xFF91A9BF.toInt() } + } + }) { text("left") } + div({ + key = "positioned.relative.target" + onMouseEnter = { positionedDemoLastHover = "relative-target" } + onMouseClick = { positionedDemoLastClick = "relative-target" } + style = { + position = PositionMode.Relative + left = if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } + right = rightOffset.px + top = if (positionedDemoUseTop) { + topOffset.px + } else { + null + } + bottom = bottomOffset.px + padding = 5.px + backgroundColor = 0xFF4A6A87.toInt() + border { width = 1.px; color = 0xFFB5CFE6.toInt() } + zIndex = zBlue + } + }) { text("relative target") } + div({ + style = { + padding = 5.px + backgroundColor = 0xFF455B70.toInt() + border { width = 1.px; color = 0xFF91A9BF.toInt() } + } + }) { text("right") } + } + } + + div({ + style = { + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + padding = 5.px + } + }) { + text("C. Absolute: out-of-flow, anchored by nearest positioned ancestor (or root when none).") + div({ + key = "positioned.absolute.sample" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + padding = 5.px + 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 } }) + div({ + key = "positioned.absolute.container" + style = { + position = PositionMode.Relative + overflowY = Overflow.Auto + padding = 5.px + border { width = 1.px; color = 0xFF8AA0B4.toInt() } + backgroundColor = 0xFF344252.toInt() + } + }) { + div({ + key = "positioned.absolute.inner" + onMouseEnter = { positionedDemoLastHover = "absolute-inside" } + onMouseClick = { + positionedDemoLastClick = "absolute-inside" + } + style = { + position = PositionMode.Absolute + left = if (positionedDemoUseLeft) { + leftOffset.px + } else { + null + } + right = rightOffset.px + top = if (positionedDemoUseTop) { + topOffset.px + } else { + null + } + bottom = bottomOffset.px + padding = 5.px + backgroundColor = 0xAA2B4E73.toInt() + border { width = 1.px; color = 0xFFD2E8FF.toInt() } + zIndex = zGreen + } + }) { text("absolute in relative ancestor") } + repeat(8) { index -> + text("flow line ${index + 1} (absolute should not reserve space)") + } + } + } + } + + div({ + style = { + border { width = 1.px; color = 0xFF6A7D8F.toInt() } + padding = 5.px + } + }) { + div({ + key = "positioned.absolute.rootBadge" + onMouseEnter = { positionedDemoLastHover = "absolute-root" } + onMouseClick = { positionedDemoLastClick = "absolute-root" } + style = { + position = PositionMode.Absolute + left = rootAnchoredLeft.px + top = rootAnchoredTop.px + padding = 5.px + backgroundColor = 0xCC2C5A89.toInt() + border { width = 1.px; color = 0xFFB9D9FA.toInt() } + zIndex = 18 + } + }) { + text("absolute -> root") + } + + text("D/F. Fixed under scroll: anchored to current root viewport while normal content scrolls.") + div({ + key = "positioned.scroll.sample" + style = { + position = PositionMode.Relative + overflowY = Overflow.Auto + padding = 5.px + border { width = 1.px; color = 0xFF73879A.toInt() } + backgroundColor = 0xFF27323E.toInt() + display = Display.Flex + flexDirection = FlexDirection.Column + maxHeight = 8.em + gap = 2.px + } + }) { + div({ + key = "positioned.scroll.relative" + style = { + position = PositionMode.Relative + left = (leftOffset / 2).px + top = (topOffset / 2).px + padding = 5.px + backgroundColor = 0xFF496B8A.toInt() + border { width = 1.px; color = 0xFFB6D5EE.toInt() } + } + }) { + text("relative in scroller") + } + div({ + key = "positioned.scroll.absolute" + style = { + position = PositionMode.Absolute + left = 6.px + top = 34.px + padding = 5.px + backgroundColor = 0xAA345469.toInt() + border { width = 1.px; color = 0xFFCFE6F4.toInt() } + zIndex = 7 + } + }) { text("absolute in scroller") } + repeat(24) { index -> + text("scroll line ${index + 1}") + } + button("scroll action", { + key = "positioned.scroll.button" + onMouseClick = { + positionedDemoScrollClicks += 1 + positionedDemoLastClick = "scroll-action" + } + }) + text("scroll action clicks=${positionedDemoScrollClicks}", { style = { color = DEMO_MUTED } }) + } + } + + div({ + key = "positioned.fixed.badge" + onMouseEnter = { positionedDemoLastHover = "fixed-badge" } + onMouseClick = { positionedDemoLastClick = "fixed-badge" } + style = { + position = PositionMode.Fixed + left = if (positionedDemoUseLeft) { + (fixedBaseLeft + leftOffset).coerceAtLeast(0).px + } else { + null + } + right = rightOffset.px + 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() } + zIndex = 28 + } + }) { + text("fixed -> root viewport") + } + + div({ + key = "positioned.sticky.surface" + style = { + 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( + "Inspector target key: positioned.sticky.xy.target", + { style = { color = DEMO_MUTED } } + ) + + stickyVerticalGroup( + onSetLastHover = { positionedDemoLastHover = it }, + onSetLastClick = { positionedDemoLastClick = it }, + stickyTopClicks = positionedDemoStickyTopClicks, + onStickyTopClick = { positionedDemoStickyTopClicks += 1 } + ) + stickyHorizontalGroup() + stickyXYGroup( + onSetLastHover = { positionedDemoLastHover = it }, + onSetLastClick = { positionedDemoLastClick = it }, + stickyCombinedClicks = positionedDemoStickyCombinedClicks, + onStickyCombinedClick = { positionedDemoStickyCombinedClicks += 1 } + ) + stickyNoInsets() + stickyClamp() + } + + text("E/G. z-index + hit-testing: topmost visible positioned node should win input.") + div({ + key = "positioned.z.overlap" + style = { + position = PositionMode.Relative + border { width = 1.px; color = 0xFF6F8498.toInt() } + backgroundColor = 0xFF283340.toInt() + } + }) { + positionedOverlapCard( + key = "positioned.z.blue", + label = "blue z=$zBlue", + left = 8, + top = 6, + zIndex = zBlue, + color = CARD_BLUE, + onHover = { positionedDemoLastHover = "blue" }, + onClick = { + positionedDemoBlueClicks += 1 + positionedDemoLastClick = "blue" + } + ) + positionedOverlapCard( + key = "positioned.z.green", + label = "green z=$zGreen", + left = 34, + top = 20, + zIndex = zGreen, + color = CARD_GREEN, + onHover = { positionedDemoLastHover = "green" }, + onClick = { + positionedDemoGreenClicks += 1 + positionedDemoLastClick = "green" + } + ) + positionedOverlapCard( + key = "positioned.z.red", + label = "red z=$zRed", + left = 60, + top = 34, + zIndex = zRed, + color = CARD_RED, + onHover = { positionedDemoLastHover = "red" }, + onClick = { + positionedDemoRedClicks += 1 + positionedDemoLastClick = "red" + } + ) + } + text( + "clicks blue=${positionedDemoBlueClicks}, green=${positionedDemoGreenClicks}, red=${positionedDemoRedClicks}", + { style = { color = DEMO_MUTED } } + ) + + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 3.px + } + }) { + 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 } } + ) + } + + div({ + key = "positioned.tie.sample" + style = { + position = PositionMode.Relative + border { width = 1.px; color = 0xFF6C7F91.toInt() } + backgroundColor = 0xFF283440.toInt() + } + }) { + if (positionedDemoTieSwap) { + positionedTieCard( + label = "second", + left = 28, + top = 16, + zIndex = 3, + color = 0xFF54718F.toInt(), + onSetLastHover = { positionedDemoLastHover = it }, + onTieClick = { + positionedDemoTieSecondClicks += 1 + positionedDemoLastClick = it + } + ) + positionedTieCard( + label = "first", + left = 16, + top = 8, + zIndex = 3, + color = 0xFF6B8CB0.toInt(), + onSetLastHover = { positionedDemoLastHover = it }, + onTieClick = { + positionedDemoTieFirstClicks += 1 + positionedDemoLastClick = it + } + ) + } else { + positionedTieCard( + label = "first", + left = 16, + top = 8, + zIndex = 3, + color = 0xFF6B8CB0.toInt(), + onSetLastHover = { positionedDemoLastHover = it }, + onTieClick = { + positionedDemoTieFirstClicks += 1 + positionedDemoLastClick = it + } + ) + positionedTieCard( + label = "second", + left = 28, + top = 16, + zIndex = 3, + color = 0xFF54718F.toInt(), + onSetLastHover = { positionedDemoLastHover = it }, + onTieClick = { + positionedDemoTieSecondClicks += 1 + positionedDemoLastClick = it + } + ) + } + } + text( + "tie clicks first=${positionedDemoTieFirstClicks}, second=${positionedDemoTieSecondClicks}", + { style = { color = DEMO_MUTED } } + ) + + text("Mixed static vs positioned overlap (stage rule).") + div({ + key = "positioned.mixed.sample" + style = { + position = PositionMode.Relative + border { width = 1.px; color = 0xFF6E8196.toInt() } + backgroundColor = 0xFF293541.toInt() + } + }) { + div({ + key = "positioned.mixed.static" + onMouseEnter = { positionedDemoLastHover = "mixed-static" } + onMouseClick = { + positionedDemoMixedStaticClicks += 1 + positionedDemoLastClick = "mixed-static" + } + style = { + position = PositionMode.Static + zIndex = 999 + padding = 5.px + backgroundColor = 0xFF667F9A.toInt() + border { width = 1.px; color = 0xFFC4D8EB.toInt() } + } + }) { + text("static z=999") + } + div({ + key = "positioned.mixed.positioned" + onMouseEnter = { positionedDemoLastHover = "mixed-positioned" } + onMouseClick = { + positionedDemoMixedPositionedClicks += 1 + positionedDemoLastClick = "mixed-positioned" + } + style = { + position = PositionMode.Relative + left = 18.px + top = 12.px + zIndex = -100 + padding = 5.px + backgroundColor = 0xCC2F536F.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 } } + ) + repeat(40) { + div({ + style = { + padding = 1.px + border { width = 1.px; color = 0xFF617A90.toInt() } + } + }) { + text("Hi there, #$it pyj", { style { fontSize = it.px } }) + } + } + } +} + +private fun UiScope.stickyVerticalGroup( + onSetLastHover: (String) -> Unit, + onSetLastClick: (String) -> Unit, + stickyTopClicks: Int, + onStickyTopClick: () -> Unit +) { + div({ + key = "positioned.sticky.vertical.group" + style = { + border { width = 1.px; color = 0xFF6C8096.toInt() } + padding = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + } + }) { + text("Vertical sticky basics: top=0, bottom=0, top+bottom => top wins") + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 3.px + } + }) { + div({ + style = { + width = 50.percent + border { width = 1.px; color = 0xFF6A7E90.toInt() } + padding = 3.px + } + }) { + text("top=0 (interactive)") + div({ + key = "positioned.sticky.vertical.top.scroller" + style = { + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 7.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + padding = 2.px + } + }) { + button("sticky top action", { + key = "positioned.sticky.vertical.top.target" + onMouseEnter = { onSetLastHover("sticky-top") } + onMouseClick = { + onStickyTopClick() + onSetLastClick("sticky-top") + } + style = { + position = PositionMode.Sticky + top = 0.px + zIndex = 6 + } + }) + repeat(14) { line -> + text("top sticky line ${line + 1}") + } + } + } + div({ + style = { + width = 50.percent + border { width = 1.px; color = 0xFF6A7E90.toInt() } + padding = 3.px + } + }) { + text("bottom=0") + div({ + key = "positioned.sticky.vertical.bottom.scroller" + style = { + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 7.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + padding = 2.px + } + }) { + repeat(12) { line -> + text("bottom sticky line ${line + 1}") + } + div({ + key = "positioned.sticky.vertical.bottom.target" + style = { + position = PositionMode.Sticky + bottom = 0.px + zIndex = 5 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC446181.toInt() + } + }) { + text("sticky bottom block") + } + repeat(8) { line -> + text("tail line ${line + 1}") + } + } + } + } + div({ + key = "positioned.sticky.vertical.precedence.scroller" + style = { + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 6.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + padding = 2.px + } + }) { + div({ + key = "positioned.sticky.vertical.precedence.target" + style = { + position = PositionMode.Sticky + top = 6.px + bottom = 0.px + zIndex = 5 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC3F5871.toInt() + } + }) { text("top+bottom set -> top wins (top=6)") } + repeat(10) { line -> text("precedence line ${line + 1}") } + } + text( + "sticky top clicks=$stickyTopClicks", + { style = { color = DEMO_MUTED } } + ) + } +} + +private fun UiScope.stickyHorizontalGroup() { + div({ + key = "positioned.sticky.horizontal.group" + style = { + border { width = 1.px; color = 0xFF6C8096.toInt() } + padding = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + } + }) { + text("Horizontal sticky basics: left=0, right=0, left+right => left wins") + div({ + key = "positioned.sticky.horizontal.left.scroller" + style = { + overflowX = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 2.px + padding = 2.px + } + }) { + div({ + key = "positioned.sticky.horizontal.left.target" + style = { + position = PositionMode.Sticky + left = 0.px + zIndex = 5 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC3F617B.toInt() + } + }) { text("left=0") } + repeat(16) { idx -> + text("left track ${idx + 1}") + } + } + div({ + key = "positioned.sticky.horizontal.right.scroller" + style = { + overflowX = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 2.px + padding = 2.px + } + }) { + repeat(10) { idx -> + text("right track ${idx + 1}") + } + div({ + key = "positioned.sticky.horizontal.right.target" + style = { + position = PositionMode.Sticky + right = 0.px + zIndex = 5 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC45637F.toInt() + } + }) { text("right=0") } + repeat(10) { idx -> + text("tail ${idx + 1}") + } + } + div({ + key = "positioned.sticky.horizontal.precedence.scroller" + style = { + overflowX = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 2.px + padding = 2.px + } + }) { + div({ + key = "positioned.sticky.horizontal.precedence.target" + style = { + position = PositionMode.Sticky + left = 8.px + right = 0.px + zIndex = 5 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC415A74.toInt() + } + }) { text("left+right set -> left wins (left=8)") } + repeat(14) { idx -> + text("precedence ${idx + 1}") + } + } + } + +} + +private fun UiScope.stickyXYGroup( + onSetLastHover: (String) -> Unit, + onSetLastClick: (String) -> Unit, + stickyCombinedClicks: Int, + onStickyCombinedClick: () -> Unit +) { + div({ + key = "positioned.sticky.xy.group" + style = { + border { width = 1.px; color = 0xFF6C8096.toInt() } + padding = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + } + }) { + text("Combined-axis sticky: left=0 + top=0 (render/interaction/Inspector target)") + div({ + key = "positioned.sticky.xy.scroller" + style = { + overflowX = Overflow.Auto + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 7.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + padding = 2.px + } + }) { + button("sticky x+y target", { + key = "positioned.sticky.xy.target" + onMouseEnter = { onSetLastHover("sticky-xy") } + onMouseClick = { + onStickyCombinedClick() + onSetLastClick("sticky-xy") + } + style = { + position = PositionMode.Sticky + left = 0.px + top = 0.px + zIndex = 7 + } + }) + repeat(16) { line -> + text("xy sticky line ${line + 1} ....................................................") + } + } + text( + "sticky x+y clicks=$stickyCombinedClicks", + { style = { color = DEMO_MUTED } } + ) + } +} + +private fun UiScope.stickyNoInsets() { + div({ + key = "positioned.sticky.inactive.group" + style = { + border { width = 1.px; color = 0xFF6C8096.toInt() } + padding = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + } + }) { + text("Inactive comparison: position=sticky with no insets stays inactive on both axes.") + div({ + key = "positioned.sticky.inactive.scroller" + style = { + overflowX = Overflow.Auto + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 6.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + padding = 2.px + } + }) { + div({ + key = "positioned.sticky.inactive.target" + style = { + position = PositionMode.Sticky + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC455F78.toInt() + } + }) { text("sticky without insets") } + repeat(12) { line -> + text("inactive line ${line + 1} .............................") + } + } + } +} + +private fun UiScope.stickyClamp() { + div({ + key = "positioned.sticky.clamp.group" + style = { + border { width = 1.px; color = 0xFF6C8096.toInt() } + padding = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + } + }) { + text("Containment clamp: sticky movement is clamped by direct parent containing block.") + div({ + key = "positioned.sticky.clamp.scroller" + style = { + overflowY = Overflow.Auto + border { width = 1.px; color = 0xFF8097AB.toInt() } + maxHeight = 7.em + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 2.px + padding = 2.px + } + }) { + repeat(6) { idx -> text("outer line ${idx + 1}") } + div({ + key = "positioned.sticky.clamp.parent" + style = { + border { width = 1.px; color = 0xFF8FA5B9.toInt() } + backgroundColor = 0xFF2F3D4C.toInt() + maxHeight = 6.em + overflowY = Overflow.Auto + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 1.px + padding = 2.px + } + }) { + div({ + key = "positioned.sticky.clamp.target" + style = { + position = PositionMode.Sticky + top = 0.px + zIndex = 6 + padding = 3.px + border { width = 1.px; color = 0xFF9FC2DF.toInt() } + backgroundColor = 0xCC3F5A74.toInt() + } + }) { text("clamped sticky top") } + repeat(14) { idx -> text("inner clamp line ${idx + 1}") } + } + repeat(8) { idx -> text("outer tail ${idx + 1}") } + } + } +} + +data class ControlsProps( + val modeIndex: Int, + val demoMode: PositionMode, + val useLeft: Boolean, + val useTop: Boolean, + val leftOffset: Int, + val rightOffset: Int, + val topOffset: Int, + val bottomOffset: Int, + val zBlue: Int, + val zGreen: Int, + val zRed: Int, + val lastHover: String, + val lastClick: String, + val onModeCycle: () -> Unit, + val onToggleUseLeft: () -> Unit, + val onToggleUseTop: () -> Unit, + val onSetLeft: (Long) -> Unit, + val onSetRight: (Long) -> Unit, + val onSetTop: (Long) -> Unit, + val onSetBottom: (Long) -> Unit, + val onSetZBlue: (Long) -> Unit, + val onSetZGreen: (Long) -> Unit, + val onSetZRed: (Long) -> Unit, + val onReset: () -> Unit +) + +private fun UiScope.controls(props: ControlsProps) { + div({ + key = "positioned.controls" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + gap = 3.px + padding = 5.px + border { width = 1.px; color = 0xFF617A90.toInt() } + backgroundColor = 0xFF2A3541.toInt() + position = PositionMode.Sticky + top = 0.px + zIndex = 999 + } + }) { + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + gap = 10.px + } + }) { + 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("Reset", { + onMouseClick = { props.onReset() } + }) + select { + for (i in 0..5) { + option("$i", "$i") + } + } + input(type = InputType.Text()) + input(type = InputType.Checkbox(listOf(1, 2, 3).map { InputOption(id = "$it", label = "$it") })) + input(type = InputType.Radio(listOf(1, 2, 3).map { InputOption(id = "$it", label = "$it") })) + input(type = InputType.Number()) + } + + positionedRangeControl( + label = "left", + key = "positioned.controls.left", + value = props.leftOffset.toLong(), + min = OFFSET_MIN.toLong(), + max = OFFSET_MAX.toLong(), + onChange = props.onSetLeft + ) + positionedRangeControl( + label = "right", + key = "positioned.controls.right", + value = props.rightOffset.toLong(), + min = 0, + max = OFFSET_MAX.toLong(), + onChange = props.onSetRight + ) + positionedRangeControl( + label = "top", + key = "positioned.controls.top", + value = props.topOffset.toLong(), + min = OFFSET_MIN.toLong(), + max = OFFSET_MAX.toLong(), + onChange = props.onSetTop + ) + positionedRangeControl( + label = "bottom", + key = "positioned.controls.bottom", + value = props.bottomOffset.toLong(), + min = 0, + max = OFFSET_MAX.toLong(), + onChange = props.onSetBottom + ) + positionedRangeControl( + label = "z blue", + key = "positioned.controls.zBlue", + value = props.zBlue.toLong(), + min = Z_MIN.toLong(), + max = Z_MAX.toLong(), + onChange = props.onSetZBlue + ) + positionedRangeControl( + label = "z green", + key = "positioned.controls.zGreen", + value = props.zGreen.toLong(), + min = Z_MIN.toLong(), + max = Z_MAX.toLong(), + onChange = props.onSetZGreen + ) + positionedRangeControl( + label = "z red", + key = "positioned.controls.zRed", + value = props.zRed.toLong(), + min = Z_MIN.toLong(), + max = Z_MAX.toLong(), + onChange = props.onSetZRed + ) + text( + "hover=${props.lastHover} click=${props.lastClick}", + { style = { color = DEMO_MUTED } } + ) + } +} + +private fun UiScope.positionedRangeControl( + label: String, + key: String, + value: Long, + min: Long, + max: Long, + onChange: (Long) -> Unit +) { + div({ + this.key = "$key-container" + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + width = 100.percent + justifyContent = JustifyContent.Start + } + }) { + text("$label = $value", { + style = { + color = DEMO_MUTED + width = 10.percent + } + }) + input( + InputType.Range( + value = value, + min = min, + max = max, + step = 1 + ), + { + this.key = key + onInput = { event -> + val next = (event.parsedValue as? Long) ?: event.value.toLongOrNull() ?: value + onChange(next.coerceIn(min, max)) + } + style = { + width = 90.percent + } + } + ) + } +} + +private fun UiScope.positionedOverlapCard( + key: String, + label: String, + left: Int, + top: Int, + zIndex: Int, + color: Int, + onHover: () -> Unit, + onClick: () -> Unit +) { + div({ + this.key = key + onMouseEnter = { onHover() } + onMouseClick = { onClick() } + style = { + position = PositionMode.Absolute + this.left = left.px + this.top = top.px + padding = 5.px + backgroundColor = color + border { width = 1.px; this.color = 0xFFE6F1FD.toInt() } + this.zIndex = zIndex + } + }) { + text(label) + } +} + +private fun UiScope.positionedTieCard( + label: String, + left: Int, + top: Int, + zIndex: Int, + color: Int, + onSetLastHover: (String) -> Unit, + onTieClick: (String) -> Unit +) { + div({ + key = "positioned.tie.$label" + onMouseEnter = { onSetLastHover("tie-$label") } + onMouseClick = { onTieClick("tie-$label") } + style = { + position = PositionMode.Absolute + this.left = left.px + this.top = top.px + padding = 5.px + backgroundColor = color + border { width = 1.px; this.color = 0xFFDDEDFD.toInt() } + this.zIndex = zIndex + } + }) { + text("$label z=$zIndex") + } +} + + + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/StylesheetsSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/StylesheetsSection.kt new file mode 100644 index 0000000..570081b --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/StylesheetsSection.kt @@ -0,0 +1,328 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.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.mcForge1122.demo.support.DEMO_MUTED + +fun UiScope.stylesheetsSection( + onLogHook: (String, Event, String?) -> Unit, + onInfo: (String) -> Unit, + loadStylesheetText: () -> String, + saveStylesheetText: (String) -> Unit, + onReloadStylesheets: () -> Unit +) { + val initialLoad by useMemo { + runCatching { loadStylesheetText() } + } + var stylesheetReloadCount by useState(0) + var stylesheetDemoTextValue by useState("") + var stylesheetDemoClickCount by useState(0) + var stylesheetEditorValue by useState(initialLoad.getOrDefault("")) + var stylesheetEditorStatus by useState( + if (initialLoad.isSuccess) { + "loaded" + } else { + "load failed: ${initialLoad.exceptionOrNull()?.javaClass?.simpleName ?: "unknown"}" + } + ) + + div({ + key = "section.stylesheets" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("This section demonstrates DSS selectors, pseudo-states, vars and inline override.") + text("Edit /dsgl/styles/*.dss then click Reload stylesheets.", { + style = { color = DEMO_MUTED } + }) + + div({ + id = "stylesEditorCard" + key = "styles.editor.card" + className = "style-card editor" + style = { + padding = 4.px + gap = 3.px + border { width = 1.px; color = 0xFF5E6A77.toInt() } + } + }) { + text("Demo stylesheet editor: showcase_styles.dss") + textarea({ + placeholder = "Stylesheet content" + key = "styles.editor.textarea" + value = stylesheetEditorValue + style = { + width = 100.percent + height = 92.px + } + onInput = { event -> + stylesheetEditorValue = event.value + } + onValueChange = { event -> + stylesheetEditorValue = event.value + } + }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("Save", { + key = "styles.editor.save" + onMouseClick = { event -> + runCatching { + saveStylesheetText(stylesheetEditorValue) + }.onSuccess { + stylesheetEditorStatus = "saved" + onInfo("Stylesheet saved") + }.onFailure { ex -> + stylesheetEditorStatus = "save failed: ${ex.javaClass.simpleName}" + } + onLogHook("styles.editor.save", event, null) + } + }) + button("Load", { + key = "styles.editor.load" + onMouseClick = { event -> + runCatching { + loadStylesheetText() + }.onSuccess { loaded -> + stylesheetEditorValue = loaded + stylesheetEditorStatus = "loaded" + onInfo("Stylesheet loaded") + }.onFailure { ex -> + stylesheetEditorStatus = "load failed: ${ex.javaClass.simpleName}" + } + onLogHook("styles.editor.load", event, null) + } + }) + button("Reload stylesheets", { + key = "styles.reload.button" + id = "stylesReloadButton" + className = "primary" + onMouseClick = { event -> + onReloadStylesheets() + stylesheetReloadCount += 1 + stylesheetEditorStatus = "reloaded #$stylesheetReloadCount" + onLogHook("styles.reload.onMouseClick", event, null) + } + }) + } + text( + "status=$stylesheetEditorStatus; reloads=$stylesheetReloadCount; clicks=$stylesheetDemoClickCount", + { style = { color = DEMO_MUTED } } + ) + } + + div({ + id = "stylesSelectorsCard" + key = "styles.selectors.card" + className = "style-card selectors" + style = { + padding = 4.px + gap = 3.px + border { width = 1.px; color = 0xFF5E6A77.toInt() } + } + }) { + text("Selector matrix", { + id = "stylesSelectorsTitle" + }) + text("Targets: button, .accent, button.primary, #dangerAction", { + style = { color = DEMO_MUTED } + }) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("button", { + key = "styles.selector.type" + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.selector.type", event, null) + } + }) + button(".accent", { + key = "styles.selector.class" + className = "accent" + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.selector.class", event, null) + } + }) + button("button.primary", { + key = "styles.selector.typeClass" + className = "primary" + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.selector.typeClass", event, null) + } + }) + } + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("#dangerAction", { + key = "styles.selector.id" + id = "dangerAction" + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.selector.id", event, null) + } + }) + button("Inline > stylesheet", { + key = "styles.selector.inline" + className = "primary" + style = { + backgroundColor = 0xFF7A3A3A.toInt() + foregroundColor = 0xFFFFFFFF.toInt() + borderColor = 0xFFAA6666.toInt() + borderWidth = 1.px + } + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.selector.inline", event, null) + } + }) + } + } + + div({ + id = "stylesStatesCard" + key = "styles.states.card" + className = "style-card states" + style = { + padding = 4.px + gap = 3.px + border { width = 1.px; color = 0xFF5E6A77.toInt() } + } + }) { + text("Pseudo-states: :hover, :active, :focus, :disabled") + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + button("Hover / Active target", { + key = "styles.state.hoverActive" + id = "hoverActiveTarget" + className = "interactive-demo" + onMouseClick = { event -> + stylesheetDemoClickCount += 1 + onLogHook("styles.state.hoverActive", event, null) + } + }) + input( + InputType.Text( + value = stylesheetDemoTextValue, + placeholder = "Focus target" + ), + { + key = "styles.state.focusInput" + id = "focusInput" + className = "interactive-demo" + style = { width = 100.percent } + onInput = { event -> + stylesheetDemoTextValue = event.value + onLogHook("styles.state.focusInput.onInput", event, "value=${event.value}") + } + } + ) + button("Disabled", { + key = "styles.state.disabled" + id = "disabledTarget" + className = "interactive-demo" + disabled = true + }) + } + } + + div({ + key = "styles.variables.card" + id = "stylesVarsCard" + className = "style-card vars-demo" + style = { + padding = 4.px + gap = 2.px + border { width = 1.px; color = 0xFF5E6A77.toInt() } + } + }) { + text("Variable demo uses :root { --primary: ... } and var(--primary)") + text("Try: .vars-demo { backgroundColor: var(--primary); borderColor: var(--accent); }", { + style = { color = DEMO_MUTED } + }) + text("focusInputValue='$stylesheetDemoTextValue'", { + style = { color = DEMO_MUTED } + }) + } + + div({ + id = "stylesUnitsCard" + key = "styles.units.card" + className = "style-card units-demo" + style = { + padding = 4.px + gap = 3.px + border { width = 1.px; color = 0xFF5E6A77.toInt() } + } + }) { + text("CSS units demo: px, em, %, vw, vh") + text("Resize window to see vw/vh change; % is relative to the playground.", { + style = { color = DEMO_MUTED } + }) + + div({ + key = "styles.units.vwChip" + className = "units-vw-chip" + style = { height = 12.px } + }) { + text("20vw") + } + + div({ + key = "styles.units.playground" + className = "units-playground" + style = { height = 66.px } + }) { + div({ + key = "styles.units.percentBox" + className = "units-percent-box" + }) { + text("50% x 40%") + } + } + + text("1.25em text with 1em spacing", { + className = "units-em-text" + }) + + div({ + key = "styles.units.vhBar" + className = "units-vh-bar" + }) { + text("8vh") + } + } + } +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextEditingSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextEditingSection.kt new file mode 100644 index 0000000..f1755e8 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextEditingSection.kt @@ -0,0 +1,194 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.event.KeyCodes +import org.dreamfinity.dsgl.core.event.KeyModifiers +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.mcForge1122.demo.support.DEMO_MUTED +import org.dreamfinity.dsgl.mcForge1122.demo.support.DEMO_OK + +private const val SINGLE_KEY = "textEditing.single" +private const val PASSWORD_KEY = "textEditing.password" +private const val AREA_KEY = "textEditing.area" +private const val FAIL_COLOR = 0xFFFF8A8A.toInt() + +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" + ) + var textEditingSawSelectionDrag by useState(false) + var textEditingSawShiftSelection by useState(false) + var textEditingSawClipboardShortcut by useState(false) + var textEditingSawFocus by useState(false) + + div({ + key = "section.textEditing" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + text("HTML-like text editing: caret blink, selection and clipboard shortcuts") + text("Use Ctrl on Windows/Linux or Cmd on macOS for copy/cut/paste/select-all/undo/redo.", { + style = { color = DEMO_MUTED } + }) + + text("Single-line input") + input( + InputType.Text( + value = textEditingSingleValue, + placeholder = "Type and select text" + ), + { + key = SINGLE_KEY + style = { width = 100.percent } + onFocusGain = { + textEditingSawFocus = true + onLogHook("textEditing.single.focus", it, null) + } + onInput = { event -> + textEditingSingleValue = event.value + } + onMouseDrag = { event -> + textEditingSawSelectionDrag = true + onLogHook("textEditing.selection.drag", event, "key=$SINGLE_KEY") + } + onKeyDown = { event -> + if (KeyModifiers.shiftDown && isArrowLike(event.keyCode)) { + textEditingSawShiftSelection = true + onLogHook("textEditing.selection.shiftKey", event, "key=$SINGLE_KEY code=${event.keyCode}") + } + if (KeyModifiers.shortcutDown && isClipboardShortcut(event.keyCode)) { + textEditingSawClipboardShortcut = true + onLogHook("textEditing.clipboard", event, "key=$SINGLE_KEY code=${event.keyCode}") + } + } + } + ) + text("Single-line: caret + selection visible in control", { + style = { color = DEMO_MUTED } + }) + + text("Password input (copy/cut restricted, paste allowed)") + input( + InputType.Password( + value = textEditingPasswordValue, + placeholder = "password" + ), + { + key = PASSWORD_KEY + style = { width = 100.percent } + onFocusGain = { + textEditingSawFocus = true + onLogHook("textEditing.password.focus", it, null) + } + onInput = { event -> + textEditingPasswordValue = event.value + } + onMouseDrag = { event -> + textEditingSawSelectionDrag = true + onLogHook("textEditing.selection.drag", event, "key=$PASSWORD_KEY") + } + onKeyDown = { event -> + if (KeyModifiers.shiftDown && isArrowLike(event.keyCode)) { + textEditingSawShiftSelection = true + onLogHook("textEditing.selection.shiftKey", event, "key=$PASSWORD_KEY code=${event.keyCode}") + } + if (KeyModifiers.shortcutDown && isClipboardShortcut(event.keyCode)) { + textEditingSawClipboardShortcut = true + onLogHook("textEditing.clipboard", event, "key=$PASSWORD_KEY code=${event.keyCode}") + } + } + } + ) + text("Password: masked selection/caret behavior", { + style = { color = DEMO_MUTED } + }) + + text("Textarea") + textarea({ + placeholder = "Multiline editing" + key = AREA_KEY + style = { + width = 100.percent + height = 3.em + } + value = textEditingAreaValue + onFocusGain = { + textEditingSawFocus = true + onLogHook("textEditing.area.focus", it, null) + } + onInput = { event -> + textEditingAreaValue = event.value + } + onMouseDrag = { event -> + textEditingSawSelectionDrag = true + onLogHook("textEditing.selection.drag", event, "key=$AREA_KEY") + } + onKeyDown = { event -> + if (KeyModifiers.shiftDown && isArrowLike(event.keyCode)) { + textEditingSawShiftSelection = true + onLogHook("textEditing.selection.shiftKey", event, "key=$AREA_KEY code=${event.keyCode}") + } + if (KeyModifiers.shortcutDown && isClipboardShortcut(event.keyCode)) { + textEditingSawClipboardShortcut = true + onLogHook("textEditing.clipboard", event, "key=$AREA_KEY code=${event.keyCode}") + } + } + }) + text("Textarea: multiline selection + scroll-aware caret", { + style = { color = DEMO_MUTED } + }) + + text("Checklist") + checklistLine("caret blinks when focused", textEditingSawFocus) + checklistLine("mouse drag selects and highlights text", textEditingSawSelectionDrag) + checklistLine("Shift + arrows extends selection", textEditingSawShiftSelection) + checklistLine("copy/cut/paste/undo/redo shortcuts are handled", textEditingSawClipboardShortcut) + + button("Reset Checklist", { + onMouseClick = { + textEditingSawSelectionDrag = false + textEditingSawShiftSelection = false + textEditingSawClipboardShortcut = false + textEditingSawFocus = false + onLogHook("textEditing.checklist.reset", it, null) + } + }) + } +} + +private fun UiScope.checklistLine(textValue: String, done: Boolean) { + val mark = if (done) "[ok]" else "[ ]" + val color = if (done) DEMO_OK else FAIL_COLOR + text("$mark $textValue", { + style = { + this.color = color + foregroundColor = color + } + }) +} + +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 isClipboardShortcut(keyCode: Int): Boolean { + return keyCode == KeyCodes.C || + keyCode == KeyCodes.X || + keyCode == KeyCodes.V || + keyCode == KeyCodes.A || + keyCode == KeyCodes.Z +} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextWrapSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextWrapSection.kt new file mode 100644 index 0000000..36fd928 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/sections/TextWrapSection.kt @@ -0,0 +1,120 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.sections + +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.mcForge1122.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." + +fun UiScope.textWrapSection(onInfo: (String) -> Unit) { + val minWidth = 96 + val maxWidth = 320 + var textWrapNoWrap by useState(false) + var textWrapWidth by useState(176L) + val panelWidth = textWrapWidth.toInt().coerceIn(minWidth, maxWidth) + val mode = if (textWrapNoWrap) TextWrap.NoWrap else TextWrap.Wrap + + div({ + key = "section.textWrap" + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Column + } + }) { + 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 } } + ) + + div({ + style = { + gap = 4.px + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button( + if (mode == TextWrap.Wrap) "Mode: wrap" else "Mode: nowrap", + { + onMouseClick = { + textWrapNoWrap = !textWrapNoWrap + onInfo("TextWrap mode=${if (textWrapNoWrap) "nowrap" else "wrap"}") + } + } + ) + button("Reset width", { + onMouseClick = { + textWrapWidth = ((minWidth + maxWidth) / 2).toLong() + } + }) + } + + input( + InputType.Range( + value = panelWidth.toLong(), + min = minWidth.toLong(), + max = maxWidth.toLong(), + step = 2 + ), + { + key = "textWrap.width" + style = { width = 100.percent } + onInput = { event -> + 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 } } + ) + + div({ + key = "textWrap.panel" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + width = panelWidth.px + padding = 3.px + backgroundColor = 0xFF2B3542.toInt() + gap = 2.px + border { width = 1.px; color = 0xFF6F8298.toInt() } + } + + }) { + text("Text node (static)", { style = { textWrap = mode } }) + text(WRAP_SAMPLE_TEXT, { style = { textWrap = mode } }) + text("Text node (lambda)") + text(WRAP_SAMPLE_WORD, { style = { textWrap = mode } }) + button("Button label: $WRAP_SAMPLE_WORD", { + style = { + width = 100.percent + textWrap = mode + } + }) + textarea({ + placeholder = "Wrap demo area" + key = "textWrap.textarea" + value = WRAP_TEXTAREA_SAMPLE + style = { + width = 100.percent + height = 36.px + textWrap = mode + } + }) + } + } +} + + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/CapabilityChecklistCatalog.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/CapabilityChecklistCatalog.kt new file mode 100644 index 0000000..4e81865 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/CapabilityChecklistCatalog.kt @@ -0,0 +1,363 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.support + +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") +} + +enum class CapabilityId( + val label: String, + val group: CapabilityGroup +) { + BUILDER_DIV("builder: div", CapabilityGroup.DSL_BUILDERS), + BUILDER_OVERLAY("builder: overlay", 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), + BUILDER_IMG("builder: img", CapabilityGroup.DSL_BUILDERS), + BUILDER_ITEM_STACK("builder: itemStack", CapabilityGroup.DSL_BUILDERS), + BUILDER_INPUT("builder: input", CapabilityGroup.DSL_BUILDERS), + BUILDER_TEXTAREA("builder: textarea", CapabilityGroup.DSL_BUILDERS), + + INPUT_TEXT("input: Text", CapabilityGroup.INPUT_TYPES), + INPUT_PASSWORD("input: Password", CapabilityGroup.INPUT_TYPES), + INPUT_NUMBER("input: Number", CapabilityGroup.INPUT_TYPES), + INPUT_RANGE("input: Range", CapabilityGroup.INPUT_TYPES), + INPUT_CHECKBOX("input: Checkbox", CapabilityGroup.INPUT_TYPES), + INPUT_RADIO("input: Radio", CapabilityGroup.INPUT_TYPES), + INPUT_DATE("input: Date", CapabilityGroup.INPUT_TYPES), + + HOOK_MOUSE_ENTER("hook: onMouseEnter", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_LEAVE("hook: onMouseLeave", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_OVER("hook: onMouseOver", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_MOVE("hook: onMouseMove", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_DOWN("hook: onMouseDown", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_UP("hook: onMouseUp", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_CLICK("hook: onMouseClick", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_DRAG("hook: onMouseDrag", CapabilityGroup.EVENT_HOOKS), + HOOK_MOUSE_WHEEL("hook: onMouseWheel", CapabilityGroup.EVENT_HOOKS), + HOOK_KEY_DOWN("hook: onKeyDown", CapabilityGroup.EVENT_HOOKS), + HOOK_KEY_UP("hook: onKeyUp", CapabilityGroup.EVENT_HOOKS), + HOOK_KEY_PRESSED("hook: onKeyPressed", CapabilityGroup.EVENT_HOOKS), + HOOK_KEY_RELEASED("hook: onKeyReleased", CapabilityGroup.EVENT_HOOKS), + HOOK_FOCUS("hook: onFocus", CapabilityGroup.EVENT_HOOKS), + HOOK_BLUR("hook: onBlur", CapabilityGroup.EVENT_HOOKS), + HOOK_INPUT("hook: onInput", CapabilityGroup.EVENT_HOOKS), + HOOK_CHANGE("hook: onChange", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG_START("hook: onDragStart", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG("hook: onDrag", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG_END("hook: onDragEnd", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG_ENTER("hook: onDragEnter", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG_OVER("hook: onDragOver", CapabilityGroup.EVENT_HOOKS), + HOOK_DRAG_LEAVE("hook: onDragLeave", CapabilityGroup.EVENT_HOOKS), + HOOK_DROP("hook: onDrop", CapabilityGroup.EVENT_HOOKS), + + EVENT_INSPECTOR("Event Inspector panel", CapabilityGroup.SHOWCASE_FEATURES), + CAPABILITY_CHECKLIST("Capability Checklist panel", CapabilityGroup.SHOWCASE_FEATURES), + EVENT_CANCELLATION("Event cancellation/bubbling demo", CapabilityGroup.SHOWCASE_FEATURES), + LAYOUT_VALIDATOR("Layout validator strict bounds", CapabilityGroup.SHOWCASE_FEATURES), + DISPLAY_BLOCK("Display: block", CapabilityGroup.SHOWCASE_FEATURES), + DISPLAY_INLINE("Display: inline", CapabilityGroup.SHOWCASE_FEATURES), + DISPLAY_NONE("Display: none", CapabilityGroup.SHOWCASE_FEATURES), + DISPLAY_FLEX("Display: flex", CapabilityGroup.SHOWCASE_FEATURES), + DISPLAY_GRID("Display: grid", CapabilityGroup.SHOWCASE_FEATURES), + TEXT_WRAP("Text wrap style demo", CapabilityGroup.SHOWCASE_FEATURES), + MSDF_FONT_SWITCH("MSDF font switching", CapabilityGroup.SHOWCASE_FEATURES), + MSDF_OPACITY("MSDF opacity rendering", CapabilityGroup.SHOWCASE_FEATURES), + MSDF_WRAP("MSDF wrapping + measurement", CapabilityGroup.SHOWCASE_FEATURES), + 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_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_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), + 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), + STYLESHEET_PSEUDO_STATES("Stylesheet pseudo-states demo", CapabilityGroup.SHOWCASE_FEATURES), + STYLESHEET_VARIABLES("Stylesheet variables demo", CapabilityGroup.SHOWCASE_FEATURES), + STYLESHEET_INLINE_OVERRIDE("Inline style override demo", CapabilityGroup.SHOWCASE_FEATURES), + STYLESHEET_PROGRAMMATIC_RELOAD("Programmatic stylesheet reload", CapabilityGroup.SHOWCASE_FEATURES), + REFS_OBJECT("Object ref (.current) demo", CapabilityGroup.SHOWCASE_FEATURES), + REFS_CALLBACK("Callback ref attach/detach demo", CapabilityGroup.SHOWCASE_FEATURES), + REFS_IMPERATIVE_FOCUS("Imperative focus via ref", CapabilityGroup.SHOWCASE_FEATURES), + DND_SMOOTH_GHOST("Smooth drag ghost", CapabilityGroup.SHOWCASE_FEATURES), + DND_DATA_TRANSFER("DataTransfer payload demo", CapabilityGroup.SHOWCASE_FEATURES), + DND_DROP_EFFECT("Drop effect demo", CapabilityGroup.SHOWCASE_FEATURES), + FOCUS_RETENTION("Focus retention with stable keys", CapabilityGroup.SHOWCASE_FEATURES), + STATE_REBUILD("State-driven rebuild demo", CapabilityGroup.SHOWCASE_FEATURES), + MANUAL_INVALIDATE("Manual invalidate demo", CapabilityGroup.SHOWCASE_FEATURES), + + IMAGE_RESOURCE("Image: resource path", CapabilityGroup.MC_ADAPTER_FEATURES), + IMAGE_FILE("Image: file:// path", CapabilityGroup.MC_ADAPTER_FEATURES), + 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) +} + +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 + .flatMap { capabilitiesForSection(it) } + .toSet() + } +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoSection.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoSection.kt new file mode 100644 index 0000000..4d45407 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoSection.kt @@ -0,0 +1,32 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.support + +enum class DemoSection( + val title: String, + val subtitle: String +) { + OVERVIEW("Overview", "How to use the showcase"), + INSPECTOR("Inspector", "Global in-game element/style/layout inspector (F8/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"), + 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"), + 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"), + TEXT_EDITING("Text Editing", "Caret blink, selection and clipboard shortcuts"), + REFS("Hooks", "useRef/useState/useMemo/useCallback/useReducer/useContext/useEffect showcase"), + 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") +} + + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoUi.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoUi.kt new file mode 100644 index 0000000..9cb89cd --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/DemoUi.kt @@ -0,0 +1,9 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.support + +const val DEMO_BG: Int = 0xFF1C1F24.toInt() +const val DEMO_SURFACE: Int = 0xFF252A31.toInt() +const val DEMO_SURFACE_ALT: Int = 0xFF2D333B.toInt() +const val DEMO_ACCENT: Int = 0xFF3E6B9E.toInt() +const val DEMO_OK: Int = 0xFF64C37D.toInt() +const val DEMO_ERR: Int = 0xFFE06A6A.toInt() +const val DEMO_MUTED: Int = 0xFFB0B7C1.toInt() diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventFormatting.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventFormatting.kt new file mode 100644 index 0000000..5665f94 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventFormatting.kt @@ -0,0 +1,56 @@ +package org.dreamfinity.dsgl.mcForge1122.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 -> "" + } + 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" + return truncateForPanel(raw, 118) +} + +fun truncateForPanel(value: String, maxLen: Int): String { + if (value.length <= maxLen) return value + if (maxLen <= 3) return value.take(maxLen) + return value.take(maxLen - 3) + "..." +} + +private fun safeChar(ch: Char): String { + if (ch == '\u0000') return "\\0" + if (ch == '\n') return "\\n" + if (ch == '\r') return "\\r" + 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-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventLogEntry.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventLogEntry.kt new file mode 100644 index 0000000..a474089 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/EventLogEntry.kt @@ -0,0 +1,7 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.support + +data class EventLogEntry( + val sequence: Int, + val line: String, + val color: Int +) diff --git a/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/PersistentPanels.kt b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/PersistentPanels.kt new file mode 100644 index 0000000..cfe634c --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/demo/support/PersistentPanels.kt @@ -0,0 +1,113 @@ +package org.dreamfinity.dsgl.mcForge1122.demo.support + +import org.dreamfinity.dsgl.core.DsglColors +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.style.Display +import org.dreamfinity.dsgl.core.style.FlexDirection + +fun UiScope.renderEventInspectorPanel( + eventLogs: List, + maxEventLogs: Int, + visibleEventLines: Int, + onClearLogs: () -> Unit +) { + div({ + key = "panel.eventInspector" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + flexGrow = 1f + gap = 4.px + padding = 20.px + backgroundColor = DEMO_SURFACE_ALT + color = DsglColors.TEXT + border { width = 1.px; color = DsglColors.BORDER } + } + + }) { + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + text("Event Inspector") + button("Clear", { + style = { } + onMouseClick = { onClearLogs() } + }) + } + text("Stored: ${eventLogs.size}/$maxEventLogs", { style = { color = DEMO_MUTED } }) + if (eventLogs.isEmpty()) { + text("No events yet. Interact with any demo area.", { style = { color = DEMO_MUTED } }) + } else { + eventLogs + .take(visibleEventLines) + .forEach { entry -> + text("#${entry.sequence} ${entry.line}", { style = { color = entry.color } }) + } + } + } +} + +fun UiScope.renderChecklistPanel( + implementedCapabilities: Set, + checklistPage: Int, + checklistPageSize: Int, + onSetChecklistPage: (Int) -> Unit, + onMoveChecklistPage: (Int) -> Unit +) { + val required = CapabilityChecklistCatalog.required + val pageSize = checklistPageSize + val pageCount = ((required.size + pageSize - 1) / pageSize).coerceAtLeast(1) + val safePage = checklistPage.coerceIn(0, pageCount - 1) + if (safePage != checklistPage) { + onSetChecklistPage(safePage) + } + val from = safePage * pageSize + val to = minOf(required.size, from + pageSize) + val pageItems = required.subList(from, to) + + div({ + key = "panel.capabilityChecklist" + style = { + display = Display.Flex + flexDirection = FlexDirection.Column + flexGrow = 1f + padding = 20.px + gap = 4.px + backgroundColor = DEMO_SURFACE_ALT + color = DsglColors.TEXT + border { width = 1.px; color = DsglColors.BORDER } + } + + }) { + text("Capability Checklist") + div({ + style = { + display = Display.Flex + flexDirection = FlexDirection.Row + } + }) { + button("<", { + onMouseClick = { onMoveChecklistPage(-1) } + }) + text("Page ${safePage + 1}/$pageCount", { style = { color = DEMO_MUTED } }) + button(">", { + onMouseClick = { onMoveChecklistPage(1) } + }) + } + pageItems.forEach { capability -> + val ok = implementedCapabilities.contains(capability) + text("${if (ok) "[OK]" else "[MISS]"} ${capability.label}", { + style = { color = if (ok) DEMO_OK else DEMO_ERR } + }) + } + val missing = required.count { !implementedCapabilities.contains(it) } + text("Missing: $missing / ${required.size}", { style = { color = if (missing == 0) DEMO_OK else DEMO_ERR } }) + } +} + diff --git a/adapters/mc-forge-1-12-2/demo/src/main/resources/META-INF/MANIFEST.MF b/adapters/mc-forge-1-12-2/demo/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..9db1830 --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,9 @@ +Manifest-Version: 1.0 +Specification-Title: ${modName} +Specification-Version: ${modVersion} +Specification-Vendor: ${modAuthor} +Implementation-Title: ${modId} +Implementation-Version: ${modVersion} +Implementation-Vendor: ${modAuthor} +Implementation-Vendor-Id: ${modGroup} +Built-For-MC: ${gameVersion} diff --git a/adapters/mc-forge-1-12-2/demo/src/main/resources/mcmod.info b/adapters/mc-forge-1-12-2/demo/src/main/resources/mcmod.info new file mode 100644 index 0000000..8ed582e --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/main/resources/mcmod.info @@ -0,0 +1,18 @@ +[ + { + "modid": "${modId}", + "name": "${modName}", + "description": "${modDescription}", + "version": "${modVersion}", + "mcversion": "${gameVersion}", + "url": "", + "updateUrl": "", + "authorList": [ + "${modAuthor}" + ], + "credits": "${modCredits}", + "logoFile": "${modIcon}", + "screenshots": [], + "dependencies": [] + } +] diff --git a/adapters/mc-forge-1-12-2/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt b/adapters/mc-forge-1-12-2/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt new file mode 100644 index 0000000..1d1b57a --- /dev/null +++ b/adapters/mc-forge-1-12-2/demo/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/demo/sections/PositionedLayoutStickyDemoIntegrationTests.kt @@ -0,0 +1,309 @@ +package org.dreamfinity.dsgl.mcForge1710.demo.sections + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.dom.DOMNode +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.collectHoverChain +import org.dreamfinity.dsgl.core.inspector.InspectorController +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.core.style.StyleEngine +import org.dreamfinity.dsgl.mcForge1710.demo.ShowcaseWindow +import org.dreamfinity.dsgl.mcForge1710.demo.support.DemoSection +import java.lang.reflect.Field +import java.lang.reflect.Method +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +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 + } + + @AfterTest + fun cleanup() { + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @Test + fun `positioned layout section wires one cohesive sticky demo surface`() { + val fixture = renderFixture() + val root = fixture.tree.root + + assertNotNull(findByKey(root, "positioned.sticky.surface")) + assertNotNull(findByKey(root, "positioned.sticky.vertical.group")) + assertNotNull(findByKey(root, "positioned.sticky.horizontal.group")) + assertNotNull(findByKey(root, "positioned.sticky.xy.group")) + assertNotNull(findByKey(root, "positioned.sticky.inactive.group")) + assertNotNull(findByKey(root, "positioned.sticky.clamp.group")) + } + + @Test + fun `sticky showcase examples keep vertical horizontal and combined behavior live`() { + val fixture = renderFixture() + scrollMainSectionToSticky(fixture) + + val topScroller = requireContainer(fixture.tree.root, "positioned.sticky.vertical.top.scroller") + val topTarget = requireNode(fixture.tree.root, "positioned.sticky.vertical.top.target") + topScroller.setScrollOffsets(0, 80) + + val leftScroller = requireContainer(fixture.tree.root, "positioned.sticky.horizontal.left.scroller") + val leftTarget = requireNode(fixture.tree.root, "positioned.sticky.horizontal.left.target") + leftScroller.setScrollOffsets(90, 0) + + val xyScroller = requireContainer(fixture.tree.root, "positioned.sticky.xy.scroller") + val xyTarget = requireNode(fixture.tree.root, "positioned.sticky.xy.target") + xyScroller.setScrollOffsets(70, 60) + + fixture.tree.render(ctx, width, height) + + val topViewport = topScroller.scrollContainerState().viewportRect + assertEquals(topViewport.y, transformedRect(topTarget).y) + + val leftViewport = leftScroller.scrollContainerState().viewportRect + assertEquals(leftViewport.x, transformedRect(leftTarget).x) + + val xyViewport = xyScroller.scrollContainerState().viewportRect + val xyRect = transformedRect(xyTarget) + assertEquals(xyViewport.x, xyRect.x) + assertEquals(xyViewport.y, xyRect.y) + } + + @Test + fun `sticky showcase interaction path resolves sticky final geometry`() { + val fixture = renderFixture() + scrollMainSectionToSticky(fixture) + + val topScroller = requireContainer(fixture.tree.root, "positioned.sticky.vertical.top.scroller") + val topTarget = requireNode(fixture.tree.root, "positioned.sticky.vertical.top.target") + val xyTarget = requireNode(fixture.tree.root, "positioned.sticky.xy.target") + val topRect = transformedRect(topTarget) + val topViewport = topScroller.scrollContainerState().viewportRect + assertTrue( + intersects(topRect, topViewport), + "Sticky top target must remain visible in its scroller viewport; targetRect=$topRect viewport=$topViewport" + ) + + val topPoint = findPointInsideTarget(fixture.tree.root, topTarget, topRect) + val topCenterX = topRect.x + topRect.width / 2 + val topCenterY = topRect.y + topRect.height / 2 + val topCenterWinner = hoverWinnerKey(fixture.tree.root, topCenterX, topCenterY) + assertNotNull( + topPoint, + "Expected a hover-resolvable point inside sticky top target. " + + "targetRect=$topRect viewport=$topViewport centerWinner=$topCenterWinner" + ) + assertEquals(topTarget, collectHoverChain(fixture.tree.root, topPoint.first, topPoint.second).lastOrNull()) + + val xyPoint = findPointInsideTarget(fixture.tree.root, xyTarget, transformedRect(xyTarget)) + assertNotNull(xyPoint, "Expected a hover-resolvable point inside sticky combined target") + assertEquals(xyTarget, collectHoverChain(fixture.tree.root, xyPoint.first, xyPoint.second).lastOrNull()) + } + + @Test + fun `sticky showcase inspector uses same pick and highlight geometry`() { + val fixture = renderFixture() + scrollMainSectionToSticky(fixture) + + val xyTarget = requireNode(fixture.tree.root, "positioned.sticky.xy.target") + val xyRect = transformedRect(xyTarget) + val xyPoint = findPointInsideTarget(fixture.tree.root, xyTarget, xyRect) + assertNotNull(xyPoint, "Expected a point that resolves to sticky x+y target before inspector probe") + + val expectedPicked = collectHoverChain(fixture.tree.root, xyPoint.first, xyPoint.second).lastOrNull() + assertNotNull(expectedPicked) + + val inspector = InspectorController().also { it.toggle() } + inspector.onLayoutCommitted(fixture.tree.root, 1L) + invokeInspectorInternalByName( + inspector, + "onNativeDomExpandedPanelRect", + Rect(700, 30, 280, 260), + width, + height + ) + inspector.onCursorMoved(xyPoint.first, xyPoint.second) + invokeInspectorInternalByName(inspector, "buildDomSnapshot", width, height) + + assertEquals(expectedPicked.key?.toString(), inspector.hoveredKey) + + val highlightRect = inspectorHoveredBorderRect(inspector) + assertEquals(transformedRect(expectedPicked), highlightRect) + } + + private data class Fixture( + val window: ShowcaseWindow, + 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() + } + 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") + val controls = findByKey(fixture.tree.root, "positioned.controls") + val viewport = sectionScroller.scrollContainerState().viewportRect + val stickyRect = transformedRect(stickySurface) + val controlsHeight = controls?.let { transformedRect(it).height } ?: 0 + val desiredStickySurfaceTopY = viewport.y + controlsHeight + 8 + val targetScrollY = (stickyRect.y - desiredStickySurfaceTopY).coerceAtLeast(0) + sectionScroller.setScrollOffsets(0, targetScrollY) + fixture.tree.render(ctx, width, height) + } + + private fun requireContainer(root: DOMNode, key: String): ContainerNode { + return 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 findByKey(root: DOMNode, key: String): DOMNode? { + if (root.key?.toString() == key) return root + root.children.forEach { child -> + val match = findByKey(child, key) + if (match != null) return match + } + return null + } + + private fun transformedRect(node: DOMNode): Rect { + val world = node.worldTransformMatrix() + val b = node.bounds + val p1 = world.transform(b.x.toFloat(), b.y.toFloat()) + val p2 = world.transform((b.x + b.width).toFloat(), b.y.toFloat()) + val p3 = world.transform(b.x.toFloat(), (b.y + b.height).toFloat()) + val p4 = world.transform((b.x + b.width).toFloat(), (b.y + b.height).toFloat()) + val minX = minOf(p1.first, p2.first, p3.first, p4.first) + val maxX = maxOf(p1.first, p2.first, p3.first, p4.first) + val minY = minOf(p1.second, p2.second, p3.second, p4.second) + val maxY = maxOf(p1.second, p2.second, p3.second, p4.second) + val x = floor(minX.toDouble()).toInt() + val y = floor(minY.toDouble()).toInt() + val w = ceil((maxX - minX).toDouble()).toInt().coerceAtLeast(0) + val h = ceil((maxY - minY).toDouble()).toInt().coerceAtLeast(0) + return Rect(x, y, w, h) + } + + private fun findPointInsideTarget(root: DOMNode, target: DOMNode, rect: Rect): Pair? { + if (rect.width <= 0 || rect.height <= 0) return null + val stepX = max(1, rect.width / 8) + val stepY = max(1, rect.height / 8) + var y = rect.y + 1 + while (y < rect.y + rect.height - 1) { + var x = rect.x + 1 + while (x < rect.x + rect.width - 1) { + if (collectHoverChain(root, x, y).lastOrNull() === target) { + return x to y + } + x += stepX + } + y += stepY + } + val centerX = rect.x + rect.width / 2 + val centerY = rect.y + rect.height / 2 + return if (collectHoverChain(root, centerX, centerY).lastOrNull() === target) { + centerX to centerY + } else { + null + } + } + + private fun hoverWinnerKey(root: DOMNode, x: Int, y: Int): String? { + return 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 + val noOverlapY = a.y + a.height <= b.y || b.y + b.height <= a.y + return !noOverlapX && !noOverlapY + } + + private fun inspectorHoveredBorderRect(inspector: InspectorController): Rect? { + val debugHoveredHighlight = findMethodByNameAndArity(inspector.javaClass, "debugHoveredHighlight", 0) + debugHoveredHighlight.isAccessible = true + val snapshot = debugHoveredHighlight.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? { + val method = findMethodByNameAndArity(inspector.javaClass, methodName, args.size) + method.isAccessible = true + return method.invoke(inspector, *args) + } + + private fun findField(clazz: Class<*>, fieldName: String): Field { + var current: Class<*>? = clazz + while (current != null) { + val field = current.declaredFields.firstOrNull { it.name == fieldName } + if (field != null) return field + current = current.superclass + } + error("Field '$fieldName' not found on ${clazz.name}") + } + + private fun findMethod( + clazz: Class<*>, + methodName: String, + parameterTypes: Array> + ): 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 + } + if (method != null) return method + current = current.superclass + } + error("Method '$methodName/$arity' not found on ${clazz.name}") + } +} diff --git a/adapters/mc-forge-1-12-2/gradle.properties b/adapters/mc-forge-1-12-2/gradle.properties new file mode 100644 index 0000000..cbea73d --- /dev/null +++ b/adapters/mc-forge-1-12-2/gradle.properties @@ -0,0 +1,27 @@ +moduleVersion=0.0.1 + +# Minecraft and Forge params +gameVersion=1.12.2 +forgeVersion=14.23.5.2864 + +# Mod params +modGroup=org.dreamfinity +modId=dsgl +modName=dsgl +modArchivesName=dsgl +modAuthor=Veritaris +modIcon= +modDescription=Dreamfinity Simple GUI Library +modCredits=Veritaris +buildVersion=0 +modVersion=0.0.2 + +# Mod dev params +isClientBuild=false +clientRunArgs="--username" "Dreamfinity" +serverRunArgs="--no-gui" + +startParameter.offline=true + +# Publishing params +publishProjectDepsOnly=true diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglFonts.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglFonts.kt new file mode 100644 index 0000000..f65b3ca --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglFonts.kt @@ -0,0 +1,53 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import org.dreamfinity.dsgl.core.font.FontPreloadSummary +import org.dreamfinity.dsgl.core.font.FontRegistry +import java.io.File + +object DsglFonts { + private val lock = Any() + + @Volatile + private var initialized: Boolean = false + + @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 + ) + + 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 } + } + synchronized(lock) { + if (initialized) { + 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 warmed = FontRegistry.predecodeAtlases(warmFontIds) + if (warmed > 0) { + println("[DSGL-MSDF] predecoded atlases for $warmed warm fonts") + } + lastSummary = summary + initialized = true + return summary + } + } + + fun summaryOrNull(): FontPreloadSummary? = lastSummary +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglScreenHost.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglScreenHost.kt new file mode 100644 index 0000000..976646f --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/DsglScreenHost.kt @@ -0,0 +1,1401 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.client.Minecraft +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.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.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.event.* +import org.dreamfinity.dsgl.core.hooks.HookHotReloadRemountException +import org.dreamfinity.dsgl.core.hooks.HookRenderSessionMode +import org.dreamfinity.dsgl.core.host.DsglWindowHost +import org.dreamfinity.dsgl.core.host.Viewport +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.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.lwjgl.input.Keyboard +import org.lwjgl.input.Mouse +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.util.* + +/** + * Minecraft 1.12.2 host that owns UI lifecycle and boilerplate. + * + * Subclass or instantiate with a [DsglWindow] and open it via + * `Minecraft.getMinecraft().displayGuiScreen(...)`. + */ +abstract class DsglScreenHost( + private val windowFactory: () -> DsglWindow, + var rendersCount: Long = 0 +) : GuiScreen(), DsglWindowHost { + companion object { + @Volatile + private var stylesPreloadedOnce: Boolean = false + } + + constructor(window: DsglWindow) : this({ window }) + + override lateinit var window: DsglWindow + private lateinit var adapter: Mc1122UiAdapter + private var domTree: DomTree? = null + private var lastWidth: Int = 0 + private var lastHeight: Int = 0 + private var lastViewport: Viewport = Viewport(width = 0, height = 0) + private var needsRender: Boolean = true + private var needsLayout: Boolean = true + private var lastMouseEvent: Long = 0 + private var eventButton: Int = -1 + private var lastMouseX: Int = 0 + private var lastMouseY: Int = 0 + private var lastMoveX: Int = Int.MIN_VALUE + private var lastMoveY: Int = Int.MIN_VALUE + 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 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 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 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 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) { + "" + } + } + + override fun writeText(value: String) { + try { + setClipboardString(value) + } catch (_: Exception) { + } + } + } + + override fun initGui() { + DsglFonts.ensureInitialized(mc.gameDir, javaClass.classLoader) + adapter = Mc1122UiAdapter(mc) + ClipboardBridge.install(clipboardAccess) + ScreenColorSamplerBridge.install( + 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) + } + } + ) + inspector.deactivate() + inspectorPointerCaptured = false + colorSamplerOwnershipRouter.reset() + activeColorSamplerOwner = ActiveColorSamplerOwner.None + activeInlineColorSamplerNode = null + layoutRevision = 0L + StyleEngine.clearAllInspectorOverrides() + StyleAnimationEngine.clear() + StyleEngine.setStylesDirectory(File(mc.gameDir, "dsgl/styles")) + if (!stylesPreloadedOnce) { + StyleEngine.forceReloadStylesheets() + stylesPreloadedOnce = true + } + window = windowFactory() + window.attachHost(this) + window.markOpened(Instant.now(), ZoneId.systemDefault()) + needsRender = true + needsLayout = true + window.onOpen() + updateSize(force = true) + } + + override fun drawScreen(mouseX: Int, mouseY: Int, partialTicks: Float) { + if (!::adapter.isInitialized) return + frameIndex += 1 + tracePhase("draw.start") + 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 + 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) + } + lastFrameNanos = nowNanos + OverlayLayerDebugState.updateFrameTiming(dtSeconds) + window.tick(dtSeconds.toFloat(), partialTicks) + val animationVisualsChanged = StyleAnimationEngine.tickAndApply(tree.root, dtSeconds, partialTicks) + if (animationVisualsChanged) { + tree.markVisualDirty() + } + var stylesAlreadyApplied = false + var layoutCommittedThisFrame = false + if (needsLayout) { + tracePhase("layout.start") + if (tryCommitLayout(tree, "drawScreen")) { + needsLayout = false + stylesAlreadyApplied = true + layoutCommittedThisFrame = true + tracePhase("layout.end") + } else { + tracePhase("layout.fail") + adapter.paint(composedCommandsBuffer) + flushPendingCleanup() + super.drawScreen(mouseX, mouseY, partialTicks) + captureColorPickerEyedropperSamples() + return + } + } + 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) + ) + 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 + } + 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) + refreshActiveColorSamplerOwner(tree.root) + val applicationOverlayCommands = if (!appOverlayRenderEnabled) { + 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( + inspectedRoot = tree.root, + inspectedLayoutRevision = layoutRevision, + cursorX = dsglMouseX, + cursorY = dsglMouseY, + 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() + } + } + val debugOverlayCommands = runCatching { + debugOverlayHost.render(lastWidth, lastHeight) + debugOverlayHost.paint(adapter) + }.getOrElse { + 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) + } + DndRuntime.engine.onFrame(tree.root, dtSeconds) + 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() + hoverTarget = null + } else { + updateHover(tree.root, hoverChain, dsglMouseX, dsglMouseY, dx, dy) + hoverTarget = hoverChain.lastOrNull() + if (dragCaptureTarget != null && hasFocusChangedSinceCapture()) { + releaseDragCapture() + } + if (dx != 0 || dy != 0) { + val moveEvent = MouseMoveEvent(dsglMouseX, dsglMouseY, prevX, prevY) + moveEvent.target = resolveForcedPointerTarget() ?: dragCaptureTarget ?: 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 + ) + ColorPickerRuntime.engine.appendOverlayCommands(applicationOverlayCommandsBuffer) + appendInlineColorPickerOverlayCommands(applicationOverlayCommandsBuffer) + } + OverlayLayerContracts.composePaintCommands( + applicationRoot = commands, + applicationOverlay = applicationOverlayCommandsBuffer, + systemOverlay = systemOverlayCommands, + debug = debugOverlayCommands, + out = stagingCommandsBuffer, + shouldRenderLayer = OverlayLayerDebugState::isRenderEnabled + ) + val keepPrevious = shouldKeepPreviousFrameCommands( + tree = tree, + rebuiltThisFrame = rebuiltThisFrame, + layoutCommittedThisFrame = layoutCommittedThisFrame, + candidate = stagingCommandsBuffer + ) + if (!keepPrevious) { + composedCommandsBuffer.clear() + composedCommandsBuffer.addAll(stagingCommandsBuffer) + } else { + blankFrameGuardSkips += 1 + tracePhase("commands.guard-preserved") + } + tracePhase("commands.end") + adapter.paint(composedCommandsBuffer) + tracePhase("draw.end") + maybeLogPerf(tree) + flushPendingCleanup() + super.drawScreen(mouseX, mouseY, partialTicks) + captureColorPickerEyedropperSamples() + } + + override fun keyTyped(typedChar: Char, keyCode: Int) { + window.onKeyTyped(typedChar, keyCode) + } + + override fun onGuiClosed() { + ClipboardBridge.install(null) + ScreenColorSamplerBridge.install(null) + FocusManager.clearFocus() + DndRuntime.engine.cancelActiveDrag() + ColorPickerRuntime.engine.closeAll() + SelectRuntime.engine.closeAll() + ContextMenuRuntime.engine.closeAll() + clearActiveTarget() + flushPendingCleanup() + clearHoverChainStates() + inspector.deactivate() + inspectorPointerCaptured = false + colorSamplerOwnershipRouter.reset() + activeColorSamplerOwner = ActiveColorSamplerOwner.None + activeInlineColorSamplerNode = null + layoutRevision = 0L + StyleEngine.clearAllInspectorOverrides() + StyleAnimationEngine.clear() + domTree?.clearRefs() + applicationOverlayHost.clearRefs() + systemOverlayHost.clearRefs() + debugOverlayHost.clearRefs() + domTree?.root?.let { root -> + EventBus.run { root.clearListenersDeep() } + } + hoverChain.clear() + hoverTarget = null + releaseDragCapture() + lastFrameNanos = 0L + window.disposeHookRuntime() + window.onClose() + super.onGuiClosed() + } + + override fun doesGuiPauseGame(): Boolean = false + + override fun requestRebuild(reason: String?) { + needsRender = true + } + + override fun requestRedraw() { + } + + override fun getViewport(): Viewport { + return lastViewport + } + + private fun updateSize(force: Boolean) { + val viewport = adapter.viewport() + val width = viewport.width + val height = viewport.height + lastViewport = viewport + if (force || width != lastWidth || height != lastHeight) { + ContextMenuRuntime.engine.closeAll() + lastWidth = width + lastHeight = height + needsLayout = true + needsRender = true + window.onResize(width, height) + } + } + + private fun rebuildIfNeeded(): Boolean { + val hotSwapped = HotReloadBridge.consumeHotSwap() + if (!hotSwapped && !needsRender && domTree != null) { + return false + } + + if (hotSwapped) { + println("Hot swapped - re-building the DOM") + } + + return try { + tracePhase("rebuild.start") + rendersCount++ + val nextTree = renderWithHookSession(hotSwapped) + val currentTree = domTree + if (currentTree == null) { + domTree = nextTree + } else { + val reconcile = currentTree.reconcileWith(nextTree) + if (reconcile.detachedRoots.isNotEmpty()) { + pendingCleanupRoots.addAll(reconcile.detachedRoots) + flushPendingCleanup() + } + domTree = currentTree + } + // Reconcile may involve selector-state mutations on template nodes. + // Force a full style pass on the active retained tree to avoid one-frame unstyled flashes. + StyleEngine.markSelectorStateChanged() + needsRender = false + needsLayout = true + domTree?.root?.let { root -> + FocusManager.retainFocus(root) + restoreDragCapture(root) + DndRuntime.engine.rebindAfterReconcile(root) + } + window.commitRenderBuild() + tracePhase("rebuild.end") + true + } catch (error: Throwable) { + window.discardRenderBuild() + logPipelineError( + key = "rebuild", + message = "[DSGL] Rebuild failed; keeping previous committed frame/tree: ${error.message}" + ) + false + } + } + + private fun renderWithHookSession(hotSwapped: Boolean): DomTree { + val mode = if (hotSwapped) HookRenderSessionMode.HotReload else HookRenderSessionMode.Normal + val maxAttempts = if (hotSwapped) 8 else 1 + var attempt = 0 + var lastRemountRequest: HookHotReloadRemountException? = null + + while (attempt < maxAttempts) { + attempt += 1 + window.beginRenderBuild(mode) + var remountRequested = false + try { + return window.render() + } catch (remount: HookHotReloadRemountException) { + if (!hotSwapped) { + throw remount + } + remountRequested = true + lastRemountRequest = remount + println(remount.message) + } finally { + window.endRenderBuild() + if (remountRequested) { + window.discardRenderBuild() + } + } + } + + throw IllegalStateException( + "Hot-reload hook remount recovery exceeded $maxAttempts attempts: ${lastRemountRequest?.message}" + ) + } + + override fun handleKeyboardInput() { + updateSize(force = false) + 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) + ) + 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( + keyCode = keyCode, + keyChar = keyChar, + inspectorMouseX = inspectorMouseX, + inspectorMouseY = inspectorMouseY + ) + ) { + mc.dispatchKeypresses() + return + } + if (keyCode == Keyboard.KEY_F6) { + StyleEngine.forceReloadStylesheets() + requestRebuild("style reload") + } + 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) + } + } + } + } else { + val keyboardBlocked = inspector.active && ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + pressedKeys.remove(keyCode) + logInspectorInput("keyboard up consumed keyCode=$keyCode") + mc.dispatchKeypresses() + return + } + if (pressedKeys.remove(keyCode)) { + EventBus.post(KeyboardKeyUpEvent(keyChar, keyCode)) + } + } + + mc.dispatchKeypresses() + } + + override fun handleMouseInput() { + updateSize(force = false) + rebuildIfNeeded() + val tree = domTree ?: return + if (needsLayout) { + if (tryCommitLayout(tree, "handleMouseInput")) { + needsLayout = false + } else { + return + } + } + + 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( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f + ) + SelectRuntime.engine.onFrame( + measureContext = adapter, + viewportWidth = lastWidth, + viewportHeight = lastHeight, + viewportScale = 1f + ) + systemOverlayHost.onInputFrame(lastWidth, lastHeight) + inspectorPointerCaptured = inspector.isPointerCaptured + systemOverlayHost.syncFrame( + inspectedRoot = tree.root, + inspectedLayoutRevision = layoutRevision, + cursorX = mouseX, + cursorY = 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 + } + + + refreshHoverTarget(mouseX, mouseY) + + if (mouseButton > 2) return + + 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() + } + } + } + } 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) + } + } + 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 + ) + } + } + } + + if (dWheel != 0) { + val wheelTarget = resolveWheelTarget() + if (wheelTarget != null) { + val wheelEvent = MouseWheelEvent(mouseX, mouseY, dWheel) + wheelEvent.target = wheelTarget + EventBus.post(wheelEvent) + if (!wheelEvent.cancelled) { + bubbleGenericWheel(wheelTarget, mouseX, mouseY, dWheel) + } + } + } + + lastMouseX = mouseX + lastMouseY = mouseY + } + + private fun consumeOverlayKeyDown( + 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 + ) + + UiLayerId.ApplicationOverlay -> consumeApplicationOverlayKeyDown(keyCode, keyChar) + UiLayerId.ApplicationRoot -> false + } + }, + isLayerInputEnabled = OverlayLayerDebugState::isInputEnabled + ) + return consumedBy != null + } + + private fun consumeSystemOverlayKeyDown( + keyCode: Int, + keyChar: Char, + inspectorMouseX: Int, + inspectorMouseY: Int + ): Boolean { + if (systemOverlayHost.handleKeyDown(keyCode, keyChar)) { + return true + } + val keyboardBlocked = inspector.active && ( + inspector.shouldConsumeKeyboard(inspectorMouseX, inspectorMouseY) || + inspector.mode == InspectorMode.Locked + ) + if (keyboardBlocked) { + logInspectorInput("keyboard down consumed keyCode=$keyCode") + return true + } + return false + } + + private fun consumeApplicationOverlayKeyDown(keyCode: Int, keyChar: Char): Boolean { + if (ColorPickerRuntime.engine.handleKeyDown(keyCode, keyChar)) { + return true + } + if (applicationOverlayHost.handleKeyDown(keyCode, keyChar)) { + return true + } + if (SelectRuntime.engine.handleKeyDown(keyCode, keyChar)) { + return true + } + if (ContextMenuRuntime.engine.handleKeyDown(keyCode)) { + 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 consumeDebugPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mappedButton: MouseButton?, + mouseButton: Int, + buttonPressed: Boolean + ): Boolean { + if (dWheel != 0 && debugOverlayHost.handleMouseWheel(mouseX, mouseY, dWheel)) { + return true + } + if (mouseButton != -1 && mappedButton != null) { + return if (buttonPressed) { + debugOverlayHost.handleMouseDown(mouseX, mouseY, mappedButton) + } else { + debugOverlayHost.handleMouseUp(mouseX, mouseY, mappedButton) + } + } + if (mouseButton == -1 && debugOverlayHost.handleMouseMove(mouseX, mouseY)) { + return true + } + return false + } + + private fun consumeSystemOverlayPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mouseButton: Int, + mappedButton: MouseButton?, + buttonPressed: Boolean + ): Boolean { + if (dWheel != 0 && systemOverlayHost.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) + } + if (consumedBySystemOverlay) { + return true + } + } else if (mouseButton == -1 && systemOverlayHost.handleMouseMove(mouseX, mouseY)) { + return true + } + + val inspectorConsumesPointer = inspector.shouldConsumePointer(mouseX, mouseY) + if (!inspectorConsumesPointer) return false + if (!buttonPressed && mouseButton != -1) { + inspectorPointerCaptured = false + } + logInspectorInput("pointer event consumed by inspector bounds button=$mouseButton wheel=$dWheel") + return true + } + + private fun consumeApplicationOverlayPointerEvent( + mouseX: Int, + mouseY: Int, + dWheel: Int, + mouseButton: Int, + mappedButton: MouseButton?, + 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)) { + return true + } + } + + if (dWheel != 0 && applicationOverlayHost.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) { + return true + } + } else if (mouseButton == -1 && applicationOverlayHost.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 + } + + private fun consumeOverlayPointerState(mouseX: Int, mouseY: Int) { + eventButton = -1 + clearActiveTarget() + releaseDragCapture() + lastMouseX = mouseX + lastMouseY = mouseY + } + + private fun mapButton(button: Int): MouseButton? { + return when (button) { + 0 -> MouseButton.LEFT + 1 -> MouseButton.RIGHT + 2 -> MouseButton.MIDDLE + else -> null + } + } + + init { + inspector.installColorPickerHost(systemOverlayHost.systemInspectorColorPickerPopupHost()) + } + + private fun refreshActiveColorSamplerOwner(root: DOMNode?) { + val inlineByToken = LinkedHashMap() + if (root != null) { + collectActiveInlineColorSamplers(root, inlineByToken) + } + val focusedInline = FocusManager.focusedNode() as? ColorPickerInlineNode + 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 + } + } + + private fun collectActiveInlineColorSamplers( + node: DOMNode, + out: MutableMap + ) { + if (node is ColorPickerInlineNode && node.wantsGlobalPointerInput()) { + out.putIfAbsent(colorSamplerToken(node), node) + } + for (child in node.children) { + collectActiveInlineColorSamplers(child, out) + } + } + + private fun colorSamplerToken(node: ColorPickerInlineNode): Any { + return node.key ?: node + } + + private fun resolveForcedPointerTarget(): DOMNode? { + if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { + val inline = activeInlineColorSamplerNode + if (inline != null && inline.wantsGlobalPointerInput()) { + return inline + } + } + return null + } + + private fun appendInlineColorPickerOverlayCommands(out: MutableList) { + val layer = OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) + if (layer != UiLayerId.ApplicationOverlay) return + if (activeColorSamplerOwner is ActiveColorSamplerOwner.Inline) { + val inline = activeInlineColorSamplerNode ?: return + if (!inline.wantsGlobalPointerInput()) return + inline.appendEyedropperOverlayCommands( + viewportWidth = lastWidth.coerceAtLeast(1), + viewportHeight = lastHeight.coerceAtLeast(1), + out = out + ) + } + } + + private fun captureColorPickerEyedropperSamples() { + refreshActiveColorSamplerOwner(domTree?.root) + if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.System) == UiLayerId.SystemOverlay) { + systemOverlayHost.captureSystemColorPickerEyedropperSample() + } + if (OverlayLayerContracts.resolveTransientLayer(OverlayOwnerScope.Application) != UiLayerId.ApplicationOverlay) { + return + } + when (activeColorSamplerOwner) { + ActiveColorSamplerOwner.Popup -> ColorPickerRuntime.engine.captureEyedropperSample() + is ActiveColorSamplerOwner.Inline -> { + val inline = activeInlineColorSamplerNode + if (inline != null && inline.wantsGlobalPointerInput()) { + inline.captureEyedropperSample() + } + } + + ActiveColorSamplerOwner.None -> { + if (ColorPickerRuntime.engine.hasActiveEyedropper()) { + ColorPickerRuntime.engine.captureEyedropperSample() + } + } + } + } + + private fun flushPendingCleanup() { + if (pendingCleanupRoots.isEmpty()) return + val detachedRoots = pendingCleanupRoots.toList() + pendingCleanupRoots.clear() + detachedRoots.forEach { root -> + EventBus.run { root.clearListenersDeep() } + } + } + + internal fun debugPendingCleanupCount(): Int = pendingCleanupRoots.size + + internal fun debugBindTreeForTests(tree: DomTree, needsLayout: Boolean = false) { + domTree = tree + this.needsLayout = needsLayout + } + + internal fun debugRefreshHoverTargetForTests(mouseX: Int, mouseY: Int) { + refreshHoverTarget(mouseX, mouseY) + } + + internal fun debugHoverTargetForTests(): DOMNode? = hoverTarget + + internal fun debugResolvePointerDownTargetForTests(): DOMNode? = resolvePointerDownTarget() + + internal fun debugResolveClickTargetForTests(): DOMNode? = resolveClickTarget() + + internal fun debugSetNeedsRenderForTests(value: Boolean) { + needsRender = value + } + + internal fun debugRebuildIfNeededForTests(): Boolean = rebuildIfNeeded() + + private fun setDragCapture(target: DOMNode) { + dragCaptureTarget = target + dragCaptureKey = target.key + dragCaptureClass = target.javaClass + dragCaptureFocusKey = FocusManager.focusedNode()?.key + } + + private fun releaseDragCapture() { + dragCaptureTarget?.cancelPointerCapture() + RangeInputNode.clearActiveDrag() + SingleLineInputNode.clearActiveDrag() + TextAreaNode.clearActiveDrag() + dragCaptureTarget = null + dragCaptureKey = null + dragCaptureClass = null + dragCaptureFocusKey = null + } + + private fun setActiveTarget(target: DOMNode?) { + if (target?.styleDisabled == true) return + if (activeTarget === target) return + activeTarget?.setActiveState(false) + activeTarget = target + activeTarget?.setActiveState(true) + } + + private fun clearActiveTarget() { + activeTarget?.setActiveState(false) + 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 + } + + private fun refreshHoverTarget(mouseX: Int, mouseY: Int) { + val tree = domTree ?: return + if (needsLayout) { + if (tryCommitLayout(tree, "refreshHoverTarget")) { + needsLayout = false + } else { + return + } + } + val chain = collectHoverChain(tree.root, mouseX, mouseY) + hoverTarget = chain.lastOrNull() + } + + private fun resolvePointerDownTarget(): DOMNode? { + return resolveForcedPointerTarget() ?: hoverTarget + } + + private fun resolvePointerUpTarget(): DOMNode? { + return dragCaptureTarget ?: resolveForcedPointerTarget() ?: hoverTarget + } + + private fun resolveClickTarget(): DOMNode? { + return hoverTarget + } + + private fun resolveWheelTarget(): DOMNode? { + val focused = FocusManager.focusedNode() + if (focused is TextAreaNode) { + val hovered = hoverTarget + if (!isSameOrAncestor(focused, hovered)) { + return focused + } + } + return hoverTarget + } + + 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)) { + return true + } + current = current.parent + } + 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) + } + hoverChain.clear() + } + + private fun logInspectorInput(message: String) { + if (!inspectorInputDebug) return + println("[DSGL-InspectorInput] $message") + } + + private fun tryCommitLayout(tree: DomTree, phase: String): Boolean { + return try { + tree.render(adapter, lastWidth, lastHeight) + val rootBounds = tree.root.bounds + 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." + ) + return false + } + layoutRevision++ + inspector.onLayoutCommitted(tree.root, layoutRevision) + true + } catch (error: Throwable) { + logPipelineError( + key = "layout.$phase", + message = "[DSGL] Layout commit failed in $phase; keeping previous frame: ${error.message}" + ) + false + } + } + + private fun shouldKeepPreviousFrameCommands( + tree: DomTree, + rebuiltThisFrame: Boolean, + layoutCommittedThisFrame: Boolean, + 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." + ) + return composedCommandsBuffer.isNotEmpty() + } + if (candidate.isNotEmpty()) return false + if (composedCommandsBuffer.isEmpty()) return false + if (!rebuiltThisFrame && !layoutCommittedThisFrame) return false + if (!hasRenderableNodes(tree.root)) return false + logPipelineError( + key = "blank.guard", + message = "[DSGL] Guarded against blank rebuild frame; keeping previous commands." + ) + return true + } + + private data class CommandShape( + val valid: Boolean, + val clipDepth: Int, + val transformDepth: Int, + val opacityDepth: Int + ) + + private fun validateCommandShape(commands: List): CommandShape { + var clipDepth = 0 + var transformDepth = 0 + var opacityDepth = 0 + for (command in commands) { + when (command) { + is RenderCommand.PushClip -> clipDepth += 1 + is RenderCommand.PopClip -> { + clipDepth -= 1 + if (clipDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + is RenderCommand.PushTransform -> transformDepth += 1 + is RenderCommand.PopTransform -> { + transformDepth -= 1 + if (transformDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + is RenderCommand.PushOpacity -> opacityDepth += 1 + is RenderCommand.PopOpacity -> { + opacityDepth -= 1 + if (opacityDepth < 0) return CommandShape(false, clipDepth, transformDepth, opacityDepth) + } + + else -> Unit + } + } + return CommandShape( + valid = clipDepth == 0 && transformDepth == 0 && opacityDepth == 0, + clipDepth = clipDepth, + transformDepth = transformDepth, + opacityDepth = opacityDepth + ) + } + + private fun hasRenderableNodes(node: DOMNode): Boolean { + if (node.display != Display.None && node.children.isNotEmpty()) { + return true + } + node.children.forEach { child -> + if (hasRenderableNodes(child)) return true + } + return false + } + + private fun tracePhase(phase: String) { + if (!phaseTraceDebug) return + println("[DSGL-RebuildTrace] frame=$frameIndex phase=$phase needsRender=$needsRender needsLayout=$needsLayout") + } + + private fun logPipelineError(key: String, message: String) { + val now = System.currentTimeMillis() + val previous = pipelineErrorLogTimes[key] ?: 0L + if (now - previous < 2_000L) return + pipelineErrorLogTimes[key] = now + println(message) + } + + private fun maybeLogPerf(tree: DomTree) { + if (!perfDebug) return + val now = System.currentTimeMillis() + if (now - lastPerfLogMs < 2_000L) return + lastPerfLogMs = now + val paintStats = tree.paintStats() + 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" + ) + } +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/GlUtils.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/GlUtils.kt new file mode 100644 index 0000000..f1716e1 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/GlUtils.kt @@ -0,0 +1,101 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.client.renderer.RenderHelper +import org.lwjgl.opengl.GL11 + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withStack( + attributes: List, + block: () -> Unit +) { + val attributesBitMask = attributes.reduce { a, b -> a or b } + withStack(attributesBitMask) { block() } +} + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withStack( + attributesBitMask: Int = 0, + block: () -> Unit +) { + GL11.glPushMatrix() + if (attributesBitMask != 0) { + GL11.glPushAttrib(attributesBitMask) + } + try { + block() + } finally { + if (attributesBitMask != 0) { + GL11.glPopAttrib() + } + GL11.glPopMatrix() + } +} + +/** + * Enables and disables GL capabilities for the duration of [block]. + */ +inline fun withAttributes( + enable: List = emptyList(), + disable: List = emptyList(), + block: () -> Unit +) { + val wasDisabled = mutableListOf() + for (capability in enable) { + if(!GL11.glIsEnabled(capability)) { + GL11.glEnable(capability) + wasDisabled += capability + } + } + + val wasEnabled = mutableListOf() + for (capability in disable) { + if(GL11.glIsEnabled(capability)) { + GL11.glDisable(capability) + wasEnabled += capability + } + } + try { + block() + } finally { + for (capability in wasDisabled) { + GL11.glDisable(capability) + } + for (capability in wasEnabled) { + GL11.glEnable(capability) + } + } +} + +/** + * Executes a block with an OpenGL matrix/attribute stack push/pop. + */ +inline fun withAttributes( + enableBitMask: Int? = null, + disableBitMask: Int? = null, + block: () -> Unit +) { + enableBitMask?.let { GL11.glEnable(it) } + disableBitMask?.let { GL11.glDisable(it) } + try { + block() + } finally { + enableBitMask?.let { GL11.glDisable(it) } + disableBitMask?.let { GL11.glEnable(it) } + } +} + +/** + * Runs [block] with standard item lighting enabled. + */ +inline fun withItemGuiLightning(block: () -> Unit) { + RenderHelper.enableGUIStandardItemLighting() + try { + block() + } finally { + RenderHelper.disableStandardItemLighting() + } +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/Mc1122UiAdapter.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/Mc1122UiAdapter.kt new file mode 100644 index 0000000..d8f148f --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/Mc1122UiAdapter.kt @@ -0,0 +1,1592 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.Gui +import net.minecraft.client.renderer.RenderItem +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.host.Viewport +import org.dreamfinity.dsgl.core.host.dsglRectToGlScissor +import org.dreamfinity.dsgl.core.render.RenderCommand +import org.dreamfinity.dsgl.mcForge1122.scissorsHelper.ScissorContext +import org.dreamfinity.dsgl.mcForge1122.text.MsdfTextRenderer +import org.lwjgl.BufferUtils +import org.lwjgl.opengl.* +import java.io.File +import java.net.URL +import javax.imageio.ImageIO + +/** + * Minecraft 1.12.2 adapter that turns DSGL render commands into Minecraft calls. + */ +class Mc1122UiAdapter(private val mc: Minecraft, var paintsCount: Long = 0L) : UiMeasureContext { + private enum class ReadbackApi { + OpenGl30, + ArbFramebufferObject, + ExtFramebufferObject, + Legacy + } + + private data class ReadbackBindingState( + val readFramebufferBinding: Int, + val drawFramebufferBinding: Int, + val framebufferBinding: Int, + val currentReadBuffer: Int + ) { + val usingFramebufferObject: Boolean + get() = readFramebufferBinding != 0 + } + + private data class ReadbackSetup( + val previousReadBuffer: Int, + val appliedReadBuffer: Int, + val shouldRestore: Boolean + ) + + private data class FramebufferBindingSnapshot( + val readFramebufferBinding: Int, + val drawFramebufferBinding: Int, + val framebufferBinding: Int + ) + + private data class SceneTextureSource( + val textureId: Int, + val textureWidth: Int, + val textureHeight: Int + ) + + private data class MagnifierCaptureShader( + val programId: Int, + val sourceTextureUniform: Int, + val sourceOriginUniform: Int, + val sourceSizeUniform: Int, + val viewportSizeUniform: Int, + val sourceTextureSizeUniform: Int, + val fallbackColorUniform: Int + ) + + companion object { + private val imageCache: MutableMap = HashMap() + private val dynamicTexturesCache: MutableMap = HashMap() + 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 = """ + #version 120 + uniform sampler2D uSourceTexture; + uniform vec2 uSourceOriginTopLeft; + uniform vec2 uSourceSize; + uniform vec2 uViewportSize; + uniform vec2 uSourceTextureSize; + uniform vec4 uFallbackColor; + varying vec2 vUv; + void main() { + vec2 dstPixel = floor(vUv * uSourceSize); + float sourceX = uSourceOriginTopLeft.x + dstPixel.x; + float sourceYTop = uSourceOriginTopLeft.y + (uSourceSize.y - 1.0 - dstPixel.y); + bool inside = + sourceX >= 0.0 && + sourceYTop >= 0.0 && + sourceX < uViewportSize.x && + sourceYTop < uViewportSize.y; + if (!inside) { + gl_FragColor = uFallbackColor; + return; + } + float sourceYBottom = (uViewportSize.y - 1.0) - sourceYTop; + vec2 sourceUv = vec2( + (sourceX + 0.5) / uSourceTextureSize.x, + (sourceYBottom + 0.5) / uSourceTextureSize.y + ); + vec4 sampled = texture2D(uSourceTexture, sourceUv); + gl_FragColor = vec4(sampled.rgb, 1.0); + } + """.trimIndent() + } + + private val itemRenderer: RenderItem + get() = mc.renderItem + private val textRenderer: MsdfTextRenderer = MsdfTextRenderer() + private val opacityStack: MutableList = ArrayList(8) + private var opacityMultiplier: Float = 1f + private val errorLogTimes: MutableMap = linkedMapOf() + 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) + private var sampleAreaBuffer = BufferUtils.createByteBuffer(4 * 256) + private val glIntStateQueryBuffer = BufferUtils.createIntBuffer(16) + private val glFloatStateQueryBuffer = BufferUtils.createFloatBuffer(16) + + private var capturedRegionTextureId: Int = 0 + private var capturedRegionFramebufferId: Int = 0 + private var capturedRegionWidth: Int = 0 + private var capturedRegionHeight: Int = 0 + private var capturedRegionValid: Boolean = false + private var capturedRegionFallbackColor: Int = 0xFF000000.toInt() + private var magnifierCaptureShader: MagnifierCaptureShader? = null + private var magnifierCaptureShaderInitFailed: Boolean = false + + private val checkerTextureCache: LinkedHashMap = LinkedHashMap(16, 0.75f, true) + private val checkerTextureUploadBuffer = BufferUtils.createByteBuffer(16) + private val maxCheckerTextures: Int = 32 + + private var cachedViewport: Viewport = Viewport(width = 1, height = 1, scale = 1f, x = 0, y = 0) + private var cachedDisplayWidth: Int = -1 + 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 measureTextRange( + text: String, + startIndex: Int, + endIndexExclusive: Int, + fontId: String?, + fontSize: Int? + ): Int { + return 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 fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { + return textRenderer.fontLineMetrics(fontId, fontSize) + } + + fun viewport(): Viewport { + val displayWidth = mc.displayWidth.coerceAtLeast(1) + val displayHeight = mc.displayHeight.coerceAtLeast(1) + if (displayWidth != cachedDisplayWidth || displayHeight != cachedDisplayHeight) { + cachedDisplayWidth = displayWidth + cachedDisplayHeight = displayHeight + cachedViewport = Viewport( + width = displayWidth, + height = displayHeight, + scale = 1f, + x = 0, + y = 0 + ) + } + return cachedViewport + } + + fun sampleScreenColor(x: Int, y: Int): Int? { + val viewport = viewport() + if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) return null + val readY = viewport.height - 1 - y + samplePixelBuffer.clear() + return try { + val setup = beginReadback() + if (readbackDiagnosticsVerbose) { + diagnoseReadbackSource( + path = "sampleScreenColor", + sourceX = x, + sourceY = y, + sourceWidth = 1, + sourceHeight = 1, + setup = setup + ) + } + try { + GL11.glReadPixels(x, readY, 1, 1, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, samplePixelBuffer) + } finally { + endReadback(setup) + } + val r = samplePixelBuffer.get(0).toInt() and 0xFF + val g = samplePixelBuffer.get(1).toInt() and 0xFF + val b = samplePixelBuffer.get(2).toInt() and 0xFF + val a = samplePixelBuffer.get(3).toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } catch (_: Throwable) { + null + } + } + + 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 + val viewport = viewport() + var i = 0 + while (i < required) { + outArgb[i] = 0 + i++ + } + + val srcX = x.coerceIn(0, viewport.width) + val srcY = y.coerceIn(0, viewport.height) + val maxW = viewport.width - srcX + val maxH = viewport.height - srcY + val srcW = minOf(width, maxW).coerceAtLeast(0) + val srcH = minOf(height, maxH).coerceAtLeast(0) + if (srcW <= 0 || srcH <= 0) return false + + val byteCount = srcW * srcH * 4 + if (sampleAreaBuffer.capacity() < byteCount) { + sampleAreaBuffer = BufferUtils.createByteBuffer(byteCount) + } + sampleAreaBuffer.clear() + sampleAreaBuffer.limit(byteCount) + return try { + val readY = viewport.height - (srcY + srcH) + val setup = beginReadback() + if (readbackDiagnosticsVerbose) { + diagnoseReadbackSource( + path = "sampleScreenArea", + sourceX = srcX, + sourceY = srcY, + sourceWidth = srcW, + sourceHeight = srcH, + setup = setup + ) + } + try { + GL11.glReadPixels(srcX, readY, srcW, srcH, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, sampleAreaBuffer) + } finally { + endReadback(setup) + } + val dstOffsetX = (srcX - x).coerceAtLeast(0) + val dstOffsetY = (srcY - y).coerceAtLeast(0) + var row = 0 + while (row < srcH) { + val glRow = srcH - 1 - row + var col = 0 + while (col < srcW) { + val srcIndex = (glRow * srcW + col) * 4 + val r = sampleAreaBuffer.get(srcIndex).toInt() and 0xFF + val g = sampleAreaBuffer.get(srcIndex + 1).toInt() and 0xFF + val b = sampleAreaBuffer.get(srcIndex + 2).toInt() and 0xFF + val a = sampleAreaBuffer.get(srcIndex + 3).toInt() and 0xFF + val dstX = dstOffsetX + col + val dstY = dstOffsetY + row + outArgb[dstY * width + dstX] = (a shl 24) or (r shl 16) or (g shl 8) or b + col++ + } + row++ + } + true + } catch (_: Throwable) { + false + } + } + + private fun captureScreenRegion(command: RenderCommand.CaptureScreenRegion, viewport: Viewport) { + val sourceWidth = command.sourceWidth.coerceAtLeast(1) + val sourceHeight = command.sourceHeight.coerceAtLeast(1) + capturedRegionFallbackColor = command.fallbackColor + ensureCapturedRegionTexture(sourceWidth, sourceHeight) + if (capturedRegionTextureId == 0) { + capturedRegionValid = false + return + } + val sceneTextureSource = resolveActiveSceneTextureSource() + val shader = ensureMagnifierCaptureShader() + if (sceneTextureSource == null || shader == null || sceneTextureSource.textureId == capturedRegionTextureId) { + capturedRegionValid = fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) + if (readbackDiagnosticsVerbose) { + logRateLimited( + key = "magnifier:capture:fallback", + message = "[DSGL-Magnifier] Falling back to solid fill preview. sourceTexture=${sceneTextureSource?.textureId ?: 0} shaderReady=${shader != null}" + ) + } + return + } + val framebufferSnapshot = snapshotFramebufferBindings() + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val previousDrawBuffer = GL11.glGetInteger(GL11.GL_DRAW_BUFFER) + val previousTextureBinding = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + snapshotViewportState() + val previousViewportX = viewportXFromSnapshot() + val previousViewportY = viewportYFromSnapshot() + val previousViewportWidth = viewportWidthFromSnapshot() + val previousViewportHeight = viewportHeightFromSnapshot() + var renderingSucceeded = false + try { + if (!ensureCapturedRegionFramebuffer()) { + return + } + bindDrawFramebuffer(capturedRegionFramebufferId) + attachCapturedRegionTextureToFramebuffer() + if (!isCurrentFramebufferComplete()) { + return + } + val attachment = defaultColorAttachmentReadBuffer() + GL11.glDrawBuffer(attachment) + GL11.glReadBuffer(attachment) + GL11.glViewport(0, 0, sourceWidth, sourceHeight) + GL11.glDisable(GL11.GL_SCISSOR_TEST) + GL11.glDisable(GL11.GL_BLEND) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glDisable(GL11.GL_DEPTH_TEST) + GL11.glEnable(GL11.GL_TEXTURE_2D) + ARBShaderObjects.glUseProgramObjectARB(shader.programId) + GL13.glActiveTexture(GL13.GL_TEXTURE0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, sceneTextureSource.textureId) + ARBShaderObjects.glUniform1iARB(shader.sourceTextureUniform, 0) + ARBShaderObjects.glUniform2fARB( + shader.sourceOriginUniform, + command.sourceX.toFloat(), + command.sourceY.toFloat() + ) + ARBShaderObjects.glUniform2fARB(shader.sourceSizeUniform, sourceWidth.toFloat(), sourceHeight.toFloat()) + ARBShaderObjects.glUniform2fARB( + shader.viewportSizeUniform, + viewport.width.toFloat(), + viewport.height.toFloat() + ) + ARBShaderObjects.glUniform2fARB( + shader.sourceTextureSizeUniform, + sceneTextureSource.textureWidth.toFloat(), + sceneTextureSource.textureHeight.toFloat() + ) + val fallbackAlpha = ((command.fallbackColor ushr 24) and 0xFF) / 255f + val fallbackRed = ((command.fallbackColor ushr 16) and 0xFF) / 255f + val fallbackGreen = ((command.fallbackColor ushr 8) and 0xFF) / 255f + val fallbackBlue = (command.fallbackColor and 0xFF) / 255f + ARBShaderObjects.glUniform4fARB( + shader.fallbackColorUniform, + fallbackRed, + fallbackGreen, + fallbackBlue, + fallbackAlpha + ) + GL11.glColor4f(1f, 1f, 1f, 1f) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(0f, 0f) + GL11.glVertex2f(-1f, -1f) + GL11.glTexCoord2f(1f, 0f) + GL11.glVertex2f(1f, -1f) + GL11.glTexCoord2f(1f, 1f) + GL11.glVertex2f(1f, 1f) + GL11.glTexCoord2f(0f, 1f) + GL11.glVertex2f(-1f, 1f) + GL11.glEnd() + renderingSucceeded = true + } catch (error: Throwable) { + if (readbackDiagnosticsVerbose) { + logRateLimited( + key = "magnifier:capture:error", + message = "[DSGL-Magnifier] GPU capture failed: ${error.message ?: error::class.java.simpleName}" + ) + } + } finally { + ARBShaderObjects.glUseProgramObjectARB(0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, previousTextureBinding) + GL11.glReadBuffer(previousReadBuffer) + GL11.glDrawBuffer(previousDrawBuffer) + restoreFramebufferBindings(framebufferSnapshot) + GL11.glViewport( + previousViewportX, + previousViewportY, + previousViewportWidth, + previousViewportHeight + ) + } + capturedRegionValid = + renderingSucceeded || fillCapturedRegionFallbackTexture(command.fallbackColor, sourceWidth, sourceHeight) + } + + private fun drawCapturedScreenRegion(command: RenderCommand.DrawCapturedScreenRegion) { + if (command.width <= 0 || command.height <= 0) return + if (!capturedRegionValid || capturedRegionTextureId == 0) { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(capturedRegionFallbackColor) + ) + return + } + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(0f, 1f) + GL11.glVertex2f(command.x.toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(1f, 1f) + GL11.glVertex2f((command.x + command.width).toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(1f, 0f) + GL11.glVertex2f((command.x + command.width).toFloat(), (command.y + command.height).toFloat()) + GL11.glTexCoord2f(0f, 0f) + GL11.glVertex2f(command.x.toFloat(), (command.y + command.height).toFloat()) + GL11.glEnd() + } + + private fun drawCheckerboard(command: RenderCommand.DrawCheckerboard) { + if (command.width <= 0 || command.height <= 0) return + val cellSize = command.cellSize.coerceAtLeast(1) + val textureId = resolveCheckerTextureId(command.lightColor, command.darkColor) + if (textureId == 0) { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(command.lightColor) + ) + return + } + val patternSize = (cellSize * 2f).coerceAtLeast(1f) + val u0 = (command.x + command.offsetX) / patternSize + val v0 = (command.y + command.offsetY) / patternSize + val u1 = u0 + (command.width / patternSize) + val v1 = v0 + (command.height / patternSize) + + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_CULL_FACE) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBegin(GL11.GL_QUADS) + GL11.glTexCoord2f(u0, v0) + GL11.glVertex2f(command.x.toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(u1, v0) + GL11.glVertex2f((command.x + command.width).toFloat(), command.y.toFloat()) + GL11.glTexCoord2f(u1, v1) + GL11.glVertex2f((command.x + command.width).toFloat(), (command.y + command.height).toFloat()) + GL11.glTexCoord2f(u0, v1) + GL11.glVertex2f(command.x.toFloat(), (command.y + command.height).toFloat()) + GL11.glEnd() + } + + private fun resolveCheckerTextureId(lightColor: Int, darkColor: Int): Int { + val key = checkerTextureKey(lightColor, darkColor) + checkerTextureCache[key]?.let { return it } + + val textureId = GL11.glGenTextures() + if (textureId == 0) return 0 + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT) + + 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.flip() + + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1) + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA, + 2, + 2, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + checkerTextureUploadBuffer + ) + + checkerTextureCache[key] = textureId + while (checkerTextureCache.size > maxCheckerTextures) { + 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 ensureCapturedRegionTexture(width: Int, height: Int) { + if (capturedRegionTextureId == 0) { + capturedRegionTextureId = GL11.glGenTextures() + if (capturedRegionTextureId == 0) return + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + 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) + capturedRegionWidth = 0 + capturedRegionHeight = 0 + } + if (capturedRegionWidth == width && capturedRegionHeight == height) return + capturedRegionWidth = width + capturedRegionHeight = height + GL11.glBindTexture(GL11.GL_TEXTURE_2D, capturedRegionTextureId) + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1) + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA, + capturedRegionWidth, + capturedRegionHeight, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + null as java.nio.ByteBuffer? + ) + } + + private fun ensureCapturedRegionFramebuffer(): Boolean { + if (capturedRegionFramebufferId != 0) return true + capturedRegionFramebufferId = generateFramebufferObject() + return capturedRegionFramebufferId != 0 + } + + private fun resolveActiveSceneTextureSource(): SceneTextureSource? { + val state = detectReadbackBindingState() + if (!state.usingFramebufferObject) return null + 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) + if (textureId <= 0) return null + val previousTextureBinding = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + return try { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + val textureWidth = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_WIDTH) + val textureHeight = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_HEIGHT) + if (textureWidth <= 0 || textureHeight <= 0) return null + SceneTextureSource(textureId = textureId, textureWidth = textureWidth, textureHeight = textureHeight) + } finally { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, previousTextureBinding) + } + } + + private fun getFramebufferAttachmentObjectType(colorAttachment: Int): Int { + return 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.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 + ) + + 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.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 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 + ) + if (linkStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) + throw IllegalStateException("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") + ) + magnifierCaptureShader = shader + shader + } catch (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}" + ) + } + null + } + } + + private fun compileShaderObject(type: Int, source: String): Int { + val shader = ARBShaderObjects.glCreateShaderObjectARB(type) + ARBShaderObjects.glShaderSourceARB(shader, source) + ARBShaderObjects.glCompileShaderARB(shader) + 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") + } + return shader + } + + private fun generateFramebufferObject(): Int { + return when (readbackApi) { + ReadbackApi.OpenGl30 -> GL30.glGenFramebuffers() + ReadbackApi.ArbFramebufferObject -> ARBFramebufferObject.glGenFramebuffers() + ReadbackApi.ExtFramebufferObject -> EXTFramebufferObject.glGenFramebuffersEXT() + ReadbackApi.Legacy -> 0 + } + } + + private fun snapshotFramebufferBindings(): FramebufferBindingSnapshot { + return FramebufferBindingSnapshot( + readFramebufferBinding = currentReadFramebufferBinding(), + drawFramebufferBinding = currentDrawFramebufferBinding(), + framebufferBinding = currentFramebufferBinding() + ) + } + + private fun restoreFramebufferBindings(snapshot: FramebufferBindingSnapshot) { + when (readbackApi) { + ReadbackApi.OpenGl30 -> { + GL30.glBindFramebuffer(GL30.GL_READ_FRAMEBUFFER, snapshot.readFramebufferBinding) + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, snapshot.drawFramebufferBinding) + } + + ReadbackApi.ArbFramebufferObject -> { + ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_READ_FRAMEBUFFER, + snapshot.readFramebufferBinding + ) + ARBFramebufferObject.glBindFramebuffer( + ARBFramebufferObject.GL_DRAW_FRAMEBUFFER, + snapshot.drawFramebufferBinding + ) + } + + ReadbackApi.ExtFramebufferObject -> { + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + snapshot.framebufferBinding + ) + } + + ReadbackApi.Legacy -> Unit + } + } + + 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.ExtFramebufferObject -> EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + framebufferId + ) + + ReadbackApi.Legacy -> Unit + } + } + + private fun attachCapturedRegionTextureToFramebuffer() { + when (readbackApi) { + 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.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 + + 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 { + val snapshot = snapshotFramebufferBindings() + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val previousDrawBuffer = GL11.glGetInteger(GL11.GL_DRAW_BUFFER) + snapshotViewportState() + val previousViewportX = viewportXFromSnapshot() + val previousViewportY = viewportYFromSnapshot() + val previousViewportWidth = viewportWidthFromSnapshot() + val previousViewportHeight = viewportHeightFromSnapshot() + snapshotClearColorState() + val previousClearRed = clearRedFromSnapshot() + val previousClearGreen = clearGreenFromSnapshot() + val previousClearBlue = clearBlueFromSnapshot() + val previousClearAlpha = clearAlphaFromSnapshot() + return try { + if (!ensureCapturedRegionFramebuffer()) return false + bindDrawFramebuffer(capturedRegionFramebufferId) + attachCapturedRegionTextureToFramebuffer() + if (!isCurrentFramebufferComplete()) return false + val attachment = defaultColorAttachmentReadBuffer() + GL11.glDrawBuffer(attachment) + GL11.glViewport(0, 0, width, height) + val alpha = ((fallbackColor ushr 24) and 0xFF) / 255f + val red = ((fallbackColor ushr 16) and 0xFF) / 255f + val green = ((fallbackColor ushr 8) and 0xFF) / 255f + val blue = (fallbackColor and 0xFF) / 255f + GL11.glClearColor(red, green, blue, alpha) + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT) + true + } catch (_: Throwable) { + false + } finally { + GL11.glClearColor( + previousClearRed, + previousClearGreen, + previousClearBlue, + previousClearAlpha + ) + GL11.glReadBuffer(previousReadBuffer) + GL11.glDrawBuffer(previousDrawBuffer) + restoreFramebufferBindings(snapshot) + GL11.glViewport( + previousViewportX, + previousViewportY, + previousViewportWidth, + previousViewportHeight + ) + } + } + + private fun snapshotViewportState() { + glIntStateQueryBuffer.clear() + GL11.glGetInteger(GL11.GL_VIEWPORT, glIntStateQueryBuffer) + } + + 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() { + glFloatStateQueryBuffer.clear() + GL11.glGetFloat(GL11.GL_COLOR_CLEAR_VALUE, glFloatStateQueryBuffer) + } + + 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 { + val r = ((argb ushr 16) and 0xFF).toByte() + val g = ((argb ushr 8) and 0xFF).toByte() + val b = (argb and 0xFF).toByte() + val a = if (forceOpaqueAlpha) 0xFF.toByte() else ((argb ushr 24) and 0xFF).toByte() + return byteArrayOf(r, g, b, a) + } + + /** Executes DSGL render commands using Minecraft rendering APIs. */ + override fun paint(commands: List) { + paintsCount++ + opacityStack.clear() + opacityMultiplier = 1f + val transformStack = RenderCommandTransformStack() + transformStack.reset() + val viewport = viewport() + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS) + try { + ScissorContext.clear() + GL11.glDisable(GL11.GL_SCISSOR_TEST) + GL11.glViewport(viewport.x, viewport.y, viewport.width, viewport.height) + GL11.glMatrixMode(GL11.GL_PROJECTION) + GL11.glPushMatrix() + GL11.glLoadIdentity() + GL11.glOrtho(0.0, viewport.width.toDouble(), viewport.height.toDouble(), 0.0, -1000.0, 1000.0) + GL11.glMatrixMode(GL11.GL_MODELVIEW) + GL11.glPushMatrix() + GL11.glLoadIdentity() + GL11.glAlphaFunc(GL11.GL_GREATER, 0.0f) + try { + for (command in commands) { + when (command) { + is RenderCommand.DrawRect -> { + Gui.drawRect( + command.x, + command.y, + command.x + command.width, + command.y + command.height, + applyOpacity(command.color) + ) + } + + is RenderCommand.DrawColorField -> { + drawColorField( + x = command.x, + y = command.y, + width = command.width, + height = command.height, + hueDeg = command.hueDeg + ) + } + + is RenderCommand.DrawHueBar -> { + drawHueBar( + x = command.x, + y = command.y, + width = command.width, + height = command.height + ) + } + + is RenderCommand.DrawAlphaBar -> { + drawAlphaBar( + x = command.x, + y = command.y, + width = command.width, + height = command.height, + rgbColor = command.rgbColor + ) + } + + is RenderCommand.DrawCheckerboard -> { + drawCheckerboard(command) + } + + is RenderCommand.DrawText -> { + try { + /*textRenderer.draw( + command = command, + opacityMultiplier = opacityMultiplier + )*/ + } catch (error: LinkageError) { + logRateLimited( + key = "drawText:linkage", + message = "[DSGL] Skipping DrawText due linkage error in text renderer: ${error.message}" + ) + } catch (error: Throwable) { + logRateLimited( + key = "drawText:runtime", + message = "[DSGL] Skipping DrawText due renderer error: ${error.message}" + ) + } + } + + is RenderCommand.DrawImage -> { + val location = resolveImage(command.resource) ?: continue + mc.textureManager.bindTexture(location) + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + Gui.drawModalRectWithCustomSizedTexture( + command.x, + command.y, + 0f, + 0f, + command.width, + command.height, + command.width.toFloat(), + command.height.toFloat() + ) + } + + is RenderCommand.CaptureScreenRegion -> { + captureScreenRegion(command, viewport) + } + + is RenderCommand.DrawCapturedScreenRegion -> { + drawCapturedScreenRegion(command) + } + + is RenderCommand.DrawItemStack -> { + val stack = (command.stack as? McItemStackRef)?.stack ?: continue + drawItemStack( + stack = stack, + x = command.x, + y = command.y, + size = command.size, + width = command.width, + rotY = command.rotYDeg, + rotX = command.rotXDeg + ) + } + + is RenderCommand.PushClip -> { + 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 + ) + } + + is RenderCommand.PopClip -> { + ScissorContext.pop() + } + + is RenderCommand.PushTransform -> { + transformStack.push(command) + GL11.glPushMatrix() + GL11.glTranslatef(command.originX, command.originY, 0f) + GL11.glTranslatef(command.translateX, command.translateY, 0f) + GL11.glRotatef(command.rotateDeg, 0f, 0f, 1f) + GL11.glScalef(command.scaleX, command.scaleY, 1f) + GL11.glTranslatef(-command.originX, -command.originY, 0f) + } + + is RenderCommand.PopTransform -> { + transformStack.pop() + GL11.glPopMatrix() + } + + is RenderCommand.PushOpacity -> { + opacityStack.add(opacityMultiplier) + opacityMultiplier = (opacityMultiplier * command.opacity).coerceIn(0f, 1f) + } + + is RenderCommand.PopOpacity -> { + opacityMultiplier = + if (opacityStack.isEmpty()) 1f else opacityStack.removeAt(opacityStack.lastIndex) + } + } + } + } finally { + GL11.glAlphaFunc(GL11.GL_GREATER, 0.1f) + GL11.glMatrixMode(GL11.GL_MODELVIEW) + GL11.glPopMatrix() + GL11.glMatrixMode(GL11.GL_PROJECTION) + GL11.glPopMatrix() + GL11.glMatrixMode(GL11.GL_MODELVIEW) + } + } finally { + ScissorContext.clear() + transformStack.reset() + opacityStack.clear() + opacityMultiplier = 1f + GL11.glPopAttrib() + } + } + + private fun applyOpacity(color: Int): Int { + if (opacityMultiplier >= 0.999f) return color + val alpha = ((color ushr 24) and 0xFF) + val scaled = (alpha * opacityMultiplier).toInt().coerceIn(0, 255) + return (color and 0x00FF_FFFF) or (scaled shl 24) + } + + 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 + val hueColor = (hsvToArgbInt(normalizedHue, 1f, 1f) and 0x00FF_FFFF) or (0xFF shl 24) + + drawGradientBlock { + drawHorizontalGradientRectRaw( + x, y, width, height, + applyOpacity(0xFFFFFFFF.toInt()), + applyOpacity(hueColor) + ) + drawVerticalGradientRectRaw( + x, y, width, height, + applyOpacity(0x00000000), + applyOpacity(0xFF000000.toInt()) + ) + } + } + + 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)) + drawHorizontalGradientRect(startX, y, segmentWidth, height, startColor, endColor) + index += 1 + } + } + + 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) + val rightColor = applyOpacity(rgbOnly or (0xFF shl 24)) + drawHorizontalGradientRect(x, y, width, height, leftColor, rightColor) + } + + 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) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + drawHorizontalGradientRectRaw(x, y, width, height, leftColor, rightColor) + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glEnable(GL11.GL_TEXTURE_2D) + 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) + GL11.glDepthMask(false) + GL11.glDisable(GL11.GL_ALPHA_TEST) + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + GL11.glShadeModel(GL11.GL_SMOOTH) + + block() + + GL11.glShadeModel(GL11.GL_FLAT) + GL11.glDisable(GL11.GL_BLEND) + GL11.glEnable(GL11.GL_ALPHA_TEST) + GL11.glDepthMask(true) + GL11.glEnable(GL11.GL_DEPTH_TEST) + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glColor4f(1f, 1f, 1f, 1f) + } + + private fun drawHorizontalGradientRectRaw( + x: Int, + y: Int, + width: Int, + height: Int, + leftColor: Int, + rightColor: Int + ) { + GL11.glBegin(GL11.GL_QUADS) + glColor(leftColor) + GL11.glVertex2f(x.toFloat(), y.toFloat()) + GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) + glColor(rightColor) + GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) + GL11.glVertex2f((x + width).toFloat(), y.toFloat()) + GL11.glEnd() + } + + private fun drawVerticalGradientRectRaw( + x: Int, + y: Int, + width: Int, + height: Int, + topColor: Int, + bottomColor: Int + ) { + GL11.glBegin(GL11.GL_QUADS) + glColor(topColor) + GL11.glVertex2f((x + width).toFloat(), y.toFloat()) + GL11.glVertex2f(x.toFloat(), y.toFloat()) + glColor(bottomColor) + GL11.glVertex2f(x.toFloat(), (y + height).toFloat()) + GL11.glVertex2f((x + width).toFloat(), (y + height).toFloat()) + 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) + } + + private fun hsvToArgbInt(hueDeg: Float, saturation: Float, value: Float): Int { + val h = ((hueDeg % 360f) + 360f) % 360f + val s = saturation.coerceIn(0f, 1f) + val v = value.coerceIn(0f, 1f) + 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 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) + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun beginReadback(): ReadbackSetup { + val previousReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + val desiredReadBuffer = selectReadBufferForActiveTarget(previousReadBuffer) + if (desiredReadBuffer == previousReadBuffer) { + return ReadbackSetup( + previousReadBuffer = previousReadBuffer, + appliedReadBuffer = desiredReadBuffer, + shouldRestore = false + ) + } + GL11.glReadBuffer(desiredReadBuffer) + return ReadbackSetup( + previousReadBuffer = previousReadBuffer, + appliedReadBuffer = desiredReadBuffer, + shouldRestore = true + ) + } + + private fun endReadback(setup: ReadbackSetup) { + if (!setup.shouldRestore) return + if (setup.previousReadBuffer == setup.appliedReadBuffer) return + GL11.glReadBuffer(setup.previousReadBuffer) + } + + private fun selectReadBufferForActiveTarget(currentReadBuffer: Int): Int { + val readFramebufferBinding = currentReadFramebufferBinding() + if (readFramebufferBinding == 0) { + return GL11.GL_BACK + } + if (isColorAttachmentReadBuffer(currentReadBuffer)) { + return currentReadBuffer + } + return defaultColorAttachmentReadBuffer() + } + + private fun resolveReadbackApi(): ReadbackApi { + val caps = GLContext.getCapabilities() + return when { + caps.OpenGL30 -> ReadbackApi.OpenGl30 + caps.GL_ARB_framebuffer_object -> ReadbackApi.ArbFramebufferObject + caps.GL_EXT_framebuffer_object -> ReadbackApi.ExtFramebufferObject + else -> ReadbackApi.Legacy + } + } + + private fun currentReadFramebufferBinding(): Int { + return 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) { + 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) { + 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) { + 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() + return ReadbackBindingState( + readFramebufferBinding = readFramebufferBinding, + drawFramebufferBinding = currentDrawFramebufferBinding(), + framebufferBinding = currentFramebufferBinding(), + currentReadBuffer = GL11.glGetInteger(GL11.GL_READ_BUFFER) + ) + } + + private fun diagnoseReadbackSource( + path: String, + sourceX: Int, + sourceY: Int, + sourceWidth: Int, + sourceHeight: Int, + 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) + } + logRateLimited( + key = "readback:$path:${state.readFramebufferBinding}:${setup.appliedReadBuffer}", + message = message + ) + } + + private fun isReadBufferCompatibleWithActiveTarget(readBuffer: Int, state: ReadbackBindingState): Boolean { + return if (state.usingFramebufferObject) { + readBuffer == GL11.GL_NONE || isColorAttachmentReadBuffer(readBuffer) + } else { + when (readBuffer) { + GL11.GL_BACK, + GL11.GL_FRONT, + GL11.GL_LEFT, + GL11.GL_RIGHT, + GL11.GL_FRONT_LEFT, + GL11.GL_FRONT_RIGHT, + GL11.GL_BACK_LEFT, + GL11.GL_BACK_RIGHT -> true + + else -> false + } + } + } + + private fun isColorAttachmentReadBuffer(readBuffer: Int): Boolean { + return 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.Legacy -> false + } + } + + private fun glEnumName(value: Int): String { + return when (value) { + GL11.GL_NONE -> "GL_NONE" + GL11.GL_FRONT -> "GL_FRONT" + GL11.GL_BACK -> "GL_BACK" + GL11.GL_LEFT -> "GL_LEFT" + GL11.GL_RIGHT -> "GL_RIGHT" + GL11.GL_FRONT_LEFT -> "GL_FRONT_LEFT" + GL11.GL_FRONT_RIGHT -> "GL_FRONT_RIGHT" + GL11.GL_BACK_LEFT -> "GL_BACK_LEFT" + GL11.GL_BACK_RIGHT -> "GL_BACK_RIGHT" + GL30.GL_COLOR_ATTACHMENT0, + ARBFramebufferObject.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() + val previous = errorLogTimes[key] ?: 0L + if (now - previous < 3_000L) return + errorLogTimes[key] = now + println(message) + } + + 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 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)) { + val scale = size / 16.0f + GL11.glTranslatef(drawX.toFloat(), y.toFloat(), 0.0f) + GL11.glScalef(scale, scale, 1.0f) + var font = stack.item.getFontRenderer(stack) + if (font == null) + font = mc.fontRenderer + itemRenderer.renderItemAndEffectIntoGUI(stack, 0, 0) + itemRenderer.renderItemOverlayIntoGUI( + font, + stack, + 0, + 0, + null + ) + } + } + } + + 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) + + withStack { + withAttributes(enable = listOf(GL11.GL_BLEND, GL11.GL_DEPTH_TEST, GL12.GL_RESCALE_NORMAL)) { + withItemGuiLightning { + GL11.glTranslated(drawX.toDouble(), y.toDouble(), 100.0) + GL11.glScaled(scale.toDouble(), scale.toDouble(), scale.toDouble()) + GL11.glTranslated(8.0, 8.0, 0.0) + GL11.glRotated(rotX, 1.0, 0.0, 0.0) + GL11.glRotated(rotY, 0.0, 1.0, 0.0) + GL11.glTranslated(-8.0, -8.0, 0.0) + itemRenderer.renderItemAndEffectIntoGUI(stack, 0, 0) + } + } + } + } + + private fun drawItemStack( + stack: ItemStack, + x: Int, + y: Int, + size: Int, + width: Int, + rotY: Double, + rotX: Double + ) { + withStack { // (attributesBitMask = GL11.GL_ALL_ATTRIB_BITS) + val previousZ = itemRenderer.zLevel + try { + itemRenderer.zLevel = 0f + GL11.glColor4f(1f, 1f, 1f, opacityMultiplier.coerceIn(0f, 1f)) + if (isBlockStack(stack)) { + draw3DItem(stack, x, y, size, width, rotY, rotX) + } else { + draw2DItem(stack, x, y, size, width) + } + } finally { + itemRenderer.zLevel = previousZ + } + } + } + + private fun resolveImage(source: String): ResourceLocation? { + imageCache[source]?.let { return it } + + return when { + source.startsWith("http://") || source.startsWith("https://") -> { + val url = runCatching { URL(source) }.getOrNull() ?: return null + val file = remoteFileFor(url) + if (!file.exists()) { + if (!downloadToFile(url, file)) return null + } + loadDynamicTexture(file, source) + } + + source.startsWith("file://") -> { + var relative = source.removePrefix("file://") + while (relative.startsWith("/") || relative.startsWith("\\")) { + relative = relative.substring(1) + } + val baseDir = File(mc.gameDir, "dsgl") + val file = File(baseDir, relative) + loadDynamicTexture(file, source) + } + + else -> { + val location = ResourceLocation(source) + imageCache[source] = location + location + } + } + } + + private fun remoteFileFor(url: URL): File { + val host = if (url.host.isNullOrBlank()) "unknown" else url.host + var path = url.path + if (path.isBlank() || path == "/") { + path = "/index" + } + if (path.startsWith("/")) { + path = path.substring(1) + } + path = path.replace("..", "_") + val baseDir = File(mc.gameDir, "dsgl/cache/downloads") + return File(baseDir, host + File.separator + path) + } + + private fun downloadToFile(url: URL, file: File): Boolean { + return try { + file.parentFile?.mkdirs() + url.openStream().use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + true + } catch (ex: Exception) { + false + } + } + + private fun loadDynamicTexture(file: File, cacheKey: String): ResourceLocation? { + if (!file.exists()) return null + val cached = imageCache[cacheKey] + if (cached != null) return cached + return try { + val image = ImageIO.read(file) ?: return null + val texture = DynamicTexture(image) + dynamicTexturesCache[cacheKey] = texture + val name = "dsgl_${cacheKey.hashCode().toString(16)}" + val location = mc.textureManager.getDynamicTextureLocation(name, texture) + imageCache[cacheKey] = location + location + } catch (ex: Exception) { + null + } + } +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/McItemStackRef.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/McItemStackRef.kt new file mode 100644 index 0000000..36f6cf8 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/McItemStackRef.kt @@ -0,0 +1,9 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import net.minecraft.item.ItemStack +import org.dreamfinity.dsgl.core.ItemStackRef + +/** + * Wrapper for Minecraft 1.12.2 [ItemStack] to satisfy [ItemStackRef]. + */ +class McItemStackRef(val stack: ItemStack) : ItemStackRef diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/RenderCommandTransformStack.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/RenderCommandTransformStack.kt new file mode 100644 index 0000000..19f7e29 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/RenderCommandTransformStack.kt @@ -0,0 +1,79 @@ +package org.dreamfinity.dsgl.mcForge1122 + +import kotlin.math.ceil +import kotlin.math.floor +import org.dreamfinity.dsgl.core.dom.layout.AffineTransform2D +import org.dreamfinity.dsgl.core.render.RenderCommand + +internal data class GuiClipRect( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +internal class RenderCommandTransformStack { + private val stack: ArrayDeque = ArrayDeque() + private var current: AffineTransform2D = AffineTransform2D.IDENTITY + + fun reset() { + stack.clear() + current = AffineTransform2D.IDENTITY + } + + fun push(command: RenderCommand.PushTransform) { + stack.addLast(current) + current = current.times(command.toAffineTransform()) + } + + fun pop() { + current = if (stack.isNotEmpty()) stack.removeLast() else AffineTransform2D.IDENTITY + } + + fun currentTransform(): AffineTransform2D = current + + fun transformPoint(x: Float, y: Float): Pair { + return current.transform(x, y) + } + + 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) { + return GuiClipRect(x, y, 0, 0) + } + if (current == AffineTransform2D.IDENTITY) { + 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 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 resolvedX = floor(minX.toDouble()).toInt() + val resolvedY = floor(minY.toDouble()).toInt() + val resolvedWidth = ceil((maxX - minX).toDouble()).toInt().coerceAtLeast(0) + val resolvedHeight = ceil((maxY - minY).toDouble()).toInt().coerceAtLeast(0) + return GuiClipRect(resolvedX, resolvedY, resolvedWidth, resolvedHeight) + } + + 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) + } +} + diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorContext.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorContext.kt new file mode 100644 index 0000000..16795c5 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorContext.kt @@ -0,0 +1,42 @@ +package org.dreamfinity.dsgl.mcForge1122.scissorsHelper + +import org.lwjgl.opengl.GL11 +import java.util.* + +object ScissorContext { + val instance = ScissorContext + val stack: Deque = ArrayDeque() + var scissorsEnabledByContext = false + + 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) + } + val scissorsArea = + ScissorsArea(x.toInt(), y.toInt(), width.toInt(), height.toInt()) intersectionWith stack.peekFirst() + stack.push(scissorsArea) + GL11.glScissor(scissorsArea.x, scissorsArea.y, scissorsArea.width, scissorsArea.height) + return scissorsArea + } + + fun pop(): ScissorsArea? { + if (stack.isEmpty()) return null + + val removed = stack.pop() + val current = stack.peekFirst() + if (current != null) { + GL11.glScissor(current.x, current.y, current.width, current.height) + } else { + if (scissorsEnabledByContext) GL11.glDisable(GL11.GL_SCISSOR_TEST) + scissorsEnabledByContext = false + } + return removed + } + + fun clear() { + while (stack.isNotEmpty()) { + pop() + } + } +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorsArea.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorsArea.kt new file mode 100644 index 0000000..807535b --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/scissorsHelper/ScissorsArea.kt @@ -0,0 +1,21 @@ +package org.dreamfinity.dsgl.mcForge1122.scissorsHelper + +import kotlin.math.max +import kotlin.math.min + +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 { + 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) + val y2 = min(this.y + this.height, another.y + another.height) + ScissorsArea( + x1, + y1, + max(0, x2 - x1), + max(0, y2 - y1) + ) + } ?: this +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfRuntimeDebugSettings.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfRuntimeDebugSettings.kt new file mode 100644 index 0000000..633a17a --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfRuntimeDebugSettings.kt @@ -0,0 +1,6 @@ +package org.dreamfinity.dsgl.mcForge1122.text + +object MsdfRuntimeDebugSettings { + @Volatile + var decorationGuidesEnabled: Boolean = java.lang.Boolean.getBoolean("dsgl.msdf.debug.decorations") +} diff --git a/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfTextRenderer.kt b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfTextRenderer.kt new file mode 100644 index 0000000..8693d40 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/kotlin/org/dreamfinity/dsgl/mcForge1122/text/MsdfTextRenderer.kt @@ -0,0 +1,1188 @@ +package org.dreamfinity.dsgl.mcForge1122.text + +import org.dreamfinity.dsgl.core.font.* +import org.dreamfinity.dsgl.core.dom.layout.FontLineMetrics +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.concurrent.ConcurrentHashMap + +internal class MsdfTextRenderer { + private data class PreparedText( + val text: String, + val styleSpans: List + ) + + private data class LayoutCacheKey( + val text: String, + val primaryFontId: String, + val fontSize: Int, + val textFormatting: TextFormatting, + val baseFlagsMask: Int, + val styleSpansHash: Int + ) + + private data class CachedLineLayout( + val start: Int, + val shaped: ShapedText + ) + + private data class LayoutCacheEntry(val lines: List) + + private class SegmentBuffer(initialCapacity: Int = 64) { + private var startX = FloatArray(initialCapacity) + private var endX = FloatArray(initialCapacity) + private var y = FloatArray(initialCapacity) + private var thickness = FloatArray(initialCapacity) + private var color = IntArray(initialCapacity) + private var kind = IntArray(initialCapacity) + + var size: Int = 0 + private set + + fun clear() { + size = 0 + } + + fun appendMerged( + segmentKind: Int, + segmentStartX: Float, + segmentEndX: Float, + segmentY: Float, + segmentThickness: Float, + segmentColor: Int + ) { + if (segmentEndX <= segmentStartX) return + val last = size - 1 + if (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 + ) { + endX[last] = segmentEndX + return + } + ensureCapacity(size + 1) + kind[size] = segmentKind + startX[size] = segmentStartX + endX[size] = segmentEndX + y[size] = segmentY + thickness[size] = segmentThickness + color[size] = segmentColor + size += 1 + } + + 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) { + if (required <= startX.size) return + var next = startX.size + while (next < required) { + next = (next * 2).coerceAtLeast(required) + } + startX = startX.copyOf(next) + endX = endX.copyOf(next) + y = y.copyOf(next) + thickness = thickness.copyOf(next) + color = color.copyOf(next) + kind = kind.copyOf(next) + } + } + + private data class RendererDebugCounters( + var drawCalls: Long = 0L, + var layoutCacheHits: Long = 0L, + var layoutCacheMisses: Long = 0L, + 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 + ) + + private data class ObfuscationBuckets( + val byAdvanceBucket: Map>, + val expandedByAdvanceBucket: Map>, + val sortedKeys: List, + val allGlyphs: List + ) + + private data class LineSlice( + val start: 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 + } + } + private var programId: Int = 0 + private var uniformAtlas: Int = -1 + private var uniformPxRange: Int = -1 + 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") + } + private val obfuscationBuckets: MutableMap = linkedMapOf() + private var obfuscationLastNano: Long = System.nanoTime() + private var obfuscationAccumSec: Double = 0.0 + private var obfuscationTimeSlice: Long = 0 + private val maxTextureSize: Int by lazy { GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE).coerceAtLeast(1) } + 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") + } + private var debugLastLogMs: Long = 0L + fun measureText(text: String, fontId: String?, fontSize: Int?): Int { + return FontRegistry.measureText(text, fontId, fontSize) + } + + 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) + } + + fun lineHeight(fontId: String?, fontSize: Int?): Int { + return FontRegistry.lineHeight(fontId, fontSize) + } + + fun fontLineMetrics(fontId: String?, fontSize: Int?): FontLineMetrics? { + val font = FontRegistry.get(fontId) ?: return null + val metrics = font.meta.metrics + if (metrics.emSize <= 0f || metrics.lineHeight <= 0f) return null + return FontLineMetrics( + emSize = metrics.emSize, + lineHeightEm = metrics.lineHeight, + ascenderEm = metrics.ascender, + 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 fontSize = FontRegistry.resolveFontSize(command.fontSize) + val prepared = prepareText(command) + val layoutEntry = getOrBuildLayoutCacheEntry( + command = command, + prepared = prepared, + primaryFont = primaryFont, + fontSize = fontSize + ) + if (prepared.text.isEmpty() || layoutEntry.lines.isEmpty()) return + + val debugDecorationGuidesEnabled = MsdfRuntimeDebugSettings.decorationGuidesEnabled + updateObfuscationClock() + segmentBuffer.clear() + + val depthWasEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST) + if (depthWasEnabled) { + GL11.glDisable(GL11.GL_DEPTH_TEST) + } + + 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 + ) + debugGlyphResolution(prepared.text, primaryFont) + + if (!useProgram()) return + + GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + + var lineTop = command.y.toFloat() + var lineIndex = 0 + var spanIndex = 0 + var globalGlyphIndex = 0 + var activeFontId: String? = null + var activeTexture: FontTextureHandle? = null + var glBegun = false + var currentDrawColor: Int = Int.MIN_VALUE + var activeResolvedSpanIndex = Int.MIN_VALUE + var activeStyleColor = withOpacity(command.color, opacityMultiplier) + var activeStyleFlags = baseFlagsMask(command) + + fun beginForFont(font: LoadedMsdfFont): FontTextureHandle? { + if (activeFontId == font.descriptor.fontId && activeTexture != null && glBegun) { + return activeTexture + } + if (glBegun) { + GL11.glEnd() + glBegun = false + } + val texture = textureFor(font) ?: return null + GL13.glActiveTexture(GL13.GL_TEXTURE0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture.textureId) + ARBShaderObjects.glUniform1iARB(uniformAtlas, 0) + ARBShaderObjects.glUniform1fARB(uniformPxRange, font.meta.atlas.distanceRange) + GL11.glBegin(GL11.GL_QUADS) + glBegun = true + activeFontId = font.descriptor.fontId + activeTexture = texture + currentDrawColor = Int.MIN_VALUE + return texture + } + + try { + layoutEntry.lines.forEach { line -> + 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 + ) + var lineStartX = command.x.toFloat() + var lineEndX = lineStartX + var glyphIndexInLine = 0 + var lastObfuscatedGlyphIndex: Int? = null + var cachedShapedFontId: String? = null + var cachedShapedFont: LoadedMsdfFont = primaryFont + + 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 + ) { + 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 + ) + } 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 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) + } + if (glyph == null && runtimeFallbackFont != null) { + val fallbackByCodepoint = resolveGlyphForShapedInput(runtimeFallbackFont, shapedGlyph) + if (fallbackByCodepoint != null) { + glyphFont = runtimeFallbackFont + glyph = fallbackByCodepoint + } else { + preferredMissingGlyph(runtimeFallbackFont)?.let { fallbackDefault -> + glyphFont = runtimeFallbackFont + glyph = fallbackDefault + } + } + } + debugCounters.glyphResolutionRequests += 1 + + val styleBold = (activeStyleFlags and STYLE_FLAG_BOLD) != 0 + val styleItalic = (activeStyleFlags and STYLE_FLAG_ITALIC) != 0 + val styleUnderline = (activeStyleFlags and STYLE_FLAG_UNDERLINE) != 0 + val styleStrikethrough = (activeStyleFlags and STYLE_FLAG_STRIKETHROUGH) != 0 + val styleObfuscated = (activeStyleFlags and STYLE_FLAG_OBFUSCATED) != 0 + + val boldAdvance = + if (styleBold && !TextStyleMetrics.isWhitespaceCodepoint(shapedGlyph.sourceCodepoint)) { + BOLD_ADVANCE_EXTRA_PX.toFloat() + } else { + 0f + } + val glyphAdvance = shapedGlyph.advance + boldAdvance + val glyphStartX = command.x + shapedGlyph.x + val glyphEndX = glyphStartX + glyphAdvance + if (glyphStartX < lineStartX) lineStartX = glyphStartX + if (glyphEndX > lineEndX) lineEndX = glyphEndX + + val resolvedGlyph = glyph + if (resolvedGlyph != null && resolvedGlyph.drawable) { + val texture = beginForFont(glyphFont) + if (texture == null) { + lastObfuscatedGlyphIndex = null + glyphIndexInLine += 1 + globalGlyphIndex += 1 + return@forEach + } + if (activeStyleColor != currentDrawColor) { + val r = ((activeStyleColor ushr 16) and 0xFF) / 255f + val g = ((activeStyleColor ushr 8) and 0xFF) / 255f + val b = (activeStyleColor and 0xFF) / 255f + val a = ((activeStyleColor ushr 24) and 0xFF) / 255f + GL11.glColor4f(r, g, b, a) + currentDrawColor = activeStyleColor + } + + val drawGlyph = + if (styleObfuscated && ObfuscationTextSelector.shouldObfuscateCodepoint(shapedGlyph.sourceCodepoint)) { + resolveObfuscatedGlyph( + font = glyphFont, + sourceKey = command.sourceKey ?: command.text, + original = resolvedGlyph, + lineIndex = lineIndex, + glyphIndexInLine = glyphIndexInLine, + avoidGlyphIndex = lastObfuscatedGlyphIndex + ) + } else { + resolvedGlyph + } + + val effectiveGlyph = drawGlyph ?: resolvedGlyph + val glyphScale = TextDecorationLayout.scalePx(fontSize, glyphFont.meta.metrics.emSize) + emitGlyphQuad( + glyph = effectiveGlyph, + baselineY = baselineY + shapedGlyph.y, + cursorX = glyphStartX, + atlasWidth = texture.width, + atlasHeight = texture.height, + fontScalePx = glyphScale, + italic = styleItalic, + italicSkewPx = glyphScale * 0.2f + ) + if (styleBold) { + emitGlyphQuad( + glyph = effectiveGlyph, + baselineY = baselineY + shapedGlyph.y, + cursorX = glyphStartX + 0.75f, + atlasWidth = texture.width, + atlasHeight = texture.height, + fontScalePx = glyphScale, + italic = styleItalic, + italicSkewPx = glyphScale * 0.2f + ) + } + lastObfuscatedGlyphIndex = if (styleObfuscated) effectiveGlyph.glyphIndex else null + } else { + lastObfuscatedGlyphIndex = null + } + + if (glyphEndX > glyphStartX) { + if (styleUnderline) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_UNDERLINE, + segmentStartX = glyphStartX, + segmentEndX = glyphEndX, + segmentY = lineMetrics.underlineY, + segmentThickness = lineMetrics.underlineThickness, + segmentColor = activeStyleColor + ) + } + if (styleStrikethrough) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_STRIKETHROUGH, + segmentStartX = glyphStartX, + segmentEndX = glyphEndX, + segmentY = lineMetrics.strikethroughY, + segmentThickness = lineMetrics.strikethroughThickness, + segmentColor = activeStyleColor + ) + } + } + + globalGlyphIndex += 1 + glyphIndexInLine += 1 + } + + if (debugDecorationGuidesEnabled && lineEndX > lineStartX) { + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_BASELINE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineRecord.baselineY.coerceIn( + lineRecord.lineTopY, + lineRecord.lineTopY + lineRecord.lineHeightPx + ), + segmentThickness = 1f, + segmentColor = 0x66FFAA00 + ) + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_UNDERLINE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineMetrics.underlineY, + segmentThickness = lineMetrics.underlineThickness, + segmentColor = 0x6600FF00 + ) + segmentBuffer.appendMerged( + segmentKind = SEGMENT_DEBUG_STRIKE, + segmentStartX = lineStartX, + segmentEndX = lineEndX, + segmentY = lineMetrics.strikethroughY, + segmentThickness = lineMetrics.strikethroughThickness, + segmentColor = 0x66FF00FF + ) + } + + lineTop += lineHeight + lineIndex += 1 + } + } finally { + if (glBegun) { + GL11.glEnd() + } + ARBShaderObjects.glUseProgramObjectARB(0) + } + + drawDecorationSegments(segmentBuffer, debugDecorationGuidesEnabled) + maybeLogPerformance() + } finally { + if (depthWasEnabled) { + GL11.glEnable(GL11.GL_DEPTH_TEST) + } + } + } + + private fun prepareText(command: RenderCommand.DrawText): PreparedText { + if (command.textFormatting != TextFormatting.Minecraft) { + return PreparedText( + text = command.text, + styleSpans = command.textStyleSpans + ) + } + + if (command.textStyleSpans.isNotEmpty()) { + return PreparedText( + text = command.text, + 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 + ) + } + return PreparedText( + text = parsed.plainText, + styleSpans = spans + ) + } + + private fun splitLines(text: String): List { + if (text.isEmpty()) return listOf(LineSlice(0, 0)) + val lines = ArrayList(4) + var start = 0 + var index = 0 + while (index < text.length) { + if (text[index] == '\n') { + lines += LineSlice(start = start, endExclusive = index) + start = index + 1 + } + index += 1 + } + lines += LineSlice(start = start, endExclusive = text.length) + return lines + } + + 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 indexLooksMissing = isMissingGlyphIndex(font, shapedGlyph.glyphIndex, fromIndex) + val indexMatchesSource = if (!indexLooksMissing) { + val fromIndexCodepoint = fromIndex?.codepoint + fromIndex != null && ( + fromIndexCodepoint == null || + fromIndexCodepoint == sourceCodepoint + ) + } else { + false + } + + if (sourceCodepoint == REPLACEMENT_CODEPOINT) { + return preferredMissingGlyph(font) + } + + if (indexMatchesSource) return fromIndex + + val fromCodepoint = font.meta.glyph(sourceCodepoint) + if (fromCodepoint != null) return fromCodepoint + + return preferredMissingGlyph(font) + } + + private fun preferredMissingGlyph(font: LoadedMsdfFont): MsdfGlyph? { + val meta = font.meta + val replacementByCodepoint = meta.glyph(REPLACEMENT_CODEPOINT) + if (replacementByCodepoint != null) return replacementByCodepoint + val questionByCodepoint = meta.glyph('?'.code) + if (questionByCodepoint != null) return questionByCodepoint + + val questionByIndex = font.preferredQuestionGlyphIndex?.let(meta::glyphByIndex) + if (questionByIndex != null) return questionByIndex + 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 + return meta.fallbackGlyph() + } + + private fun isShapedGlyphMissingInFont(font: LoadedMsdfFont, shapedGlyph: ShapedGlyph): Boolean { + if (TextStyleMetrics.isWhitespaceCodepoint(shapedGlyph.sourceCodepoint)) return false + val fromIndex = font.meta.glyphByIndex(shapedGlyph.glyphIndex) + return isMissingGlyphIndex(font, shapedGlyph.glyphIndex, fromIndex) + } + + private fun isMissingGlyphIndex(font: LoadedMsdfFont, glyphIndex: Int, glyph: MsdfGlyph?): Boolean { + if (glyph == null) return true + val preferredMissingIndex = font.preferredMissingGlyphIndex + if (preferredMissingIndex != null && glyphIndex == preferredMissingIndex) return true + if (glyphIndex == 0 && (glyph.codepoint == null || glyph.codepoint == REPLACEMENT_CODEPOINT)) return true + return false + } + + private fun emitGlyphQuad( + glyph: MsdfGlyph, + baselineY: Float, + cursorX: Float, + atlasWidth: Int, + atlasHeight: Int, + fontScalePx: Float, + italic: Boolean, + italicSkewPx: Float + ) { + val plane = glyph.planeBounds ?: return + val atlas = glyph.atlasBounds ?: return + + val x0 = cursorX + plane.left * fontScalePx + val x1 = cursorX + plane.right * fontScalePx + val y0 = baselineY - plane.top * fontScalePx + val y1 = baselineY - plane.bottom * fontScalePx + val skew = if (italic) italicSkewPx else 0f + + val u0 = atlas.left / atlasWidth.toFloat() + val u1 = atlas.right / atlasWidth.toFloat() + val v0 = atlas.bottom / atlasHeight.toFloat() + val v1 = atlas.top / atlasHeight.toFloat() + + GL11.glTexCoord2f(u0, v0) + GL11.glVertex2f(x0, y1) + GL11.glTexCoord2f(u1, v0) + GL11.glVertex2f(x1, y1) + GL11.glTexCoord2f(u1, v1) + GL11.glVertex2f(x1 + skew, y0) + GL11.glTexCoord2f(u0, v1) + GL11.glVertex2f(x0 + skew, y0) + } + + private fun getOrBuildLayoutCacheEntry( + command: RenderCommand.DrawText, + prepared: PreparedText, + primaryFont: LoadedMsdfFont, + 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) + ) + synchronized(layoutCache) { + val cached = layoutCache[key] + if (cached != null) { + debugCounters.layoutCacheHits += 1 + return cached + } + } + + debugCounters.layoutCacheMisses += 1 + val slices = splitLines(prepared.text) + 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 built = LayoutCacheEntry(lines = lines) + synchronized(layoutCache) { + layoutCache[key] = built + } + return built + } + + private fun drawDecorationSegments(segments: SegmentBuffer, includeDebug: Boolean) { + if (segments.size == 0) return + val texture2dWasEnabled = GL11.glIsEnabled(GL11.GL_TEXTURE_2D) + val blendWasEnabled = GL11.glIsEnabled(GL11.GL_BLEND) + val alphaTestWasEnabled = GL11.glIsEnabled(GL11.GL_ALPHA_TEST) + val lightingWasEnabled = GL11.glIsEnabled(GL11.GL_LIGHTING) + val cullWasEnabled = GL11.glIsEnabled(GL11.GL_CULL_FACE) + ARBShaderObjects.glUseProgramObjectARB(0) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, 0) + if (lightingWasEnabled) GL11.glDisable(GL11.GL_LIGHTING) + if (alphaTestWasEnabled) GL11.glDisable(GL11.GL_ALPHA_TEST) + if (cullWasEnabled) GL11.glDisable(GL11.GL_CULL_FACE) + if (!blendWasEnabled) GL11.glEnable(GL11.GL_BLEND) + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA) + if (texture2dWasEnabled) GL11.glDisable(GL11.GL_TEXTURE_2D) + GL11.glBegin(GL11.GL_QUADS) + try { + var index = 0 + while (index < segments.size) { + val kind = segments.kindAt(index) + val isDebug = kind == SEGMENT_DEBUG_BASELINE || + kind == SEGMENT_DEBUG_UNDERLINE || + kind == SEGMENT_DEBUG_STRIKE + if (isDebug && !includeDebug) { + index += 1 + continue + } + val color = segments.colorAt(index) + val r = ((color ushr 16) and 0xFF) / 255f + val g = ((color ushr 8) and 0xFF) / 255f + val b = (color and 0xFF) / 255f + 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) + ) + GL11.glVertex2f(segments.startXAt(index), y0) + GL11.glVertex2f(segments.endXAt(index), y0) + GL11.glVertex2f(segments.endXAt(index), y1) + GL11.glVertex2f(segments.startXAt(index), y1) + index += 1 + } + } finally { + GL11.glEnd() + if (texture2dWasEnabled) GL11.glEnable(GL11.GL_TEXTURE_2D) + if (!blendWasEnabled) GL11.glDisable(GL11.GL_BLEND) + if (alphaTestWasEnabled) GL11.glEnable(GL11.GL_ALPHA_TEST) + if (lightingWasEnabled) GL11.glEnable(GL11.GL_LIGHTING) + if (cullWasEnabled) GL11.glEnable(GL11.GL_CULL_FACE) + } + } + + private fun baseFlagsMask(command: RenderCommand.DrawText): Int { + return flagsMask( + bold = command.bold, + italic = command.italic, + underline = command.underline, + strikethrough = command.strikethrough, + obfuscated = command.obfuscated + ) + } + + private fun flagsMask( + bold: Boolean, + italic: Boolean, + underline: Boolean, + strikethrough: Boolean, + obfuscated: Boolean + ): Int { + var mask = 0 + if (bold) mask = mask or STYLE_FLAG_BOLD + if (italic) mask = mask or STYLE_FLAG_ITALIC + if (underline) mask = mask or STYLE_FLAG_UNDERLINE + if (strikethrough) mask = mask or STYLE_FLAG_STRIKETHROUGH + if (obfuscated) mask = mask or STYLE_FLAG_OBFUSCATED + return mask + } + + private fun styleSpansFingerprint(spans: List): Int { + if (spans.isEmpty()) return 0 + var hash = 1 + spans.forEach { span -> + hash = 31 * hash + span.start + hash = 31 * hash + span.end + hash = 31 * hash + if (span.bold) 1 else 0 + hash = 31 * hash + if (span.italic) 1 else 0 + hash = 31 * hash + if (span.underline) 1 else 0 + hash = 31 * hash + if (span.strikethrough) 1 else 0 + hash = 31 * hash + if (span.obfuscated) 1 else 0 + } + return hash + } + + private fun resolveObfuscatedGlyph( + font: LoadedMsdfFont, + sourceKey: String, + original: MsdfGlyph, + lineIndex: Int, + glyphIndexInLine: Int, + avoidGlyphIndex: Int? + ): MsdfGlyph? { + 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 + 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 primary = candidates[primaryIndex] + if (avoidGlyphIndex == null || candidates.size <= 1 || primary.glyphIndex != avoidGlyphIndex) { + return primary + } + val secondaryIndex = (primaryIndex + 1 + (obfuscationTimeSlice.toInt() and 3)) % candidates.size + val secondary = candidates[secondaryIndex] + if (secondary.glyphIndex != avoidGlyphIndex) return secondary + return candidates.firstOrNull { it.glyphIndex != avoidGlyphIndex } ?: primary + } + + 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)) + } + if (glyphs.isEmpty()) { + return ObfuscationBuckets( + byAdvanceBucket = emptyMap(), + expandedByAdvanceBucket = emptyMap(), + sortedKeys = emptyList(), + allGlyphs = emptyList() + ) + } + val grouped = linkedMapOf>() + glyphs.forEach { glyph -> + grouped.getOrPut(advanceBucketKey(glyph.advance)) { ArrayList() }.add(glyph) + } + val sorted = grouped.keys.sorted() + val frozenGrouped = grouped.mapValues { (_, value) -> value.toList() } + val expanded = linkedMapOf>() + sorted.forEach { key -> + expanded[key] = expandCandidatesForBucket( + grouped = frozenGrouped, + sortedKeys = sorted, + baseKey = key + ) + } + return ObfuscationBuckets( + byAdvanceBucket = frozenGrouped, + expandedByAdvanceBucket = expanded, + sortedKeys = sorted, + allGlyphs = glyphs + ) + } + + private fun advanceBucketKey(advance: Float): Int { + return (advance * 100f).toInt() + } + + private fun nearestExpandedCandidates(buckets: ObfuscationBuckets, key: Int): List? { + val keys = buckets.sortedKeys + if (keys.isEmpty()) return null + var nearest = keys.first() + var distance = kotlin.math.abs(nearest - key) + keys.forEach { candidate -> + val nextDistance = kotlin.math.abs(candidate - key) + if (nextDistance < distance) { + distance = nextDistance + nearest = candidate + } + } + return buckets.expandedByAdvanceBucket[nearest] + } + + private fun expandCandidatesForBucket( + grouped: Map>, + sortedKeys: List, + baseKey: Int + ): List { + val byDistance = sortedKeys.sortedBy { key -> kotlin.math.abs(key - baseKey) } + val out = ArrayList(MIN_OBFUSCATION_CANDIDATES) + byDistance.forEach { key -> + val candidates = grouped[key].orEmpty() + if (candidates.isNotEmpty()) { + out.addAll(candidates) + } + if (out.size >= MIN_OBFUSCATION_CANDIDATES) { + return@forEach + } + } + return if (out.isEmpty()) grouped.values.flatten() else out + } + + private fun updateObfuscationClock() { + val now = System.nanoTime() + val dt = (now - obfuscationLastNano).coerceAtLeast(0L) / 1_000_000_000.0 + obfuscationLastNano = now + obfuscationAccumSec += dt + val step = OBFUSCATION_TIME_STEP_SEC + if (obfuscationAccumSec >= step) { + val ticks = (obfuscationAccumSec / step).toLong() + obfuscationAccumSec -= ticks * step + obfuscationTimeSlice += ticks + } + } + + private fun textureFor(font: LoadedMsdfFont): FontTextureHandle? { + val fontId = font.descriptor.fontId + textures[fontId]?.let { return it } + font.handle?.let { return it } + + return runCatching { + val handle = uploadTexture(font) + textures[fontId] = handle + handle + }.onSuccess { + font.handle = it + font.atlasPayload.markLoadedToGPUTexture() + }.onFailure { error -> + logRateLimited("texture:$fontId", "[DSGL-MSDF] Failed to load atlas '$fontId': ${error.message}") + }.getOrNull() + } + + private fun uploadTexture(font: LoadedMsdfFont): FontTextureHandle { + val bitmap = font.atlasPayload.ensureDecoded() + 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" + ) + } + val buffer = BufferUtils.createByteBuffer(width * height * 4) + buffer.put(ByteBuffer.wrap(bitmap.rgbaBytes)) + buffer.flip() + + val textureId = GL11.glGenTextures() + GL11.glBindTexture(GL11.GL_TEXTURE_2D, textureId) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR) + 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 + ) + } + 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) + }" + ) + } + debugCounters.textureUploads += 1 + debugCounters.textureUploadBytes += (width.toLong() * height.toLong() * 4L) + return FontTextureHandle(textureId = textureId, width = width, height = height) + } + + private fun drainGlErrors(): Int { + var firstError = GL11.GL_NO_ERROR + while (true) { + val error = GL11.glGetError() + if (error == GL11.GL_NO_ERROR) break + if (firstError == GL11.GL_NO_ERROR) { + firstError = error + } + } + return firstError + } + + private inline fun scopedGlError(block: () -> Unit): Int { + drainGlErrors() + block() + return drainGlErrors() + } + + 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 + programId = loaded + uniformAtlas = ARBShaderObjects.glGetUniformLocationARB(programId, "uAtlas") + uniformPxRange = ARBShaderObjects.glGetUniformLocationARB(programId, "uPxRange") + } + ARBShaderObjects.glUseProgramObjectARB(programId) + return true + } + + 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 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 + ) + if (linkStatus == GL11.GL_FALSE) { + val info = ARBShaderObjects.glGetInfoLogARB(program, 4096) + throw IllegalStateException("Program link failed: $info") + } + return program + } + + private fun compileShader(type: Int, source: String): Int { + val shader = ARBShaderObjects.glCreateShaderObjectARB(type) + ARBShaderObjects.glShaderSourceARB(shader, source) + ARBShaderObjects.glCompileShaderARB(shader) + 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") + } + return shader + } + + private fun withOpacity(color: Int, opacityMultiplier: Float): Int { + if (opacityMultiplier >= 0.999f) return color + val alpha = ((color ushr 24) and 0xFF) + val scaled = (alpha * opacityMultiplier).toInt().coerceIn(0, 255) + return (color and 0x00FF_FFFF) or (scaled shl 24) + } + + private fun logRateLimited(key: String, message: String) { + val now = System.currentTimeMillis() + val previous = errorLogTimes[key] ?: 0L + if (now - previous < 3_000L) return + errorLogTimes[key] = now + println(message) + } + + private fun maybeLogPerformance() { + if (!debugPerformanceEnabled) return + val now = System.currentTimeMillis() + if (now - debugLastLogMs < 1_000L) return + debugLastLogMs = now + 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}" + ) + } + + private fun debugGlyphResolution(text: String, font: LoadedMsdfFont) { + if (!debugGlyphResolutionEnabled) return + val sample = text.take(64) + 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" + ) + 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) + val atlasGlyph = loaded?.meta?.glyphByIndex(glyph.glyphIndex) + println( + "[DSGL-MSDF] font=${glyph.fontId} glyphIndex=${glyph.glyphIndex} sourceCp=U+%04X found=%s".format( + glyph.sourceCodepoint, + atlasGlyph != null + ) + ) + } + } + + companion object { + private const val MAX_LAYOUT_CACHE_ENTRIES: Int = 512 + private const val MIN_OBFUSCATION_CANDIDATES: Int = 24 + private const val OBFUSCATION_TIME_STEP_SEC: Double = 0.05 + private const val STYLE_FLAG_BOLD: Int = 1 shl 0 + private const val STYLE_FLAG_ITALIC: Int = 1 shl 1 + private const val STYLE_FLAG_UNDERLINE: Int = 1 shl 2 + private const val STYLE_FLAG_STRIKETHROUGH: Int = 1 shl 3 + private const val STYLE_FLAG_OBFUSCATED: Int = 1 shl 4 + private const val SEGMENT_UNDERLINE: Int = 1 + private const val SEGMENT_STRIKETHROUGH: Int = 2 + private const val SEGMENT_DEBUG_BASELINE: Int = 3 + private const val SEGMENT_DEBUG_UNDERLINE: Int = 4 + private const val SEGMENT_DEBUG_STRIKE: Int = 5 + private const val REPLACEMENT_CODEPOINT: Int = 0xFFFD + + private const val VERTEX_SHADER_SOURCE: String = """ + #version 120 + varying vec2 vTexCoord; + varying vec4 vColor; + + void main() { + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; + vTexCoord = gl_MultiTexCoord0.st; + vColor = gl_Color; + } + """ + + private const val FRAGMENT_SHADER_SOURCE: String = """ + #version 120 + uniform sampler2D uAtlas; + uniform float uPxRange; + varying vec2 vTexCoord; + varying vec4 vColor; + + float median(float a, float b, float c) { + return max(min(a, b), min(max(a, b), c)); + } + + void main() { + vec4 sample = texture2D(uAtlas, vTexCoord); + float dist = max(median(sample.r, sample.g, sample.b), sample.a) - 0.5; + float w = 0.5 / max(uPxRange, 0.0001); + float alpha = smoothstep(-w, w, dist); + gl_FragColor = vec4(vColor.rgb, vColor.a * alpha); + } + """ + } +} diff --git a/adapters/mc-forge-1-12-2/src/main/resources/META-INF/MANIFEST.MF b/adapters/mc-forge-1-12-2/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..9db1830 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,9 @@ +Manifest-Version: 1.0 +Specification-Title: ${modName} +Specification-Version: ${modVersion} +Specification-Vendor: ${modAuthor} +Implementation-Title: ${modId} +Implementation-Version: ${modVersion} +Implementation-Vendor: ${modAuthor} +Implementation-Vendor-Id: ${modGroup} +Built-For-MC: ${gameVersion} diff --git a/adapters/mc-forge-1-12-2/src/main/resources/mcmod.info b/adapters/mc-forge-1-12-2/src/main/resources/mcmod.info new file mode 100644 index 0000000..8ed582e --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/main/resources/mcmod.info @@ -0,0 +1,18 @@ +[ + { + "modid": "${modId}", + "name": "${modName}", + "description": "${modDescription}", + "version": "${modVersion}", + "mcversion": "${gameVersion}", + "url": "", + "updateUrl": "", + "authorList": [ + "${modAuthor}" + ], + "credits": "${modCredits}", + "logoFile": "${modIcon}", + "screenshots": [], + "dependencies": [] + } +] diff --git a/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt b/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt new file mode 100644 index 0000000..7baa683 --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/DsglScreenHostUsedGeometryTests.kt @@ -0,0 +1,287 @@ +package org.dreamfinity.dsgl.mcForge1710 + +import org.dreamfinity.dsgl.core.DomTree +import org.dreamfinity.dsgl.core.DsglWindow +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.UiMeasureContext +import org.dreamfinity.dsgl.core.event.EventBus +import org.dreamfinity.dsgl.core.event.collectHoverChain +import org.dreamfinity.dsgl.core.inspector.InspectorController +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.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.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 + } + + @Test + fun `app host hover and click target match core for absolute outside ancestor bounds`() { + val fixture = createAbsoluteOutsideAncestorFixture() + fixture.tree.render(ctx, width = 260, height = 140) + val host = createHostWithTree(fixture.tree) + + refreshHoverTarget(host, 105, 10) + + val hostHover = hoverTarget(host) + val hostClickTarget = resolveClickTarget(host) + val coreHover = collectHoverChain(fixture.root, 105, 10).lastOrNull() + + assertSame(fixture.child, hostHover) + assertSame(fixture.child, hostClickTarget) + assertSame(coreHover, hostClickTarget) + } + + @Test + fun `app host context menu pointer-down target matches core ordering for overlap`() { + val fixture = createPositionedOverlapFixture() + fixture.tree.render(ctx, width = 220, height = 140) + val host = createHostWithTree(fixture.tree) + + refreshHoverTarget(host, 10, 10) + + val pointerDownTarget = resolvePointerDownTarget(host) + val coreHover = collectHoverChain(fixture.root, 10, 10).lastOrNull() + + assertSame(fixture.fixed, pointerDownTarget) + assertSame(coreHover, pointerDownTarget) + } + + @Test + fun `core app-host and inspector agree on positioned overlap target`() { + val fixture = createPositionedOverlapFixture() + fixture.tree.render(ctx, width = 220, height = 140) + val host = createHostWithTree(fixture.tree) + val inspector = InspectorController().also { it.toggle() } + inspector.onLayoutCommitted(fixture.root, 1L) + + refreshHoverTarget(host, 10, 10) + inspector.onCursorMoved(10, 10) + + val coreHover = collectHoverChain(fixture.root, 10, 10).lastOrNull() + val hostHover = hoverTarget(host) + val inspectorHoverKey = inspector.hoveredKey + + assertSame(coreHover, hostHover) + assertEquals(coreHover?.key?.toString(), inspectorHoverKey) + } + + @Test + fun `app host preserves fixed root clipping and non-fixed ancestor overflow clipping`() { + val fixture = createClipSemanticsFixture() + fixture.tree.render(ctx, width = 200, height = 120) + val host = createHostWithTree(fixture.tree) + + refreshHoverTarget(host, 185, 25) + assertSame(fixture.fixed, hoverTarget(host)) + assertSame(collectHoverChain(fixture.root, 185, 25).lastOrNull(), hoverTarget(host)) + + refreshHoverTarget(host, 145, 95) + assertSame(fixture.root, hoverTarget(host)) + assertSame(collectHoverChain(fixture.root, 145, 95).lastOrNull(), hoverTarget(host)) + + refreshHoverTarget(host, 225, 28) + assertNull(hoverTarget(host)) + assertEquals( + collectHoverChain(fixture.root, 225, 28).lastOrNull(), + 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 host = object : DsglScreenHost(window) {} + host.window = window + host.debugSetNeedsRenderForTests(true) + host.debugRebuildIfNeededForTests() + val baseline = debugEventBusSnapshot() + + repeat(80) { + host.debugSetNeedsRenderForTests(true) + host.debugRebuildIfNeededForTests() + assertEquals( + "detached cleanup queue must be drained in the same rebuild cycle", + 0, + host.debugPendingCleanupCount() + ) + } + + val after = debugEventBusSnapshot() + val nodeDelta = after.registeredNodes - baseline.registeredNodes + val callbackDelta = after.registeredCallbacks - baseline.registeredCallbacks + assertTrue( + "listener node registrations grew unexpectedly: baseline=$baseline after=$after", + nodeDelta <= 2 + ) + assertTrue( + "listener callback registrations grew unexpectedly: baseline=$baseline after=$after", + callbackDelta <= 24 + ) + } + + private fun createHostWithTree(tree: DomTree): DsglScreenHost { + val host = object : DsglScreenHost(object : DsglWindow() { + override fun render(): DomTree { + return tree + } + }) {} + host.debugBindTreeForTests(tree, needsLayout = false) + return host + } + + private fun refreshHoverTarget(host: DsglScreenHost, mouseX: Int, mouseY: Int) { + host.debugRefreshHoverTargetForTests(mouseX, mouseY) + } + + private fun hoverTarget(host: DsglScreenHost): DOMNode? { + return host.debugHoverTargetForTests() + } + + private fun resolvePointerDownTarget(host: DsglScreenHost): DOMNode? { + return host.debugResolvePointerDownTargetForTests() + } + + private fun resolveClickTarget(host: DsglScreenHost): DOMNode? { + return host.debugResolveClickTargetForTests() + } + + private data class AbsoluteOutsideAncestorFixture( + val tree: DomTree, + val root: ContainerNode, + 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) + return AbsoluteOutsideAncestorFixture(DomTree(root), root, child) + } + + private data class PositionedOverlapFixture( + val tree: DomTree, + val root: ContainerNode, + 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) + return PositionedOverlapFixture(DomTree(root), root, fixed) + } + + private data class ClipSemanticsFixture( + val tree: DomTree, + val root: ContainerNode, + 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) + return ClipSemanticsFixture(DomTree(root), root, fixed) + } + + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { + return StyleDeclarations().apply { + entries.forEach { (property, literal) -> + set(property, StyleExpression.Literal(literal)) + } + } + } + + private data class EventBusListenerSnapshot( + val registeredNodes: Int, + val registeredCallbacks: Int + ) + + private fun debugEventBusSnapshot(): EventBusListenerSnapshot { + val snapshot = EventBus.debugListenerSnapshot() + return EventBusListenerSnapshot( + registeredNodes = snapshot.registeredNodes, + registeredCallbacks = snapshot.registeredCallbacks + ) + } +} diff --git a/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt b/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt new file mode 100644 index 0000000..4abb01d --- /dev/null +++ b/adapters/mc-forge-1-12-2/src/test/kotlin/org/dreamfinity/dsgl/mcForge1710/StickyControlClipAlignmentTests.kt @@ -0,0 +1,357 @@ +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 +import org.dreamfinity.dsgl.core.dom.elements.ContainerNode +import org.dreamfinity.dsgl.core.dom.elements.NumberInputNode +import org.dreamfinity.dsgl.core.dom.elements.SelectNode +import org.dreamfinity.dsgl.core.dom.elements.SingleLineInputNode +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.render.RenderCommand +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 + +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 + } + + @AfterTest + fun cleanup() { + FocusManager.clearFocus() + StyleEngine.clearAllInspectorOverrides() + StyleEngine.clearCache() + } + + @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") } + ) + FocusManager.requestFocus(fixture.control as SingleLineInputNode) + fixture.tree.paint(ctx) + + val observations = observeCommands(fixture.tree.paint(ctx)) + assertTextInsideActiveClip(observations, "hello") + assertCaretInsideActiveClip(observations, "hello") + } + + @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") } + ) + FocusManager.requestFocus(fixture.control as SingleLineInputNode) + fixture.tree.paint(ctx) + + val visible = visibleRect(fixture.control) + assertNotEquals(fixture.control.bounds.y, visible.y) + + val observations = observeCommands(fixture.tree.paint(ctx)) + assertTextInsideActiveClip(observations, "hello") + assertCaretInsideActiveClip(observations, "hello") + } + + @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") } + ) + fixture.tree.paint(ctx) + + val visible = visibleRect(fixture.control) + assertNotEquals(fixture.control.bounds.y, visible.y) + + val observations = observeCommands(fixture.tree.paint(ctx)) + assertTextInsideActiveClip(observations, "42") + } + + @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" + ) + } + ) + fixture.tree.paint(ctx) + + val visible = visibleRect(fixture.control) + assertNotEquals(fixture.control.bounds.y, visible.y) + + val observations = observeCommands(fixture.tree.paint(ctx)) + assertTextInsideActiveClip(observations, "Alpha") + } + + @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 tree = DomTree(root) + tree.render(ctx, viewportWidth, viewportHeight) + root.setScrollOffsets(0, 46) + tree.render(ctx, viewportWidth, viewportHeight) + + val commands = tree.paint(ctx) + val observations = observeCommands(commands) + val nestedText = observations.texts.firstOrNull { it.text == "nested" } + assertNotNull(nestedText) + assertNotNull(nestedText.activeClip) + assertTrue(contains(nestedText.activeClip, nestedText.x, nestedText.y)) + + val transformedClipCount = observations.pushClips.count { it.transformed != it.raw } + assertTrue(transformedClipCount >= 1) + + val visible = visibleRect(input) + 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 + } + if (sticky) { + 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 = 280 + }.applyParent(root) + + val tree = DomTree(root) + tree.render(ctx, viewportWidth, viewportHeight) + if (scrollY > 0) { + root.setScrollOffsets(0, scrollY) + tree.render(ctx, viewportWidth, viewportHeight) + } + return ControlFixture(tree = tree, control = control) + } + + private fun observeCommands(commands: List): CommandObservations { + val transform = RenderCommandTransformStack() + val clipStack = ArrayDeque() + val pushClips = ArrayList() + val texts = ArrayList() + val rects = ArrayList() + commands.forEach { command -> + when (command) { + is RenderCommand.PushTransform -> transform.push(command) + 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)) + pushClips += ObservedClipPush(raw = raw, transformed = transformed) + clipStack.addLast(transformed) + } + + 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() + ) + } + + 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() + ) + } + + else -> Unit + } + } + return CommandObservations(pushClips = pushClips, texts = texts, rects = rects) + } + + private fun assertTextInsideActiveClip(observations: CommandObservations, text: String) { + val observed = observations.texts.firstOrNull { it.text == text } + assertNotNull(observed, "Expected DrawText('$text')") + 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}" + ) + } + + 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 + } + 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}" + ) + } + + private fun visibleRect(node: DOMNode): Rect { + val world = node.worldTransformMatrix() + val b = node.bounds + val topLeft = world.transform(b.x.toFloat(), b.y.toFloat()) + val topRight = world.transform((b.x + b.width).toFloat(), b.y.toFloat()) + val bottomLeft = world.transform(b.x.toFloat(), (b.y + b.height).toFloat()) + val bottomRight = world.transform((b.x + b.width).toFloat(), (b.y + b.height).toFloat()) + 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 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) + } + + private fun contains(clip: GuiClipRect?, x: Int, y: Int): Boolean { + clip ?: return false + return x >= clip.x && y >= clip.y && x < clip.x + clip.width && y < clip.y + clip.height + } + + private fun containsRect(clip: GuiClipRect?, rect: GuiClipRect): Boolean { + clip ?: return false + val right = rect.x + rect.width + val bottom = rect.y + rect.height + return rect.x >= clip.x && + rect.y >= clip.y && + right <= clip.x + clip.width && + bottom <= clip.y + clip.height + } + + private fun floorToInt(value: Float): Int = kotlin.math.floor(value.toDouble()).toInt() + + private fun styleDeclarations(vararg entries: Pair): StyleDeclarations { + return StyleDeclarations().apply { + entries.forEach { (property, literal) -> + set(property, StyleExpression.Literal(literal)) + } + } + } + + private data class ControlFixture( + val tree: DomTree, + val control: DOMNode + ) + + private data class CommandObservations( + val pushClips: List, + val texts: List, + val rects: List + ) + + private data class ObservedClipPush( + val raw: GuiClipRect, + val transformed: GuiClipRect + ) + + private data class ObservedText( + val text: String, + val x: Int, + val y: Int, + val activeClip: GuiClipRect? + ) + + private data class ObservedRect( + val transformed: GuiClipRect, + val rawWidth: Int, + val rawHeight: Int, + val activeClip: GuiClipRect? + ) +} diff --git a/gradle.properties b/gradle.properties index 50da364..96fab40 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,5 @@ version=0.0.1 startParameter.offline=true org.gradle.jvmargs=-Xmx4g # adapters turn on / off for development -enableMinecraftForge1710=true +enableMinecraftForge1710=false +enableMinecraftForge1122=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b9f101..0d54c09 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,11 +9,23 @@ pluginManagement { maven(url = "https://maven.minecraftforge.net") gradlePluginPortal() mavenCentral() + maven { + // RetroFuturaGradle + name = "GTNH Maven" + setUrl("https://nexus.gtnewhorizons.com/repository/public/") + mavenContent { + includeGroupByRegex("com\\.gtnewhorizons\\..+") + includeGroup("com.gtnewhorizons") + } + } } if (isAdapterEnabled("MinecraftForge1710")) { includeBuild("adapters/mc-forge-1-7-10/adapter-build-logic") } + if (isAdapterEnabled("MinecraftForge1122")) { + includeBuild("adapters/mc-forge-1-12-2/adapter-build-logic") + } } rootProject.name = "dsgl" @@ -24,3 +36,7 @@ if (isAdapterEnabled("MinecraftForge1710")) { include(":adapters:mc-forge-1-7-10") include(":adapters:mc-forge-1-7-10:demo") } +if (isAdapterEnabled("MinecraftForge1122")) { + include(":adapters:mc-forge-1-12-2") + include(":adapters:mc-forge-1-12-2:demo") +}