diff --git a/Notes.md b/Notes.md index f6500bd..0a0a8ce 100644 --- a/Notes.md +++ b/Notes.md @@ -8,6 +8,13 @@ https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames https://github.com/leonbloy/pngj +### Compose bugs + - Can't use Lazy layouts + - It will crash once for no reason. If the error is caught and UI is restarted, it'll continue working fine. + - LocalDensity.current will cause crashes + - + + ### Design choices `withLock` implementation found in `UpdateQueue` and `MapAreaAccess` - These are operations that can be accessed from multiple threads but none of them are crucial do be finished quickly. diff --git a/build.gradle.kts b/build.gradle.kts index 596bdd0..4c9c512 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,8 @@ plugins { kotlin("jvm") version "2.0.0" id ("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" id("com.github.johnrengelman.shadow") version "8.1.1" + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" } loom { @@ -47,10 +49,12 @@ version = getCurrentVersion() group = maven_group repositories { + google() mavenCentral() maven(url = "https://repo.maven.apache.org/maven2/") maven(url = "https://maven.shedaniel.me/") //TODO use maven local or multi project build to build from sources maven(url = "https://maven.terraformersmc.com") //TODO use maven local or multi project build to build from sources + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } //configurations { @@ -77,7 +81,7 @@ dependencies { } modCompileOnlyApi("com.terraformersmc", "modmenu", "8.0.0") - val ktorVersion = "2.3.5" + val ktorVersion = "2.3.11" extraLibs(implementation("io.ktor", "ktor-server-core-jvm", ktorVersion)) extraLibs(implementation("io.ktor", "ktor-server-cio-jvm", ktorVersion)) extraLibs(implementation("io.ktor", "ktor-server-content-negotiation", ktorVersion)) @@ -86,7 +90,42 @@ dependencies { extraLibs(implementation("io.ktor", "ktor-server-cors", ktorVersion)) extraLibs(implementation("org.tukaani", "xz", "1.9")) - extraLibs(implementation("com.akuleshov7", "ktoml-core", "0.5.0")) + extraLibs(implementation("com.akuleshov7", "ktoml-core", "0.5.1")) + + extraLibs("org.jetbrains.skiko:skiko:0.8.4") { + attributes { +// attribute(Attribute.of("org.jetbrains.compose.ui", String::class.java), "desktop") +// attribute(Attribute.of("org.jetbrains.compose.ui", String::class.java), "awtRuntimeElements-published") +// attribute(Attribute.of("org.jetbrains.compose.ui", String::class.java), "awt") +// attribute(Attribute.of("org.gradle.libraryelements", String::class.java), LibraryElements.JAR) +// attribute(Attribute.of("org.gradle.usage", String::class.java), Usage.JAVA_RUNTIME) +// attribute(Attribute.of("org.jetbrains.kotlin.platform.type", String::class.java), "jvm") + + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class.java, Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category::class.java, Category.LIBRARY)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, LibraryElements.JAR)) + attribute(Attribute.of("org.jetbrains.kotlin.platform.type", String::class.java), "jvm") + attribute(Attribute.of("ui", String::class.java), "awt") + } + } + + val composeBom = project.dependencies.platform("androidx.compose:compose-bom:2024.05.00") +// implementation(composeBom) +// implementation(compose.desktop.currentOs) //TODO make builds for different OSes compose.desktop.common +// implementation(compose.desktop.common) +// implementation(compose.material) + extraLibs(implementation(composeBom)!!) +// extraLibs(implementation(compose.desktop.currentOs)!!) //TODO make builds for different OSes compose.desktop.common + extraLibs(implementation(compose.desktop.common)!!) + extraLibs(implementation(compose.desktop.macos_arm64)!!) + extraLibs(implementation(compose.desktop.macos_x64)!!) + extraLibs(implementation(compose.desktop.windows_x64)!!) + extraLibs(implementation(compose.desktop.linux_x64)!!) + extraLibs(implementation(compose.desktop.linux_arm64)!!) + extraLibs(implementation(compose.material)!!) +// implementation("dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.3.9") +// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.4.0") + // extraLibs(implementation("org.ojalgo", "ojalgo", "53.0.0")) // extraLibs(implementation("ai.hypergraph", "kotlingrad", "0.4.7")) // extraLibs(implementation("ar.com.hjg", "pngj", "2.1.0")) @@ -98,6 +137,12 @@ dependencies { implementation(kotlin("stdlib-jdk8")) } +configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("org.jetbrains.skiko:skiko")).using(module("org.jetbrains.skiko:skiko-awt:0.8.4")) + } +} + @Suppress("UnstableApiUsage") tasks.getByName("processResources") { filesMatching("fabric.mod.json") { diff --git a/gradle.properties b/gradle.properties index 0701097..abd127d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,3 +14,5 @@ fabric_version=0.99.4+1.20.6 maven_group = dev.wefhy archives_base_name = whymap mod_id = WhyMap + +compose.version=1.6.7 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 535dde2..cd40a0c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,14 +1,17 @@ pluginManagement { repositories { +// google() maven(url = "https://maven.fabricmc.net/") { name = "Fabric" } mavenCentral() gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } -// plugins { + plugins { // id 'fabric-loom' version loom_version // id "org.jetbrains.kotlin.jvm" version kotlin_version -// } + id("org.jetbrains.compose").version("1.6.10") + } } diff --git a/src/main/java/dev/wefhy/whymap/WhyMapClient.kt b/src/main/java/dev/wefhy/whymap/WhyMapClient.kt index 3141edc..36e0a82 100644 --- a/src/main/java/dev/wefhy/whymap/WhyMapClient.kt +++ b/src/main/java/dev/wefhy/whymap/WhyMapClient.kt @@ -4,7 +4,7 @@ package dev.wefhy.whymap import com.mojang.blaze3d.systems.RenderSystem import dev.wefhy.whymap.WhyMapMod.Companion.activeWorld -import dev.wefhy.whymap.clothconfig.ConfigEntryPoint.getConfigScreen +import dev.wefhy.whymap.compose.ui.ConfigScreen import dev.wefhy.whymap.config.UserSettings.MinimapPosition import dev.wefhy.whymap.config.WhyUserSettings import dev.wefhy.whymap.events.FeatureUpdateQueue @@ -249,7 +249,8 @@ class WhyMapClient : ClientModInitializer { WhyUserSettings.mapSettings.minimapMode = WhyUserSettings.mapSettings.minimapMode.next() } if (kbModSettings.wasPressed()) { - mc.setScreen(getConfigScreen(null)) +// mc.setScreen(getConfigScreen(null)) + mc.setScreen(ConfigScreen()) } //TODO https://discord.com/channels/507304429255393322/807617488313516032/895854464060227665 diff --git a/src/main/java/dev/wefhy/whymap/WhyMapMod.kt b/src/main/java/dev/wefhy/whymap/WhyMapMod.kt index aac39b8..8fb6d89 100644 --- a/src/main/java/dev/wefhy/whymap/WhyMapMod.kt +++ b/src/main/java/dev/wefhy/whymap/WhyMapMod.kt @@ -120,7 +120,7 @@ class WhyMapMod : ModInitializer { val tmpWorld = activeWorld activeWorld = null tmpWorld?.close() - RegionUpdateQueue.reset() + RegionUpdateQueue.reset() //TODO should I reset all queues? LOGGER.info("Saved all data") WorldEventQueue.addUpdate(WorldEventQueue.WorldEvent.LeaveWorld) } diff --git a/src/main/java/dev/wefhy/whymap/compose/ComposeView.kt b/src/main/java/dev/wefhy/whymap/compose/ComposeView.kt new file mode 100644 index 0000000..22e713d --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ComposeView.kt @@ -0,0 +1,208 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.scene.MultiLayerComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import dev.wefhy.whymap.utils.Accessors.clientWindow +import dev.wefhy.whymap.utils.WhyDispatchers +import dev.wefhy.whymap.utils.WhyDispatchers.launchOnMain +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import net.minecraft.client.gui.DrawContext +import org.jetbrains.skiko.MainUIDispatcher +import java.awt.Component +import java.io.Closeable +import java.util.concurrent.Executors +import java.awt.event.KeyEvent as AwtKeyEvent + +@OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class) +open class ComposeView( + nativeWidth: Int, + nativeHeight: Int, + private val density: Density, + private val content: @Composable () -> Unit +) : Closeable { + @OptIn(ExperimentalCoroutinesApi::class) + private val rawSingleThreadDispatcher = MainUIDispatcher.limitedParallelism(1) + protected val singleThreadDispatcher = rawSingleThreadDispatcher + + CoroutineExceptionHandler { _, throwable -> println(throwable) } + private var invalidated = true +// private val screenScale = 2 //TODO This is Macbook specific + private var nativeWidth by mutableStateOf(nativeWidth) + private var nativeHeight by mutableStateOf(nativeHeight) + private val coroutineContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val directRenderer: Renderer = DirectRenderer(nativeWidth, nativeHeight) + private val boxedContent: @Composable () -> Unit + get() = { + val(dpWidth, dpHeight) = with(LocalDensity.current) { + nativeWidth.toDp() to nativeHeight.toDp() + } + Box(Modifier.size(dpWidth, dpHeight)) { + content() + } + } + + //TODO use ImageComposeScene, seems more popular? + private val scene = MultiLayerComposeScene(coroutineContext = WhyDispatchers.MainDispatcher, density = density) { +// private val scene = MultiLayerComposeScene(coroutineContext = singleThreadDispatcher, density = density) { +// private val scene = MultiLayerComposeScene(coroutineContext = coroutineContext, density = density) { +// private val scene = SingleLayerComposeScene(coroutineContext = coroutineContext, density = density) { + invalidated = true + } + + init { + scene.setContent(boxedContent) + } + + private inline fun onComposeThread(crossinline block: () -> Unit) = launchOnMain { + block() + } + + private fun Offset.toComposeCoords(): Offset { + return this * clientWindow.scaleFactor.toFloat() + } + + fun passLMBClick(x: Float, y: Float) = onComposeThread { + scene.sendPointerEvent( + eventType = PointerEventType.Press, + Offset(x, y).toComposeCoords(), + ) + } + + fun passMouseMove(x: Float, y: Float) = onComposeThread { + scene.sendPointerEvent( + eventType = PointerEventType.Move, + Offset(x, y).toComposeCoords(), + ) + } + + fun passLMBRelease(x: Float, y: Float) = onComposeThread { + scene.sendPointerEvent( + eventType = PointerEventType.Release, + Offset(x, y).toComposeCoords() + ) + } + + fun passScroll(x: Float, y: Float, scrollX: Float, scrollY: Float) = onComposeThread { + scene.sendPointerEvent( + eventType = PointerEventType.Scroll, + Offset(x, y).toComposeCoords(), + scrollDelta = Offset(scrollX, scrollY), + ) + } + +// private fun getAwtKeyEvent(key: Int, action: Int, modifiers: Int): KeyEvent { +// val k = Key(key) +// return java.awt.event.KeyEvent( +// scene, +// action, +// System.currentTimeMillis(), +// modifiers, +// key, +// k.toString().first() +// ).let { +// KeyEvent(k, KeyEventType.KeyDown, codePoint = key) +// } +// } + val dummy = object : Component() {} + + private fun createKeyEvent(awtId: Int, time: Long, awtMods: Int, key: Int, char: Char, location: Int) = KeyEvent( + AwtKeyEvent(dummy, awtId, time, awtMods, key, char, location) + ) + + private fun remapKeycode(key: Int, char: Char): Int { + return when (key) { + 0x0 -> char.toInt() + else -> key + } + } + + + fun passKeyPress(key: Int, action: Int, modifiers: Int) = onComposeThread { +// scene.sendKeyEvent(androidx.compose.ui.input.key.KeyEvent(AwtKeyEvent.KEY_TYPED, System.nanoTime() / 1_000_000, getAwtMods(), remapKeycode(key, char), 0.toChar(), AwtKeyEvent.KEY_LOCATION_STANDARD)) +// scene.sendKeyEvent(KeyEvent(AwtKeyEvent.KEY_TYPED, System.nanoTime() / 1_000_000, getAwtMods(), remapKeycode(key, char), 0.toChar(), AwtKeyEvent.KEY_LOCATION_STANDARD)) + val time = System.nanoTime() / 1_000_000 + val kmod = modifiers//getAwtMods() + val char = Key(key).toString().first() + val native1 = createKeyEvent(AwtKeyEvent.KEY_PRESSED, time, kmod, remapKeycode(key, char), 0.toChar(), AwtKeyEvent.KEY_LOCATION_STANDARD) + val native2 = createKeyEvent(AwtKeyEvent.KEY_TYPED, time, kmod, 0, char, AwtKeyEvent.KEY_LOCATION_UNKNOWN) +// val k = Key(key) +// val event1 = KeyEvent(k, KeyEventType.KeyDown, codePoint = key, nativeEvent = native1) +// scene.sendKeyEvent(event1) +// val event2 = KeyEvent(k, KeyEventType.Unknown, codePoint = key, nativeEvent = native2) +// scene.sendKeyEvent(event2) + val event = KeyEvent(Key(key), KeyEventType.KeyDown, codePoint = key, isShiftPressed = (modifiers and 1) != 0, nativeEvent = native1) + scene.sendKeyEvent(event)//getAwtKeyEvent(key, action, modifiers))) + val event2 = KeyEvent(Key(key), KeyEventType.Unknown, codePoint = key, isShiftPressed = (modifiers and 1) != 0, nativeEvent = native2) + scene.sendKeyEvent(event2)//getAwtKeyEvent(key, action, modifiers))) + } + + fun passKeyRelease(key: Int, action: Int, modifiers: Int) = onComposeThread { + val event = KeyEvent(Key(key), KeyEventType.KeyUp, codePoint = key) + scene.sendKeyEvent(event)//getAwtKeyEvent(key, action, modifiers))) + } + + var isRendering = false + + fun render(drawContext: DrawContext, tickDelta: Float) { +// println("Trying to start rendering on thread ${Thread.currentThread().name}!") + if (isRendering) throw Exception("Already rendering!") + isRendering = true +// if (!invalidated) return Unit.also { +// println("Cancelled rendering on thread ${Thread.currentThread().name}!") +// isRendering = false +// } + nativeWidth = clientWindow.framebufferWidth + nativeHeight = clientWindow.framebufferHeight + directRenderer.onSizeChange( + nativeWidth, + nativeHeight + ) + directRenderer.render(drawContext, tickDelta) { glCanvas -> + /** + * So the problem is + * - scene.render needs to run on minecraft render thread + * - but it also needs to run on the same thread as the scene + * - scene under the hood probably uses `val MainUIDispatcher: CoroutineDispatcher get() = SwingDispatcher` + * For some reason, this only happens + * + * The problem is in GlobalSnapshotManager.ensureStarted - it uses swing thread to consume events + */ + + try { +// println("Rendering START!") + scene.render(glCanvas, System.nanoTime()) +// println("Rendered END!") + invalidated = false + } catch (e: Exception) { + e.printStackTrace() + scene.setContent(boxedContent) + } + } +// println("Finished rendering on thread ${Thread.currentThread().name}!") + isRendering = false + } + + override fun close() { + directRenderer.close() + scene.close() + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/DirectRenderer.kt b/src/main/java/dev/wefhy/whymap/compose/DirectRenderer.kt new file mode 100644 index 0000000..ac3ee22 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/DirectRenderer.kt @@ -0,0 +1,109 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose + +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.asComposeCanvas +import com.mojang.blaze3d.systems.RenderSystem +import dev.wefhy.whymap.utils.WhyDispatchers.blockOnMain +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.render.BufferRenderer +import org.jetbrains.skia.* +import org.jetbrains.skia.FramebufferFormat.Companion.GR_GL_RGBA8 +import org.lwjgl.opengl.GL11.* +import org.lwjgl.opengl.GL13.GL_TEXTURE0 +import org.lwjgl.opengl.GL13.glActiveTexture +import org.lwjgl.opengl.GL14.GL_FUNC_ADD +import org.lwjgl.opengl.GL14.glBlendEquation +import org.lwjgl.opengl.GL30.GL_FRAMEBUFFER_BINDING +import org.lwjgl.opengl.GL33 + +internal class DirectRenderer(var width: Int, var height: Int) : Renderer() { + +// private val device = MTLCreateSystemDefaultDevice() ?: throw IllegalStateException("Can't create MTLDevice") +// private val commandQueue = device.newCommandQueue() ?: throw IllegalStateException("Can't create MTLCommandQueue") +// private val context: DirectContext by lazy { DirectContext.makeMetal() } + private val context: DirectContext by lazy { DirectContext.makeGL() } + private var renderTarget: BackendRenderTarget? = null + private var surface: Surface? = null + private var composeCanvas: Canvas? = null + + init { + println("Init DirectRenderer with width: $width, height: $height") + System.setProperty("skiko.macos.opengl.enabled", "true") + } + + private fun initilaize() { + RenderSystem.assertOnRenderThread() + surface?.close() + renderTarget?.close() + renderTarget = BackendRenderTarget.makeGL(width, height, 0, 8, glGetInteger(GL_FRAMEBUFFER_BINDING), GR_GL_RGBA8).also { + surface = Surface.makeFromBackendRenderTarget( + context, it, SurfaceOrigin.BOTTOM_LEFT, SurfaceColorFormat.RGBA_8888, ColorSpace.sRGB + ) + } + composeCanvas = surface?.canvas?.asComposeCanvas() + } + + override fun onSizeChange(width: Int, height: Int) { + if (this.width == width && this.height == height) return + println("DirectRenderer onSizeChange: $width, $height") + RenderSystem.assertOnRenderThread() + this.width = width + this.height = height + initilaize() + } + + override fun render(drawContext: DrawContext, tickDelta: Float, block: (Canvas) -> Unit) = blockOnMain { + RenderSystem.assertOnRenderThread() + enterManaged() + if (composeCanvas == null) { + initilaize() + } + block(composeCanvas!!) + surface!!.flush() + exitManaged() + context.resetAll() + } + + override fun invalidate() { + + } + + override fun close() { + surface?.close() + renderTarget?.close() + context.close() + } + + private fun enterManaged() { + RenderSystem.assertOnRenderThread() + RenderSystem.pixelStore(GL_UNPACK_ROW_LENGTH, 0) + RenderSystem.pixelStore(GL_UNPACK_SKIP_PIXELS, 0) + RenderSystem.pixelStore(GL_UNPACK_SKIP_ROWS, 0) + RenderSystem.pixelStore(GL_UNPACK_ALIGNMENT, 4) + } + + private fun exitManaged() { + RenderSystem.assertOnRenderThread() + BufferRenderer.reset() + GL33.glBindSampler(0, 0) + RenderSystem.disableBlend() + glDisable(GL_BLEND) + RenderSystem.blendFunc(GL_SRC_ALPHA, GL_ONE) + glBlendFunc(GL_SRC_ALPHA, GL_ONE) + RenderSystem.blendEquation(GL_FUNC_ADD) + glBlendEquation(GL_FUNC_ADD) + RenderSystem.colorMask(true, true, true, true) + glColorMask(true, true, true, true) + RenderSystem.depthMask(true) + glDepthMask(true) + RenderSystem.disableScissor() + glDisable(GL_SCISSOR_TEST) + glDisable(GL_STENCIL_TEST) + RenderSystem.disableDepthTest() + glDisable(GL_DEPTH_TEST) + glActiveTexture(GL_TEXTURE0) + RenderSystem.activeTexture(GL_TEXTURE0) + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/Renderer.kt b/src/main/java/dev/wefhy/whymap/compose/Renderer.kt new file mode 100644 index 0000000..ab72755 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/Renderer.kt @@ -0,0 +1,13 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose + +import androidx.compose.ui.graphics.Canvas +import net.minecraft.client.gui.DrawContext +import java.io.Closeable + +internal abstract class Renderer: Closeable { + abstract fun render(drawContext: DrawContext, tickDelta: Float, block: (Canvas) -> Unit) + abstract fun invalidate() + abstract fun onSizeChange(width: Int, height: Int) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/styles/Colors.kt b/src/main/java/dev/wefhy/whymap/compose/styles/Colors.kt new file mode 100644 index 0000000..3f1bca7 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/styles/Colors.kt @@ -0,0 +1,51 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.styles + +import androidx.compose.material.Colors +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.ui.graphics.Color + +val mcColors = Colors( + primary = Color(0xFFbbbb66), + primaryVariant = Color(0xFF66bb55), + secondary = Color(0xFF215c16), + secondaryVariant = Color(0xFF215c16), + background = Color(0xFF282218), + surface = Color(0xFF181818), + error = Color(0xFFCF6679), + onPrimary = Color.Black, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White, + onError = Color.Black, + false +) + +val noctuaColors = lightColors( + primary = Color(0xFF551805), +// primaryVariant = Color(0xFF551805), + secondary = Color(0xFFccad8f), + secondaryVariant = Color.White, + background = Color(0xFFE7CEB5), +// surface = Color(0xFFE7CEB5), +// error = Color(0xFFCF6679), +) + + +//val darkColorsOriginal = Colors( +// primary = Color(0xFFBB86FC), +// primaryVariant = Color(0xFF3700B3), +// secondary = Color(0xFF03DAC6), +// secondaryVariant = secondary, +// background = Color(0xFF121212), +// surface = Color(0xFF121212), +// error = Color(0xFFCF6679), +// onPrimary = Color.Black, +// onSecondary = Color.Black, +// onBackground = Color.White, +// onSurface = Color.White, +// onError = Color.Black, +// false +//) \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/styles/Fonts.kt b/src/main/java/dev/wefhy/whymap/compose/styles/Fonts.kt new file mode 100644 index 0000000..f1743d6 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/styles/Fonts.kt @@ -0,0 +1,37 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.styles + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.Font + +object MinecraftFont { + val minecraftFontFamily = FontFamily( + Font( + resource = "fonts/minecraft-font/MinecraftRegular.otf", + style = FontStyle.Normal, + weight = FontWeight.Normal + ), + Font( + resource = "fonts/minecraft-font/MinecraftItalic.otf", + style = FontStyle.Italic, + weight = FontWeight.Normal + ), + Font( + resource = "fonts/minecraft-font/MinecraftBold.otf", + style = FontStyle.Normal, + weight = FontWeight.Bold + ), + Font( + resource = "fonts/minecraft-font/MinecraftBoldItalic.otf", + style = FontStyle.Italic, + weight = FontWeight.Bold + ) + ) + val background = Color(0xFF6E6E6E) +// val shadow = Color(0xFF404040) + val shadow = Color.Black.copy(alpha = 0.5f) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/styles/McTheme.kt b/src/main/java/dev/wefhy/whymap/compose/styles/McTheme.kt new file mode 100644 index 0000000..75f6d35 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/styles/McTheme.kt @@ -0,0 +1,80 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.styles + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + + +private val defaultMcTextStyle = TextStyle( + fontFamily = MinecraftFont.minecraftFontFamily, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + shadow = Shadow( + color = MinecraftFont.shadow, + offset = Offset(4f, 4f), + blurRadius = 0.5f + ) +) + +private val mcStyleNoShadow = TextStyle( + fontFamily = MinecraftFont.minecraftFontFamily, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal +) + +@Composable +fun McTheme(colors: Colors, content: @Composable () -> Unit) { +// val shadowColor = if (isSystemInDarkTheme()) Color.Black.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.5f) + MaterialTheme( + colors = colors, + typography = Typography( + defaultFontFamily = MinecraftFont.minecraftFontFamily, + button = TextStyle( + fontFamily = MinecraftFont.minecraftFontFamily, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + fontSize = 20.sp, + shadow = Shadow( + color = MinecraftFont.shadow, + offset = Offset(4f, 4f), + blurRadius = 0.5f + ) + ), +// body1 = TextStyle( +// fontFamily = MinecraftFont.minecraftFontFamily, +// fontWeight = FontWeight.Normal, +// fontStyle = FontStyle.Normal, +// fontSize = 16.sp, +// shadow = Shadow( +// color = MinecraftFont.shadow, +// offset = Offset(4f, 4f), +// blurRadius = 0.5f +// ) +// ), + h1 = defaultMcTextStyle, + caption = mcStyleNoShadow, + subtitle1 = mcStyleNoShadow, + ), + shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) + ), + content = content + ) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/AddEditWaypoint.kt b/src/main/java/dev/wefhy/whymap/compose/ui/AddEditWaypoint.kt new file mode 100644 index 0000000..53ef327 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/AddEditWaypoint.kt @@ -0,0 +1,79 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.wefhy.whymap.WhyMapMod.Companion.activeWorld +import dev.wefhy.whymap.compose.utils.ComposeUtils.goodBackground +import dev.wefhy.whymap.waypoints.CoordXYZ +import dev.wefhy.whymap.waypoints.CoordXYZ.Companion.toCoordXYZ +import net.minecraft.client.MinecraftClient + +@Composable +fun AddEditWaypoint(original: WaypointEntry? = null, onDismiss: () -> Unit = {}) { + var waypoint by remember(original) { mutableStateOf(original ?: WaypointEntry.new(0).copy(coords = MinecraftClient.getInstance()?.player?.pos?.toCoordXYZ() ?: CoordXYZ.ZERO)) } + Column(Modifier.background(waypoint.color.copy(alpha = 0.25f)).height(220.dp).padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + WorkaroundTextFieldSimple(waypoint.name, { waypoint = waypoint.copy(name = it) }, Modifier.weight(1f), label = { Text("Name") }) +// WorkaroundTextFieldSimple(waypoint.name, { waypoint = waypoint.copy(name = it) }, Modifier.weight(1f), label = { Text("Name") }) + if (original != null) { + Icon(Icons.Default.Delete, contentDescription = "Delete", Modifier.clickable { + activeWorld?.waypoints?.remove(original.asOnlineWaypoint()) + onDismiss() + }.padding(8.dp)) + } + } + Row(Modifier.padding(0.dp, 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + WorkaroundTextFieldSimple(waypoint.coords.x.toString(), { waypoint = waypoint.copy(coords = waypoint.coords.copy(x = it.toIntOrNull() ?: 0)) }, Modifier.weight(1f), label = { Text("X") }) + WorkaroundTextFieldSimple(waypoint.coords.y.toString(), { waypoint = waypoint.copy(coords = waypoint.coords.copy(y = it.toIntOrNull() ?: 0)) }, Modifier.weight(1f), label = { Text("Y") }) + WorkaroundTextFieldSimple(waypoint.coords.z.toString(), { waypoint = waypoint.copy(coords = waypoint.coords.copy(z = it.toIntOrNull() ?: 0)) }, Modifier.weight(1f), label = { Text("Z") }) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + ColorSelector { waypoint = waypoint.copy(color = it) } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Button( + modifier = Modifier.weight(1f), + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error, contentColor = MaterialTheme.colors.onError) + ) { + Icon(Icons.Default.Clear, contentDescription = "Cancel") + } + Button( + modifier = Modifier.weight(1f), + onClick = { + if (original != null) { + activeWorld?.waypoints?.remove(original.asOnlineWaypoint()) + } + activeWorld?.waypoints?.add(waypoint.asOnlineWaypoint()) + onDismiss() + }, + colors = ButtonDefaults.buttonColors(backgroundColor = waypoint.color, contentColor = waypoint.color.goodBackground()) + ) { + Icon(Icons.Default.Check, contentDescription = "Add") + } + } + } + } +} + +@Preview +@Composable +private fun PreviewAddEditWaypoint() { + Column { + AddEditWaypoint() + AddEditWaypoint(WaypointEntry(12, "Test", Color.Red, 0f, CoordXYZ(0, 0, 0))) + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/ColorSelector.kt b/src/main/java/dev/wefhy/whymap/compose/ui/ColorSelector.kt new file mode 100644 index 0000000..a20d5c6 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/ColorSelector.kt @@ -0,0 +1,43 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.wefhy.whymap.compose.utils.ComposeUtils + +@Composable +fun ColorSelector(modifier: Modifier = Modifier, onSelect: (Color) -> Unit) { + val availableColors = ComposeUtils.goodColors + Card(elevation = 8.dp, modifier = modifier) { + LazyHorizontalGrid(GridCells.Fixed(2), Modifier.padding(4.dp)) { + items(availableColors.size) { + ColorButton(color = availableColors[it], onSelect = onSelect) + } + } + } +} + +@Composable +fun ColorButton(color: Color, onSelect: (Color) -> Unit) { + Card(Modifier.requiredSize(40.dp, 40.dp).padding(4.dp).clickable { onSelect(color) }, elevation = 4.dp) { + Box(modifier = Modifier.background(color)) + } +} + +@Preview +@Composable +private fun ColorSelectorPreview() { + ColorSelector {} +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/ComposeConstants.kt b/src/main/java/dev/wefhy/whymap/compose/ui/ComposeConstants.kt new file mode 100644 index 0000000..0fb3468 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/ComposeConstants.kt @@ -0,0 +1,9 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +object ComposeConstants { + const val minScale = 0.1f + const val maxScale = 50f + val scaleRange = minScale..maxScale +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/ConfigScreen.kt b/src/main/java/dev/wefhy/whymap/compose/ui/ConfigScreen.kt new file mode 100644 index 0000000..ad3b04c --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/ConfigScreen.kt @@ -0,0 +1,387 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.animation.* +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.wefhy.whymap.WhyMapClient.Companion.kbModSettings +import dev.wefhy.whymap.WhyMapMod +import dev.wefhy.whymap.compose.ComposeView +import dev.wefhy.whymap.compose.styles.McTheme +import dev.wefhy.whymap.compose.styles.mcColors +import dev.wefhy.whymap.compose.styles.noctuaColors +import dev.wefhy.whymap.compose.utils.collectAsMutableState +import dev.wefhy.whymap.utils.Accessors.clientInstance +import dev.wefhy.whymap.utils.Accessors.clientWindow +import dev.wefhy.whymap.utils.LocalTileBlock +import net.minecraft.client.MinecraftClient +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.Screen +import net.minecraft.text.Text +import net.minecraft.util.math.Vec3d + +class ConfigScreen : Screen(Text.of("Config")) { + + + + private val composeView = ComposeView( + nativeWidth = clientWindow.framebufferWidth, + nativeHeight = clientWindow.framebufferWidth, + density = Density(2f) + ) { + val vm = MapViewModel(rememberCoroutineScope()) + var visible by remember { mutableStateOf(false) } + val isDarkTheme by vm.isDark.collectAsState() + McTheme(colors = if (isDarkTheme) mcColors else noctuaColors) { //todo change theme according to minecraft day/night or real life + LaunchedEffect(Unit) { + visible = true + } + AnimatedVisibility(visible, enter = scaleIn() + fadeIn()) { + UI(vm) + } +// Scaffold { +// } + } + } + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { +// super.render(context, mouseX, mouseY, delta) + composeView.render(context, delta) + } + + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + +// println("Mouse clicked at $mouseX, $mouseY") + composeView.passLMBClick(mouseX.toFloat(), mouseY.toFloat()) + return super.mouseClicked(mouseX, mouseY, button) + } + + override fun mouseMoved(mouseX: Double, mouseY: Double) { + composeView.passMouseMove(mouseX.toFloat(), mouseY.toFloat()) + super.mouseMoved(mouseX, mouseY) + } + + override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean { +// println("Mouse released at $mouseX, $mouseY") + composeView.passLMBRelease(mouseX.toFloat(), mouseY.toFloat()) + return super.mouseReleased(mouseX, mouseY, button) + } + + override fun mouseScrolled( + mouseX: Double, + mouseY: Double, + horizontalAmount: Double, + verticalAmount: Double + ): Boolean { +// println("Mouse scrolled at $mouseX, $mouseY, $horizontalAmount, $verticalAmount") + composeView.passScroll(mouseX.toFloat(), mouseY.toFloat(), horizontalAmount.toFloat(), verticalAmount.toFloat()) + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount) + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { +// if (kbModSettings.matchesKey(keyCode, scanCode)) { +// println("Closing settings!") +// close() +// return true +// } + composeView.passKeyPress(keyCode, scanCode, modifiers) + return super.keyPressed(keyCode, scanCode, modifiers) + } + + override fun keyReleased(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + composeView.passKeyRelease(keyCode, scanCode, modifiers) + return super.keyReleased(keyCode, scanCode, modifiers) + } + + override fun close() { + composeView.close() + super.close() + } +} + +private var i = 0 + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun UI(vm: MapViewModel) { + var clicks by remember { mutableStateOf(0) } + var showList by remember { mutableStateOf(true) } + var showMap by remember { mutableStateOf(true) } + var isDark by vm.isDark.collectAsMutableState() + Card( + border = BorderStroke(1.dp, Color(0.05f, 0.1f, 0.2f)), + elevation = 20.dp, modifier = Modifier/*.padding(200.dp, 0.dp, 0.dp, 0.dp)*/.padding(8.dp) + ) { + Box { + Column(Modifier.background(MaterialTheme.colors.background)) { + var showDropDown by remember { mutableStateOf(false) } + TopAppBar({ + Text("WhyMap") + + //smaller text in cursive + Text("by wefhy", fontSize = 12.sp, modifier = Modifier.padding(4.dp).offset(0.dp, 4.dp), fontStyle = FontStyle.Italic) + DimensionDropDown() +// Spacer(Modifier.weight(1f)) +// BetterDimensionDrop() + }, actions = { + IconButton( + onClick = { showDropDown = true }) { + Icon(Icons.Filled.MoreVert, null) + + } + DropdownMenu( + showDropDown, { showDropDown = false } + // offset = DpOffset((-102).dp, (-64).dp), + ) { + DropdownMenuItem(/*icon = { + Icon( + Icons.Filled.Home + ) + },*/ onClick = { + showDropDown = false + }) { Text(text = "Drop down item") } + } + }) + Row(Modifier.padding(8.dp)) { + var hovered by remember { mutableStateOf(null) } + var updateCount by remember { mutableStateOf(0) } + var center by remember { mutableStateOf(clientInstance.player?.pos ?: Vec3d.ZERO) } + val entries = remember { mutableStateListOf() } + var waypointRefresh by remember { mutableStateOf(0) } + println("Recomposition ${i++}") + Column { + Text("Clicks: $clicks") + Button(onClick = { clicks++ }) { + Text("Click me!") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Show List") + Switch(checked = showList, onCheckedChange = { showList = it }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Show Map") + Switch(checked = showMap, onCheckedChange = { showMap = it }) + } +// Text("Hovered: ${hovered?.name ?: "None"}") + } + + remember(waypointRefresh) { + val waypoints = WhyMapMod.activeWorld?.waypoints?.waypoints ?: emptyList() + entries.clear() + entries.addAll(waypoints.mapIndexed { i, it -> + WaypointEntry( + waypointId = i, + name = it.name, + color = it.color?.let { + if (it.first() != '#') return@let null + Color(it.drop(1).toInt(16)).copy(alpha = 1f) + } ?: Color.Black, + distance = clientInstance?.player?.pos?.distanceTo(it.location.toVec3d())?.toFloat() ?: 0f, + coords = it.location, + ) + }) + } + + Spacer(Modifier.weight(0.000001f)) + + AnimatedVisibility( + showMap, + Modifier.weight(1f), + enter = expandIn(), + exit = shrinkOut() + ) { + MapTileView(LocalTileBlock(center), entries, hovered, updateCount) + } + + AnimatedVisibility( + showList, + enter = expandIn(), + exit = shrinkOut() + ) { + WaypointsView(entries, { + println("Refresh!") + waypointRefresh++ + }, { + println("Clicked on ${it.name}, centering on ${it.coords}") + showMap = true + center = it.coords.toVec3d() + updateCount++ + }, { entry, hovering -> + println("Hovering over ${entry.name}, hovering: $hovering") + if (hovering) { + hovered = entry + } else { + if (hovered == entry) + hovered = null + } + }) + } + } + } + FloatingActionButton(onClick = { isDark = !isDark }, Modifier.align(Alignment.BottomStart).padding(8.dp)) { + val im = if (isDark) Icons.TwoTone.ModeNight else Icons.TwoTone.WbSunny + Icon(im, contentDescription = "Theme") + } + } + } +} + + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun BetterDimensionDrop() { + val coffeeDrinks = arrayOf("OverWorld", "Nether", "End", "Neth/OW overlay") + var expanded by remember { mutableStateOf(false) } + var selectedText by remember { mutableStateOf(coffeeDrinks[0]) } + + Box( + modifier = Modifier +// .fillMaxWidth() + .padding(4.dp) + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { +// TextField( +// value = selectedText, +// textStyle = MaterialTheme.typography.body1, +// onValueChange = {}, +// readOnly = true, +// trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, +// // modifier = Modifier.menuAnchor() TODO this will be required in material3 +// ) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + textStyle = LocalTextStyle.current.copy(fontSize = 18.sp).merge( + TextStyle(color = TextFieldDefaults.textFieldColors().textColor(true).value) + ), + value = selectedText, + onValueChange = {}, +// textStyle = TextStyle.Default.copy(fontSize = 18.sp), + modifier = Modifier +// .background( +// color = colors.background, +// shape = TextFieldDefaults.TextFieldShape//RoundedCornerShape(13.dp) +// ) +// .indicatorLine( +// enabled = enabled, +// isError = false, +// interactionSource = interactionSource, +// colors = TextFieldDefaults.outlinedTextFieldColors(), +// focusedIndicatorLineThickness = 0.dp, //to hide the indicator line +// unfocusedIndicatorLineThickness = 0.dp //to hide the indicator line +// ) + .height(42.dp), + + +// modifier = Modifier +// .fillMaxWidth() +// .padding(8.dp) +// .border(1.dp, Color.Black) + ) { + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = selectedText, + enabled = true, + innerTextField = it, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = PaddingValues(16.dp, 0.dp, 0.dp, 0.dp), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + } + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + coffeeDrinks.forEach { item -> + DropdownMenuItem( + onClick = { + selectedText = item + expanded = false + } + ) { Text(text = item) } + } + } + } + } +} + +@Composable +fun DimensionDropDown() { + var expanded by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf("OverWorld") } + + Box( + modifier = Modifier.fillMaxWidth() + .wrapContentSize(Alignment.TopEnd).border(1.dp, Color.Black, shape = RoundedCornerShape(4.dp)) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { expanded = !expanded }.padding(8.dp)) { +// IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "More" + ) +// } + Text(selected) + } + + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + onClick = { selected = "OverWorld"; expanded = false } + ) { Text("OverWorld") } + DropdownMenuItem( + onClick = { selected = "Nether"; expanded = false } + ) { Text("Nether") } + DropdownMenuItem( + onClick = { selected = "End"; expanded = false } + ) { Text("End") } + DropdownMenuItem( + onClick = { selected = "Neth/OW overlay"; expanded = false } + ) { Text("Neth/OW overlay") } + } + } +} + +@Preview +@Composable +private fun preview() { + val vm = MapViewModel(rememberCoroutineScope()) + MaterialTheme(colors = darkColors()) { + Scaffold { + UI(vm) + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/Icons.kt b/src/main/java/dev/wefhy/whymap/compose/ui/Icons.kt new file mode 100644 index 0000000..cd76dc9 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/Icons.kt @@ -0,0 +1,136 @@ +package dev.wefhy.whymap.compose.ui + +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +val Icons.TwoTone.ModeNight: ImageVector + get() { + if (_modeNight != null) { + return _modeNight!! + } + _modeNight = materialIcon(name = "TwoTone.ModeNight") { + materialPath(fillAlpha = 0.3f, strokeAlpha = 0.3f) { + moveTo(9.5f, 4.0f) + curveTo(9.16f, 4.0f, 8.82f, 4.02f, 8.49f, 4.07f) + curveTo(10.4f, 6.23f, 11.5f, 9.05f, 11.5f, 12.0f) + reflectiveCurveToRelative(-1.1f, 5.77f, -3.01f, 7.93f) + curveTo(8.82f, 19.98f, 9.16f, 20.0f, 9.5f, 20.0f) + curveToRelative(4.41f, 0.0f, 8.0f, -3.59f, 8.0f, -8.0f) + reflectiveCurveTo(13.91f, 4.0f, 9.5f, 4.0f) + close() + } + materialPath { + moveTo(9.5f, 2.0f) + curveToRelative(-1.82f, 0.0f, -3.53f, 0.5f, -5.0f, 1.35f) + curveToRelative(2.99f, 1.73f, 5.0f, 4.95f, 5.0f, 8.65f) + reflectiveCurveToRelative(-2.01f, 6.92f, -5.0f, 8.65f) + curveTo(5.97f, 21.5f, 7.68f, 22.0f, 9.5f, 22.0f) + curveToRelative(5.52f, 0.0f, 10.0f, -4.48f, 10.0f, -10.0f) + reflectiveCurveTo(15.02f, 2.0f, 9.5f, 2.0f) + close() + moveTo(9.5f, 20.0f) + curveToRelative(-0.34f, 0.0f, -0.68f, -0.02f, -1.01f, -0.07f) + curveToRelative(1.91f, -2.16f, 3.01f, -4.98f, 3.01f, -7.93f) + reflectiveCurveToRelative(-1.1f, -5.77f, -3.01f, -7.93f) + curveTo(8.82f, 4.02f, 9.16f, 4.0f, 9.5f, 4.0f) + curveToRelative(4.41f, 0.0f, 8.0f, 3.59f, 8.0f, 8.0f) + reflectiveCurveTo(13.91f, 20.0f, 9.5f, 20.0f) + close() + } + } + return _modeNight!! + } + +private var _modeNight: ImageVector? = null + +val Icons.TwoTone.WbSunny: ImageVector + get() { + if (_wbSunny != null) { + return _wbSunny!! + } + _wbSunny = materialIcon(name = "TwoTone.WbSunny") { + materialPath(fillAlpha = 0.3f, strokeAlpha = 0.3f) { + moveTo(12.0f, 7.5f) + curveToRelative(-2.21f, 0.0f, -4.0f, 1.79f, -4.0f, 4.0f) + reflectiveCurveToRelative(1.79f, 4.0f, 4.0f, 4.0f) + reflectiveCurveToRelative(4.0f, -1.79f, 4.0f, -4.0f) + reflectiveCurveToRelative(-1.79f, -4.0f, -4.0f, -4.0f) + close() + } + materialPath { + moveTo(5.34f, 6.25f) + lineToRelative(1.42f, -1.41f) + lineToRelative(-1.8f, -1.79f) + lineToRelative(-1.41f, 1.41f) + close() + moveTo(1.0f, 10.5f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(2.0f) + lineTo(1.0f, 12.5f) + close() + moveTo(11.0f, 0.55f) + horizontalLineToRelative(2.0f) + lineTo(13.0f, 3.5f) + horizontalLineToRelative(-2.0f) + close() + moveTo(18.66f, 6.255f) + lineToRelative(-1.41f, -1.407f) + lineToRelative(1.79f, -1.79f) + lineToRelative(1.406f, 1.41f) + close() + moveTo(17.24f, 18.16f) + lineToRelative(1.79f, 1.8f) + lineToRelative(1.41f, -1.41f) + lineToRelative(-1.8f, -1.79f) + close() + moveTo(20.0f, 10.5f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-3.0f) + close() + moveTo(12.0f, 5.5f) + curveToRelative(-3.31f, 0.0f, -6.0f, 2.69f, -6.0f, 6.0f) + reflectiveCurveToRelative(2.69f, 6.0f, 6.0f, 6.0f) + reflectiveCurveToRelative(6.0f, -2.69f, 6.0f, -6.0f) + reflectiveCurveToRelative(-2.69f, -6.0f, -6.0f, -6.0f) + close() + moveTo(12.0f, 15.5f) + curveToRelative(-2.21f, 0.0f, -4.0f, -1.79f, -4.0f, -4.0f) + reflectiveCurveToRelative(1.79f, -4.0f, 4.0f, -4.0f) + reflectiveCurveToRelative(4.0f, 1.79f, 4.0f, 4.0f) + reflectiveCurveToRelative(-1.79f, 4.0f, -4.0f, 4.0f) + close() + moveTo(11.0f, 19.5f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.95f) + horizontalLineToRelative(-2.0f) + close() + moveTo(3.55f, 18.54f) + lineToRelative(1.41f, 1.41f) + lineToRelative(1.79f, -1.8f) + lineToRelative(-1.41f, -1.41f) + close() + } + } + return _wbSunny!! + } + +private var _wbSunny: ImageVector? = null diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/MapTile.kt b/src/main/java/dev/wefhy/whymap/compose/ui/MapTile.kt new file mode 100644 index 0000000..63ecfd0 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/MapTile.kt @@ -0,0 +1,276 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Place +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition.PlatformDefault.x +import androidx.compose.ui.window.WindowPosition.PlatformDefault.y +import dev.wefhy.whymap.WhyMapMod.Companion.activeWorld +import dev.wefhy.whymap.compose.ui.ComposeConstants.scaleRange +import dev.wefhy.whymap.compose.utils.ComposeUtils.goodBackground +import dev.wefhy.whymap.compose.utils.ComposeUtils.toImageBitmap +import dev.wefhy.whymap.compose.utils.ComposeUtils.toLocalTileBlock +import dev.wefhy.whymap.compose.utils.ComposeUtils.toOffset +import dev.wefhy.whymap.config.WhyMapConfig.tileResolution +import dev.wefhy.whymap.utils.* +import dev.wefhy.whymap.utils.Accessors.clientInstance +import dev.wefhy.whymap.utils.ImageWriter.encodeJPEG +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image +import java.io.ByteArrayOutputStream + +enum class MapControl { + User, Target +} + +private suspend fun renderDetail(tile: LocalTileChunk) = withContext(WhyDispatchers.Render) { +// val bufferedImage = activeWorld?.experimentalTileGenerator?.getTile(tile.chunkPos) ?: return@withContext null //TODO render directly to compose canvas! +// bufferedImage.toImageBitmap(intermediate = ImageFormat.JPEG) + activeWorld?.experimentalTileGenerator?.getComposeTile(tile) +} +private suspend fun renderRegion(tile: LocalTileRegion) = withContext(WhyDispatchers.Render) { + activeWorld?.mapRegionManager?.getRegionForTilesRendering(tile) { + if (!isActive) return@getRegionForTilesRendering null.also { println("Cancel early 1") } + renderWhyImageNow().imageBitmap + } +} +private suspend fun renderThumbnail(tile: LocalTileThumbnail) = withContext(WhyDispatchers.Render) { + activeWorld?.thumbnailsManager?.getThumbnail(tile)?.let { + try { + Image.makeFromEncoded(it.toByteArray()).toComposeImageBitmap() + } catch (e: Throwable) { + null + } + } +} +//private suspend inline fun render(tile: LocalTile): ImageBitmap? = when (T::class) { +// TileZoom.RegionZoom::class -> renderRegion(tile as LocalTileRegion) +// TileZoom.ThumbnailZoom::class -> renderThumbnail(tile as LocalTileThumbnail) +// TileZoom.ChunkZoom::class -> renderDetail(tile as LocalTileChunk) +// else -> throw IllegalArgumentException("Unsupported zoom level: ${T::class.simpleName}") +//} + +private suspend inline fun render(tile: LocalTile): ImageBitmap? = when (tile.zoom.zoom) { + TileZoom.RegionZoom.zoom -> renderRegion(tile as LocalTileRegion) + TileZoom.ThumbnailZoom.zoom -> renderThumbnail(tile as LocalTileThumbnail) + TileZoom.ChunkZoom.zoom -> renderDetail(tile as LocalTileChunk) + else -> throw IllegalArgumentException("Unsupported zoom level: ${T::class.simpleName}") +} + + + + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MapTileView(startPosition: LocalTileBlock, waypoints: List = emptyList(), hovered: WaypointEntry?, updateCount: Int = 0) { + //TODO layers - on max zoom just color the tile if the region file exists + var mapControl by remember { mutableStateOf(MapControl.Target) } + var animationTarget by remember { mutableStateOf(startPosition) } + remember(updateCount) { + mapControl = MapControl.Target + animationTarget = startPosition + } + val animationCenter by animateOffsetAsState(animationTarget.toOffset(), animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + var scale by remember { mutableStateOf(1f) } + var center by remember { mutableStateOf(startPosition.toOffset()) } + remember(animationCenter, mapControl) { + if (mapControl == MapControl.Target) { + center = animationCenter + } + } + var canvasSize by remember { mutableStateOf(Size.Zero) } + val zoom = remember(scale, canvasSize) { + val tilesPerCanvas = canvasSize.maxDimension / tileResolution.toFloat() + val tilesPerVisibleCanvas = tilesPerCanvas / scale / 5 //5 is number of tiles visible on the screen + when(tilesPerVisibleCanvas) { + in 0f..0.05f -> TileZoom.ChunkZoom + in 0.05f.. 1.1f -> TileZoom.RegionZoom + else -> TileZoom.ThumbnailZoom + } +// when { +// scale < 0.5 -> TileZoom.ThumbnailZoom +// scale < 16 -> TileZoom.RegionZoom +// else -> TileZoom.ChunkZoom +// } + } + val tileRadius = when(zoom) { + TileZoom.ChunkZoom -> 4 + TileZoom.RegionZoom -> 2 + TileZoom.ThumbnailZoom -> 3 + else -> 0 + } + val nTiles = tileRadius * 2 + 1 + val block by remember { derivedStateOf { center.toLocalTileBlock() } } //startPosition - LocalTileBlock(offsetX.toInt(), offsetY.toInt()) + val centerTile = block.parent(zoom) + val minTile = centerTile - LocalTile(tileRadius, tileRadius, zoom) + val maxTile = centerTile + LocalTile(tileRadius, tileRadius, zoom) + val dontDispose = remember { mutableSetOf>() } + val images = remember { mutableStateMapOf, ImageBitmap>() } + dontDispose.removeAll { it.x !in minTile.x..maxTile.x || it.z !in minTile.z..maxTile.z } + for (tile in (minTile..maxTile).toList().sortedByDescending { it distanceTo centerTile }) { + if (tile in dontDispose) continue + val index = tile.z.mod(nTiles) * nTiles + tile.x.mod(nTiles) + LaunchedEffect(tile) { + assert(tile !in images) +// images.remove(tile) //TODO actually just return if already loaded. But this should be handled by dontDispose + println("MapTileView LaunchedEffect, tile: $tile, index: $index") + val image = render(tile) + if (!isActive) return@LaunchedEffect Unit.also { println("Cancel early 2") } + image?.let { + images[tile] = it + } + dontDispose.add(tile) + } + } + + Box { + Card( + elevation = 8.dp + ) { + Canvas(modifier = Modifier + .fillMaxSize() +// .background(Color(0.1f, 0.1f, 0.1f)) + .background(Color.Black) + .clipToBounds() + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + center -= dragAmount / scale + animationTarget = center.toLocalTileBlock() + mapControl = MapControl.User + } + } + .onPointerEvent(PointerEventType.Scroll) { + val scrollDelta = it.changes.fold(Offset.Zero) { acc, c -> acc + c.scrollDelta } + scale = (scale * (1 + scrollDelta.y / 10)).coerceIn(scaleRange) + } + ) { + canvasSize = size + val filterQuality = if (scale > 1) FilterQuality.None else FilterQuality.Low + val res = (tileResolution / zoom.scale).toInt() + scale(scale) { + translate(size.width / 2, size.height / 2) { + translate(-center.x, -center.y) { + for (tile in minTile..maxTile) { + val image = images[tile] + val drawOffset = tile.getStart() + image?.let { im -> + drawImage(im, dstOffset = IntOffset(drawOffset.x, drawOffset.z), dstSize = IntSize(res, res), filterQuality = filterQuality) + } + } + waypoints.sortedBy { it == hovered }.forEach { + val offset = it.coords.toLocalBlock().toOffset() + Offset(0.5f, 0.5f) + val size = if (it == hovered) 16f else 8f + val outlineWidth = if (it == hovered) 5f else 2f + drawCircle( + color = it.color, + radius = size / scale, + center = offset, + style = Fill + ) + drawCircle( + color = it.color.goodBackground(), + radius = size / scale, + center = offset, + style = Stroke(outlineWidth / scale) + ) + } + val player = clientInstance?.player?: return@scale + val playerPos = player.pos + val playerYaw = player.yaw + val offset = Offset(playerPos.x.toFloat(), playerPos.z.toFloat()) + translate(offset.x, offset.y) { + rotate(playerYaw, pivot = Offset(0f, 0f)) { + drawPath( + path = Path().apply { + val size = 16f / scale + moveTo(0f, 0f) + lineTo(-size, -size) + lineTo(0f, 1.5f*size) + lineTo(size, -size) + close() + }, + color = Color.Red, + style = Fill + ) + } + } + } + } + } + } + //center icon + Icon( + imageVector = Icons.Default.Place, + contentDescription = "Center", + modifier = Modifier.align(Alignment.BottomStart).padding(8.dp).size(32.dp).clip( + CircleShape).clickable { + val player = clientInstance?.player?: return@clickable + val playerPos = player.pos + animationTarget = Offset(playerPos.x.toFloat(), playerPos.z.toFloat()).toLocalTileBlock() + mapControl = MapControl.Target + }.background(MaterialTheme.colors.background).padding(4.dp) + ) + } + } +} +@Composable +fun CachedCanvas(modifier: Modifier = Modifier, block: DrawScope.() -> Unit) { + Spacer(modifier = Modifier.fillMaxSize().drawWithCache { + println("CachedCanvas recompose") + onDrawWithContent { + block() + } + }) +} + +@Composable +fun WrappedCanvas(modifier: Modifier = Modifier, block: Canvas.(Size) -> Unit) { + Canvas(modifier) { +// this.drawIntoCanvas { +// +// } + val image = ImageBitmap( + size.width.toInt(), + size.height.toInt(), + ImageBitmapConfig.Argb8888) + val canvas = Canvas(image) + canvas.block(size) + drawImage(image) + } +} diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/MapViewModel.kt b/src/main/java/dev/wefhy/whymap/compose/ui/MapViewModel.kt new file mode 100644 index 0000000..6d1a43a --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/MapViewModel.kt @@ -0,0 +1,24 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.runtime.* +import dev.wefhy.whymap.config.UserSettings +import dev.wefhy.whymap.config.WhyUserSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +class MapViewModel(scope: CoroutineScope) { + var isDark = MutableStateFlow(WhyUserSettings.generalSettings.theme == UserSettings.Theme.DARK) + init { + scope.launch { + isDark.collectLatest { + WhyUserSettings.generalSettings.theme = if (it) UserSettings.Theme.DARK else UserSettings.Theme.LIGHT + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/WaypointsView.kt b/src/main/java/dev/wefhy/whymap/compose/ui/WaypointsView.kt new file mode 100644 index 0000000..666a0bb --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/WaypointsView.kt @@ -0,0 +1,264 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.awtEventOrNull +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sun.org.apache.xalan.internal.lib.ExsltStrings.padding +import dev.wefhy.whymap.compose.views.SortingOptions +import dev.wefhy.whymap.utils.Accessors.clientInstance +import dev.wefhy.whymap.utils.rand +import dev.wefhy.whymap.utils.roundToString +import dev.wefhy.whymap.waypoints.CoordXYZ +import dev.wefhy.whymap.waypoints.CoordXYZ.Companion.toCoordXYZ +import dev.wefhy.whymap.waypoints.OnlineWaypoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.minecraft.advancement.criterion.InventoryChangedCriterion.Conditions.items +import java.awt.event.KeyEvent +import java.awt.event.KeyEvent.VK_BACK_SPACE +import java.text.SimpleDateFormat +import java.util.* + + +data class WaypointEntry( + val waypointId: Int, + val name: String, + val color: Color, + val distance: Float, + val coords: CoordXYZ, + val date: Date? = null, + val waypointType: Type? = null +) { + fun asOnlineWaypoint(): OnlineWaypoint { + return OnlineWaypoint(name, null, coords, "#${(color.toArgb() and 0xFFFFFF).toString(16)}") + } + enum class Type { + SPAWN, DEATH, TODO, HOME, SIGHTSEEING + } + + companion object { + fun new(id: Int) = WaypointEntry( + waypointId = id, + name = "", + color = Color.White, + distance = 0f, + coords = CoordXYZ(0, 0, 0), + date = Date(), + waypointType = null + ) + } +} + +@Composable +fun WaypointEntryView(waypointEntry: WaypointEntry, modifier: Modifier = Modifier, onEdit: () -> Unit = {}) { + val dateFormatter = SimpleDateFormat("HH:mm, EEE, MMM d", Locale.getDefault()) + Card(modifier = modifier.fillMaxWidth(), elevation = 8.dp) { + Box( + Modifier + .fillMaxWidth() + .background(waypointEntry.color.copy(alpha = 0.25f)) + .clipToBounds() + .padding(4.dp) + ) { + Column(modifier = Modifier.padding(4.dp)) { + Row { + Text(text = waypointEntry.name, fontWeight = FontWeight.Bold, fontSize = 20.sp) + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = "${waypointEntry.waypointType ?: ""}", + modifier = Modifier.align(Alignment.CenterEnd), + fontStyle = FontStyle.Italic, + fontSize = 17.sp + ) + } + } + + Text(text = "${waypointEntry.distance.roundToString(0)}m", fontSize = 16.sp) + Text(text = waypointEntry.date?.let {dateFormatter.format(it)} ?: "Now", color = Color.Gray, fontSize = 14.sp) + } + val c = waypointEntry.coords + Text( + text = "${c.x}, ${c.y}, ${c.z}", + modifier = Modifier + .align(Alignment.BottomEnd) + .clip(RoundedCornerShape(8.dp)) + .background( + waypointEntry.color +// Color.Blue + ) + .padding(6.dp), + color = if (waypointEntry.color.luminance() > 0.5f) Color.Black else Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .clickable { onEdit() } + ) + } + } +} + +enum class WaypointSorting(name: String) { + ALPHABETICAL("Alphabetical"), + DISTANCE("Distance to player"), + DATE("Date"), + LOCATION("Distance to map center") +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun WaypointsView(waypoints: List, onRefresh: () -> Unit, onClick: (WaypointEntry) -> Unit = {}, onHover: (WaypointEntry, Boolean) -> Unit = {_, _ -> }) { + val refreshScope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + var search by rememberSaveable { mutableStateOf("") } + var reverse by remember { mutableStateOf(false) } + var sorting by remember { mutableStateOf(WaypointSorting.ALPHABETICAL) } + val filtered by remember { + derivedStateOf { + waypoints.filter { it.name.contains(search, ignoreCase = true) }.let { + when (sorting) { + WaypointSorting.ALPHABETICAL -> it.sortedBy { it.name } + WaypointSorting.DISTANCE -> it.sortedBy { it.distance } + WaypointSorting.DATE -> it.sortedBy { it.date } + WaypointSorting.LOCATION -> it.sortedBy { it.coords.toVec3d().length() } + } + }.let { + if (reverse) it.reversed() else it + } + } + } + var addEditWaypoint by remember { mutableStateOf(false) } + var editedWaypoint by remember { mutableStateOf(null) } + + fun refresh() = refreshScope.launch { + refreshing = true + onRefresh() + delay(100) + refreshing = false + } + + val state = rememberPullRefreshState(refreshing, ::refresh) + + Box(Modifier.fillMaxHeight().pullRefresh(state).clipToBounds()) { + Column { + Row { + SortingOptions(sorting) { + sorting = it + } + + //reverse sorting + IconButton(onClick = { reverse = !reverse }) { + Icon( + imageVector = if (reverse) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "Reverse", + modifier = Modifier.padding(8.dp).align(Alignment.CenterVertically) + ) + } + +// TextField( +// value = search, +// onValueChange = { search = it }, +// label = { Text("Search") }, +// modifier = Modifier.width(200.dp).padding(8.dp) +// ) + } + LazyColumn( + modifier = Modifier.width(270.dp).weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp, 8.dp, 8.dp, 16.dp), + ) { + items(filtered, key = { it.waypointId }) { wp -> + WaypointEntryView(wp, Modifier.clickable { + onClick(wp) + }.onPointerEvent(PointerEventType.Enter) { + onHover(wp, true) + }.onPointerEvent(PointerEventType.Exit) { + onHover(wp, false) + }.animateItemPlacement()) { + if (editedWaypoint == wp) { + editedWaypoint = null + addEditWaypoint = false + } else { + editedWaypoint = wp + addEditWaypoint = true + } + } + } + item { + Spacer(modifier = Modifier.height(80.dp)) + } + } + + AnimatedVisibility(visible = addEditWaypoint) { + Box(Modifier.width(272.dp)) { + AddEditWaypoint(editedWaypoint) { + editedWaypoint = null + addEditWaypoint = false + onRefresh() + } + } + } + } + AnimatedVisibility(visible = !addEditWaypoint, Modifier.align(Alignment.BottomEnd)) { + FloatingActionButton(onClick = { + addEditWaypoint = true + }, Modifier.padding(8.dp)) { + Icon(Icons.Default.Add, contentDescription = "Theme") + } + } + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + } +} + +private fun viewEntry(id: Int) = WaypointEntry( + waypointId = id, + name = "Hello", + color = Color(rand.nextInt()), + distance = 123.57f, + date = Date(), + coords = CoordXYZ(1, 2, 3), +) + +@Preview +@Composable +fun Preview2() { + WaypointsView( + listOf(viewEntry(0), viewEntry(1), viewEntry(2)), {} + ) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/ui/WorkaroundTextFieldSimple.kt b/src/main/java/dev/wefhy/whymap/compose/ui/WorkaroundTextFieldSimple.kt new file mode 100644 index 0000000..e4f1194 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/ui/WorkaroundTextFieldSimple.kt @@ -0,0 +1,166 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.TextFieldColors +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import dev.wefhy.whymap.compose.utils.WorkaroundInteractions +import dev.wefhy.whymap.compose.utils.WorkaroundKeyEventRecognizer + + +@Composable +fun WorkaroundTextFieldSimple( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.TextFieldShape, + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + onSubmit: () -> Unit = {}, + onTab: () -> Unit = {}, +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + colors.textColor(enabled).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + var text by remember { mutableStateOf(TextFieldValue(value)) } + remember(value) { text = text.copy(text = value) } + + val recognizer = remember { + WorkaroundKeyEventRecognizer(object : WorkaroundInteractions() { + + override fun onCharacterTyped(c: Char, shift: Boolean) { + val char = if (shift) c.uppercaseChar() else c + val newText = text.text.take(text.selection.min) + char + text.text.drop(text.selection.max) + text = text.copy( + text = newText, + selection = textRange(text.selection.min + 1, text.selection.max + 1) + ) + onValueChange(newText) + } + + override fun onBackspace() { + val newText = if (text.selection.min == text.selection.max) { + text.text.safeTake(text.selection.min - 1) + text.text.drop(text.selection.max) + } else { + text.text.take(text.selection.min) + text.text.drop(text.selection.max) + } +// val newText = text.text.dropLast(1) + text = text.copy( + text = newText, + selection = cursor(text.selection.min - 1) + ) + onValueChange(newText) + } + + override fun onDelete() { + val newText = text.text.drop(1) + text = text.copy( + text = newText, + selection = cursor(text.selection.min - 1) + ) + onValueChange(newText) + } + + override fun onEnter() { + onSubmit() + } + + override fun onTab() { + onTab() + } + + override fun onLeftArrow(shift: Boolean) { + text = text.copy( + selection = cursor(text.selection.min - 1) + ) + } + + override fun onRightArrow(shift: Boolean) { + text = text.copy( + selection = cursor(text.selection.max + 1) + ) + } + }) + } + + @OptIn(ExperimentalMaterialApi::class) + (BasicTextField( + value = text, + modifier = modifier + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, isError, interactionSource, colors) + .defaultMinSize( + minWidth = TextFieldDefaults.MinHeight * 2, //TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ) + .onKeyEvent { + recognizer.onKeyEvent(it) + true + }, + onValueChange = { text = it }, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(colors.cursorColor(isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.TextFieldDecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors + ) + } + )) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/utils/ComposeUtils.kt b/src/main/java/dev/wefhy/whymap/compose/utils/ComposeUtils.kt new file mode 100644 index 0000000..78a0fc3 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/utils/ComposeUtils.kt @@ -0,0 +1,45 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.utils + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.toOffset +import dev.wefhy.whymap.utils.ImageFormat +import dev.wefhy.whymap.utils.ImageWriter.encode +import dev.wefhy.whymap.utils.ImageWriter.encodeJPEG +import dev.wefhy.whymap.utils.LocalTileBlock +import org.jetbrains.skia.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream + +object ComposeUtils { + fun LocalTileBlock.toOffset(): Offset = androidx.compose.ui.unit.IntOffset(x, z).toOffset() + fun Offset.toLocalTileBlock(): LocalTileBlock = LocalTileBlock(x.toInt(), y.toInt()) + val goodColors = listOf( + Color(0xFF0000FF), + Color(0xFF00FF00), + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFFFF00FF), + Color(0xFF00FFFF), + Color(0xFF000000), + Color(0xFFFFFFFF), + Color(0xFF808080), + Color(0xFF663300) + ) + fun Color.goodBackground(): Color = if (luminance() > 0.5f) Color.Black else Color.White + + fun BufferedImage.toImageBitmap(intermediate: ImageFormat): ImageBitmap? { + val stream = ByteArrayOutputStream() + stream.encode(this, intermediate) + return try { + Image.makeFromEncoded(stream.toByteArray()).toComposeImageBitmap() + } catch (e: Throwable) { + null + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/utils/StateAdapters.kt b/src/main/java/dev/wefhy/whymap/compose/utils/StateAdapters.kt new file mode 100644 index 0000000..c07f682 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/utils/StateAdapters.kt @@ -0,0 +1,54 @@ +// Copyright (c) 2024 wefhy + +@file:Suppress("NOTHING_TO_INLINE") + +package dev.wefhy.whymap.compose.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + + +class MutableStateAdapter( + private val state: State, + private val mutate: (T) -> Unit +) : MutableState { + + override var value: T + get() = state.value + set(value) { + mutate(value) + } + + override fun component1(): T = value + override fun component2(): (T) -> Unit = { value = it } +} + + +@Composable +inline fun MutableStateFlow.collectAsMutableState( + context: CoroutineContext = EmptyCoroutineContext +): MutableState = MutableStateAdapter( + state = collectAsState(context), + mutate = { value = it } +) + +//@Composable +//fun MutableStateFlow.collectAsMutableState( +// context: CoroutineContext = EmptyCoroutineContext +//): MutableState = MutableStateFlowWrapperState(this) +// +//private class MutableStateFlowWrapperState( +// private val state: MutableStateFlow +//) : MutableState { +// override var value: T +// get() = state.value +// set(value) { state.value = value } +// +// override fun component1(): T = value +// override fun component2(): (T) -> Unit = { value = it } +//} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundInteractions.kt b/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundInteractions.kt new file mode 100644 index 0000000..f520bac --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundInteractions.kt @@ -0,0 +1,19 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.utils + +import androidx.compose.ui.text.TextRange + +abstract class WorkaroundInteractions { + abstract fun onCharacterTyped(c: Char, shift: Boolean = false) + abstract fun onBackspace() + abstract fun onDelete() + abstract fun onEnter() + abstract fun onLeftArrow(shift: Boolean = false) + abstract fun onRightArrow(shift: Boolean = false) + abstract fun onTab() + protected inline fun cursor(position: Int) = textRange(position, position) + protected inline fun textRange(min: Int, max: Int) = TextRange(min.coerceAtLeast(0), max.coerceAtLeast(0)) + protected fun String.replace(range: TextRange, replacement: String) = take(range.min) + replacement + drop(range.max) + protected fun String.safeTake(index: Int) = take(index.coerceAtLeast(0)) +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundKeyEventRecognizer.kt b/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundKeyEventRecognizer.kt new file mode 100644 index 0000000..ba3078b --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/utils/WorkaroundKeyEventRecognizer.kt @@ -0,0 +1,71 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.utils + +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.key.Key.Companion +import java.awt.event.KeyEvent.VK_BACK_SPACE +import java.awt.event.KeyEvent as AwtKeyEvent + +class WorkaroundKeyEventRecognizer(val interactions: WorkaroundInteractions) { + + companion object { + + } + + object Weird { + val lShift = 0x154 + val rShift = 0x158 + val leftArraw = 263 + val rightArraw = 262 + val tab = 258 + val enter = 257 + val backspace = 259 + } + + val letters = setOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z + ) + val numbers = setOf( + Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, Key.Zero + ) + val numpad = setOf( + Key.NumPad0, Key.NumPad1, Key.NumPad2, Key.NumPad3, Key.NumPad4, Key.NumPad5, Key.NumPad6, Key.NumPad7, Key.NumPad8, Key.NumPad9 + ) + val symbols = setOf( + Key.Spacebar, Key.Comma, Key.Period, Key.Semicolon, Key.Slash, Key.Minus, Key.Equals + ) + val special = setOf( + Key.Backspace, Key.Delete, Key.Enter, Key.DirectionLeft, Key.DirectionRight + ) + val modifiers = setOf( + Key.ShiftRight, Key.ShiftLeft, Key.CtrlRight, Key.CtrlLeft, Key.AltLeft, Key.AltRight + ) + + fun onKeyEvent(keyEvent: KeyEvent) { + if (keyEvent.type != KeyEventType.KeyDown) return + val key = keyEvent.key + when (key) { + Key.Backspace -> interactions.onBackspace() + Key.Delete -> interactions.onDelete() + Key.Enter -> interactions.onEnter() + Key.DirectionLeft -> interactions.onLeftArrow(keyEvent.isShiftPressed) + Key.DirectionRight -> interactions.onRightArrow(keyEvent.isShiftPressed) + Key.Spacebar -> interactions.onCharacterTyped(' ', keyEvent.isShiftPressed) + in letters -> interactions.onCharacterTyped(AwtKeyEvent.getKeyText(key.nativeKeyCode).first(), keyEvent.isShiftPressed) + in numbers -> interactions.onCharacterTyped(AwtKeyEvent.getKeyText(key.nativeKeyCode).first(), keyEvent.isShiftPressed) + in numpad -> interactions.onCharacterTyped(AwtKeyEvent.getKeyText(key.nativeKeyCode).first(), keyEvent.isShiftPressed) + in symbols -> interactions.onCharacterTyped(AwtKeyEvent.getKeyText(key.nativeKeyCode).first(), keyEvent.isShiftPressed) + else -> { + when(key.nativeKeyCode) { + Weird.tab -> interactions.onTab() + Weird.backspace -> interactions.onBackspace() + Weird.enter -> interactions.onEnter() + Weird.leftArraw -> interactions.onLeftArrow(keyEvent.isShiftPressed) + Weird.rightArraw -> interactions.onRightArrow(keyEvent.isShiftPressed) + else -> println("Unhandled key: $key, native: ${key.nativeKeyCode}") + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/compose/views/IconRadio.kt b/src/main/java/dev/wefhy/whymap/compose/views/IconRadio.kt new file mode 100644 index 0000000..3b67851 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/compose/views/IconRadio.kt @@ -0,0 +1,79 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.compose.views + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.sharp.Edit +import androidx.compose.material.icons.sharp.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import dev.wefhy.whymap.compose.ui.WaypointSorting + +@Composable +fun IconRadioButton( + selected: Boolean, + onSelected: () -> Unit, + icon: ImageVector, + modifier: Modifier = Modifier +) { + OutlinedButton( + onClick = onSelected, + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colors.primary else Color.Gray + ), + modifier = modifier.width(48.dp).height(48.dp) + ) { + Icon( + modifier = Modifier.requiredWidth(48.dp).requiredHeight(48.dp), + imageVector = icon, + contentDescription = null + ) + } +} + +@Composable +fun SortingOptions( + selectedOption: WaypointSorting, + onOptionSelected: (WaypointSorting) -> Unit +) { + Row(Modifier.padding(16.dp, 0.dp)) { + IconRadioButton( + selected = selectedOption == WaypointSorting.ALPHABETICAL, + onSelected = { onOptionSelected(WaypointSorting.ALPHABETICAL) }, + icon = Icons.Sharp.Edit + ) + IconRadioButton( + selected = selectedOption == WaypointSorting.DISTANCE, + onSelected = { onOptionSelected(WaypointSorting.DISTANCE) }, + icon = Icons.Sharp.Person + ) + IconRadioButton( + selected = selectedOption == WaypointSorting.DATE, + onSelected = { onOptionSelected(WaypointSorting.DATE) }, + icon = Icons.Default.DateRange + ) + IconRadioButton( + selected = selectedOption == WaypointSorting.LOCATION, + onSelected = { onOptionSelected(WaypointSorting.LOCATION) }, + icon = Icons.Default.LocationOn + ) + } +} + +@Preview +@Composable +fun Preview() { + SortingOptions(WaypointSorting.DATE) {} +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/config/UserSettings.kt b/src/main/java/dev/wefhy/whymap/config/UserSettings.kt index f55b061..ce10402 100644 --- a/src/main/java/dev/wefhy/whymap/config/UserSettings.kt +++ b/src/main/java/dev/wefhy/whymap/config/UserSettings.kt @@ -16,6 +16,7 @@ data class UserSettings( // "tripwire", // "vine", // ), + var theme: Theme = Theme.LIGHT, var mapScale: Double = 1.0, var displayHud: Boolean = true, var minimapPosition: MinimapPosition = MinimapPosition.TOP_LEFT, @@ -39,6 +40,11 @@ data class UserSettings( DEBUG } + enum class Theme { + LIGHT, + DARK + } + companion object { fun help(): String = UserSettings::class.java.declaredFields .filter { it.type.isEnum } diff --git a/src/main/java/dev/wefhy/whymap/config/WhyMapConfig.kt b/src/main/java/dev/wefhy/whymap/config/WhyMapConfig.kt index 83f4271..0461373 100644 --- a/src/main/java/dev/wefhy/whymap/config/WhyMapConfig.kt +++ b/src/main/java/dev/wefhy/whymap/config/WhyMapConfig.kt @@ -50,7 +50,7 @@ object WhyMapConfig { val nativeZoomLevel = blockZoom - storageTileLog //17 val tileResolution = storageTileBlocks //512 - val regionThumbnailResolution = tileResolution shr regionThumbnailScaleLog + val regionThumbnailResolution = tileResolution shr regionThumbnailScaleLog //128 val legacyMetadataSize = 16 // bytes val metadataSize = 64 // bytes diff --git a/src/main/java/dev/wefhy/whymap/config/WhyUserSettings.kt b/src/main/java/dev/wefhy/whymap/config/WhyUserSettings.kt index d5b44b8..50ca9d0 100644 --- a/src/main/java/dev/wefhy/whymap/config/WhyUserSettings.kt +++ b/src/main/java/dev/wefhy/whymap/config/WhyUserSettings.kt @@ -20,6 +20,7 @@ object WhyUserSettings: WhySettings() { fun load(userSettings: UserSettings) { mapSettings.mapScale = userSettings.mapScale generalSettings.displayHud = userSettings.displayHud + generalSettings.theme = userSettings.theme mapSettings.minimapPosition = userSettings.minimapPosition mapSettings.minimapMode = userSettings.minimapMode serverSettings.exposeHttpApi = userSettings.exposeHttpApi @@ -30,6 +31,7 @@ object WhyUserSettings: WhySettings() { return UserSettings( mapScale = mapSettings.mapScale, displayHud = generalSettings.displayHud, + theme = generalSettings.theme, minimapPosition = mapSettings.minimapPosition, minimapMode = mapSettings.minimapMode, exposeHttpApi = serverSettings.exposeHttpApi, @@ -41,6 +43,7 @@ object WhyUserSettings: WhySettings() { class GeneralSettingsCategory : WhySettingsCategory("General") { var displayHud by SettingsEntry(true).addToggle("Display HUD") var hudColor by SettingsEntry(WhyColor.White).addColorPicker("HUD color") + var theme by SettingsEntry(UserSettings.Theme.LIGHT).addToggle("Theme") } class MapSettingsCategory: WhySettingsCategory("Map") { diff --git a/src/main/java/dev/wefhy/whymap/mixin/DebugRendererMixin.java b/src/main/java/dev/wefhy/whymap/mixin/DebugRendererMixin.java new file mode 100644 index 0000000..2f0f6c9 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/mixin/DebugRendererMixin.java @@ -0,0 +1,22 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.mixin; + +import dev.wefhy.whymap.overlay.WaypointRenderer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.debug.DebugRenderer; +import net.minecraft.client.util.math.MatrixStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(DebugRenderer.class) +public abstract class DebugRendererMixin { + @Inject(method = "render", at = @At("RETURN")) + private void renderDebugRenderers(MatrixStack matrixStack, VertexConsumerProvider.Immediate vtx, + double cameraX, double cameraY, double cameraZ, CallbackInfo ci) + { + WaypointRenderer.INSTANCE.renderDebugRenderers(matrixStack, vtx, cameraX, cameraY, cameraZ); + } +} diff --git a/src/main/java/dev/wefhy/whymap/mixin/MainUIDispatcherMixin.java b/src/main/java/dev/wefhy/whymap/mixin/MainUIDispatcherMixin.java new file mode 100644 index 0000000..b29eb95 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/mixin/MainUIDispatcherMixin.java @@ -0,0 +1,25 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.mixin; + +//public class GlobalSnapshotManagerMixin { +//} + + +import dev.wefhy.whymap.utils.WhyDispatchers; +import kotlinx.coroutines.CoroutineDispatcher; +import org.jetbrains.skiko.MainUIDispatcher_awtKt; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(value = MainUIDispatcher_awtKt.class, remap = false) +public class MainUIDispatcherMixin { + @Inject(method = "getMainUIDispatcher", at = @At("HEAD"), cancellable = true) +//@Inject(method = "getMainUIDispatcher()Lkotlinx/coroutines/CoroutineDispatcher;", at = @At("HEAD"), cancellable = true) +//@Inject(method = "Lkotlinx/coroutines/CoroutineDispatcher;", at = @At("HEAD"), cancellable = true) + private static void getMainUIDispatcher(CallbackInfoReturnable cir) { + cir.setReturnValue(WhyDispatchers.INSTANCE.getMainDispatcher()); + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/overlay/WaypointRenderer.kt b/src/main/java/dev/wefhy/whymap/overlay/WaypointRenderer.kt new file mode 100644 index 0000000..ffb4a84 --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/overlay/WaypointRenderer.kt @@ -0,0 +1,25 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.overlay + +import dev.wefhy.whymap.WhyMapMod.Companion.activeWorld +import net.minecraft.client.render.VertexConsumerProvider +import net.minecraft.client.render.debug.DebugRenderer +import net.minecraft.client.util.math.MatrixStack +import net.minecraft.util.math.Vec3d + +object WaypointRenderer { + fun renderDebugRenderers(matrixStack: MatrixStack?, vertexConsumers: VertexConsumerProvider?, camX: Double, camY: Double, camZ: Double) { + val playerPos = Vec3d(camX, camY, camZ) + for (waypoint in activeWorld?.waypoints?.waypoints ?: emptyList()) { + var loc = waypoint.location.toVec3d() + val distance = loc.distanceTo(playerPos) + if (distance > 100) { + loc = playerPos.add(loc.subtract(playerPos).normalize().multiply(100.0)) + } + val size = 0.002f * distance.coerceAtMost(100.0).toFloat() + //todo parse color + DebugRenderer.drawString(matrixStack, vertexConsumers, "${waypoint.name}(${distance.toInt()}m)", loc.x, loc.y, loc.z, -1, size, true, 0.0f, true) + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTextureProvider.kt b/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTextureProvider.kt index d18f353..5392c36 100644 --- a/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTextureProvider.kt +++ b/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTextureProvider.kt @@ -2,6 +2,10 @@ package dev.wefhy.whymap.tiles.details +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import dev.wefhy.whymap.compose.utils.ComposeUtils.toImageBitmap +import dev.wefhy.whymap.utils.ImageFormat import dev.wefhy.whymap.whygraphics.WhyTile import dev.wefhy.whymap.whygraphics.WhyTile.Companion.asWhyTile import net.minecraft.block.Block @@ -14,8 +18,10 @@ import kotlin.jvm.optionals.getOrNull object ExperimentalTextureProvider { val waterTexture by lazy { getBitmap("water")} //TODO move to separate file + val waterComposeTexture by lazy { getComposeTexture("water")!! } //TODO move to separate file private val loadedTextures = mutableMapOf?>() + private val loadedComposeTextures = mutableMapOf?>() private val classLoader = javaClass.classLoader private val loadedWhyTiles = mutableMapOf>() @@ -35,6 +41,18 @@ object ExperimentalTextureProvider { val missingTextures = mutableListOf() + fun getComposeTexture(block: Block): ImageBitmap? { + return getComposeTexture(block.translationKey.split('.').last()) + } + + fun getComposeTexture(name: String): ImageBitmap? { + return loadedComposeTextures.getOrPut(name) { +// Optional.of(getBitmap(name)?.toImageBitmap(ImageFormat.PNG) ?: return@getOrPut Optional.empty()) + Optional.of(getBitmap(name)?.toComposeImageBitmap() ?: return@getOrPut Optional.empty()) + //TODO check whether .toComposeImageBitmap() is faster, it has a weird comment in the source code + }?.getOrNull() + } + @OptIn(ExperimentalStdlibApi::class) fun getBitmap(name: String): BufferedImage? { // MinecraftClient.getInstance().resourceManager.getResource() diff --git a/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTileGenerator.kt b/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTileGenerator.kt index 105f98c..be4d684 100644 --- a/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTileGenerator.kt +++ b/src/main/java/dev/wefhy/whymap/tiles/details/ExperimentalTileGenerator.kt @@ -2,20 +2,23 @@ package dev.wefhy.whymap.tiles.details +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* import dev.wefhy.whymap.CurrentWorldProvider import dev.wefhy.whymap.WhyWorld import dev.wefhy.whymap.communication.quickaccess.BlockQuickAccess.foliageBlocksSet import dev.wefhy.whymap.communication.quickaccess.BlockQuickAccess.ignoreDepthTint import dev.wefhy.whymap.communication.quickaccess.BlockQuickAccess.waterBlocks import dev.wefhy.whymap.communication.quickaccess.BlockQuickAccess.waterLoggedBlocks +import dev.wefhy.whymap.tiles.details.ExperimentalTextureProvider.waterComposeTexture import dev.wefhy.whymap.tiles.details.ExperimentalTextureProvider.waterTexture import dev.wefhy.whymap.tiles.region.MapArea -import dev.wefhy.whymap.utils.LocalTile -import dev.wefhy.whymap.utils.RectArea -import dev.wefhy.whymap.utils.TileZoom -import dev.wefhy.whymap.utils.chunkPos +import dev.wefhy.whymap.utils.* import dev.wefhy.whymap.whygraphics.* import kotlinx.coroutines.withContext +import net.minecraft.block.Blocks import net.minecraft.util.math.ChunkPos import java.awt.Color import java.awt.Graphics2D @@ -28,6 +31,7 @@ context(CurrentWorldProvider) class ExperimentalTileGenerator { val renderedTiles = mutableMapOf>() + val renderedComposeTiles = mutableMapOf>() //TODO this map is never freed?! @OptIn(ExperimentalStdlibApi::class) @@ -36,6 +40,10 @@ class ExperimentalTileGenerator { Optional.ofNullable(renderTile(position)) }.getOrNull() + suspend fun getComposeTile(tile: LocalTileChunk): ImageBitmap? = renderedComposeTiles.getOrPut(tile) { + Optional.ofNullable(renderComposeTile(tile)) + }.getOrNull() + suspend fun MapArea.renderIntersection(g2d: Graphics2D, area: RectArea, offsetX: Int, offsetY: Int) { val chunks = ((area intersect location) ?: return).list() println("rendering ${chunks.size} chunks at $location, offset: $offsetX, $offsetY") @@ -53,6 +61,117 @@ class ExperimentalTileGenerator { }//.joinAll() } + private fun ColorMatrix.setOffset(floatArray: FloatArray) { + for (i in 0 until 4) { + this[i, 4] += floatArray[i] + } + } + + private fun ColorMatrix.setToScale(floatArray: FloatArray) { + for (i in 0 until 4) { + this[i, i] = floatArray[i] + } + } + + private fun MapArea.renderAt(canvas: Canvas, position: ChunkPos, offsetX: Int = 0, offsetY: Int = 0) { + val chunk = getChunk(position) ?: return + val chunkOverlay = getChunkOverlay(position) ?: return + val biomeFoliage = getChunkBiomeFoliageAndWater(position) ?: return + val depthmap = getChunkDepthmap(position) ?: return + val normalmap = getChunkNormals(position) ?: return + // val originalComposite = g2d.composite + // val alphaComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f) + val drawSize = Size(16f, 16f) + for (y in 0 until 16) { + for (x in 0 until 16) { + val drawPosX = offsetX + x * 16 + val drawPosY = offsetY + y * 16 + val drawOffset = Offset(drawPosX.toFloat(), drawPosY.toFloat()) + val drawRect = Rect(drawOffset, drawSize) + val block = chunk[y][x] + val (foliageColor, oceanColor) = biomeFoliage[y][x] + val blockOverlay = chunkOverlay[y][x] + val depth = depthmap[y][x].toUByte().toInt() + val normalShade = normalmap[y][x].shade + + //TODO handle lava separately + + val blockColorFilter = + if (foliageBlocksSet.contains(block)) { + (foliageColor * normalShade).floatArray + } else { + normalShade.floatArray + } + val source = ExperimentalTextureProvider.getComposeTexture(block.block) + if (source != null) { + canvas.drawImage( //TODO java.lang.IllegalArgumentException: Rescaling cannot be performed on an indexed image + source, + drawOffset, + Paint().apply { colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { + setToScale(blockColorFilter[0], blockColorFilter[1], blockColorFilter[2], blockColorFilter[3]) + }) }, + ) + } else { + canvas.drawRect( + drawPosX.toFloat(), + drawPosY.toFloat(), + 16f, + 16f, + Paint().apply { color = androidx.compose.ui.graphics.Color(block.getMapColor(null, null).color) } + ) + } + if (depth == 0 || blockOverlay.isAir) continue + val sourceOverlay = ExperimentalTextureProvider.getComposeTexture(blockOverlay.block) + val tmp1 = (1 - depth * 0.02f).coerceAtLeast(0f) + val alpha = (3 - tmp1 * tmp1) / 3 + val darken = -depth * 1.6f + + val c = if (waterBlocks.contains(blockOverlay)) oceanColor + else if (foliageBlocksSet.contains(blockOverlay)) foliageColor + else WhyColor.White + + val darkenArray = if (!ignoreDepthTint.contains(blockOverlay)) { + floatArrayOf(darken, darken, darken, 0f) + } else FloatArray(4) + + val newRop = if (waterLoggedBlocks.contains(blockOverlay)) { +// val waterRop = RescaleOp(oceanColor.floatArray.apply { this[3] = alpha * 1.6f }, darkenArray, null) + val waterRop = ColorMatrix().apply { + setToScale(oceanColor.floatArray.apply { this[3] = alpha * 1.6f }) + setOffset(darkenArray) + } + canvas.drawImage( + waterComposeTexture, + drawOffset, + Paint().apply { colorFilter = ColorFilter.colorMatrix(waterRop) } + ) +// RescaleOp(c.floatArray, FloatArray(4), null) + val cc = c.floatArray + ColorMatrix().apply { setToScale(cc) } + } else { +// RescaleOp(c.floatArray.apply { this[3] = alpha * 1.6f }, darkenArray, null) //TODO don't change alpha for non-water! + ColorMatrix().apply { + setToScale(c.floatArray.apply { this[3] = alpha * 1.6f }) + setOffset(darkenArray) + } + } + + if (sourceOverlay != null) { + canvas.drawImage(sourceOverlay, drawOffset, Paint().apply { colorFilter = ColorFilter.colorMatrix(newRop) }) + } else { + canvas.drawRect( + drawRect, + Paint().apply { color = androidx.compose.ui.graphics.Color( + (c + (-depth * 4)).intRGB or ((alpha * 255).toInt() shl 24) //TODO use proper alpha! + ) } + ) + } + + //TODO refactor this rendering finally + } + } + } + private fun MapArea.renderAt(g2d: Graphics2D, position: ChunkPos, offsetX: Int = 0, offsetY: Int = 0) { val chunk = getChunk(position) ?: return val chunkOverlay = getChunkOverlay(position) ?: return @@ -129,6 +248,25 @@ class ExperimentalTileGenerator { } } + private suspend fun renderComposeTile(tile: LocalTileChunk): ImageBitmap? { + return try { + currentWorld.mapRegionManager.getRegionForTilesRendering( + tile.parent(TileZoom.RegionZoom) + ) { + val bitmap = ImageBitmap(16 * 16, 16 * 16) + val canvas = Canvas(bitmap) + renderAt(canvas, tile.chunkPos) + bitmap + } + } catch (e: IndexOutOfBoundsException) { + println(" Failed to render chunk (${tile.x}, ${tile.z}) due to out of bounds") + null + } catch (e: IllegalArgumentException) { + println("Failed to render chunk (${tile.x}, ${tile.z}) due do indexed image (probably)") + null + } + } + private suspend fun renderTile(position: ChunkPos): BufferedImage? { return try { currentWorld.mapRegionManager.getRegionForTilesRendering( diff --git a/src/main/java/dev/wefhy/whymap/tiles/region/MapArea.kt b/src/main/java/dev/wefhy/whymap/tiles/region/MapArea.kt index b06ad59..096f54b 100644 --- a/src/main/java/dev/wefhy/whymap/tiles/region/MapArea.kt +++ b/src/main/java/dev/wefhy/whymap/tiles/region/MapArea.kt @@ -68,7 +68,7 @@ class MapArea private constructor(val location: LocalTileRegion) { val biomeMap: Array = Array(storageTileBlocks) { ByteArray(storageTileBlocks) { 0 } } // at least 7 bits, possibly 8 val lightMap: Array = Array(storageTileBlocks) { ByteArray(storageTileBlocks) { 0 } } // at least 4 bits val depthMap: Array = Array(storageTileBlocks) { ByteArray(storageTileBlocks) { 0 } } // at least 8 bits -// val exists = Array(storageTileChunks) { Array(storageTileChunks) { false } } // 1 bit +// val exists = Array(storageTileChunks) { Array(storageTileChunks) { false } } // 1 bit TODO use timestamp! val file = currentWorld.getFile(location) val thumbnailFile = currentWorld.getThumbnailFile(location) @@ -508,6 +508,11 @@ class MapArea private constructor(val location: LocalTileRegion) { } } +// val renderedImage +// get() = remember(someHash) { +// 4 //TODO implement something like this that doesn't need a composable. +// } + private fun _renderWhyImage(): WhyTiledImage { var failCounter = 0 val image = WhyTiledImage.BuildForRegion { y, x -> diff --git a/src/main/java/dev/wefhy/whymap/tiles/region/MapRegionManager.kt b/src/main/java/dev/wefhy/whymap/tiles/region/MapRegionManager.kt index 72325d3..7b3641a 100644 --- a/src/main/java/dev/wefhy/whymap/tiles/region/MapRegionManager.kt +++ b/src/main/java/dev/wefhy/whymap/tiles/region/MapRegionManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import net.minecraft.client.MinecraftClient import java.util.concurrent.ConcurrentHashMap context(CurrentWorldProvider) @@ -57,7 +58,7 @@ class MapRegionManager { private suspend fun cleanupRegions() = withContext(WhyDispatchers.LowPriority) { // TODO make sure this runs on correct dispatcher to avoid context switching - val playerPos = (currentWorld as? CurrentWorld)?.player?.pos + val playerPos = MinecraftClient.getInstance()?.player?.pos regionLoaders.values.map { launch { it.clean(playerPos) } }.forEach { it.join() } } diff --git a/src/main/java/dev/wefhy/whymap/utils/Accessors.kt b/src/main/java/dev/wefhy/whymap/utils/Accessors.kt new file mode 100644 index 0000000..a80643e --- /dev/null +++ b/src/main/java/dev/wefhy/whymap/utils/Accessors.kt @@ -0,0 +1,10 @@ +// Copyright (c) 2024 wefhy + +package dev.wefhy.whymap.utils + +import net.minecraft.client.MinecraftClient + +object Accessors { + inline val clientInstance get() = MinecraftClient.getInstance() + inline val clientWindow get() = clientInstance.window +} \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/utils/MapTile.kt b/src/main/java/dev/wefhy/whymap/utils/MapTile.kt index 3cb0338..8ff1e9a 100644 --- a/src/main/java/dev/wefhy/whymap/utils/MapTile.kt +++ b/src/main/java/dev/wefhy/whymap/utils/MapTile.kt @@ -5,14 +5,17 @@ package dev.wefhy.whymap.utils import dev.wefhy.whymap.config.WhyMapConfig import dev.wefhy.whymap.utils.TileZoom.* import net.minecraft.util.math.ChunkPos +import net.minecraft.util.math.Vec3d import java.io.File +import kotlin.math.hypot +import kotlin.math.pow open class MapTile(val x: Int, val z: Int, val zoom: Z) where Z : TileZoom { fun toLocalTile(): LocalTile { return LocalTile( - x - zoom.shift, - z - zoom.shift, + x - zoom.offset, + z - zoom.offset, zoom ) } @@ -51,16 +54,21 @@ open class MapTile(val x: Int, val z: Int, val zoom: Z) where Z : TileZoom { } typealias LocalTileBlock = LocalTile +fun LocalTileBlock(x: Int, z: Int) = LocalTile(x, z, BlockZoom) +fun LocalTileBlock(pos: Vec3d) = LocalTileBlock(pos.x.toInt(), pos.z.toInt()) typealias LocalTileChunk = LocalTile +fun LocalTileChunk(x: Int, z: Int) = LocalTile(x, z, ChunkZoom) typealias LocalTileRegion = LocalTile +fun LocalTileRegion(x: Int, z: Int) = LocalTile(x, z, RegionZoom) typealias LocalTileThumbnail = LocalTile +fun LocalTileThumbnail(x: Int, z: Int) = LocalTile(x, z, ThumbnailZoom) open class LocalTile(val x: Int, val z: Int, val zoom: Z) { fun toMapTile(): MapTile { return MapTile( - x + zoom.shift, - z + zoom.shift, + x + zoom.offset, + z + zoom.offset, zoom ) } @@ -176,6 +184,22 @@ open class LocalTile(val x: Int, val z: Int, val zoom: Z) { } override fun toString() = "LocalTile$zoom{x: $x, z: $z}" + + infix fun distanceTo(other: LocalTile): Double { + return hypot((x - other.x).toDouble(), (z - other.z).toDouble()) + } + + operator fun rangeTo(maxTile: LocalTile): Sequence> { + val xRange = x..maxTile.x + val zRange = z..maxTile.z + return sequence { + for (x in xRange) { + for (z in zRange) { + yield(LocalTile(x, z, zoom)) + } + } + } + } } val LocalTileChunk.chunkPos @@ -189,7 +213,8 @@ sealed class TileZoom(val zoom: Int) { object RegionZoom : TileZoom(WhyMapConfig.regionZoom) object ThumbnailZoom : TileZoom(WhyMapConfig.thumbnailZoom) - val shift = 1 shl (zoom - 1) + val offset = 1 shl (zoom - 1) + val scale = 2.0.pow(zoom - WhyMapConfig.regionZoom) } fun File.resolve(tile: MapTile) = this diff --git a/src/main/java/dev/wefhy/whymap/utils/Utils.kt b/src/main/java/dev/wefhy/whymap/utils/Utils.kt index 84bc524..d535e66 100644 --- a/src/main/java/dev/wefhy/whymap/utils/Utils.kt +++ b/src/main/java/dev/wefhy/whymap/utils/Utils.kt @@ -5,6 +5,7 @@ package dev.wefhy.whymap.utils +import androidx.compose.ui.input.key.* import dev.wefhy.whymap.config.WhyMapConfig.logsDateFormatter import dev.wefhy.whymap.config.WhyMapConfig.logsEntryTimeFormatter import dev.wefhy.whymap.config.WhyMapConfig.pathForbiddenCharacters @@ -30,7 +31,9 @@ const val _1_255 = 1f / 255 const val _1_3 = 1f / 3 const val _1_2 = 1f / 2 const val bestHashConst = 92821 - +val rand = Random(0) +inline val Double.d1 get() = roundToString(1) +inline val Double.d2 get() = roundToString(2) inline fun Double.roundToString(places: Int) = String.format("%.${places}f", this) inline fun Float.roundToString(places: Int) = String.format("%.${places}f", this) @@ -42,6 +45,24 @@ internal inline fun Double.significant(places: Int) = String.format("%.${_signif internal inline fun Double.significantBy(max: Double, places: Int) = String.format("%.${max._significant(places)}f", this) +//fun KeyEvent.toCompose() = KeyEvent( +// nativeKeyEvent = InternalKeyEvent( +// key = Key( +// nativeKeyCode = keyCode, +// nativeKeyLocation = keyLocationForCompose +// ), +// type = when (id) { +// KEY_PRESSED -> KeyEventType.KeyDown +// KEY_RELEASED -> KeyEventType.KeyUp +// else -> KeyEventType.Unknown +// }, +// codePoint = keyChar.code, +// modifiers = toPointerKeyboardModifiers(), +// nativeEvent = this +// ) +//) + + fun BufferedImage.getAverageColor(): Int { // This can only average up to 128x128 textures without integer overflow!!! val bytes = (data.dataBuffer as DataBufferByte).data val length = bytes.size @@ -209,13 +230,11 @@ fun Raster.fillWithColor(color: Int) { } } -val R = Random(0) - fun WritableRaster.fillWithColor2(color: Int) { println("${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}, ${minX} ${minY} ${width} ${height}") for (y in 0 until height) { for (x in 0 until width) { - setPixel(x, y, intArrayOf(color, R.nextInt(), R.nextInt())) + setPixel(x, y, intArrayOf(color, rand.nextInt(), rand.nextInt())) } } } diff --git a/src/main/java/dev/wefhy/whymap/utils/WhyDispatchers.kt b/src/main/java/dev/wefhy/whymap/utils/WhyDispatchers.kt index 382d505..e8d5f65 100644 --- a/src/main/java/dev/wefhy/whymap/utils/WhyDispatchers.kt +++ b/src/main/java/dev/wefhy/whymap/utils/WhyDispatchers.kt @@ -2,7 +2,11 @@ package dev.wefhy.whymap.utils +import dev.wefhy.whymap.utils.Accessors.clientInstance +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.util.concurrent.* import java.util.concurrent.atomic.AtomicInteger import kotlin.math.max @@ -15,6 +19,20 @@ object WhyDispatchers { val Render = newReversePriorityFixedThreadPool(safeThreads).asCoroutineDispatcher() val Encoding = newReversePriorityFixedThreadPool(safeThreads).asCoroutineDispatcher() val LowPriority = Executors.newFixedThreadPool(safeThreads, LowPriorityThreadFactory).asCoroutineDispatcher() + val MainDispatcher by lazy { clientInstance.asCoroutineDispatcher() } + val MainScope by lazy { CoroutineScope(MainDispatcher) } + + fun launchOnMain(block: suspend () -> Unit) { + MainScope.launch { + block() + } + } + + fun blockOnMain(block: suspend () -> Unit) { + runBlocking(MainDispatcher) { + block() + } + } object LowPriorityThreadFactory : ThreadFactory { private const val priority = Thread.MIN_PRIORITY diff --git a/src/main/java/dev/wefhy/whymap/waypoints/Waypoint.kt b/src/main/java/dev/wefhy/whymap/waypoints/Waypoint.kt index cfc1824..29fe481 100644 --- a/src/main/java/dev/wefhy/whymap/waypoints/Waypoint.kt +++ b/src/main/java/dev/wefhy/whymap/waypoints/Waypoint.kt @@ -3,7 +3,9 @@ package dev.wefhy.whymap.waypoints import dev.wefhy.whymap.utils.CoordinateConversion +import dev.wefhy.whymap.utils.LocalTileBlock import kotlinx.serialization.Serializable +import net.minecraft.util.math.Vec3d @Serializable class LocalWaypoint(var name: String, val location: CoordXYZ, var color: String? = null, var initials: String? = null, val isDeath: Boolean? = null, val isBed: Boolean? = null) { @@ -26,6 +28,14 @@ data class CoordXYZ(val x: Int, val y: Int, val z: Int) { return LatLng(deg.first, deg.second) } + fun toVec3d(): Vec3d { + return Vec3d(x.toDouble(), y.toDouble(), z.toDouble()) + } + + fun toLocalBlock(): LocalTileBlock { + return LocalTileBlock(x, z) + } + fun toLatLngWithHalfBlockOffset(): LatLng { val deg = CoordinateConversion.coord2deg(x.toDouble() + 0.5, z.toDouble() + 0.5) return LatLng(deg.first, deg.second) @@ -34,4 +44,11 @@ data class CoordXYZ(val x: Int, val y: Int, val z: Int) { override fun toString(): String { return "(x=$x, y=$y, z=$z)" } + + companion object { + val ZERO = CoordXYZ(0, 0, 0) + fun Vec3d.toCoordXYZ(): CoordXYZ { + return CoordXYZ(x.toInt(), y.toInt(), z.toInt()) + } + } } \ No newline at end of file diff --git a/src/main/java/dev/wefhy/whymap/waypoints/Waypoints.kt b/src/main/java/dev/wefhy/whymap/waypoints/Waypoints.kt index 21f164b..6049e5a 100644 --- a/src/main/java/dev/wefhy/whymap/waypoints/Waypoints.kt +++ b/src/main/java/dev/wefhy/whymap/waypoints/Waypoints.kt @@ -13,7 +13,7 @@ import net.minecraft.util.math.GlobalPos context(CurrentWorldProvider) class Waypoints { private val file = currentWorld.worldPath.resolve("waypoints.txt") - private val waypoints: MutableList = try { + val waypoints: MutableList = try { if (file.exists()) Json.decodeFromString(file.readText()) else mutableListOf() diff --git a/src/main/java/dev/wefhy/whymap/whygraphics/WhyColor.kt b/src/main/java/dev/wefhy/whymap/whygraphics/WhyColor.kt index 4c0d1b1..73a9ee2 100644 --- a/src/main/java/dev/wefhy/whymap/whygraphics/WhyColor.kt +++ b/src/main/java/dev/wefhy/whymap/whygraphics/WhyColor.kt @@ -37,6 +37,13 @@ val FastWhyColor.intB val FastWhyColor.intARGB inline get() = (intA shl 24) or (intR shl 16) or (intG shl 8) or intB +val WhyColor.composeColor +// inline get() = androidx.compose.ui.graphics.Color(r, g, b, a) //TODO this causes crash due to negative red! How can it be negative? + inline get() = androidx.compose.ui.graphics.Color(intR, intG, intB, intA) + +val FastWhyColor.composeColor + inline get() = androidx.compose.ui.graphics.Color(r, g, b, a) + fun FastWhyColor.toWhyColor(): WhyColor = WhyColor(r, g, b, a) inline fun WhyColor.toFastWhyColor(): FastWhyColor = floatArrayOf(a, r, g, b) diff --git a/src/main/java/dev/wefhy/whymap/whygraphics/WhyTile.kt b/src/main/java/dev/wefhy/whymap/whygraphics/WhyTile.kt index 64f3bb3..3ff9bc9 100644 --- a/src/main/java/dev/wefhy/whymap/whygraphics/WhyTile.kt +++ b/src/main/java/dev/wefhy/whymap/whygraphics/WhyTile.kt @@ -2,8 +2,18 @@ package dev.wefhy.whymap.whygraphics +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.toOffset import dev.wefhy.whymap.utils.ExpensiveCall +import dev.wefhy.whymap.utils.memoize import net.minecraft.client.texture.NativeImage +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo import java.awt.image.BufferedImage import java.awt.image.WritableRaster @@ -42,6 +52,10 @@ open class WhyTile(val data: Array = Array(arraySize) { WhyColor.Trans ) } + val memoizedAverage = memoize { + average() + } + fun average(): WhyColor { var r = 0f @@ -74,6 +88,43 @@ open class WhyTile(val data: Array = Array(arraySize) { WhyColor.Trans ) } + context(Canvas) + fun drawTile(xOffset: Int, yOffset: Int) { +// val image = toImageBitmap() + val image = memoizedImageBitmap(this) +// println("Drawing $image at $xOffset, $yOffset of size ${image.width}, ${image.height}") +// drawRect(memoizedAverage(this).composeColor, Offset(xOffset.toFloat(), yOffset.toFloat()), Size(chunkSize.toFloat(), chunkSize.toFloat())) +// if (rand.nextInt(4) == 1) { + drawImage(image, topLeftOffset = IntOffset(xOffset, yOffset).toOffset(), paint = Paint()) +// } + } + + val memoizedImageBitmap = memoize { + toImageBitmap() + } + + fun toImageBitmap(): ImageBitmap { //TODO cache + val bytes = ByteArray(arraySize shl 2) + for (i in 0 until arraySize) { + val color = data[i] + val index = i shl 2 + bytes[index] = color.intR.toByte() + bytes[index + 1] = color.intG.toByte() + bytes[index + 2] = color.intB.toByte() +// bytes[index + 3] = color.intA.toByte() + bytes[index + 3] = if (color.intA > 0) 0xFF.toByte() else 0.toByte() + } + val image = Image.makeRaster( //TODO this can get a NativePointer for maybe faster drawing + imageInfo = ImageInfo.makeN32Premul(width, height).withColorType(ColorType.RGBA_8888), + bytes = bytes, + rowBytes = width * 4 + ) + return image.toComposeImageBitmap()//.also { +// println("Converted to ImageBitmap: " + +// "${it.colorSpace}, ${it.width}, ${it.height}, ${it.config}") +// } + } + fun writeInto(raster: WritableRaster, xOffset: Int, yOffset: Int) { var i = 0 // println("Writing tile at $xOffset, $yOffset") diff --git a/src/main/java/dev/wefhy/whymap/whygraphics/WhyTiledImage.kt b/src/main/java/dev/wefhy/whymap/whygraphics/WhyTiledImage.kt index 8f13567..d13e9a0 100644 --- a/src/main/java/dev/wefhy/whymap/whygraphics/WhyTiledImage.kt +++ b/src/main/java/dev/wefhy/whymap/whygraphics/WhyTiledImage.kt @@ -2,6 +2,9 @@ package dev.wefhy.whymap.whygraphics +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig import dev.wefhy.whymap.config.WhyMapConfig import dev.wefhy.whymap.config.WhyMapConfig.blocksInChunkLog import dev.wefhy.whymap.utils.ExpensiveCall @@ -51,6 +54,30 @@ class WhyTiledImage( return image } + context(Canvas) + fun drawTiledImage() { + println("Drawing $this WhyTiledImage") + for (y in 0 until yTiles) { + val line = data[y] + for (x in 0 until xTiles) { + val tile = line[x] ?: continue +// println("Drawing tile at $x, $y") + tile.drawTile(x shl WhyTile.lineShl, y shl WhyTile.lineShl) + } + } + } + + val imageBitmap by lazy { + val image = ImageBitmap( + width, + height, + ImageBitmapConfig.Argb8888) + Canvas(image).apply { + drawTiledImage() + } + image + } + fun writeInto(raster: WritableRaster, offsetX: Int, offsetY: Int) { // println("Writing into raster at $offsetX, $offsetY") // raster.setPixel(offsetX, offsetY, intArrayOf(255, 0, 0)) diff --git a/src/main/resources/fonts/minecraft-font/MinecraftBold.otf b/src/main/resources/fonts/minecraft-font/MinecraftBold.otf new file mode 100644 index 0000000..87b124c Binary files /dev/null and b/src/main/resources/fonts/minecraft-font/MinecraftBold.otf differ diff --git a/src/main/resources/fonts/minecraft-font/MinecraftBoldItalic.otf b/src/main/resources/fonts/minecraft-font/MinecraftBoldItalic.otf new file mode 100644 index 0000000..1f74f38 Binary files /dev/null and b/src/main/resources/fonts/minecraft-font/MinecraftBoldItalic.otf differ diff --git a/src/main/resources/fonts/minecraft-font/MinecraftItalic.otf b/src/main/resources/fonts/minecraft-font/MinecraftItalic.otf new file mode 100644 index 0000000..6801bd8 Binary files /dev/null and b/src/main/resources/fonts/minecraft-font/MinecraftItalic.otf differ diff --git a/src/main/resources/fonts/minecraft-font/MinecraftRegular.otf b/src/main/resources/fonts/minecraft-font/MinecraftRegular.otf new file mode 100644 index 0000000..54f08ad Binary files /dev/null and b/src/main/resources/fonts/minecraft-font/MinecraftRegular.otf differ diff --git a/src/main/resources/fonts/minecraft-font/info.txt b/src/main/resources/fonts/minecraft-font/info.txt new file mode 100644 index 0000000..5238883 --- /dev/null +++ b/src/main/resources/fonts/minecraft-font/info.txt @@ -0,0 +1,2 @@ +license: Public Domain +link: https://www.fontspace.com/minecraft-font-f28180 \ No newline at end of file diff --git a/src/main/resources/whymap.mixins.json b/src/main/resources/whymap.mixins.json index c0ef9fd..1cceba6 100644 --- a/src/main/resources/whymap.mixins.json +++ b/src/main/resources/whymap.mixins.json @@ -4,10 +4,14 @@ "package": "dev.wefhy.whymap.mixin", "compatibilityLevel": "JAVA_17", "mixins": [ + "MainUIDispatcherMixin", "PlayerEntityMixin", "ServerPlayerEntityMixin" ], "injectors": { "defaultRequire": 1 - } + }, + "client": [ + "DebugRendererMixin" + ] } \ No newline at end of file