diff --git a/.github/workflows/release-matrix.yml b/.github/workflows/release-matrix.yml index 80e61a0..8cd9fe5 100644 --- a/.github/workflows/release-matrix.yml +++ b/.github/workflows/release-matrix.yml @@ -49,6 +49,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Set Up Java uses: actions/setup-java@v4 @@ -188,26 +190,25 @@ jobs: python -m pip install --upgrade aqtinstall $qtHost = "windows" - $archOutput = python -m aqt list-qt $qtHost desktop --arch $env:QT_VERSION - $archs = $archOutput -split '\s+' | Where-Object { $_ -ne '' } + $archOutput = & python -m aqt list-qt $qtHost desktop --arch $env:QT_VERSION 2>&1 + if ($LASTEXITCODE -ne 0) { throw "list-qt --arch failed" } + $archs = ($archOutput -join " ") -split '\s+' | Where-Object { $_ -ne '' } $qtArch = $archs | Where-Object { $_ -match 'msvc' -and $_ -match '64' } | Select-Object -First 1 - if (-not $qtArch) { - $qtArch = $archs | Where-Object { $_ -match '64' } | Select-Object -First 1 - } + if (-not $qtArch) { $qtArch = $archs | Where-Object { $_ -match '64' } | Select-Object -First 1 } + if (-not $qtArch) { throw "Unable to resolve Qt architecture. Available: $($archs -join ', ')" } $qtPlatform = "windows-x64" - if (-not $qtArch) { - throw "Unable to resolve Qt architecture for windows/x64. Available: $($archs -join ', ')" - } - - $modulesOutput = python -m aqt list-qt $qtHost desktop --modules $env:QT_VERSION $qtArch + $modulesOutput = & python -m aqt list-qt $qtHost desktop --modules $env:QT_VERSION $qtArch 2>&1 + if ($LASTEXITCODE -ne 0) { throw "list-qt --modules failed" } if ($modulesOutput -match '\bqtsvg\b') { Write-Host "Installing Qt with optional module qtsvg" - python -m aqt install-qt $qtHost desktop $env:QT_VERSION $qtArch -m qtsvg -O "$PWD\\.qt" + & python -m aqt install-qt $qtHost desktop $env:QT_VERSION $qtArch -m qtsvg -O "$PWD\\.qt" + if ($LASTEXITCODE -ne 0) { throw "install-qt (with qtsvg) failed" } } else { - Write-Host "qtsvg module not available for $qtHost/$qtArch on Qt $env:QT_VERSION; installing base Qt only" - python -m aqt install-qt $qtHost desktop $env:QT_VERSION $qtArch -O "$PWD\\.qt" + Write-Host "qtsvg module not available; installing base Qt only" + & python -m aqt install-qt $qtHost desktop $env:QT_VERSION $qtArch -O "$PWD\\.qt" + if ($LASTEXITCODE -ne 0) { throw "install-qt failed" } } $qtRoot = "$PWD\\.qt\\$env:QT_VERSION" @@ -495,7 +496,7 @@ jobs: permissions: contents: write needs: build - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch')) steps: - name: Checkout uses: actions/checkout@v4 @@ -560,7 +561,7 @@ jobs: needs: release permissions: contents: read - if: needs.release.result == 'success' && github.event_name != 'pull_request' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') + if: needs.release.result == 'success' && github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') && !(github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') steps: - name: Checkout uses: actions/checkout@v4 @@ -668,7 +669,7 @@ jobs: needs: release permissions: contents: read - if: needs.release.result == 'success' && github.event_name != 'pull_request' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') + if: needs.release.result == 'success' && github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') && !(github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') steps: - name: Download wingetcreate shell: pwsh diff --git a/.gitignore b/.gitignore index 630b01e..db9c475 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ build/ !**/src/test/**/build/ ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea/ *.iws *.iml *.ipr @@ -47,4 +44,12 @@ bin/ local.properties src/main/resources/icons -AppDir \ No newline at end of file +AppDir +tools/registry-builder/target/ +CMakeCache.txt +CMakeFiles/ +Makefile +cmake_install.cmake +/CompanionMod/ +hs_err_pid*.log +/.architectury-transformer/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e91fedd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tree-sitter-javascript"] + path = tree-sitter-javascript + url = https://github.com/tree-sitter/tree-sitter-javascript.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b40fb63 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +## 0.1.6 + +--- +### New +* Added an Animated Scrolling effect (**Experimental**, disabled by default). +* Added a Keymap system. +* Added a Rust utility which generates the Item database used by the Item Browser, and sets up KubeJS Typings. +* Added Code Completion (JS only for now). +* Added a Mod Config Editor Pane, which supports editing the following config types: + * TOML + * JSON, JSONC, JSON5 + * Properties + * Yaml + * Forge CFG +* Added a Mod Browser Pane, currently supporting Modrinth. Curseforge implementation is on its way. +* Added an Item Browser dock panel. +* Added a ModPack detail dock panel. +* Added a context menu for Project Files. +* Added Project Files View Modes. +* Added custom Tooltip backgrounds (**Experimental**, disabled by default). +* KubeJS Extension. Provides Code Completion with dumped Typings, using FlatBuffers, with a Database as backup. +* Added Seasonal Events. +* Added ability to set a background image for the Project view. +* Added Auto-save. +* Added Rainbow Brackets (**Experimental**, disabled by default). +* Added Extensions panel to Dashboard. + +### Gradle +* Added Tree-Sitter. +* Added extra Serialization libs for Mod Config parsing. +* Added some args to improve performance. + +### Companion Mod +* Improved WebSocket connections. +* Added KubeJS Typings support. +* Added an Item renderer which then dumps for use in Tritium's Item Browser. + +### Other +* General cleanup. +* Settings View opens faster. +* Listeners use Kotlin flows instead of array lists. +* Added better logging handling for Qt runtime warnings. +* Hopefully probably maybe possibly potentially fixed Icons DPR and scaling issues. +* Fixed window state geometry getting corrupted to oblivion due to band affiliation. +* LSPs work now, with support for JSON, XML and Python (needs some work on the installation / providing part). \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 33a50db..6378799 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,14 +7,24 @@ plugins { alias(libs.plugins.kotlin) alias(libs.plugins.serialization) alias(libs.plugins.ksp) + alias(libs.plugins.ktreesitter) idea - id("com.github.johnrengelman.shadow") version "8.1.1" + id("com.gradleup.shadow") version "9.2.0" } ksp { arg("verbose", "true") } +val grammarDir = layout.projectDirectory.dir("tree-sitter-javascript").asFile + +grammar { + grammarName.set("javascript") + baseDir.set(grammarDir) + className.set("TreeSitterJavascript") + packageName.set("io.github.tritium_launcher.launcher.ui.project.editor.treesitter.grammar") +} + group = "io.github.tritium_launcher.launcher" -version = "0.1.5" +version = "0.1.6" val tritiumVersion = project.version.toString() val os: OperatingSystem = OperatingSystem.current() @@ -27,6 +37,8 @@ val qtOs = when { else -> "unknown" } +dependencyLocking { lockAllConfigurations() } + repositories { mavenCentral() } @@ -45,9 +57,17 @@ configurations.configureEach { } dependencies { - // Kotlin + // Serialization implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.hocon) + implementation(libs.kotlinx.serialization.properties) + implementation(libs.kotlin.json5) + implementation(libs.ktoml.core) + implementation(libs.ktoml.file) + implementation(libs.yamlkt) + implementation(libs.knbt) + + // Coroutines implementation(libs.kotlinx.coroutines.core) // KSP @@ -82,10 +102,10 @@ dependencies { // MSAL4j implementation(libs.msal4j) + implementation(libs.jultoslf4j) // Logback implementation(libs.logback.classic) - implementation(libs.kotlin.reflect) // LSP implementation(libs.lsp4j) @@ -94,11 +114,81 @@ dependencies { implementation(libs.jna) implementation(libs.jna.platform) + // CommonMark + implementation(libs.commonmark) + implementation(libs.commonmark.ext.gfm.tables) + implementation(libs.commonmark.ext.gfm.strikethrough) + implementation(libs.commonmark.ext.task.list.items) + implementation(libs.commonmark.ext.image.attributes) + implementation(libs.sqlite.jdbc) + implementation(libs.flatbuffers) + + // Kotlin + implementation(libs.kotlin.reflect) + + // KTreeSitter + implementation(libs.ktreesitter) + /* Test */ testImplementation(libs.bundles.test) } +val nativeLibDir = layout.buildDirectory.dir("native/grammar") +val generatedGrammarDir = layout.buildDirectory.dir("generated") +val grammarCmakeLists = generatedGrammarDir.map { it.file("CMakeLists.txt") } +val grammarSourceDir = layout.projectDirectory.dir("tree-sitter-javascript/src") +val nativeGrammarLibName: String = when { + os.isWindows -> "ktreesitter-javascript.dll" + os.isMacOsX -> "libktreesitter-javascript.dylib" + else -> "libktreesitter-javascript.so" +} + +val patchCmakeLists by tasks.registering { + description = "Fix CMakeLists.txt include path for tree-sitter header" + dependsOn(tasks.named("generateGrammarFiles")) + inputs.file(grammarCmakeLists) + outputs.file(grammarCmakeLists) + doLast { + val cmakeFile = grammarCmakeLists.get().asFile + val content = cmakeFile.readText().replace('\\', '/') + val fixed = content.replace( + "../../tree-sitter-javascript/bindings/c", + "../../tree-sitter-javascript/bindings/c ../../tree-sitter-javascript/bindings/c/tree_sitter" + ) + if (fixed != content) { + cmakeFile.writeText(fixed) + } + } +} + +val compileGrammarNative by tasks.registering { + description = "Compile tree-sitter JavaScript grammar via CMake" + dependsOn(tasks.named("generateGrammarFiles"), patchCmakeLists) + val cmakeBuildDir = nativeLibDir.get().asFile + val srcDir = generatedGrammarDir.get().asFile + inputs.dir(grammarSourceDir).optional() + inputs.dir(srcDir.resolve("src/jni")) + inputs.file(srcDir.resolve("CMakeLists.txt")) + outputs.file(cmakeBuildDir.resolve(nativeGrammarLibName)) + onlyIf { + grammarSourceDir.asFile.exists() + } + doLast { + cmakeBuildDir.mkdirs() + ProcessBuilder("cmake", srcDir.path, "-DCMAKE_BUILD_TYPE=Release") + .directory(cmakeBuildDir) + .inheritIO() + .start() + .waitFor() + ProcessBuilder("cmake", "--build", ".", "--target", "ktreesitter-javascript", "--parallel") + .directory(cmakeBuildDir) + .inheritIO() + .start() + .waitFor() + } +} + sourceSets["main"].java.srcDirs("src/main/kotlin") idea { @@ -113,10 +203,22 @@ tasks.test { } tasks.processResources { + dependsOn(compileGrammarNative) + + val nativeSubdir = when { + os.isWindows -> if (isArm64) "windows/arm64" else "windows/x64" + os.isMacOsX -> if (isArm64) "macos/arm64" else "macos/x64" + else -> if (isArm64) "linux/arm64" else "linux/x64" + } + inputs.property("version", tritiumVersion) filesMatching("version.txt") { expand("version" to tritiumVersion) } + + from(nativeLibDir.map { it.file(nativeGrammarLibName) }) { + into("lib/$nativeSubdir") + } } tasks.jar { @@ -154,7 +256,17 @@ val preparePackageInput by tasks.registering(Sync::class) { val compileKotlin: KotlinCompile by tasks compileKotlin.apply { compilerOptions { - freeCompilerArgs.set(listOf("-Xcontext-parameters")) - allWarningsAsErrors = true + incremental = true + freeCompilerArgs.set(listOf("-Xcontext-parameters", "-progressive")) } } + +val compileJava: JavaCompile by tasks +compileJava.apply { + options.isIncremental = true +} + +tasks.clean { + delete(nativeLibDir) + delete(generatedGrammarDir) +} diff --git a/gradle.properties b/gradle.properties index 7f66658..50a874b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,17 @@ -kotlin.code.style=official -kotlin.incremental=true +org.gradle.jvmargs=-Xmx4g -Xms512m \ + -XX:+UseG1GC \ + -XX:SoftRefLRUPolicyMSPerMB=50 \ + -XX:+HeapDumpOnOutOfMemoryError \ + -XX:+OptimizeStringConcat \ + -XX:ReservedCodeCacheSize=512m \ + -Dfile.encoding=UTF-8 + + +org.gradle.parallel=true org.gradle.caching=true org.gradle.daemon=true -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -org.gradle.parallel=true -org.gradle.workers.max=6 -org.gradle.vfs.watch=true -ksp.incremental=false \ No newline at end of file + +kotlin.code.style=official +kotlin.incremental=true +kotlin.build.report.output=file +ksp.incremental=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c88644e..9fde6d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,29 @@ [versions] -kotlin = "2.2.20" -kotlinx-serialization = "1.9.0" +kotlin = "2.3.20" +kotlinx-serialization = "1.10.0" kotlinx-coroutines = "1.10.2" -ktor = "3.3.1" -msal = "1.23.1" +ktor = "3.4.3" +msal = "1.24.1" msal-persistence = "1.3.0" -logback = "1.5.27" -ksp = "2.2.20-2.0.4" +logback = "1.5.32" +jul-to-slf4j = "2.0.17" +ksp = "2.3.6" autoservice = "1.1.1" autoservice-ksp = "1.2.0" -koin = "4.1.1" -koin-annotations = "2.2.0" +koin = "4.2.1" +koin-annotations = "2.3.1" qt = "6.9.3" -lsp4j = "0.24.0" jna = "5.13.0" +commonmark = "0.28.0" +sqlite-jdbc = "3.50.3.0" +ktreesitter = "0.25.0" +lsp4j = "0.23.1" +flatbuffers = "25.2.10" + +kotlin-json5 = "0.5.0" +yamlkt = "0.13.0" +ktoml = "0.7.1" +knbt = "0.11.9" # Test kotest = "6.0.7" @@ -24,9 +34,7 @@ junit = "6.0.1" # Kotlin kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } -kotlinx-serialization-hocon = { module = "org.jetbrains.kotlinx:kotlinx-serialization-hocon", version.ref = "kotlinx-serialization" } kotlin-scripting-common = { module = "org.jetbrains.kotlin:kotlin-scripting-common", version.ref = "kotlin" } kotlin-scripting-jvm = { module = "org.jetbrains.kotlin:kotlin-scripting-jvm", version.ref = "kotlin" } kotlin-scripting-host = { module = "org.jetbrains.kotlin:kotlin-scripting-jvm-host", version.ref = "kotlin" } @@ -36,6 +44,17 @@ kotlin-scripting-dependencies = { module = "org.jetbrains.kotlin:kotlin-scriptin kotlin-scripting-dependencies-maven = { module = "org.jetbrains.kotlin:kotlin-scripting-dependencies-maven", version.ref = "kotlin" } kotlin-scripting-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable", version.ref = "kotlin" } +# Serialization +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-serialization-hocon = { module = "org.jetbrains.kotlinx:kotlinx-serialization-hocon", version.ref = "kotlinx-serialization" } +kotlinx-serialization-properties = { module = "org.jetbrains.kotlinx:kotlinx-serialization-properties", version.ref = "kotlinx-serialization" } +kotlin-json5 = { module = "li.songe:json5", name = "json5", version.ref = "kotlin-json5"} +ktoml-core = { module = "com.akuleshov7:ktoml-core", name = "ktoml-core", version.ref = "ktoml"} +ktoml-file = { module = "com.akuleshov7:ktoml-core", name = "ktoml-file", version.ref = "ktoml"} +yamlkt = { module = "net.mamoe.yamlkt:yamlkt", name = "yamlkt", version.ref = "yamlkt"} +knbt = { module = "net.benwoodworth.knbt:knbt", name = "knbt", version.ref = "knbt"} + + # KSP autoservice-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } autoservice-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoservice-ksp" } @@ -67,6 +86,7 @@ msal4j-persistence = { module = "com.microsoft.azure:msal4j-persistence-extensio # Logback logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +jultoslf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "jul-to-slf4j" } # LSP lsp4j = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j", version.ref = "lsp4j" } @@ -75,6 +95,22 @@ lsp4j = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j", version.ref = "lsp4j" jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jna-platform = { group = "net.java.dev.jna", name = "jna-platform", version.ref = "jna" } +# CommonMark +commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" } +commonmark-ext-gfm-tables = { group = "org.commonmark", name = "commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-ext-gfm-strikethrough = { group = "org.commonmark", name = "commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } +commonmark-ext-task-list-items = { group = "org.commonmark", name = "commonmark-ext-task-list-items", version.ref = "commonmark" } +commonmark-ext-image-attributes = { group = "org.commonmark", name = "commonmark-ext-image-attributes", version.ref = "commonmark" } + +# SQLite +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } + +# FlatBuffers +flatbuffers = { module = "com.google.flatbuffers:flatbuffers-java", version.ref = "flatbuffers" } + +# Kotlin Tree-sitter +ktreesitter = { module = "io.github.tree-sitter:ktreesitter", version.ref = "ktreesitter"} + # Testing kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines"} @@ -89,6 +125,7 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", vers ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktreesitter = { id = "io.github.tree-sitter.ktreesitter-plugin", version.ref = "ktreesitter" } [bundles] test = [ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..1b33c55 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41a74ee..aaaabb3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Thu Jan 02 13:02:24 EST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -133,22 +133,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,18 +200,28 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd3..db3a6ac 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/packaging/aur/tritium-launcher-bin/.SRCINFO b/packaging/aur/tritium-launcher-bin/.SRCINFO index 6ff80a2..a1f4462 100644 --- a/packaging/aur/tritium-launcher-bin/.SRCINFO +++ b/packaging/aur/tritium-launcher-bin/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tritium-launcher-bin pkgdesc = Minecraft Launcher IDE for Modpack Developers - pkgver = 0.1.5 + pkgver = 0.1.6 pkgrel = 1 url = https://github.com/Tritium-Launcher/Launcher arch = x86_64 diff --git a/packaging/aur/tritium-launcher-bin/PKGBUILD b/packaging/aur/tritium-launcher-bin/PKGBUILD index dda3bcd..3e3abf0 100644 --- a/packaging/aur/tritium-launcher-bin/PKGBUILD +++ b/packaging/aur/tritium-launcher-bin/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: FooterManDev footermandev@protonmail.com pkgname=tritium-launcher-bin -pkgver=0.1.5 +pkgver=0.1.6 pkgrel=1 pkgdesc='Minecraft Launcher IDE for Modpack Developers' arch=('x86_64') diff --git a/settings.gradle.kts b/settings.gradle.kts index c113b82..84ed7d8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,3 +10,8 @@ dependencyResolutionManagement { mavenCentral() } } + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") + +rootProject.name = "tritium" \ No newline at end of file diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Binding.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Binding.java new file mode 100644 index 0000000..19b5c8a --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Binding.java @@ -0,0 +1,61 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class Binding extends com.google.flatbuffers.Table { + public static Binding getRootAsBinding(ByteBuffer _bb) { return getRootAsBinding(_bb, new Binding()); } + public static Binding getRootAsBinding(ByteBuffer _bb, Binding obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public Binding __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String name() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String type() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer typeAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer typeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public String documentation() { int o = __offset(8); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(8, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 8, 1); } + public String side() { int o = __offset(10); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer sideAsByteBuffer() { return __vector_as_bytebuffer(10, 1); } + public ByteBuffer sideInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 10, 1); } + + public static int createBinding(FlatBufferBuilder builder, + int nameOffset, + int typeOffset, + int documentationOffset, + int sideOffset) { + builder.startTable(4); + Binding.addSide(builder, sideOffset); + Binding.addDocumentation(builder, documentationOffset); + Binding.addType(builder, typeOffset); + Binding.addName(builder, nameOffset); + return Binding.endBinding(builder); + } + + public static void startBinding(FlatBufferBuilder builder) { builder.startTable(4); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(0, nameOffset, 0); } + public static void addType(FlatBufferBuilder builder, int typeOffset) { builder.addOffset(1, typeOffset, 0); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(2, documentationOffset, 0); } + public static void addSide(FlatBufferBuilder builder, int sideOffset) { builder.addOffset(3, sideOffset, 0); } + public static int endBinding(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public Binding get(int j) { return get(new Binding(), j); } + public Binding get(Binding obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/ClassDefinition.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/ClassDefinition.java new file mode 100644 index 0000000..2e48555 --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/ClassDefinition.java @@ -0,0 +1,114 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.StringVector; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class ClassDefinition extends com.google.flatbuffers.Table { + public static ClassDefinition getRootAsClassDefinition(ByteBuffer _bb) { return getRootAsClassDefinition(_bb, new ClassDefinition()); } + public static ClassDefinition getRootAsClassDefinition(ByteBuffer _bb, ClassDefinition obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public ClassDefinition __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String fullName() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer fullNameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer fullNameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String simpleName() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer simpleNameAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer simpleNameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public byte kind() { int o = __offset(8); return o != 0 ? bb.get(o + bb_pos) : 1; } + public String typeParams(int j) { int o = __offset(10); return o != 0 ? __string(__vector(o) + j * 4) : null; } + public int typeParamsLength() { int o = __offset(10); return o != 0 ? __vector_len(o) : 0; } + public StringVector typeParamsVector() { return typeParamsVector(new StringVector()); } + public StringVector typeParamsVector(StringVector obj) { int o = __offset(10); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Method methods(int j) { return methods(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Method(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Method methods(io.github.tritium_launcher.launcher.extension.kubejs.typings.Method obj, int j) { int o = __offset(12); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int methodsLength() { int o = __offset(12); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Method.Vector methodsVector() { return methodsVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Method.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Method.Vector methodsVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Method.Vector obj) { int o = __offset(12); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Field fields(int j) { return fields(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Field(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Field fields(io.github.tritium_launcher.launcher.extension.kubejs.typings.Field obj, int j) { int o = __offset(14); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int fieldsLength() { int o = __offset(14); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Field.Vector fieldsVector() { return fieldsVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Field.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Field.Vector fieldsVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Field.Vector obj) { int o = __offset(14); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor constructors(int j) { return constructors(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor constructors(io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor obj, int j) { int o = __offset(16); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int constructorsLength() { int o = __offset(16); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor.Vector constructorsVector() { return constructorsVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor.Vector constructorsVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Constructor.Vector obj) { int o = __offset(16); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public String documentation() { int o = __offset(18); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(18, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 18, 1); } + public String superClass() { int o = __offset(20); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer superClassAsByteBuffer() { return __vector_as_bytebuffer(20, 1); } + public ByteBuffer superClassInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 20, 1); } + public String interfaces(int j) { int o = __offset(22); return o != 0 ? __string(__vector(o) + j * 4) : null; } + public int interfacesLength() { int o = __offset(22); return o != 0 ? __vector_len(o) : 0; } + public StringVector interfacesVector() { return interfacesVector(new StringVector()); } + public StringVector interfacesVector(StringVector obj) { int o = __offset(22); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + + public static int createClassDefinition(FlatBufferBuilder builder, + int fullNameOffset, + int simpleNameOffset, + byte kind, + int typeParamsOffset, + int methodsOffset, + int fieldsOffset, + int constructorsOffset, + int documentationOffset, + int superClassOffset, + int interfacesOffset) { + builder.startTable(10); + ClassDefinition.addInterfaces(builder, interfacesOffset); + ClassDefinition.addSuperClass(builder, superClassOffset); + ClassDefinition.addDocumentation(builder, documentationOffset); + ClassDefinition.addConstructors(builder, constructorsOffset); + ClassDefinition.addFields(builder, fieldsOffset); + ClassDefinition.addMethods(builder, methodsOffset); + ClassDefinition.addTypeParams(builder, typeParamsOffset); + ClassDefinition.addSimpleName(builder, simpleNameOffset); + ClassDefinition.addFullName(builder, fullNameOffset); + ClassDefinition.addKind(builder, kind); + return ClassDefinition.endClassDefinition(builder); + } + + public static void startClassDefinition(FlatBufferBuilder builder) { builder.startTable(10); } + public static void addFullName(FlatBufferBuilder builder, int fullNameOffset) { builder.addOffset(0, fullNameOffset, 0); } + public static void addSimpleName(FlatBufferBuilder builder, int simpleNameOffset) { builder.addOffset(1, simpleNameOffset, 0); } + public static void addKind(FlatBufferBuilder builder, byte kind) { builder.addByte(2, kind, 1); } + public static void addTypeParams(FlatBufferBuilder builder, int typeParamsOffset) { builder.addOffset(3, typeParamsOffset, 0); } + public static int createTypeParamsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startTypeParamsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addMethods(FlatBufferBuilder builder, int methodsOffset) { builder.addOffset(4, methodsOffset, 0); } + public static int createMethodsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startMethodsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addFields(FlatBufferBuilder builder, int fieldsOffset) { builder.addOffset(5, fieldsOffset, 0); } + public static int createFieldsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startFieldsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addConstructors(FlatBufferBuilder builder, int constructorsOffset) { builder.addOffset(6, constructorsOffset, 0); } + public static int createConstructorsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startConstructorsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(7, documentationOffset, 0); } + public static void addSuperClass(FlatBufferBuilder builder, int superClassOffset) { builder.addOffset(8, superClassOffset, 0); } + public static void addInterfaces(FlatBufferBuilder builder, int interfacesOffset) { builder.addOffset(9, interfacesOffset, 0); } + public static int createInterfacesVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startInterfacesVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static int endClassDefinition(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public ClassDefinition get(int j) { return get(new ClassDefinition(), j); } + public ClassDefinition get(ClassDefinition obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Constructor.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Constructor.java new file mode 100644 index 0000000..37b56cc --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Constructor.java @@ -0,0 +1,53 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class Constructor extends com.google.flatbuffers.Table { + public static Constructor getRootAsConstructor(ByteBuffer _bb) { return getRootAsConstructor(_bb, new Constructor()); } + public static Constructor getRootAsConstructor(ByteBuffer _bb, Constructor obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public Constructor __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter parameters(int j) { return parameters(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter parameters(io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter obj, int j) { int o = __offset(4); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int parametersLength() { int o = __offset(4); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector parametersVector() { return parametersVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector parametersVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector obj) { int o = __offset(4); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public String documentation() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + + public static int createConstructor(FlatBufferBuilder builder, + int parametersOffset, + int documentationOffset) { + builder.startTable(2); + Constructor.addDocumentation(builder, documentationOffset); + Constructor.addParameters(builder, parametersOffset); + return Constructor.endConstructor(builder); + } + + public static void startConstructor(FlatBufferBuilder builder) { builder.startTable(2); } + public static void addParameters(FlatBufferBuilder builder, int parametersOffset) { builder.addOffset(0, parametersOffset, 0); } + public static int createParametersVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startParametersVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(1, documentationOffset, 0); } + public static int endConstructor(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public Constructor get(int j) { return get(new Constructor(), j); } + public Constructor get(Constructor obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/EventBinding.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/EventBinding.java new file mode 100644 index 0000000..ce66f49 --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/EventBinding.java @@ -0,0 +1,77 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class EventBinding extends com.google.flatbuffers.Table { + public static EventBinding getRootAsEventBinding(ByteBuffer _bb) { return getRootAsEventBinding(_bb, new EventBinding()); } + public static EventBinding getRootAsEventBinding(ByteBuffer _bb, EventBinding obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public EventBinding __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String groupName() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer groupNameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer groupNameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String eventName() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer eventNameAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer eventNameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public String eventClass() { int o = __offset(8); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer eventClassAsByteBuffer() { return __vector_as_bytebuffer(8, 1); } + public ByteBuffer eventClassInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 8, 1); } + public String side() { int o = __offset(10); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer sideAsByteBuffer() { return __vector_as_bytebuffer(10, 1); } + public ByteBuffer sideInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 10, 1); } + public String extraType() { int o = __offset(12); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer extraTypeAsByteBuffer() { return __vector_as_bytebuffer(12, 1); } + public ByteBuffer extraTypeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 12, 1); } + public boolean targetRequired() { int o = __offset(14); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public String documentation() { int o = __offset(16); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(16, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 16, 1); } + + public static int createEventBinding(FlatBufferBuilder builder, + int groupNameOffset, + int eventNameOffset, + int eventClassOffset, + int sideOffset, + int extraTypeOffset, + boolean targetRequired, + int documentationOffset) { + builder.startTable(7); + EventBinding.addDocumentation(builder, documentationOffset); + EventBinding.addExtraType(builder, extraTypeOffset); + EventBinding.addSide(builder, sideOffset); + EventBinding.addEventClass(builder, eventClassOffset); + EventBinding.addEventName(builder, eventNameOffset); + EventBinding.addGroupName(builder, groupNameOffset); + EventBinding.addTargetRequired(builder, targetRequired); + return EventBinding.endEventBinding(builder); + } + + public static void startEventBinding(FlatBufferBuilder builder) { builder.startTable(7); } + public static void addGroupName(FlatBufferBuilder builder, int groupNameOffset) { builder.addOffset(0, groupNameOffset, 0); } + public static void addEventName(FlatBufferBuilder builder, int eventNameOffset) { builder.addOffset(1, eventNameOffset, 0); } + public static void addEventClass(FlatBufferBuilder builder, int eventClassOffset) { builder.addOffset(2, eventClassOffset, 0); } + public static void addSide(FlatBufferBuilder builder, int sideOffset) { builder.addOffset(3, sideOffset, 0); } + public static void addExtraType(FlatBufferBuilder builder, int extraTypeOffset) { builder.addOffset(4, extraTypeOffset, 0); } + public static void addTargetRequired(FlatBufferBuilder builder, boolean targetRequired) { builder.addBoolean(5, targetRequired, false); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(6, documentationOffset, 0); } + public static int endEventBinding(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public EventBinding get(int j) { return get(new EventBinding(), j); } + public EventBinding get(EventBinding obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Field.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Field.java new file mode 100644 index 0000000..7a8484b --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Field.java @@ -0,0 +1,63 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class Field extends com.google.flatbuffers.Table { + public static Field getRootAsField(ByteBuffer _bb) { return getRootAsField(_bb, new Field()); } + public static Field getRootAsField(ByteBuffer _bb, Field obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public Field __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String name() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String type() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer typeAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer typeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public boolean isStatic() { int o = __offset(8); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public boolean isDeprecated() { int o = __offset(10); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public String documentation() { int o = __offset(12); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(12, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 12, 1); } + + public static int createField(FlatBufferBuilder builder, + int nameOffset, + int typeOffset, + boolean isStatic, + boolean isDeprecated, + int documentationOffset) { + builder.startTable(5); + Field.addDocumentation(builder, documentationOffset); + Field.addType(builder, typeOffset); + Field.addName(builder, nameOffset); + Field.addIsDeprecated(builder, isDeprecated); + Field.addIsStatic(builder, isStatic); + return Field.endField(builder); + } + + public static void startField(FlatBufferBuilder builder) { builder.startTable(5); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(0, nameOffset, 0); } + public static void addType(FlatBufferBuilder builder, int typeOffset) { builder.addOffset(1, typeOffset, 0); } + public static void addIsStatic(FlatBufferBuilder builder, boolean isStatic) { builder.addBoolean(2, isStatic, false); } + public static void addIsDeprecated(FlatBufferBuilder builder, boolean isDeprecated) { builder.addBoolean(3, isDeprecated, false); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(4, documentationOffset, 0); } + public static int endField(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public Field get(int j) { return get(new Field(), j); } + public Field get(Field obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/KubeTypings.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/KubeTypings.java new file mode 100644 index 0000000..a780c40 --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/KubeTypings.java @@ -0,0 +1,91 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class KubeTypings extends com.google.flatbuffers.Table { + public static KubeTypings getRootAsKubeTypings(ByteBuffer _bb) { return getRootAsKubeTypings(_bb, new KubeTypings()); } + public static KubeTypings getRootAsKubeTypings(ByteBuffer _bb, KubeTypings obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public KubeTypings __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String minecraftVersion() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer minecraftVersionAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer minecraftVersionInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String loader() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer loaderAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer loaderInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition classes(int j) { return classes(new io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition classes(io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition obj, int j) { int o = __offset(8); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int classesLength() { int o = __offset(8); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition.Vector classesVector() { return classesVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition.Vector classesVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.ClassDefinition.Vector obj) { int o = __offset(8); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding bindings(int j) { return bindings(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding bindings(io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding obj, int j) { int o = __offset(10); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int bindingsLength() { int o = __offset(10); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding.Vector bindingsVector() { return bindingsVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding.Vector bindingsVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Binding.Vector obj) { int o = __offset(10); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding events(int j) { return events(new io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding events(io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding obj, int j) { int o = __offset(12); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int eventsLength() { int o = __offset(12); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding.Vector eventsVector() { return eventsVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding.Vector eventsVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.EventBinding.Vector obj) { int o = __offset(12); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding recipes(int j) { return recipes(new io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding recipes(io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding obj, int j) { int o = __offset(14); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int recipesLength() { int o = __offset(14); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding.Vector recipesVector() { return recipesVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding.Vector recipesVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeSchemaBinding.Vector obj) { int o = __offset(14); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + + public static int createKubeTypings(FlatBufferBuilder builder, + int minecraftVersionOffset, + int loaderOffset, + int classesOffset, + int bindingsOffset, + int eventsOffset, + int recipesOffset) { + builder.startTable(6); + KubeTypings.addRecipes(builder, recipesOffset); + KubeTypings.addEvents(builder, eventsOffset); + KubeTypings.addBindings(builder, bindingsOffset); + KubeTypings.addClasses(builder, classesOffset); + KubeTypings.addLoader(builder, loaderOffset); + KubeTypings.addMinecraftVersion(builder, minecraftVersionOffset); + return KubeTypings.endKubeTypings(builder); + } + + public static void startKubeTypings(FlatBufferBuilder builder) { builder.startTable(6); } + public static void addMinecraftVersion(FlatBufferBuilder builder, int minecraftVersionOffset) { builder.addOffset(0, minecraftVersionOffset, 0); } + public static void addLoader(FlatBufferBuilder builder, int loaderOffset) { builder.addOffset(1, loaderOffset, 0); } + public static void addClasses(FlatBufferBuilder builder, int classesOffset) { builder.addOffset(2, classesOffset, 0); } + public static int createClassesVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startClassesVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addBindings(FlatBufferBuilder builder, int bindingsOffset) { builder.addOffset(3, bindingsOffset, 0); } + public static int createBindingsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startBindingsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addEvents(FlatBufferBuilder builder, int eventsOffset) { builder.addOffset(4, eventsOffset, 0); } + public static int createEventsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startEventsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addRecipes(FlatBufferBuilder builder, int recipesOffset) { builder.addOffset(5, recipesOffset, 0); } + public static int createRecipesVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startRecipesVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static int endKubeTypings(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + public static void finishKubeTypingsBuffer(FlatBufferBuilder builder, int offset) { builder.finish(offset); } + public static void finishSizePrefixedKubeTypingsBuffer(FlatBufferBuilder builder, int offset) { builder.finishSizePrefixed(offset); } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public KubeTypings get(int j) { return get(new KubeTypings(), j); } + public KubeTypings get(KubeTypings obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Method.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Method.java new file mode 100644 index 0000000..5c2f1e4 --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Method.java @@ -0,0 +1,83 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.StringVector; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class Method extends com.google.flatbuffers.Table { + public static Method getRootAsMethod(ByteBuffer _bb) { return getRootAsMethod(_bb, new Method()); } + public static Method getRootAsMethod(ByteBuffer _bb, Method obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public Method __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String name() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String returnType() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer returnTypeAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer returnTypeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public String typeParams(int j) { int o = __offset(8); return o != 0 ? __string(__vector(o) + j * 4) : null; } + public int typeParamsLength() { int o = __offset(8); return o != 0 ? __vector_len(o) : 0; } + public StringVector typeParamsVector() { return typeParamsVector(new StringVector()); } + public StringVector typeParamsVector(StringVector obj) { int o = __offset(8); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter parameters(int j) { return parameters(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter parameters(io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter obj, int j) { int o = __offset(10); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int parametersLength() { int o = __offset(10); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector parametersVector() { return parametersVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector parametersVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.Parameter.Vector obj) { int o = __offset(10); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public boolean isStatic() { int o = __offset(12); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public boolean isDeprecated() { int o = __offset(14); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public String documentation() { int o = __offset(16); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(16, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 16, 1); } + + public static int createMethod(FlatBufferBuilder builder, + int nameOffset, + int returnTypeOffset, + int typeParamsOffset, + int parametersOffset, + boolean isStatic, + boolean isDeprecated, + int documentationOffset) { + builder.startTable(7); + Method.addDocumentation(builder, documentationOffset); + Method.addParameters(builder, parametersOffset); + Method.addTypeParams(builder, typeParamsOffset); + Method.addReturnType(builder, returnTypeOffset); + Method.addName(builder, nameOffset); + Method.addIsDeprecated(builder, isDeprecated); + Method.addIsStatic(builder, isStatic); + return Method.endMethod(builder); + } + + public static void startMethod(FlatBufferBuilder builder) { builder.startTable(7); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(0, nameOffset, 0); } + public static void addReturnType(FlatBufferBuilder builder, int returnTypeOffset) { builder.addOffset(1, returnTypeOffset, 0); } + public static void addTypeParams(FlatBufferBuilder builder, int typeParamsOffset) { builder.addOffset(2, typeParamsOffset, 0); } + public static int createTypeParamsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startTypeParamsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addParameters(FlatBufferBuilder builder, int parametersOffset) { builder.addOffset(3, parametersOffset, 0); } + public static int createParametersVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startParametersVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addIsStatic(FlatBufferBuilder builder, boolean isStatic) { builder.addBoolean(4, isStatic, false); } + public static void addIsDeprecated(FlatBufferBuilder builder, boolean isDeprecated) { builder.addBoolean(5, isDeprecated, false); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(6, documentationOffset, 0); } + public static int endMethod(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public Method get(int j) { return get(new Method(), j); } + public Method get(Method obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Parameter.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Parameter.java new file mode 100644 index 0000000..5174a7d --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/Parameter.java @@ -0,0 +1,49 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class Parameter extends com.google.flatbuffers.Table { + public static Parameter getRootAsParameter(ByteBuffer _bb) { return getRootAsParameter(_bb, new Parameter()); } + public static Parameter getRootAsParameter(ByteBuffer _bb, Parameter obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public Parameter __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String name() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String type() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer typeAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer typeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + + public static int createParameter(FlatBufferBuilder builder, + int nameOffset, + int typeOffset) { + builder.startTable(2); + Parameter.addType(builder, typeOffset); + Parameter.addName(builder, nameOffset); + return Parameter.endParameter(builder); + } + + public static void startParameter(FlatBufferBuilder builder) { builder.startTable(2); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(0, nameOffset, 0); } + public static void addType(FlatBufferBuilder builder, int typeOffset) { builder.addOffset(1, typeOffset, 0); } + public static int endParameter(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public Parameter get(int j) { return get(new Parameter(), j); } + public Parameter get(Parameter obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeKeyInfo.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeKeyInfo.java new file mode 100644 index 0000000..caa534a --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeKeyInfo.java @@ -0,0 +1,53 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class RecipeKeyInfo extends com.google.flatbuffers.Table { + public static RecipeKeyInfo getRootAsRecipeKeyInfo(ByteBuffer _bb) { return getRootAsRecipeKeyInfo(_bb, new RecipeKeyInfo()); } + public static RecipeKeyInfo getRootAsRecipeKeyInfo(ByteBuffer _bb, RecipeKeyInfo obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public RecipeKeyInfo __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String name() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String type() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer typeAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer typeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public boolean optional() { int o = __offset(8); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + + public static int createRecipeKeyInfo(FlatBufferBuilder builder, + int nameOffset, + int typeOffset, + boolean optional) { + builder.startTable(3); + RecipeKeyInfo.addType(builder, typeOffset); + RecipeKeyInfo.addName(builder, nameOffset); + RecipeKeyInfo.addOptional(builder, optional); + return RecipeKeyInfo.endRecipeKeyInfo(builder); + } + + public static void startRecipeKeyInfo(FlatBufferBuilder builder) { builder.startTable(3); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(0, nameOffset, 0); } + public static void addType(FlatBufferBuilder builder, int typeOffset) { builder.addOffset(1, typeOffset, 0); } + public static void addOptional(FlatBufferBuilder builder, boolean optional) { builder.addBoolean(2, optional, false); } + public static int endRecipeKeyInfo(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public RecipeKeyInfo get(int j) { return get(new RecipeKeyInfo(), j); } + public RecipeKeyInfo get(RecipeKeyInfo obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeSchemaBinding.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeSchemaBinding.java new file mode 100644 index 0000000..30f6fb4 --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/RecipeSchemaBinding.java @@ -0,0 +1,71 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.FlatBufferBuilder; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class RecipeSchemaBinding extends com.google.flatbuffers.Table { + public static RecipeSchemaBinding getRootAsRecipeSchemaBinding(ByteBuffer _bb) { return getRootAsRecipeSchemaBinding(_bb, new RecipeSchemaBinding()); } + public static RecipeSchemaBinding getRootAsRecipeSchemaBinding(ByteBuffer _bb, RecipeSchemaBinding obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public RecipeSchemaBinding __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String namespace() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer namespaceAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer namespaceInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String schemaId() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer schemaIdAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer schemaIdInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public String recipeClass() { int o = __offset(8); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer recipeClassAsByteBuffer() { return __vector_as_bytebuffer(8, 1); } + public ByteBuffer recipeClassInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 8, 1); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo keys(int j) { return keys(new io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo(), j); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo keys(io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo obj, int j) { int o = __offset(10); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int keysLength() { int o = __offset(10); return o != 0 ? __vector_len(o) : 0; } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo.Vector keysVector() { return keysVector(new io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo.Vector()); } + public io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo.Vector keysVector(io.github.tritium_launcher.launcher.extension.kubejs.typings.RecipeKeyInfo.Vector obj) { int o = __offset(10); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public String documentation() { int o = __offset(12); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer documentationAsByteBuffer() { return __vector_as_bytebuffer(12, 1); } + public ByteBuffer documentationInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 12, 1); } + + public static int createRecipeSchemaBinding(FlatBufferBuilder builder, + int namespaceOffset, + int schemaIdOffset, + int recipeClassOffset, + int keysOffset, + int documentationOffset) { + builder.startTable(5); + RecipeSchemaBinding.addDocumentation(builder, documentationOffset); + RecipeSchemaBinding.addKeys(builder, keysOffset); + RecipeSchemaBinding.addRecipeClass(builder, recipeClassOffset); + RecipeSchemaBinding.addSchemaId(builder, schemaIdOffset); + RecipeSchemaBinding.addNamespace(builder, namespaceOffset); + return RecipeSchemaBinding.endRecipeSchemaBinding(builder); + } + + public static void startRecipeSchemaBinding(FlatBufferBuilder builder) { builder.startTable(5); } + public static void addNamespace(FlatBufferBuilder builder, int namespaceOffset) { builder.addOffset(0, namespaceOffset, 0); } + public static void addSchemaId(FlatBufferBuilder builder, int schemaIdOffset) { builder.addOffset(1, schemaIdOffset, 0); } + public static void addRecipeClass(FlatBufferBuilder builder, int recipeClassOffset) { builder.addOffset(2, recipeClassOffset, 0); } + public static void addKeys(FlatBufferBuilder builder, int keysOffset) { builder.addOffset(3, keysOffset, 0); } + public static int createKeysVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startKeysVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addDocumentation(FlatBufferBuilder builder, int documentationOffset) { builder.addOffset(4, documentationOffset, 0); } + public static int endRecipeSchemaBinding(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public RecipeSchemaBinding get(int j) { return get(new RecipeSchemaBinding(), j); } + public RecipeSchemaBinding get(RecipeSchemaBinding obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/TypeKind.java b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/TypeKind.java new file mode 100644 index 0000000..456debe --- /dev/null +++ b/src/main/java/io/github/tritium_launcher/launcher/extension/kubejs/typings/TypeKind.java @@ -0,0 +1,18 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package io.github.tritium_launcher.launcher.extension.kubejs.typings; + +@SuppressWarnings("unused") +public final class TypeKind { + private TypeKind() { } + public static final byte Primitive = 0; + public static final byte Class = 1; + public static final byte Interface = 2; + public static final byte Array = 3; + public static final byte Event = 4; + + public static final String[] names = { "Primitive", "Class", "Interface", "Array", "Event", }; + + public static String name(int e) { return names[e]; } +} + diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/Arguments.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/Arguments.kt index 2e859b9..c15d305 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/Arguments.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/Arguments.kt @@ -3,6 +3,11 @@ package io.github.tritium_launcher.launcher var debugLogging: Boolean = false var genThemeSchema: Boolean = false +/** + * Manages Tritium arguments + * + * TODO: Make this better + */ internal fun manageArguments(args: List) { debugLogging = args.any { it == "-debug" || it == "--debug" || it == "-d" } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/Extensions.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/Extensions.kt index 2e84f35..b763f36 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/Extensions.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/Extensions.kt @@ -1,30 +1,36 @@ package io.github.tritium_launcher.launcher -import io.qt.core.* +import io.qt.core.QMargins +import io.qt.core.QMetaObject +import io.qt.core.QObject +import io.qt.core.Qt import io.qt.gui.QColor import io.qt.gui.QImage import io.qt.widgets.QAbstractButton import io.qt.widgets.QLayout import io.qt.widgets.QWidget -import kotlinx.io.IOException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.io.File import java.net.URI import java.net.URL -import java.nio.file.Path import kotlin.math.PI private val logger = LoggerFactory.getLogger("ExtensionFunctions") /** - * Includes various extension functions. + * Converts a [String] to Java[URL] */ - fun String.toUrl(): URL { return URI(this).toURL() } +/** + * Converts a [String] to Java[URI] + */ fun String.toURI(): URI { return URI(this) } @@ -51,6 +57,9 @@ fun String.hexToRgbString(): String { return "rgb($r,$g,$b)" } +/** + * Converts a hex color value to [QColor] object + */ fun String.hexToQColor(): QColor { val raw = this.trim().removePrefix("#") val fullHex = when(raw.length) { @@ -68,24 +77,36 @@ fun String.hexToQColor(): QColor { return QColor(r,g,b) } -/** Checks if this string matches any of the provided strings. */ +/** Checks if this [String] matches any of the provided strings */ fun String.matches(vararg strings: String): Boolean = strings.any { this == it } +/** Checks if this [List] matches any of the provided strings */ fun String.matches(strings: List): Boolean = strings.any { this == it } +/** + * Convert Double to Radians + */ fun Double.toRadians(): Double = this * (PI / 180.0) -fun Path.mkdirs(): Boolean { - return try { this.toFile().mkdirs() } catch (e: IOException) { logger.error("Error creating directory", e); false} -} - +/** + * Resolves [File] from this pathname + */ fun String.toFile(): File = File(this) +/** + * Shorthand for a uniform [QMargins] value + */ val Int.m: QMargins get() = QMargins(this, this, this, this) +/** + * Adds multiple [QWidget]s to [QLayout] + */ fun QLayout.add(vararg widgets: QWidget?) = widgets.forEach { w -> this.addWidget(w) } +/** + * [QAbstractButton] click action block + */ @JvmName("onClickedButton") fun QAbstractButton.onClicked(handler: () -> Unit) { val slotHolder = object : QObject(this) { @@ -104,42 +125,83 @@ fun QAbstractButton.onClicked(handler: () -> Unit) { this.clicked.connect(slotHolder, "handleClick()") } +/** + * Creates a Default Signal1 connection to [QObject] + */ inline fun QObject.Signal1Default1.connect(crossinline handler: (T) -> Unit): QMetaObject.Slot1 { val slot = QMetaObject.Slot1 { arg -> handler(arg) } this.connect(slot) return slot } +/** + * Creates a Private Signal0 connection to [QObject] + */ inline fun QObject.PrivateSignal0.connect(crossinline handler: () -> Unit): QMetaObject.Slot0 { val slot = QMetaObject.Slot0 { handler() } this.connect(slot) return slot } +/** + * Creates a Signal0 connection to [QObject] + */ inline fun QObject.Signal0.connect(crossinline handler: () -> Unit): QMetaObject.Slot0 { val slot = QMetaObject.Slot0 { handler() } this.connect(slot) return slot } +/** + * Creates a Signal1 connection to [QObject] + */ inline fun QObject.Signal1.connect(crossinline handler: (T) -> Unit): QMetaObject.Slot1 { val slot = QMetaObject.Slot1 { arg -> handler(arg) } this.connect(slot) return slot } +/** + * Creates a Signal2 connection to [QObject] + */ inline fun QObject.Signal2.connect(crossinline handler: (A, B) -> Unit): QMetaObject.Slot2 { val slot = QMetaObject.Slot2 { a, b -> handler(a, b) } this.connect(slot) return slot } -fun QTimer.stopIfActive() { if(this.isActive) this.stop() } +/** + * Bridges a Qt Signal0 to a Kotlin Flow. + */ +fun QObject.Signal0.asFlow(): Flow = callbackFlow { + val slot = connect { trySend(Unit) } + awaitClose { disconnect(slot) } +} + +/** + * Bridges a Qt Signal1 to a Kotlin Flow. + */ +fun QObject.Signal1.asFlow(): Flow = callbackFlow { + val slot = connect { trySend(it) } + awaitClose { disconnect(slot) } +} -fun QPropertyAnimation.stopIfRunning() { if(this.state == QAbstractAnimation.State.Running) this.stop() } +/** + * Bridges a Qt PrivateSignal0 to a Kotlin Flow. + */ +fun QObject.PrivateSignal0.asFlow(): Flow = callbackFlow { + val slot = connect { trySend(Unit) } + awaitClose { disconnect(slot) } +} +/** + * Makes an [Qt.Alignment] from [Qt.AlignmentFlag] + */ fun Qt.AlignmentFlag.asAlignment(): Qt.Alignment = Qt.Alignment(this) +/** + * Converts an AWT [BufferedImage] to [QImage] + */ fun BufferedImage.toQImage(): QImage { val argb = QImage.Format.Format_ARGB32 val qimg = QImage(width, height, argb) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/GlobalFunctions.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/GlobalFunctions.kt index 1f444ec..538286b 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/GlobalFunctions.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/GlobalFunctions.kt @@ -2,20 +2,13 @@ package io.github.tritium_launcher.launcher import io.github.tritium_launcher.launcher.io.VPath import io.qt.core.* -import io.qt.gui.QGuiApplication -import io.qt.gui.QIcon -import io.qt.gui.QImage -import io.qt.gui.QPixmap +import io.qt.gui.* import io.qt.widgets.QApplication import io.qt.widgets.QWidget import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import kotlin.reflect.KClass - -private val logger = LoggerFactory.getLogger("GlobalFunctions") //TODO: Make a central io.github.tritium_launcher.launcher.logger for things like this and others -val koinLogger: Logger = LoggerFactory.getLogger("Koin") - /** * Shortens the [System.getProperty] call */ @@ -93,6 +86,9 @@ fun currentDpr(widget: QWidget?): Double { return currentDprOnGui(widget) } +/** + * Get DPR Value from several sources + */ private fun currentDprOnGui(widget: QWidget?): Double { if (widget != null) { widget.window()?.windowHandle()?.devicePixelRatio()?.let { dpr -> @@ -128,6 +124,9 @@ private fun currentDprOnGui(widget: QWidget?): Double { return 1.0 } +/** + * Get a [QIcon] resource from classLoader + */ fun resourceIcon(resource: String, classLoader: ClassLoader): QIcon? { val stream = classLoader.getResourceAsStream(resource) ?: run { mainLogger.warn("Icon resource not found: $resource") @@ -152,28 +151,15 @@ fun Any.logger(): Logger { return LoggerFactory.getLogger(this::class.java) } +/** + * Makes a [Logger] using string for name + */ fun logger(name: String): Logger = LoggerFactory.getLogger(name) -fun logger(any: KClass<*>): Logger = LoggerFactory.getLogger(any.java) - -fun compareMCVersions(ver1: String, ver2: String): Boolean { - fun parse(v: String): Triple { - val parts = v.split('.') - val major = parts[0].toInt() - val minor = parts.getOrNull(1)?.toInt() ?: 0 - val patch = parts.getOrNull(2)?.toInt() ?: 0 - return Triple(major, minor, patch) - } - - val (_, min1, pat1) = parse(ver1) - val (_, min2, pat2) = parse(ver2) - return when { - min1 > min2 -> true - min1 < min2 -> false - pat1 >= pat2 -> true - else -> false - } -} +/** + * Makes a [Logger] using [KClass] for full qualifier name + */ +fun logger(any: KClass<*>): Logger = LoggerFactory.getLogger(any.java) /** * Format a duration in milliseconds as "m s ms", omitting larger units when zero. @@ -217,43 +203,144 @@ fun String.redactUserPath(): String { */ fun String.sanitizeForLogs(): String = redactUserPath() +/** + * TODO: Probably needs to be removed, it's unnecessary + */ fun qs(w: Int, h: Int = -1): QSize = if(h == -1) QSize(w,w) else QSize(w, h) +/** + * Get active window + */ val activeWindow: QWidget? get() { if(QApplication.instance() != null) return QApplication.activeWindow() return null } +/** + * Load [QPixmap] from filesystem with quality scaling + */ fun loadScaledPixmap(path: String, target: QSize, dprWidgetRef: QWidget? = null): QPixmap = try { - val img = QImage(path) - if (img.isNull) { - QPixmap() - } else { - val dpr = dprWidgetRef?.window()?.windowHandle()?.screen()?.devicePixelRatio - ?: QGuiApplication.primaryScreen()?.devicePixelRatio() - ?: 1.0 - - val scaleUp = target.width() * dpr > img.width() || target.height() * dpr > img.height() - val mode = if (scaleUp) Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation - - val scaledImg = img.scaled( - kotlin.math.ceil(target.width() * dpr).toInt(), - kotlin.math.ceil(target.height() * dpr).toInt(), - Qt.AspectRatioMode.KeepAspectRatio, - mode - ) - var pix = QPixmap.fromImage(scaledImg) - pix.setDevicePixelRatio(dpr) - - val logicalWidth = pix.width() / dpr - val logicalHeight = pix.height() / dpr - if (logicalWidth < target.width() || logicalHeight < target.height()) { - pix = pix.scaled(target, Qt.AspectRatioMode.KeepAspectRatio, mode) - pix.setDevicePixelRatio(1.0) - } - pix - } + loadScaledPixmap(QImage(path), target, dprWidgetRef) } catch (_: Throwable) { QPixmap() } + +/** + * Scale [QImage] to [target] size with quality scaling (DPR-aware, Fast on upscale / Smooth on downscale) + */ +fun loadScaledPixmap(img: QImage, target: QSize, dprWidgetRef: QWidget? = null): QPixmap { + if (img.isNull) return QPixmap() + + val dpr = dprWidgetRef?.window()?.windowHandle()?.screen()?.devicePixelRatio + ?: QGuiApplication.primaryScreen()?.devicePixelRatio() + ?: 1.0 + + val scaleUp = target.width() * dpr > img.width() || target.height() * dpr > img.height() + val mode = if (scaleUp) Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation + + val scaledImg = img.scaled( + kotlin.math.ceil(target.width() * dpr).toInt(), + kotlin.math.ceil(target.height() * dpr).toInt(), + Qt.AspectRatioMode.KeepAspectRatio, + mode + ) + var pix = QPixmap.fromImage(scaledImg) + pix.setDevicePixelRatio(dpr) + + val logicalWidth = pix.width() / dpr + val logicalHeight = pix.height() / dpr + if (logicalWidth < target.width() || logicalHeight < target.height()) { + pix = pix.scaled(target, Qt.AspectRatioMode.KeepAspectRatio, mode) + pix.setDevicePixelRatio(1.0) + } + return pix +} + +fun QPixmap.applyRainbowOverlay(targetSize: QSize = QSize(256, 256), opacity: Float = 1f): QPixmap { + val scaled = scaled(targetSize, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation) + val base = scaled.toImage().convertToFormat(QImage.Format.Format_ARGB32) + val size = base.size() + + var minX = size.width(); var maxX = 0 + var minY = size.height(); var maxY = 0 + + for (y in 0 until size.height()) { + for (x in 0 until size.width()) { + val a = (base.pixel(x, y) ushr 24) and 0xFF + if (a > 0) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + val gradientImg = QImage(size, QImage.Format.Format_ARGB32) + gradientImg.fill(0x00000000) + + val gx1 = minX.toDouble() + val gy1 = minY.toDouble() + val gx2 = maxX.toDouble() + val gy2 = minY + (maxY - minY) * 0.67 + + val axisX = gx2 - gx1 + val axisY = gy2 - gy1 + val axisLen2 = axisX * axisX + axisY * axisY + + data class Stop(val t: Float, val hue: Int, val lightness: Int) + val stops = listOf( + Stop(0.000f, 0, 128), + Stop(0.166f, 30, 128), + Stop(0.333f, 56, 128), + Stop(0.500f, 130, 64), + Stop(0.666f, 240, 128), + Stop(1.000f, 300, 128), + ) + + fun interpolateStop(t: Float): Pair { + val clamped = t.coerceIn(0f, 1f) + val i = stops.indexOfLast { it.t <= clamped }.coerceAtLeast(0) + val s0 = stops[i] + val s1 = stops.getOrNull(i + 1) ?: return s0.hue to s0.lightness + val f = (clamped - s0.t) / (s1.t - s0.t) + var dh = s1.hue - s0.hue + if (dh > 180) dh -= 360 + if (dh < -180) dh += 360 + val hue = ((s0.hue + dh * f).toInt() + 360) % 360 + val lightness = (s0.lightness + (s1.lightness - s0.lightness) * f).toInt() + return hue to lightness + } + + val result = QImage(size, QImage.Format.Format_ARGB32) + result.fill(0x00000000) + + for(y in 0 until size.height()) { + for(x in 0 until size.width()) { + val raw = base.pixel(x, y) + val a = (raw ushr 24) and 0xFF + if (a == 0) continue + val baseColor = QColor( + (raw ushr 16) and 0xFF, + (raw ushr 8) and 0xFF, + raw and 0xFF, + a + ) + val px = x - gx1 + val py = y - gy1 + val t = ((px * axisX + py * axisY) / axisLen2).toFloat() + val (hue, _) = interpolateStop(t) + val rainbowColor = QColor.fromHsl(hue, 255, baseColor.lightness(), a) + val blended = if (opacity >= 1f) rainbowColor else QColor( + (baseColor.red() + (rainbowColor.red() - baseColor.red()) * opacity).toInt(), + (baseColor.green() + (rainbowColor.green() - baseColor.green()) * opacity).toInt(), + (baseColor.blue() + (rainbowColor.blue() - baseColor.blue()) * opacity).toInt(), + a, + ) + result.setPixel(x, y, blended.rgba()) + } + } + + return QPixmap.fromImage(result) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/Main.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/Main.kt index 401d6cf..1832c72 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/Main.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/Main.kt @@ -3,18 +3,26 @@ package io.github.tritium_launcher.launcher import io.github.tritium_launcher.launcher.accounts.MicrosoftAuth.attemptAutoSignIn import io.github.tritium_launcher.launcher.bootstrap.runLowPriorityTasks import io.github.tritium_launcher.launcher.bootstrap.startHost +import io.github.tritium_launcher.launcher.bootstrap.startKeymap +import io.github.tritium_launcher.launcher.bootstrap.startSettings import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues -import io.github.tritium_launcher.launcher.font.loadFont +import io.github.tritium_launcher.launcher.font.FontMngr import io.github.tritium_launcher.launcher.git.Git import io.github.tritium_launcher.launcher.logging.Logs import io.github.tritium_launcher.launcher.platform.GameProcessMngr import io.github.tritium_launcher.launcher.platform.Platform import io.github.tritium_launcher.launcher.ui.dashboard.Dashboard -import io.github.tritium_launcher.launcher.ui.logging.Hotkeys +import io.github.tritium_launcher.launcher.ui.global.TooltipInterceptor +import io.github.tritium_launcher.launcher.ui.helpers.installEventFilter +import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr import io.github.tritium_launcher.launcher.ui.theme.TritiumProxyStyle +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.util.SeasonalEvents.isPrideMonth import io.qt.core.QCoreApplication +import io.qt.core.QLogging import io.qt.core.Qt +import io.qt.core.QtMsgType import io.qt.gui.QFont import io.qt.gui.QIcon import io.qt.widgets.QApplication @@ -24,25 +32,39 @@ import io.qt.widgets.QWidget import kotlinx.coroutines.runBlocking import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.util.prefs.Preferences +import org.slf4j.bridge.SLF4JBridgeHandler // TODO: Needs some cleanup internal val mainLogger: Logger = LoggerFactory.getLogger(Main::class.java) +internal val qtLogger: Logger = LoggerFactory.getLogger(Qt::class.java) +/** + * QApplication instance + */ @Volatile internal var appInstance: QApplication? = null +/** + * Global QApplication Getter + */ val TApp: QApplication get() = appInstance ?: throw IllegalStateException("QApplication not initialized.") +/** + * Used for reference elsewhere + */ lateinit var referenceWidget: QWidget +/** Main Entrypoint */ class Main { companion object { @JvmStatic fun main(vararg args: String) { + SLF4JBridgeHandler.removeHandlersForRootLogger() + SLF4JBridgeHandler.install() + installQtMessageHandler() Logs.prepareForLaunch() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> mainLogger.error("Uncaught exception on thread {}", thread.name, throwable) @@ -52,35 +74,46 @@ class Main { mainLogger.info("Starting Tritium (argCount={})", args.size) Platform.printSystemDetails(mainLogger) - if (QApplication.instance() == null) QApplication.initialize(args) + check(QApplication.instance() == null) { "QApplication already initialized" } + QApplication.initialize(args) appInstance = QApplication.instance() as QApplication - Hotkeys.install() QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, true) QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, true) - referenceWidget = QWidget() manageArguments(args.toList()) ThemeMngr.init() - val loaders = startHost(TConstants.EXT_DIR) + startSettings() + + startHost(TConstants.EXT_DIR) + + startKeymap() + Git.init() attemptAutoSignIn() val baseStyle = QStyleFactory.create("Fusion") ?: QApplication.style() QApplication.setStyle(TritiumProxyStyle(baseStyle)) - ThemeMngr.setTheme(ThemeMngr.currentThemeId) + ThemeMngr.setTheme(ThemeMngr.currentThemeIdValue) applyStartupFont() - QApplication.setWindowIcon(QIcon(resourceIcon("icons/tritium.png", TConstants.classLoader)!!)) + QApplication.setWindowIcon( + if (isPrideMonth()) { + TIcons.TritiumGrayscale.applyRainbowOverlay(opacity = 0.5f).icon + } else { + QIcon(TIcons.Tritium.scaled(qs(256, 256), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + } + ) QApplication.setDesktopFileName("tritium") QApplication.setApplicationName("tritium") TApp.aboutToQuit.connect { handleRunningGamesOnExit() } + TApp.installEventFilter(TooltipInterceptor(), CoreSettingValues.uiGameTooltipStyle) Dashboard.createAndShow() @@ -95,11 +128,28 @@ class Main { } } + private fun installQtMessageHandler() { + QLogging.qInstallMessageHandler({ type, ctx, msg -> + when(type) { + QtMsgType.QtDebugMsg -> qtLogger.debug(msg) + QtMsgType.QtWarningMsg -> qtLogger.warn(msg) + QtMsgType.QtCriticalMsg -> qtLogger.error(msg) + QtMsgType.QtFatalMsg -> qtLogger.error("[FATAL] $msg") + QtMsgType.QtInfoMsg -> qtLogger.info(msg) + QtMsgType.QtSystemMsg -> qtLogger.trace(msg) + } + }) + } + + /** + * If the game is running when trying to close Tritium, + * ask to close the game depending on [CoreSettingValues.closeGameOnExitPolicy] + */ private fun handleRunningGamesOnExit() { val running = GameProcessMngr.active().filter { it.isRunning } if (running.isEmpty()) return - val policy = CoreSettingValues.closeGameOnExitPolicy() + val policy = CoreSettingValues.closeGameOnExitPolicy val shouldClose = when (policy) { CoreSettingValues.CloseGameOnExitPolicy.Never -> false CoreSettingValues.CloseGameOnExitPolicy.Always -> true @@ -131,21 +181,15 @@ class Main { } } + /** + * Applies the startup font using the bundled Inter as default, + * or a user-configured family/size from settings. + */ private fun applyStartupFont() { - val prefs = Preferences.userRoot().node("/tritium") - val defaultLoaded = loadFont("/fonts/Inter/InterVariable.ttf")?.let { QFont(it, 10) } - - val savedFamily = prefs.get("globalFontFamily", null) - val savedSize = prefs.getInt("globalFontSize", -1) - val useSaved = !savedFamily.isNullOrBlank() && savedSize > 0 - - val fontToSet = when { - useSaved -> QFont(savedFamily, savedSize) - defaultLoaded != null -> defaultLoaded - else -> null - } + FontMngr.init() - fontToSet?.let { QApplication.setFont(it) } + val (family, size) = CoreSettingValues.globalFont() + QApplication.setFont(QFont(family, size)) } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/TConstants.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/TConstants.kt index 5059513..1d2c661 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/TConstants.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/TConstants.kt @@ -2,12 +2,18 @@ package io.github.tritium_launcher.launcher import io.github.tritium_launcher.launcher.io.VPath +/** + * General constant values + */ object TConstants { const val TR = "Tritium" const val TR_SERVICE = "TritiumLauncher" val VERSION: String by lazy { resolveVersion() } val TR_DIR: VPath = fromTR() + /** + * Directories in `~/tritium` + */ object Dirs { const val PROJECTS = "projects" const val EXTENSIONS = "extensions" @@ -17,9 +23,11 @@ object TConstants { const val MSAL = ".msal" const val ASSETS = "assets" const val SETTINGS = "settings" + const val LSPS = "lsps" } val EXT_DIR = fromTR(Dirs.EXTENSIONS) + val LSPS_DIR = fromTR(Dirs.LSPS) val classLoader: ClassLoader = javaClass.classLoader object Lists { @@ -30,6 +38,9 @@ object TConstants { ) } + /** + * Get current Tritium version + */ private fun resolveVersion(): String { val fromManifest = TConstants::class.java.`package`?.implementationVersion?.trim() if (!fromManifest.isNullOrBlank()) return fromManifest diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountCache.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountCache.kt new file mode 100644 index 0000000..d965f10 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountCache.kt @@ -0,0 +1,67 @@ +package io.github.tritium_launcher.launcher.accounts + +import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.json.Json +import java.util.concurrent.ConcurrentHashMap + +object AccountCache { + private val log = logger() + private val json = Json { ignoreUnknownKeys = true } + private val cacheDir = fromTR(TConstants.Dirs.PROFILES, "accounts").also { it.mkdirs() } + private val cache = ConcurrentHashMap>() + + init { + try { + cacheDir.listFiles(filter = { it.isDir() }).forEach { serviceDir -> + val service = serviceDir.fileName() + val serviceMap = ConcurrentHashMap() + serviceDir.listFiles(filter = { it.isFile() && it.fileName().endsWith(".json") }).forEach { file -> + try { + val contents = file.readTextOrNull() ?: return@forEach + val descriptor = json.decodeFromString(AccountDescriptor.serializer(), contents) + serviceMap[file.fileName().removeSuffix(".json")] = descriptor + } catch (t: Throwable) { + log.warn("Failed to load cached account descriptor from {}", file.toAbsolute(), t) + } + } + if (serviceMap.isNotEmpty()) { + cache[service] = serviceMap + } + } + log.info("AccountCache: loaded {} services from disk", cache.size) + } catch (t: Throwable) { + log.warn("Failed to pre-load account cache", t) + } + } + + fun getCached(service: String, accountId: String): AccountDescriptor? { + return cache[service]?.get(accountId) + } + + fun getAllCached(service: String): List { + return cache[service]?.values?.toList() ?: emptyList() + } + + fun save(service: String, accountId: String, descriptor: AccountDescriptor) { + cache.computeIfAbsent(service) { ConcurrentHashMap() }[accountId] = descriptor + val file = cacheDir.resolve(service).resolve("$accountId.json") + try { + file.parent().mkdirs() + file.writeBytesAtomic(json.encodeToString(AccountDescriptor.serializer(), descriptor).toByteArray()) + } catch (t: Throwable) { + log.warn("Failed to save cached account descriptor for {}/{}", service, accountId, t) + } + } + + fun remove(service: String, accountId: String) { + cache[service]?.remove(accountId) + val file = cacheDir.resolve(service).resolve("$accountId.json") + try { + if (file.exists()) file.delete() + } catch (t: Throwable) { + log.warn("Failed to delete cached account descriptor for {}/{}", service, accountId, t) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountDescriptor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountDescriptor.kt new file mode 100644 index 0000000..d7e915b --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountDescriptor.kt @@ -0,0 +1,17 @@ +package io.github.tritium_launcher.launcher.accounts + +import kotlinx.serialization.Serializable + +@Serializable +data class AccountDescriptor( + val id: String, + val username: String? = null, + val subtitle: String? = null, + val avatarUrl: String? = null, + val label: String? = null +) + +enum class AccountCapability { + UPLOAD, + VIEW_PROJECTS +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountProvider.kt new file mode 100644 index 0000000..feb0e38 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/AccountProvider.kt @@ -0,0 +1,37 @@ +package io.github.tritium_launcher.launcher.accounts + +import io.github.tritium_launcher.launcher.registry.Registrable +import io.qt.gui.QPixmap +import io.qt.widgets.QWidget + +interface AccountProvider : Registrable { + override val id: String + val displayName: String + val serviceIcon: QPixmap? get() = null + suspend fun listAccounts(): List + suspend fun signIn(parentWindow: QWidget? = null): AccountDescriptor? + suspend fun signInWithToken(token: String, parentWindow: QWidget? = null): AccountDescriptor? = null + suspend fun signOutAccount(accountId: String) + suspend fun switchToAccount(accountId: String): Boolean + suspend fun getAvatar(accountId: String): QPixmap? = null + suspend fun getCredentials(accountId: String): Map? + val capabilities: Set get() = emptySet() + + val authMethod: AuthMethod get() = AuthMethod.OAUTH + val tokenLabel: String? get() = null + val tokenPageUrl: String? get() = null + val supportsMultipleAccounts: Boolean get() = false + val sectionColor: String? get() = null + val infoDescription: String? get() = null + + /** + * Optional widget shown in a dialog before opening [tokenPageUrl]. + */ + fun createTokenSetupWidget(parent: QWidget?): QWidget? = null +} + +enum class AuthMethod { + OAUTH, + KEY, + OAUTH_AND_KEY +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/Api.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/Api.kt deleted file mode 100644 index 87fead3..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/Api.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.github.tritium_launcher.launcher.accounts - -import io.github.tritium_launcher.launcher.registry.Registrable -import io.qt.gui.QPixmap -import io.qt.widgets.QWidget - -/** - * Account metadata for UI and account switching. - */ -data class AccountDescriptor( - val id: String, - val username: String? = null, - val subtitle: String? = null, - val avatarUrl: String? = null, - val label: String? = null -) - -/** - * Provides account discovery and session management for a service. - */ -interface AccountProvider: Registrable { - override val id: String - /** Human-readable provider name for UI. */ - val displayName: String - - /** Lists accounts known to this provider. */ - suspend fun listAccounts(): List - - /** Starts an interactive sign-in flow. */ - suspend fun signIn(parentWindow: QWidget? = null) - - /** Signs out a specific account by id. */ - suspend fun signOutAccount(accountId: String) - - /** Switches the active account to the given id. */ - suspend fun switchToAccount(accountId: String): Boolean - - /** Returns an avatar for the account, if available. */ - suspend fun getAvatar(accountId: String): QPixmap? = null -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/CurseForgeAccount.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/CurseForgeAccount.kt new file mode 100644 index 0000000..2374b88 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/CurseForgeAccount.kt @@ -0,0 +1,112 @@ +package io.github.tritium_launcher.launcher.accounts + +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.icon +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.qt.gui.QPixmap +import io.qt.widgets.QWidget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class CurseForgeAccount : AccountProvider { + private val logger = logger() + override val id: String = "curseforge_account" + override val displayName: String = "CurseForge" + override val serviceIcon: QPixmap get() = TIcons.CURSEFORGE.icon(64) + override val capabilities: Set = setOf(AccountCapability.UPLOAD) + override val authMethod: AuthMethod = AuthMethod.KEY + override val tokenLabel: String = "API Key:" + override val tokenPageUrl: String get() = TOKEN_PAGE + override val supportsMultipleAccounts: Boolean = true + override val sectionColor: String = "151515" + override val infoDescription: String = "Used for uploading and managing ModPacks" + + private companion object { + const val UPLOAD_API_BASE = "https://minecraft.curseforge.com/api/" + const val TOKEN_PAGE = "https://www.curseforge.com/account/api-tokens" + const val STORAGE_SERVICE = "tritium_curseforge" + } + + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 15_000 + } + defaultRequest { + header("User-Agent", ClientIdentity.userAgent) + } + } + + override suspend fun listAccounts(): List = withContext(Dispatchers.IO) { + SecureStorage.listAccounts(STORAGE_SERVICE).map { id -> + AccountCache.getCached(STORAGE_SERVICE, id) ?: AccountDescriptor( + id = id, + username = id, + subtitle = id, + label = "CurseForge API Token" + ) + } + } + + override suspend fun signIn(parentWindow: QWidget?): AccountDescriptor? { + Platform.openBrowser(TOKEN_PAGE) + return null + } + + override suspend fun signInWithToken(token: String, parentWindow: QWidget?): AccountDescriptor? { + return withContext(Dispatchers.IO) { + val clean = token.trim().removePrefix("X-Api-Token ").removePrefix("x-api-token ") + if (!validateToken(clean)) { + logger.warn("CurseForge token validation failed") + return@withContext null + } + val accountId = "cf_${clean.take(8)}" + SecureStorage.store(STORAGE_SERVICE, accountId, clean) + val descriptor = AccountDescriptor( + id = accountId, + username = accountId, + subtitle = accountId, + label = "CurseForge API Token" + ) + AccountCache.save(STORAGE_SERVICE, accountId, descriptor) + descriptor + } + } + + override suspend fun signOutAccount(accountId: String) { + SecureStorage.delete(STORAGE_SERVICE, accountId) + AccountCache.remove(STORAGE_SERVICE, accountId) + } + + override suspend fun switchToAccount(accountId: String): Boolean { + return SecureStorage.retrieve(STORAGE_SERVICE, accountId) != null + } + + override suspend fun getAvatar(accountId: String): QPixmap? = null + + override suspend fun getCredentials(accountId: String): Map? { + val token = SecureStorage.retrieve(STORAGE_SERVICE, accountId) ?: return null + return mapOf("X-Api-Token" to token) + } + + private suspend fun validateToken(token: String): Boolean { + return try { + val response: HttpResponse = httpClient.get("${UPLOAD_API_BASE}game/versions") { + header("X-Api-Token", token) + } + response.status.isSuccess() + } catch (t: Throwable) { + logger.warn("CurseForge token validation request failed", t) + false + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ui/MicrosoftAccountProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/MicrosoftAccountProvider.kt similarity index 75% rename from src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ui/MicrosoftAccountProvider.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/accounts/MicrosoftAccountProvider.kt index b370e90..2bff450 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ui/MicrosoftAccountProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/MicrosoftAccountProvider.kt @@ -1,12 +1,9 @@ -package io.github.tritium_launcher.launcher.accounts.ui +package io.github.tritium_launcher.launcher.accounts -import io.github.tritium_launcher.launcher.accounts.AccountDescriptor -import io.github.tritium_launcher.launcher.accounts.AccountProvider -import io.github.tritium_launcher.launcher.accounts.MicrosoftAuth -import io.github.tritium_launcher.launcher.accounts.ProfileMngr import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.toQImage import io.github.tritium_launcher.launcher.toUrl +import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.qt.core.Qt import io.qt.gui.QPainter import io.qt.gui.QPixmap @@ -24,13 +21,22 @@ class MicrosoftAccountProvider: AccountProvider { private val logger = logger() override val id: String = "microsoft_account_provider" override val displayName: String = "Microsoft" + override val serviceIcon: QPixmap get() = TIcons.Microsoft + override val sectionColor: String = "3A4A5C" + override val infoDescription: String = "Used for Mojang authentication to download and launch Minecraft" override suspend fun listAccounts(): List = withContext(Dispatchers.IO) { try { val accounts = MicrosoftAuth.listAccounts() accounts.map { a -> val mc = ProfileMngr.Cache.getForAccount(a.homeAccountId) - AccountDescriptor(a.homeAccountId, mc?.name ?: a.username, mc?.id ?: a.username, null, mc?.name ?: a.username) + AccountDescriptor( + a.homeAccountId, + mc?.name ?: a.username, + mc?.id ?: a.username, + null, + mc?.name ?: a.username + ) } } catch (e: Exception) { logger.warn("Failed to map accounts", e) @@ -38,10 +44,13 @@ class MicrosoftAccountProvider: AccountProvider { } } - override suspend fun signIn(parentWindow: QWidget?) { - MicrosoftAuth.newSignIn() + override suspend fun signIn(parentWindow: QWidget?): AccountDescriptor? { + val profile = MicrosoftAuth.newSignIn() + return profile?.let { AccountDescriptor(it.id, it.name) } } + override suspend fun getCredentials(accountId: String): Map? = null + override suspend fun signOutAccount(accountId: String) { MicrosoftAuth.signOutAccount(accountId) } @@ -53,7 +62,7 @@ class MicrosoftAccountProvider: AccountProvider { override suspend fun getAvatar(accountId: String): QPixmap? = withContext(Dispatchers.IO) { try { val p = ProfileMngr.Cache.getForAccount(accountId) - if(p != null && p.skins.isNotEmpty()) { + if (p != null && p.skins.isNotEmpty()) { return@withContext createFacePixmap(p.skins.first().url) } @@ -73,10 +82,10 @@ class MicrosoftAccountProvider: AccountProvider { val original = QPixmap.fromImage(img.toQImage()) - val baseFace = original.copy(8,8,8,8) - val hatLayer = original.copy(40,8,8,8) + val baseFace = original.copy(8, 8, 8, 8) + val hatLayer = original.copy(40, 8, 8, 8) - val combined = QPixmap(8,8) + val combined = QPixmap(8, 8) combined.fill(Qt.GlobalColor.transparent) val painter = QPainter(combined) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ModrinthAccount.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ModrinthAccount.kt new file mode 100644 index 0000000..b714d5f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ModrinthAccount.kt @@ -0,0 +1,523 @@ +package io.github.tritium_launcher.launcher.accounts + +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.m +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.toUrl +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.icon +import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.frame +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.qt.gui.QPixmap +import io.qt.widgets.QFrame +import io.qt.widgets.QWidget +import kotlinx.coroutines.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket +import java.net.SocketTimeoutException + +class ModrinthAccount : AccountProvider { + private val logger = logger() + override val id: String = "modrinth_account" + override val displayName: String = "Modrinth" + override val serviceIcon: QPixmap get() = TIcons.MODRINTH.icon(64) + override val capabilities: Set = setOf(AccountCapability.UPLOAD, AccountCapability.VIEW_PROJECTS) + override val authMethod: AuthMethod = AuthMethod.OAUTH_AND_KEY + override val tokenLabel: String = "PAT:" + override val tokenPageUrl: String get() = TOKEN_PAGE + override val supportsMultipleAccounts: Boolean = true + override val sectionColor: String = "254C34" + override val infoDescription: String = "Used for uploading and managing ModPacks" + + override fun createTokenSetupWidget(parent: QWidget?): QWidget { + val container = QFrame(parent).apply { + frameShape = QFrame.Shape.NoFrame + } + val layout = vBoxLayout(container) { + contentsMargins = 16.m + setSpacing(12) + } + + val title = label("Modrinth Personal Access Token Scopes") { + styleSheet = qtStyle { + selector(objectName) { + fontSize(15) + fontWeight(700) + color(TColors.Text) + } + }.toStyleSheet() + } + layout.addWidget(title) + + val desc = label("Create a PAT at modrinth.com/settings/pats and select these permissions:") { + styleSheet = qtStyle { + selector(objectName) { + fontSize(12) + color(TColors.Subtext) + } + }.toStyleSheet() + wordWrap = true + } + layout.addWidget(desc) + + data class ScopeInfo(val label: String, val required: Boolean) + + val scopes = listOf( + ScopeInfo("Read user email", true), + ScopeInfo("Read user data", true), + ScopeInfo("Create projects", true), + ScopeInfo("Read projects", true), + ScopeInfo("Write projects", true), + ScopeInfo("Create versions", true), + ScopeInfo("Read versions", true), + ScopeInfo("Write versions", true), + ScopeInfo("Delete versions", false), + ) + + for (scope in scopes) { + val row = frame { + frameShape = QFrame.Shape.NoFrame + val rowLayout = hBoxLayout(this) { + setContentsMargins(8, 4, 8, 4) + setSpacing(8) + } + + val dot = label(if (scope.required) "●" else "○") { + styleSheet = "color: ${if (scope.required) TColors.Green else TColors.Subtext}; font-size: 10px;" + setFixedWidth(16) + } + rowLayout.addWidget(dot) + + val label = label(scope.label) { + styleSheet = "font-size: 12px; color: ${TColors.Text};" + } + rowLayout.addWidget(label) + + if (!scope.required) { + val tag = label("optional") { + styleSheet = "font-size: 10px; color: ${TColors.Subtext}; padding: 1px 6px; border: 1px solid ${TColors.Surface2}; border-radius: 4px;" + } + rowLayout.addWidget(tag) + } + + rowLayout.addStretch() + } + layout.addWidget(row) + } + + layout.addStretch() + return container + } + + private companion object { + // If you are forking, you are not allowed to use this client + const val CLIENT_ID = "APqt36J5" + const val CLIENT_SECRET = "ORsVS5OuxRGYl1IjQY70zken5oPo6KP3" + const val REDIRECT_PORT = 58420 + const val REDIRECT_URI = "http://127.0.0.1:$REDIRECT_PORT/callback" + const val AUTH_URL = "https://modrinth.com/auth/authorize" + const val TOKEN_URL = "https://api.modrinth.com/_internal/oauth/token" + const val API_BASE = "https://api.modrinth.com/v2/" + const val STORAGE_SERVICE = "tritium_modrinth" + const val TOKEN_PAGE = "https://modrinth.com/settings/pats" + } + + private val json = Json { ignoreUnknownKeys = true } + private val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { json(json) } + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 30_000 + } + defaultRequest { + url(API_BASE) + header("User-Agent", ClientIdentity.userAgent) + } + } + + private val oauthClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 30_000 + } + defaultRequest { + header("User-Agent", ClientIdentity.userAgent) + } + } + + override suspend fun listAccounts(): List = withContext(Dispatchers.IO) { + val accounts = SecureStorage.listAccounts(STORAGE_SERVICE) + accounts.mapNotNull { id -> + AccountCache.getCached(STORAGE_SERVICE, id) ?: run { + val token = SecureStorage.retrieve(STORAGE_SERVICE, id) ?: return@mapNotNull null + val user = fetchUser(token) + if (user != null) { + val descriptor = AccountDescriptor( + id = id, + username = user.username, + subtitle = user.id, + avatarUrl = user.avatarUrl, + label = user.name ?: user.username + ) + AccountCache.save(STORAGE_SERVICE, id, descriptor) + descriptor + } else { + AccountDescriptor(id = id, username = id, subtitle = id) + } + } + } + } + + override suspend fun signIn(parentWindow: QWidget?): AccountDescriptor? { + val code = withContext(Dispatchers.IO) { startOAuthServer() } ?: return null + return withContext(Dispatchers.IO) { exchangeCodeAndStore(code) } + } + + override suspend fun signInWithToken(token: String, parentWindow: QWidget?): AccountDescriptor? { + return withContext(Dispatchers.IO) { + val clean = token.trim().removePrefix("Bearer ").removePrefix("bearer ") + val user = fetchUser(clean) + if (user == null) { + logger.warn("Modrinth PAT validation failed: invalid token") + return@withContext null + } + SecureStorage.store(STORAGE_SERVICE, user.id, clean) + val descriptor = AccountDescriptor( + id = user.id, + username = user.username, + subtitle = user.id, + avatarUrl = user.avatarUrl, + label = user.name ?: user.username + ) + AccountCache.save(STORAGE_SERVICE, user.id, descriptor) + descriptor + } + } + + override suspend fun signOutAccount(accountId: String) { + SecureStorage.delete(STORAGE_SERVICE, accountId) + AccountCache.remove(STORAGE_SERVICE, accountId) + } + + override suspend fun switchToAccount(accountId: String): Boolean { + return SecureStorage.retrieve(STORAGE_SERVICE, accountId) != null + } + + override suspend fun getAvatar(accountId: String): QPixmap? = withContext(Dispatchers.IO) { + try { + val token = SecureStorage.retrieve(STORAGE_SERVICE, accountId) ?: run { + logger.warn("Modrinth getAvatar: no token for $accountId") + return@withContext null + } + val user = fetchUser(token) ?: run { + logger.warn("Modrinth getAvatar: fetchUser returned null for $accountId") + return@withContext null + } + val avatarUrl = user.avatarUrl ?: run { + logger.warn("Modrinth getAvatar: avatarUrl is null for ${user.username}") + return@withContext null + } + logger.info("Modrinth getAvatar: downloading from $avatarUrl") + val url = avatarUrl.toUrl() + val conn = url.openConnection() + conn.setRequestProperty("User-Agent", ClientIdentity.userAgent) + val bytes = conn.inputStream.readBytes() + if (bytes.isEmpty()) { + logger.warn("Modrinth getAvatar: empty response from $avatarUrl") + return@withContext null + } + val pix = QPixmap() + if (!pix.loadFromData(bytes)) { + logger.warn("Modrinth getAvatar: QPixmap.loadFromData failed for ${bytes.size} bytes from $avatarUrl") + return@withContext null + } + logger.info("Modrinth getAvatar: loaded ${pix.width()}x${pix.height()}") + return@withContext pix + } catch (e: Exception) { + logger.warn("Failed to fetch Modrinth avatar for $accountId", e) + null + } + } + + override suspend fun getCredentials(accountId: String): Map? { + val token = SecureStorage.retrieve(STORAGE_SERVICE, accountId) ?: return null + return mapOf("Authorization" to token) + } + + private fun startOAuthServer(): String? { + val serverSocket = ServerSocket(REDIRECT_PORT, 1, java.net.InetAddress.getByName("127.0.0.1")) + serverSocket.soTimeout = 5 * 60 * 1000 + serverSocket.reuseAddress = true + logger.info("Modrinth OAuth server listening on 127.0.0.1:{}", REDIRECT_PORT) + try { + val authUrl = URLBuilder(AUTH_URL).apply { + parameters.append("client_id", CLIENT_ID) + parameters.append("redirect_uri", REDIRECT_URI) + parameters.append("scope", "USER_READ PROJECT_READ VERSION_CREATE") + parameters.append("response_type", "code") + }.buildString() + Platform.openBrowser(authUrl) + logger.info("Waiting for OAuth callback on 127.0.0.1:{}", REDIRECT_PORT) + + val clientSocket = serverSocket.accept() + val reader = BufferedReader(InputStreamReader(clientSocket.inputStream)) + val requestLine = reader.readLine() ?: return null + logger.info("Modrinth OAuth request: {}", requestLine) + + val parts = requestLine.split(" ") + if (parts.size < 2) return null + val uri = parts[1] + val queryParams = uri.substringAfter("?").split("&").associate { + val kv = it.split("=", limit = 2) + kv[0] to (kv.getOrNull(1) ?: "") + } + val code = queryParams["code"] + + val writer = OutputStreamWriter(clientSocket.outputStream) + if (code != null) { + logger.info("Modrinth OAuth code received") + val body = "

Signed in!

You can close this window.

" + writer.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: ${body.toByteArray().size}\r\nConnection: close\r\n\r\n$body") + writer.flush() + clientSocket.close() + return code + } else { + val error = queryParams["error"] ?: "unknown" + logger.warn("Modrinth OAuth error: {}", error) + val body = "

Sign-in failed

Error: $error

" + writer.write("HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\nContent-Length: ${body.toByteArray().size}\r\nConnection: close\r\n\r\n$body") + writer.flush() + clientSocket.close() + return null + } + } catch (t: SocketTimeoutException) { + logger.warn("Modrinth OAuth timed out waiting for callback", t) + return null + } catch (t: Throwable) { + logger.warn("Modrinth OAuth failed", t) + return null + } finally { + serverSocket.close() + } + } + + private suspend fun exchangeCodeAndStore(code: String): AccountDescriptor? { + return try { + val response: HttpResponse = oauthClient.post(TOKEN_URL) { + header("Authorization", CLIENT_SECRET) + contentType(ContentType.Application.FormUrlEncoded) + setBody(FormDataContent(Parameters.build { + append("client_id", CLIENT_ID) + append("code", code) + append("redirect_uri", REDIRECT_URI) + append("grant_type", "authorization_code") + })) + } + if (!response.status.isSuccess()) { + val body = response.bodyAsText() + logger.warn("Modrinth token exchange failed: HTTP {} body={}", response.status.value, body.take(500)) + return null + } + val body = response.bodyAsText() + val tokenResponse = json.decodeFromString(body) + val accessToken = tokenResponse.accessToken + val user = fetchUser(accessToken) ?: return null + SecureStorage.store(STORAGE_SERVICE, user.id, accessToken) + logger.info("Modrinth account signed in: {} ({})", user.username, user.id) + val descriptor = AccountDescriptor( + id = user.id, + username = user.username, + subtitle = user.id, + avatarUrl = user.avatarUrl, + label = user.name ?: user.username + ) + AccountCache.save(STORAGE_SERVICE, user.id, descriptor) + descriptor + } catch (t: Throwable) { + logger.warn("Modrinth token exchange failed", t) + null + } + } + + private suspend fun fetchUser(token: String): ModrinthUser? { + return try { + val response: HttpResponse = httpClient.get("user") { + header("Authorization", token) + } + if (!response.status.isSuccess()) { + logger.warn("Modrinth user fetch failed: HTTP {}", response.status.value) + return null + } + val userResponse = json.decodeFromString(response.bodyAsText()) + logger.info("Modrinth user fetched: {} ({})", userResponse.username, userResponse.id) + ModrinthUser( + id = userResponse.id, + username = userResponse.username, + name = userResponse.name, + avatarUrl = userResponse.avatarUrl + ) + } catch (t: Throwable) { + logger.warn("Failed to fetch Modrinth user", t) + null + } + } + + suspend fun fetchConnectedModpackProjects(): List = withContext(Dispatchers.IO) { + val accounts = SecureStorage.listAccounts(STORAGE_SERVICE) + val results = mutableListOf() + for (accountId in accounts) { + val token = SecureStorage.retrieve(STORAGE_SERVICE, accountId) ?: continue + try { + results.addAll(fetchAndEnrichModpackProjects(token, accountId)) + } catch (t: Throwable) { + logger.warn("Failed to fetch Modrinth projects for account $accountId", t) + } + } + results + } + + suspend fun fetchModpackProjectsForAccount(accountId: String): List = withContext(Dispatchers.IO) { + val token = SecureStorage.retrieve(STORAGE_SERVICE, accountId) ?: return@withContext emptyList() + try { + fetchAndEnrichModpackProjects(token, accountId) + } catch (t: Throwable) { + logger.warn("Failed to fetch Modrinth projects for account $accountId", t) + emptyList() + } + } + + private suspend fun fetchAndEnrichModpackProjects(token: String, accountId: String): List { + val projects = fetchProjects(token, accountId).filter { it.projectType == "modpack" } + return coroutineScope { + projects.map { project -> + async { + val info = fetchLatestVersionInfo(token, project.id) + if (info != null) project.copy( + latestGameVersion = info.first.firstOrNull(), + latestLoaders = info.second, + latestVersionName = info.third + ) else project + } + }.awaitAll() + } + } + + private suspend fun fetchLatestVersionInfo(token: String, projectId: String): Triple, List, String>? { + return try { + val response: HttpResponse = httpClient.get("project/$projectId/version") { + parameter("featured", "true") + parameter("limit", "1") + header("Authorization", token) + } + if (!response.status.isSuccess()) return null + val versions = json.decodeFromString>(response.bodyAsText()) + versions.firstOrNull()?.let { Triple(it.gameVersions, it.loaders, it.name) } + } catch (t: Throwable) { + logger.warn("Failed to fetch latest version info for $projectId", t) + null + } + } + + private suspend fun fetchProjects(token: String, userId: String): List { + return try { + val response: HttpResponse = httpClient.get("user/$userId/projects") { + header("Authorization", token) + } + if (!response.status.isSuccess()) { + logger.warn("Modrinth projects fetch failed: HTTP {}", response.status.value) + return emptyList() + } + val projects = json.decodeFromString>(response.bodyAsText()) + projects.map { p -> + ModrinthProject( + id = p.id, + slug = p.slug, + title = p.title, + description = p.description, + projectType = p.projectType, + iconUrl = p.iconUrl, + versions = p.versions + ) + } + } catch (t: Throwable) { + logger.warn("Failed to fetch Modrinth projects", t) + emptyList() + } + } + + @Serializable + private data class ModrinthProjectResponse( + val id: String, + val slug: String? = null, + val title: String, + val description: String? = null, + @SerialName("project_type") val projectType: String, + @SerialName("icon_url") val iconUrl: String? = null, + val versions: List = emptyList() + ) + + @Serializable + private data class ModrinthVersionResponse( + val id: String, + val name: String = "", + @SerialName("game_versions") val gameVersions: List = emptyList(), + val loaders: List = emptyList() + ) + + @Serializable + private data class ModrinthTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("token_type") val tokenType: String = "Bearer", + @SerialName("expires_in") val expiresIn: Long = 0, + val scope: String = "" + ) + + @Serializable + private data class ModrinthUserResponse( + val id: String, + val username: String, + val name: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null + ) + + private data class ModrinthUser( + val id: String, + val username: String, + val name: String?, + val avatarUrl: String? + ) +} + +data class ModrinthProject( + val id: String, + val slug: String?, + val title: String, + val description: String?, + val projectType: String, + val iconUrl: String?, + val versions: List, + val latestGameVersion: String? = null, + val latestLoaders: List = emptyList(), + val latestVersionName: String = "" +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ProfileMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ProfileMngr.kt index fa636e5..34b07f8 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ProfileMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/ProfileMngr.kt @@ -16,6 +16,7 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName @@ -29,14 +30,18 @@ import java.util.concurrent.ConcurrentHashMap * Holds methods for profile management, and the profile cache. */ object ProfileMngr { + private val profileScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private const val MC_API_BASE = "https://api.minecraftservices.com" private const val PROFILE_URL = "$MC_API_BASE/minecraft/profile" private const val SKIN_CHANGE_URL = "$MC_API_BASE/minecraft/profile/skins" private const val CAPE_CHANGE_URL = "$MC_API_BASE/minecraft/profile/capes/active" - private val listeners = mutableListOf<(MCProfile?) -> Unit>() - private val progressListeners = mutableListOf<(Double) -> Unit>() + private val _profile = MutableStateFlow(null) + val profile: StateFlow = _profile.asStateFlow() + + private val _progress = MutableSharedFlow(replay = 0) + val progress: SharedFlow = _progress.asSharedFlow() private val json = Json { ignoreUnknownKeys = true } @@ -51,22 +56,12 @@ object ProfileMngr { } } - /** Adds a listener for profile changes. */ - fun addListener(listener: (MCProfile?) -> Unit) { - listeners.add(listener) - } - - /** Adds a listener for profile fetch progress. */ - fun addProgressListener(listener: (Double) -> Unit) { - progressListeners.add(listener) - } - private fun notifyProgress(progress: Double) { - progressListeners.forEach { it(progress) } + _progress.tryEmit(progress) } private fun notifyProfileChanged(profile: MCProfile?) { - listeners.forEach { it(profile) } + _profile.value = profile } private val profilesDir = fromTR(TConstants.Dirs.PROFILES).also { it.mkdirs() } @@ -87,7 +82,7 @@ object ProfileMngr { get() = profiles.isEmpty() init { - GlobalScope.launch(Dispatchers.IO) { + profileScope.launch { try { profilesDir.listFiles { it.isFile() && it.fileName().endsWith(".json") }.forEach { f -> try { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/SecureStorage.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/SecureStorage.kt new file mode 100644 index 0000000..22cf209 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/accounts/SecureStorage.kt @@ -0,0 +1,202 @@ +package io.github.tritium_launcher.launcher.accounts + +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.atomicWrite +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.Platform +import java.nio.file.Files +import java.nio.file.Path +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +//TODO: WINDOWS +internal object SecureStorage { + private val dir = fromTR(".tokens").also { it.mkdirs() } + private const val TOOL_TIMEOUT_SECONDS = 5L + private val logger = logger() + private const val AESKEYSIZE = 32 + private const val GCMIV = 12 + private const val TAGBITS = 128 + + fun store(service: String, account: String, value: String): Boolean { + when (Platform.current) { + Platform.Linux -> { + storeSecretTool(service, account, value) + } + else -> {} + } + return storeEncrypted(service, account, value) + } + + fun retrieve(service: String, account: String): String? { + when (Platform.current) { + Platform.Linux -> { + val fromTool = retrieveSecretTool(service, account) + if (fromTool != null) return fromTool + } + else -> {} + } + return retrieveEncrypted(service, account) + } + + fun delete(service: String, account: String) { + when (Platform.current) { + Platform.Linux -> { + if (commandExists("secret-tool")) { + runSecretTool( + args = listOf("secret-tool", "clear", "service", service, "account", account) + ) + } + } + else -> {} + } + val file = dir.resolve(service).resolve("$account.enc") + try { if (file.exists()) file.delete() } catch (_: Exception) {} + } + + fun listAccounts(service: String): List { + val result = mutableListOf() + val serviceDir = dir.resolve(service) + if (serviceDir.exists() && serviceDir.isDir()) { + serviceDir.listFiles { it.fileName().endsWith(".enc") }.forEach { f -> + result.add(f.fileName().removeSuffix(".enc")) + } + } + return result + } + + private fun storeSecretTool(service: String, account: String, value: String): Boolean { + if (!commandExists("secret-tool")) return false + val result = runSecretTool( + args = listOf("secret-tool", "store", "--label", "Tritium token: $service/$account", "service", service, "account", account), + stdin = value + ) + return result?.exitCode == 0 + } + + private fun retrieveSecretTool(service: String, account: String): String? { + if (!commandExists("secret-tool")) return null + val result = runSecretTool( + args = listOf("secret-tool", "lookup", "service", service, "account", account) + ) + if (result != null && result.exitCode == 0 && result.output.isNotBlank()) { + return result.output + } + return null + } + + private fun storeEncrypted(service: String, account: String, value: String): Boolean { + return try { + val key = getOrCreateKey() + val iv = ByteArray(GCMIV) + SecureRandom().nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(TAGBITS, iv) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec) + val cipherText = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val out = ByteArray(iv.size + cipherText.size) + System.arraycopy(iv, 0, out, 0, iv.size) + System.arraycopy(cipherText, 0, out, iv.size, cipherText.size) + val serviceDir = dir.resolve(service) + serviceDir.mkdirs() + atomicWrite(serviceDir.resolve("$account.enc"), out, durable = true) + true + } catch (t: Throwable) { + logger.warn("Failed to encrypt token for {}/{}", service, account, t) + false + } + } + + private fun retrieveEncrypted(service: String, account: String): String? { + return try { + val file = dir.resolve(service).resolve("$account.enc") + if (!file.exists()) return null + val data = file.bytesOrNull() ?: return null + if (data.size <= GCMIV) return null + val iv = data.copyOfRange(0, GCMIV) + val ct = data.copyOfRange(GCMIV, data.size) + val key = getOrCreateKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(TAGBITS, iv) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), spec) + String(cipher.doFinal(ct), Charsets.UTF_8) + } catch (t: Throwable) { + logger.warn("Failed to decrypt token for {}/{}", service, account, t) + null + } + } + + private val fallbackKeyFile = fromTR(".tokens").resolve("key.bin") + + private fun getOrCreateKey(): ByteArray { + val existing = loadExistingKey() + if (existing != null) return existing + val key = ByteArray(AESKEYSIZE) + SecureRandom().nextBytes(key) + try { + atomicWrite(fallbackKeyFile, key, durable = true) + fallbackKeyFile.toJFile().setReadable(false, false) + fallbackKeyFile.toJFile().setWritable(false, false) + fallbackKeyFile.toJFile().setExecutable(false, false) + fallbackKeyFile.toJFile().setReadable(true, true) + fallbackKeyFile.toJFile().setWritable(true, true) + } catch (t: Throwable) { + logger.warn("Failed to persist secure storage key", t) + } + return key + } + + private fun loadExistingKey(): ByteArray? { + return try { + if (!fallbackKeyFile.exists()) return null + val key = fallbackKeyFile.bytesOrNull() ?: return null + if (key.size != AESKEYSIZE) { + logger.warn("Secure storage key has unexpected length, ignoring") + return null + } + key + } catch (t: Throwable) { + logger.warn("Failed to load secure storage key", t) + null + } + } + + private fun commandExists(command: String): Boolean { + val path = System.getenv("PATH") ?: return false + for (dir in path.split(':')) { + if (dir.isBlank()) continue + val c = Path.of(dir, command) + if (Files.isRegularFile(c) && Files.isExecutable(c)) return true + } + return false + } + + private data class SecretToolResult(val exitCode: Int, val output: String) + + private fun runSecretTool(args: List, stdin: String? = null): SecretToolResult? { + val action = if (args.size >= 2) args[1] else "command" + if (!commandExists("secret-tool")) return null + return try { + val p = ProcessBuilder(args).redirectErrorStream(true).start() + if (stdin != null) { + p.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(stdin) } + } else { + p.outputStream.close() + } + val finished = p.waitFor(TOOL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!finished) { + p.destroyForcibly() + logger.warn("secret-tool {} timed out after {}s", action, TOOL_TIMEOUT_SECONDS) + return null + } + val output = p.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }.trim() + SecretToolResult(p.exitValue(), output) + } catch (t: Throwable) { + logger.warn("Failed to run secret-tool {}", action, t) + null + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/bootstrap/Bootstrap.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/bootstrap/Bootstrap.kt index 0b0e93e..0297b30 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/bootstrap/Bootstrap.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/bootstrap/Bootstrap.kt @@ -6,17 +6,35 @@ */ package io.github.tritium_launcher.launcher.bootstrap +import io.github.tritium_launcher.launcher.appInstance +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent import io.github.tritium_launcher.launcher.extension.ExtensionDirectoryLoader import io.github.tritium_launcher.launcher.extension.ExtensionLoader +import io.github.tritium_launcher.launcher.extension.ExtensionStateManager import io.github.tritium_launcher.launcher.extension.core.CoreExtension +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.keymap.* import io.github.tritium_launcher.launcher.registry.RegistryMngr import io.github.tritium_launcher.launcher.settings.SettingsMngr +import io.github.tritium_launcher.launcher.ui.logging.LogDialogMngr +import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr import io.ktor.utils.io.core.* +import io.qt.core.Qt +import io.qt.gui.QTextCursor +import io.qt.widgets.QApplication +import io.qt.widgets.QTextEdit +import io.qt.widgets.QWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json import org.koin.core.context.GlobalContext import org.koin.core.context.startKoin import org.koin.core.context.stopKoin -import org.koin.core.module.Module import org.koin.dsl.module import org.koin.logger.slf4jLogger @@ -33,20 +51,22 @@ private val registryCoreModule = module { */ internal fun startHost(loadExtDir: VPath): List { val core = listOf(registryCoreModule) - val discoveredModules = mutableListOf() val loaders = mutableListOf() - discoveredModules += ExtensionLoader.discoveredModules() + val discovered = ExtensionLoader.discover() + val dirResult = ExtensionDirectoryLoader.loadFrom(loadExtDir) + loaders += dirResult.loaders - val result = ExtensionDirectoryLoader.loadFrom(loadExtDir) - discoveredModules += result.modules - loaders += result.loaders + val allExtensions = discovered + dirResult.extensions + CoreExtension + ExtensionLoader.allExtensions = allExtensions - discoveredModules += CoreExtension.modules + val extState = ExtensionStateManager.load() + val enabledExtensions = allExtensions.filter { it.isBuiltin || extState.getOrDefault(it.namespace, true) } + val extModules = enabledExtensions.flatMap { it.modules } startKoin { slf4jLogger() - modules(core + discoveredModules) + modules(core + extModules) } val registryMngr = GlobalContext.get().get() @@ -62,3 +82,69 @@ internal fun stopHost(loaders: List = emptyList()) { loaders.forEach { it.close() } stopKoin() } + +internal fun startSettings() { + fun applyKeymapOverrides(e: TritiumEvent.SettingChanged) { + val raw = (e.newValue as? String)?.trim().orEmpty() + if(raw.isBlank()) return + runCatching { Json.decodeFromString( + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + raw + ) }.onSuccess { overrides -> + KeymapMngr.applyOverridesFromStrings(overrides) + } + } + + CoroutineScope(Dispatchers.Main).onEvent { e -> + when ("${e.namespace}:${e.nodeKey}") { + CoreSettingKeys.UiBackgroundImage.toString() -> ThemeMngr.refresh() + CoreSettingKeys.KeymapActionsOverview.toString() -> applyKeymapOverrides(e) + } + } +} + +internal fun startKeymap() { + fun resolveFocusGroupFromWidgetTree(): String? { + var node: QWidget? = QApplication.focusWidget() + while (node != null) { + val property = node.property("keymapFocusGroup")?.toString()?.trim() + if (!property.isNullOrBlank()) return property + node = node.parentWidget() + } + return null + } + + KeymapBootstrap.initializeDefaults() + ActionRegistry.register( + id = "logs.open_dialog", + label = "Open Log Viewer", + ) { + LogDialogMngr.openDialog() + } + KeymapMngr.declareDefault( + "logs.open_dialog", + KeyBinding.Single(Keystroke.ctrlShift(Qt.Key.Key_I.value())) + ) + ActionRegistry.registerHandler( + id = "editor.start_new_line", + allowKeyboardShortcuts = true, + focusGroups = setOf("editor") + ) { + (QApplication.focusWidget() as? QTextEdit)?.let { edit -> + val cursor = edit.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.EndOfLine) + edit.setTextCursor(cursor) + edit.insertPlainText("\n") + } + } + KeymapMngr.declareDefault( + "editor.start_new_line", + KeyBinding.Single(Keystroke(Qt.Key.Key_Return.value(), Qt.KeyboardModifier.ShiftModifier.value())) + ) + val keymapDispatcher = KeymapDispatcher(ActionRegistry) + appInstance?.installEventFilter(keymapDispatcher) + KeymapFocusMngr.registerResolver("qt.focus_widget") { + resolveFocusGroupFromWidgetTree() + } + KeymapMngr.initWithPersistence() +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/TritiumEventBus.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/TritiumEventBus.kt new file mode 100644 index 0000000..43608ff --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/TritiumEventBus.kt @@ -0,0 +1,229 @@ +package io.github.tritium_launcher.launcher.core + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +/** + * App-wide event bus for decoupled communication between components. + */ +object TritiumEventBus { + private val _events = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val events = _events.asSharedFlow() + + fun publish(event: TritiumEvent) { + _events.tryEmit(event) + } +} + +inline fun CoroutineScope.onEvent( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + noinline handler: (T) -> Unit +) { + launch(dispatcher) { + TritiumEventBus.events.collect { event -> + if (event is T) handler(event) + } + } +} + +sealed interface TritiumEvent { + /** + * Request to focus the Registry Browser on a specific ID. + */ + data class RegistryFocusRequest(val id: String) : TritiumEvent + + /** + * Fired after the mod browser finishes downloading queued mods. + */ + data object ModsInstalled : TritiumEvent + + /** + * Request the Installed Mods panel to check for updates. + */ + data object UpdateCheckRequested : TritiumEvent + + /** + * Fired when the mod download queue changes (add/remove/clear). + */ + data object QueuedDownloadsChanged : TritiumEvent + + // ── Project lifecycle ──────────────────────────────────────── + + /** + * A project has been opened in the UI. + */ + data class ProjectOpened(val project: ProjectBase) : TritiumEvent + + /** + * A project window is closing. + */ + data class ProjectClosing(val project: ProjectBase) : TritiumEvent + + /** + * A project was created (new project dialog). + */ + data class ProjectCreated(val project: ProjectBase) : TritiumEvent + + /** + * Project generation failed. + */ + data class ProjectFailedToGenerate(val project: ProjectBase, val errorMsg: String) : TritiumEvent + + /** + * All projects finished loading from the catalog. + */ + data class ProjectFinishedLoading(val projects: List) : TritiumEvent + + // ── Editor lifecycle ───────────────────────────────────────── + + /** + * An editor tab was opened. + */ + data class EditorOpened(val providerId: String?, val filePath: String?) : TritiumEvent + + /** + * An editor tab was closed. + */ + data class EditorClosed(val providerId: String?, val filePath: String?) : TritiumEvent + + /** + * A file was saved from an editor tab. + */ + data class FileSaved(val providerId: String?, val filePath: String?) : TritiumEvent + + // ── Mod lifecycle (fine-grained) ───────────────────────────── + + /** + * A single mod was installed (from queue, update, downgrade, or skipped-version install). + */ + data class ModInstalled( + val project: ProjectBase, + val projectId: String, + val modId: String, + val displayName: String, + val versionId: String, + val versionLabel: String + ) : TritiumEvent + + /** + * A mod was uninstalled. + */ + data class ModUninstalled( + val project: ProjectBase, + val projectId: String, + val modId: String, + val displayName: String + ) : TritiumEvent + + /** + * A mod was updated to a newer version. + */ + data class ModUpdated( + val project: ProjectBase, + val projectId: String, + val displayName: String, + val oldVersionId: String, + val newVersionId: String + ) : TritiumEvent + + /** + * A mod was downgraded to a previous version. + */ + data class ModDowngraded( + val project: ProjectBase, + val projectId: String, + val displayName: String, + val oldVersionId: String, + val newVersionId: String + ) : TritiumEvent + + /** + * A mod update was skipped (recorded in version history). + */ + data class ModSkipped( + val project: ProjectBase, + val projectId: String, + val displayName: String, + val skippedVersionId: String, + val skippedVersionLabel: String + ) : TritiumEvent + + /** + * A mod's enabled/disabled state was toggled. + */ + data class ModEnabledToggled( + val project: ProjectBase, + val projectId: String, + val enabled: Boolean + ) : TritiumEvent + + /** + * A mod's release-exclusion state was toggled. + */ + data class ModReleaseToggled( + val project: ProjectBase, + val projectId: String, + val excludedFromRelease: Boolean + ) : TritiumEvent + + // ── Game process ───────────────────────────────────────────── + + /** + * A game process was attached. + */ + data class GameAttached( + val projectScope: String, + val projectName: String, + val pid: Long + ) : TritiumEvent + + /** + * A game process was detached. + */ + data class GameDetached( + val projectScope: String, + val projectName: String, + val pid: Long + ) : TritiumEvent + + /** + * A game process exited. + */ + data class GameExited( + val projectScope: String, + val projectName: String, + val pid: Long, + val exitCode: Int + ) : TritiumEvent + + // ── Settings ───────────────────────────────────────────────── + + /** + * A setting value changed. + */ + data class SettingChanged( + val nodeKey: String, + val namespace: String, + val oldValue: Any?, + val newValue: Any? + ) : TritiumEvent + + // ── Export ─────────────────────────────────────────────────── + + /** + * A release manifest was exported. + */ + data class ReleaseManifestExported( + val project: ProjectBase, + val manifestPath: String + ) : TritiumEvent +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModDatabase.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModDatabase.kt new file mode 100644 index 0000000..eacd495 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModDatabase.kt @@ -0,0 +1,576 @@ +package io.github.tritium_launcher.launcher.core.mod + +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import org.sqlite.SQLiteConfig +import java.io.Closeable +import java.security.MessageDigest +import java.sql.Connection +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +data class VersionHistoryRecord( + val projectId: String, + val oldVersionId: String, + val oldVersionLabel: String, + val oldFileHash: String?, + val newVersionId: String, + val newVersionLabel: String, + val skipped: Boolean, + val changedAt: Instant +) + +@OptIn(ExperimentalTime::class) +data class InstalledMod( + val projectId: String, + val modId: String, + val fileName: String, + val displayName: String, + val side: ModSide = ModSide.BOTH, + val releaseType: String = "release", + val source: String, + val versionId: String, + val versionLabel: String, + val iconPath: String? = null, + val projectUrl: String? = null, + val fileHash: String? = null, + val installedAt: Instant? = null, + val enabled: Boolean = true, + val excludedFromRelease: Boolean = false, + val requiresManualDownload: Boolean = false, + val dependencies: List = emptyList() +) + +@OptIn(ExperimentalTime::class) +class ModDatabase(private val projectDir: VPath) : Closeable { + private val logger = logger() + private val dbPath: VPath = projectDir.resolve(".tr/mods.db") + private var conn: Connection? = null + private var needsBackup = false + + private fun connection(): Connection { + conn?.let { return it } + Class.forName("org.sqlite.JDBC") + val config = SQLiteConfig().apply { + setEncoding(SQLiteConfig.Encoding.UTF8) + setJournalMode(SQLiteConfig.JournalMode.WAL) + setBusyTimeout(5000) + } + dbPath.parent().mkdirs() + val c = config.createConnection("jdbc:sqlite:${dbPath.toAbsolute()}") + c.createStatement().execute("PRAGMA foreign_keys = ON;") + c.createStatement().execute( + //language=sql + """ + CREATE TABLE IF NOT EXISTS installed_mods ( + project_id TEXT PRIMARY KEY, + mod_id TEXT NOT NULL, + file_name TEXT NOT NULL, + display_name TEXT NOT NULL, + side TEXT NOT NULL DEFAULT 'BOTH', + release_type TEXT NOT NULL DEFAULT 'release', + source TEXT NOT NULL, + version_id TEXT NOT NULL, + version_label TEXT NOT NULL, + icon_path TEXT, + project_url TEXT, + file_hash TEXT, + installed_at INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + excluded_from_release INTEGER NOT NULL DEFAULT 0, + requires_manual_download INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + try { + c.createStatement().execute( + //language=sql + """ + ALTER TABLE installed_mods ADD COLUMN requires_manual_download INTEGER NOT NULL DEFAULT 0 + """.trimIndent() + ) + } catch (_: Exception) { } + c.createStatement().execute( + //language=sql + """ + CREATE TABLE IF NOT EXISTS release_mods ( + project_id TEXT PRIMARY KEY + ) + """.trimIndent() + ) + c.createStatement().execute( + //language=sql + """ + CREATE TABLE IF NOT EXISTS mod_dependencies ( + mod_id TEXT, + depends_on_id TEXT, + PRIMARY KEY (mod_id, depends_on_id), + FOREIGN KEY (mod_id) REFERENCES installed_mods(project_id) ON DELETE CASCADE, + FOREIGN KEY (depends_on_id) REFERENCES installed_mods(project_id) ON DELETE CASCADE + ) + """.trimIndent() + ) + c.createStatement().execute( + //language=sql + """ + CREATE TABLE IF NOT EXISTS mod_version_history ( + project_id TEXT NOT NULL, + old_version_id TEXT NOT NULL, + old_version_label TEXT NOT NULL, + old_file_hash TEXT, + new_version_id TEXT NOT NULL, + new_version_label TEXT NOT NULL, + skipped INTEGER NOT NULL DEFAULT 0, + changed_at INTEGER NOT NULL, + PRIMARY KEY (project_id, changed_at) + ) + """.trimIndent() + ) + conn = c + return c + } + + fun install(mod: InstalledMod) { + needsBackup = true + val c = connection() + c.prepareStatement( + //language=sql + """ + INSERT OR REPLACE INTO installed_mods + (project_id, mod_id, file_name, display_name, side, release_type, + source, version_id, version_label, icon_path, project_url, + file_hash, installed_at, enabled, excluded_from_release, + requires_manual_download) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { ps -> + ps.setString(1, mod.projectId) + ps.setString(2, mod.modId) + ps.setString(3, mod.fileName) + ps.setString(4, mod.displayName) + ps.setString(5, mod.side.name) + ps.setString(6, mod.releaseType) + ps.setString(7, mod.source) + ps.setString(8, mod.versionId) + ps.setString(9, mod.versionLabel) + ps.setString(10, mod.iconPath) + ps.setString(11, mod.projectUrl) + ps.setString(12, mod.fileHash) + if (mod.installedAt != null) ps.setLong(13, mod.installedAt.toEpochMilliseconds()) else ps.setNull(13, java.sql.Types.INTEGER) + ps.setInt(14, if (mod.enabled) 1 else 0) + ps.setInt(15, if (mod.excludedFromRelease) 1 else 0) + ps.setInt(16, if (mod.requiresManualDownload) 1 else 0) + ps.executeUpdate() + } + } + + fun updateIconPath(projectId: String, iconPath: String) { + needsBackup = true + connection().prepareStatement("UPDATE installed_mods SET icon_path = ? WHERE project_id = ?").use { ps -> + ps.setString(1, iconPath) + ps.setString(2, projectId) + ps.executeUpdate() + } + } + + fun uninstall(projectId: String) { + needsBackup = true + connection().prepareStatement("DELETE FROM installed_mods WHERE project_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeUpdate() + } + } + + fun getByProjectId(projectId: String): InstalledMod? { + connection().prepareStatement("SELECT * FROM installed_mods WHERE project_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> + if (rs.next()) return rowToMod(rs) + } + } + return null + } + + fun getByModId(modId: String): List { + val result = mutableListOf() + connection().prepareStatement("SELECT * FROM installed_mods WHERE mod_id = ?").use { ps -> + ps.setString(1, modId) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun getAll(): List { + val result = mutableListOf() + connection().createStatement().use { stmt -> + stmt.executeQuery("SELECT * FROM installed_mods ORDER BY installed_at DESC").use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun search(query: String): List { + val pattern = "%${query.replace("%", "\\%").replace("_", "\\_")}%" + val result = mutableListOf() + connection().prepareStatement( + //language=sql + """ + SELECT * FROM installed_mods + WHERE display_name LIKE ? ESCAPE '\' + OR mod_id LIKE ? ESCAPE '\' + OR file_name LIKE ? ESCAPE '\' + ORDER BY installed_at DESC + """.trimIndent() + ).use { ps -> + ps.setString(1, pattern) + ps.setString(2, pattern) + ps.setString(3, pattern) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun getBySide(side: ModSide): List { + val result = mutableListOf() + connection().prepareStatement("SELECT * FROM installed_mods WHERE side = ? ORDER BY installed_at DESC").use { ps -> + ps.setString(1, side.name) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun getByReleaseType(type: String): List { + val result = mutableListOf() + connection().prepareStatement("SELECT * FROM installed_mods WHERE release_type = ? ORDER BY installed_at DESC").use { ps -> + ps.setString(1, type.lowercase()) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun getBySource(source: String): List { + val result = mutableListOf() + connection().prepareStatement("SELECT * FROM installed_mods WHERE source = ? ORDER BY installed_at DESC").use { ps -> + ps.setString(1, source) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun exists(projectId: String): Boolean { + connection().prepareStatement("SELECT 1 FROM installed_mods WHERE project_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> return rs.next() } + } + } + + fun count(): Int { + connection().createStatement().use { stmt -> + stmt.executeQuery("SELECT COUNT(*) FROM installed_mods").use { rs -> + if (rs.next()) return rs.getInt(1) + } + } + return 0 + } + + fun setEnabled(projectId: String, enabled: Boolean) { + needsBackup = true + connection().prepareStatement("UPDATE installed_mods SET enabled = ? WHERE project_id = ?").use { ps -> + ps.setInt(1, if (enabled) 1 else 0) + ps.setString(2, projectId) + ps.executeUpdate() + } + } + + fun setExcludedFromRelease(projectId: String, excluded: Boolean) { + needsBackup = true + connection().prepareStatement("UPDATE installed_mods SET excluded_from_release = ? WHERE project_id = ?").use { ps -> + ps.setInt(1, if (excluded) 1 else 0) + ps.setString(2, projectId) + ps.executeUpdate() + } + } + + fun getEnabled(): List { + val result = mutableListOf() + connection().createStatement().use { stmt -> + stmt.executeQuery("SELECT * FROM installed_mods WHERE enabled = 1 ORDER BY installed_at DESC").use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun addToRelease(projectId: String) { + needsBackup = true + connection().prepareStatement("INSERT OR REPLACE INTO release_mods (project_id) VALUES (?)").use { ps -> + ps.setString(1, projectId) + ps.executeUpdate() + } + } + + fun removeFromRelease(projectId: String) { + needsBackup = true + connection().prepareStatement("DELETE FROM release_mods WHERE project_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeUpdate() + } + } + + fun isInRelease(projectId: String): Boolean { + connection().prepareStatement("SELECT 1 FROM release_mods WHERE project_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> return rs.next() } + } + } + + fun getReleaseMods(): List { + val result = mutableListOf() + connection().createStatement().use { stmt -> + stmt.executeQuery("SELECT project_id FROM release_mods").use { rs -> + while (rs.next()) result.add(rs.getString("project_id")) + } + } + return result + } + + fun getReleaseModsFull(): List { + val result = mutableListOf() + connection().createStatement().use { stmt -> + stmt.executeQuery( + """SELECT m.* FROM installed_mods m + INNER JOIN release_mods r ON m.project_id = r.project_id + WHERE m.enabled = 1 + ORDER BY m.installed_at DESC""" + ).use { rs -> + while (rs.next()) result.add(rowToMod(rs)) + } + } + return result + } + + fun setDependencies(projectId: String, dependencyIds: List) { + needsBackup = true + val c = connection() + c.prepareStatement("DELETE FROM mod_dependencies WHERE mod_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeUpdate() + } + if (dependencyIds.isEmpty()) return + c.prepareStatement("INSERT OR IGNORE INTO mod_dependencies (mod_id, depends_on_id) VALUES (?, ?)").use { ps -> + dependencyIds.forEach { depId -> + ps.setString(1, projectId) + ps.setString(2, depId) + ps.addBatch() + } + ps.executeBatch() + } + } + + fun getDependencies(projectId: String): List { + val result = mutableListOf() + connection().prepareStatement("SELECT depends_on_id FROM mod_dependencies WHERE mod_id = ?").use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rs.getString("depends_on_id")) + } + } + return result + } + + fun recordVersionChange( + projectId: String, + oldVersionId: String, + oldVersionLabel: String, + oldFileHash: String?, + newVersionId: String, + newVersionLabel: String, + skipped: Boolean = false + ) { + needsBackup = true + val c = connection() + c.prepareStatement( + //language=sql + """ + INSERT INTO mod_version_history + (project_id, old_version_id, old_version_label, old_file_hash, + new_version_id, new_version_label, skipped, changed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { ps -> + ps.setString(1, projectId) + ps.setString(2, oldVersionId) + ps.setString(3, oldVersionLabel) + ps.setString(4, oldFileHash) + ps.setString(5, newVersionId) + ps.setString(6, newVersionLabel) + ps.setInt(7, if (skipped) 1 else 0) + ps.setLong(8, Clock.System.now().toEpochMilliseconds()) + ps.executeUpdate() + } + } + + fun getVersionHistory(projectId: String): List { + val result = mutableListOf() + connection().prepareStatement( + "SELECT * FROM mod_version_history WHERE project_id = ? ORDER BY changed_at DESC" + ).use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> + while (rs.next()) result.add(rowToVersionHistory(rs)) + } + } + return result + } + + fun getPreviousVersion(projectId: String): VersionHistoryRecord? { + connection().prepareStatement( + //language=sql + """ + SELECT * FROM mod_version_history + WHERE project_id = ? AND skipped = 0 + ORDER BY changed_at DESC LIMIT 1 + """.trimIndent() + ).use { ps -> + ps.setString(1, projectId) + ps.executeQuery().use { rs -> + if (rs.next()) return rowToVersionHistory(rs) + } + } + return null + } + + fun getSkippedVersion(projectId: String, currentVersionId: String): VersionHistoryRecord? { + connection().prepareStatement( + "SELECT * FROM mod_version_history WHERE project_id = ? AND skipped = 1 AND new_version_id != ? ORDER BY changed_at DESC LIMIT 1" + ).use { ps -> + ps.setString(1, projectId) + ps.setString(2, currentVersionId) + ps.executeQuery().use { rs -> + if (rs.next()) return rowToVersionHistory(rs) + } + } + return null + } + + fun isVersionSkipped(projectId: String, versionId: String): Boolean { + connection().prepareStatement( + "SELECT 1 FROM mod_version_history WHERE project_id = ? AND new_version_id = ? AND skipped = 1 LIMIT 1" + ).use { ps -> + ps.setString(1, projectId) + ps.setString(2, versionId) + ps.executeQuery().use { rs -> return rs.next() } + } + } + + override fun close() { + if (needsBackup) { + try { backupToRegistry() } catch (e: Exception) { + logger.warn("Failed to backup mod registry", e) + } + } + conn?.close() + conn = null + } + + fun backupToRegistry() { + val registry = ModRegistryStore(projectDir) + val allMods = getAll().map { registry.entryFromInstalledMod(it) } + val data = ModRegistryData( + version = 2, + mods = allMods.associateBy { it.projectId } + ) + registry.save(data) + needsBackup = false + } + + private fun rowToMod(rs: java.sql.ResultSet): InstalledMod { + val projectId = rs.getString("project_id") + return InstalledMod( + projectId = projectId, + modId = rs.getString("mod_id"), + fileName = rs.getString("file_name"), + displayName = rs.getString("display_name"), + side = try { ModSide.valueOf(rs.getString("side")?.uppercase() ?: "BOTH") } catch (_: Exception) { ModSide.BOTH }, + releaseType = rs.getString("release_type") ?: "release", + source = rs.getString("source"), + versionId = rs.getString("version_id"), + versionLabel = rs.getString("version_label"), + iconPath = rs.getString("icon_path"), + projectUrl = rs.getString("project_url"), + fileHash = rs.getString("file_hash"), + installedAt = rs.getLong("installed_at").let { if (rs.wasNull()) null else Instant.fromEpochMilliseconds(it) }, + enabled = rs.getInt("enabled") != 0, + excludedFromRelease = rs.getInt("excluded_from_release") != 0, + requiresManualDownload = rs.getInt("requires_manual_download") != 0, + dependencies = getDependencies(projectId), + ) + } + + private fun rowToVersionHistory(rs: java.sql.ResultSet): VersionHistoryRecord { + return VersionHistoryRecord( + projectId = rs.getString("project_id"), + oldVersionId = rs.getString("old_version_id"), + oldVersionLabel = rs.getString("old_version_label"), + oldFileHash = rs.getString("old_file_hash"), + newVersionId = rs.getString("new_version_id"), + newVersionLabel = rs.getString("new_version_label"), + skipped = rs.getInt("skipped") != 0, + changedAt = Instant.fromEpochMilliseconds(rs.getLong("changed_at")) + ) + } + + private fun parseJsonList(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return try { + json.decodeFromJsonElement>(json.parseToJsonElement(raw)) + } catch (_: Exception) { emptyList() } + } + + companion object { + private val json = Json { ignoreUnknownKeys = true } + private val logger = logger() + + val ICONS_DIR: VPath = fromTR("cache", "mod-icons") + val CACHE_DIR: VPath = fromTR("mod-cache") + + init { + ICONS_DIR.mkdirs() + } + + fun sha1(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-1") + return digest.digest(bytes).joinToString("") { "%02x".format(it) } + } + + fun iconPathFor(projectId: String): VPath = ICONS_DIR.resolve("$projectId.png") + fun cachePathFor(hash: String): VPath = CACHE_DIR.resolve("$hash.jar") + + fun restoreFromRegistryIfNeeded(db: ModDatabase, projectDir: VPath) { + if (db.count() > 0) return + val registry = ModRegistryStore(projectDir) + val data = registry.load() + if (data.mods.isEmpty()) return + logger.info("Restoring mod database from registry backup ({} entries)", data.mods.size) + data.mods.values.forEach { entry -> + db.install(registry.toInstalledMod(entry)) + } + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModJarReader.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModJarReader.kt new file mode 100644 index 0000000..9dc835c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModJarReader.kt @@ -0,0 +1,104 @@ +package io.github.tritium_launcher.launcher.core.mod + +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.jar.JarFile + +data class ModJarInfo( + val modId: String, + val displayName: String, + val side: ModSide, +) + +object ModJarReader + +fun readModJarInfo(jarPath: VPath): ModJarInfo? { + return try { + JarFile(jarPath.toJFile()).use { jar -> + readFabricLike(jar) + ?: readForgeLike(jar) + ?: readQuiltLike(jar) + } + } catch (e: Exception) { + logger(ModJarReader::class).warn("Failed to read mod metadata from '${jarPath.fileName()}': ${e.message}") + null + } +} + +private fun readFabricLike(jar: JarFile): ModJarInfo? { + val entry = jar.getEntry("fabric.mod.json") ?: return null + val json = Json { ignoreUnknownKeys = true } + val obj = json.decodeFromString(jar.getInputStream(entry).readBytes().decodeToString()) + val id = obj["id"]?.jsonPrimitive?.content ?: return null + val name = obj["name"]?.jsonPrimitive?.content ?: id + val env = obj["environment"]?.jsonPrimitive?.content + val side = when (env) { + "client" -> ModSide.CLIENT + "server" -> ModSide.SERVER + else -> ModSide.BOTH + } + return ModJarInfo(modId = id, displayName = name, side = side) +} + +private fun readQuiltLike(jar: JarFile): ModJarInfo? { + val entry = jar.getEntry("quilt.mod.json") ?: return null + val json = Json { ignoreUnknownKeys = true } + val obj = json.decodeFromString(jar.getInputStream(entry).readBytes().decodeToString()) + val loader = obj["quilt_loader"]?.jsonObject ?: return null + val id = loader["id"]?.jsonPrimitive?.content ?: return null + val metadata = loader["metadata"]?.jsonObject + val name = metadata?.get("name")?.jsonPrimitive?.content ?: id + val env = loader["environment"]?.jsonPrimitive?.content + val side = when (env) { + "client" -> ModSide.CLIENT + "server" -> ModSide.SERVER + else -> ModSide.BOTH + } + return ModJarInfo(modId = id, displayName = name, side = side) +} + +private fun readForgeLike(jar: JarFile): ModJarInfo? { + val entry = jar.getEntry("META-INF/neoforge.mods.toml") + ?: jar.getEntry("META-INF/mods.toml") + ?: return null + val text = jar.getInputStream(entry).readBytes().decodeToString() + val modIdRegex = Regex("""modId\s*=\s*"([^"]+)""") + val nameRegex = Regex("""displayName\s*=\s*"([^"]+)""") + val sideRegex = Regex("""side\s*=\s*"([^"]+)""") + val modId = modIdRegex.find(text)?.groupValues?.getOrNull(1) ?: return null + val name = nameRegex.find(text)?.groupValues?.getOrNull(1) ?: modId + val textSide = sideRegex.find(text)?.groupValues?.getOrNull(1)?.uppercase() + val side = when (textSide) { + "CLIENT" -> ModSide.CLIENT + "SERVER" -> ModSide.SERVER + else -> ModSide.BOTH + } + return ModJarInfo(modId = modId, displayName = name, side = side) +} + +fun readModJarIcon(jarPath: VPath): ByteArray? { + return try { + JarFile(jarPath.toJFile()).use { jar -> + jar.entries().asSequence() + .filter { !it.isDirectory } + .filter { e -> e.name.count { c -> c == '/' } == 0 } + .filter { e -> e.name.endsWith(".png", ignoreCase = true) } + .filter { e -> + val name = e.name.lowercase() + name.contains("icon") || name.contains("logo") + } + .maxByOrNull { it.size } + ?.let { + + jar.getInputStream(it).readBytes() + } + } + } catch (e: Exception) { + logger(ModJarReader::class).warn("Failed to read mod icon from '${jarPath.fileName()}': ${e.message}") + null + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModRegistryStore.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModRegistryStore.kt new file mode 100644 index 0000000..04fd6fa --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModRegistryStore.kt @@ -0,0 +1,127 @@ +package io.github.tritium_launcher.launcher.core.mod + +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class ModRegistryEntry( + val projectId: String, + val modId: String, + val displayName: String, + val fileName: String, + val source: String, + val versionId: String, + val versionLabel: String, + val iconPath: String? = null, + val projectUrl: String? = null, + val fileHash: String? = null, + val installedAt: Long? = null, + val enabled: Boolean = true, + val excludedFromRelease: Boolean = false, + val side: String = "BOTH", + val releaseType: String = "release", + val requiresManualDownload: Boolean = false, + val dependencies: List = emptyList(), +) + +@Serializable +data class ModRegistryData( + val version: Int = 2, + val mods: Map = emptyMap(), +) + +@OptIn(ExperimentalTime::class) +class ModRegistryStore(projectDir: VPath) { + private val logger = logger() + private val registryPath: VPath = projectDir.resolve(".tr/mod-registry.json") + + fun load(): ModRegistryData { + return try { + val text = registryPath.readTextOrNull() ?: return ModRegistryData() + json.decodeFromString(text) + } catch (e: Exception) { + logger.warn("Failed to load mod registry, starting fresh", e) + ModRegistryData() + } + } + + fun save(data: ModRegistryData) { + try { + registryPath.parent().mkdirs() + registryPath.writeTextAtomic(json.encodeToString(data)) + } catch (e: Exception) { + logger.warn("Failed to save mod registry", e) + } + } + + fun updateEntry(entry: ModRegistryEntry) { + val data = load() + val updated = data.copy( + mods = data.mods + (entry.projectId to entry) + ) + save(updated) + } + + fun removeEntry(projectId: String) { + val data = load() + save(data.copy(mods = data.mods - projectId)) + } + + fun getEntry(projectId: String): ModRegistryEntry? = load().mods[projectId] + + fun getEntryByModId(modId: String): ModRegistryEntry? = + load().mods.values.firstOrNull { it.modId == modId } + + fun getAllEntries(): Collection = load().mods.values + + fun toInstalledMod(entry: ModRegistryEntry): InstalledMod = InstalledMod( + projectId = entry.projectId, + modId = entry.modId, + fileName = entry.fileName, + displayName = entry.displayName, + side = try { ModSide.valueOf(entry.side.uppercase()) } catch (_: Exception) { ModSide.BOTH }, + releaseType = entry.releaseType, + source = entry.source, + versionId = entry.versionId, + versionLabel = entry.versionLabel, + iconPath = entry.iconPath, + projectUrl = entry.projectUrl, + fileHash = entry.fileHash, + installedAt = entry.installedAt?.let { Instant.fromEpochMilliseconds(it) }, + enabled = entry.enabled, + excludedFromRelease = entry.excludedFromRelease, + requiresManualDownload = entry.requiresManualDownload, + dependencies = entry.dependencies, + ) + + fun entryFromInstalledMod(mod: InstalledMod): ModRegistryEntry = ModRegistryEntry( + projectId = mod.projectId, + modId = mod.modId, + displayName = mod.displayName, + fileName = mod.fileName, + source = mod.source, + versionId = mod.versionId, + versionLabel = mod.versionLabel, + iconPath = mod.iconPath, + projectUrl = mod.projectUrl, + fileHash = mod.fileHash, + installedAt = mod.installedAt?.toEpochMilliseconds(), + enabled = mod.enabled, + excludedFromRelease = mod.excludedFromRelease, + side = mod.side.name, + releaseType = mod.releaseType, + requiresManualDownload = mod.requiresManualDownload, + dependencies = mod.dependencies, + ) + + companion object { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModSide.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModSide.kt new file mode 100644 index 0000000..b5f942c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModSide.kt @@ -0,0 +1,7 @@ +package io.github.tritium_launcher.launcher.core.mod + +enum class ModSide { + CLIENT, + SERVER, + BOTH, +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModUpdateChecker.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModUpdateChecker.kt new file mode 100644 index 0000000..fdb3a72 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod/ModUpdateChecker.kt @@ -0,0 +1,80 @@ +package io.github.tritium_launcher.launcher.core.mod + +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.source.ModBrowserContext +import io.github.tritium_launcher.launcher.core.source.ModSource +import io.github.tritium_launcher.launcher.core.source.ModVersionOption +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.* + +object ModUpdateChecker { + private val logger = logger() + + private fun resolveContext(project: ProjectBase): ModBrowserContext? { + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta ?: return null + return ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + } + + private fun resolveSource(context: ModBrowserContext): ModSource? { + val sourceId = (context.project as? Project<*>)?.typedMeta as? ModpackMeta ?: return null + return BuiltinRegistries.ModSource.all().find { it.id == sourceId.source } + } + + suspend fun checkMod( + project: ProjectBase, + mod: InstalledMod + ): ModVersionOption? { + val context = resolveContext(project) ?: return null + val source = resolveSource(context) ?: return null + if (source.id != mod.source) return null + + return try { + val versions = source.versions(context, mod.projectId) + val latest = versions.firstOrNull() ?: return null + if (latest.id == mod.versionId) return null + + ModDatabase(project.projectDir).use { db -> + if (db.isVersionSkipped(mod.projectId, latest.id)) return null + } + + latest + } catch (t: Throwable) { + logger.warn("Failed to check update for '{}' from '{}'", mod.displayName, source.id, t) + null + } + } + + suspend fun checkAll( + project: ProjectBase, + mods: List? = null + ): Map { + val context = resolveContext(project) ?: return emptyMap() + val source = resolveSource(context) ?: return emptyMap() + + val installedMods = mods ?: withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getBySource(source.id) } + } + + val results = coroutineScope { + installedMods + .map { mod -> + async { + val update = checkMod(project, mod) + if (update != null) mod.projectId to update else null + } + } + .awaitAll() + .filterNotNull() + .toMap() + } + + return results + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigFormat.kt new file mode 100644 index 0000000..fd439af --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigFormat.kt @@ -0,0 +1,29 @@ +package io.github.tritium_launcher.launcher.core.mod_config + +import io.github.tritium_launcher.launcher.core.mod_config.formats.* +import io.github.tritium_launcher.launcher.core.mod_config.formats.JsonConfigFormat.Variant.* +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.registry.Registrable + +interface ConfigFormat: Registrable { + override val id: String + val extensions: List + fun parse(text: String): ConfigNode + fun serialize(node: ConfigNode): String + + companion object { + internal val builtin = listOf( + PropertiesConfigFormat(), + ForgeCfgConfigFormat(), + TomlConfigFormat(), + YamlConfigFormat(), + JsonConfigFormat(J), + JsonConfigFormat(JC), + JsonConfigFormat(J5) + ) + + fun of(ext: String): ConfigFormat? = + BuiltinRegistries.ConfigFormat.all() + .firstOrNull { ext in it.extensions } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigNode.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigNode.kt new file mode 100644 index 0000000..6b558d0 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/ConfigNode.kt @@ -0,0 +1,12 @@ +package io.github.tritium_launcher.launcher.core.mod_config + +sealed class ConfigNode + +class ConfigObj(val entries: LinkedHashMap = linkedMapOf()): ConfigNode() +class ConfigArray(val items: MutableList = mutableListOf()): ConfigNode() +class ConfigString(val value: String): ConfigNode() +class ConfigInt(val value: Int): ConfigNode() +class ConfigDouble(val value: Double): ConfigNode() +class ConfigBool(val value: Boolean): ConfigNode() +class ConfigComment(val text: String, val inline: Boolean = false): ConfigNode() +class ConfigNull: ConfigNode() \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/FieldMeta.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/FieldMeta.kt new file mode 100644 index 0000000..212ae70 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/FieldMeta.kt @@ -0,0 +1,8 @@ +package io.github.tritium_launcher.launcher.core.mod_config + +data class FieldMeta( + val description: String = "", + val default: String? = null, + val min: Double? = null, + val max: Double? = null +) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/ForgeCfgConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/ForgeCfgConfigFormat.kt new file mode 100644 index 0000000..8c65eac --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/ForgeCfgConfigFormat.kt @@ -0,0 +1,146 @@ +package io.github.tritium_launcher.launcher.core.mod_config.formats + +import io.github.tritium_launcher.launcher.core.mod_config.* + +class ForgeCfgConfigFormat: ConfigFormat { + override val id: String = "forge_cfg" + override val extensions: List = listOf("forge_cfg") + + override fun parse(text: String): ConfigNode { + val lines = text.lines() + val (root, _) = parseBlock(lines, 0) + return root + } + + private fun parseBlock(lines: List, startIndex: Int): Pair { + val obj = ConfigObj() + var i = startIndex + + while(i < lines.size) { + val raw = lines[i] + val line = raw.trim() + + when { + line.isEmpty() -> i++ + + line.startsWith('#') -> { + val key = "__comment_${obj.entries.size}" + obj.entries[key] = ConfigComment(line.removePrefix("#").trim()) + i++ + } + + line == "}" -> return Pair(obj, i + 1) + + line.endsWith("{") -> { + val catName = line.dropLast(1).trim().trim('"') + val (child, next) = parseBlock(lines, i + 1) + obj.entries[catName] = child + i = next + } + + else -> { + val (name, node, next) = parseProperty(lines, i) + if(name != null && node != null) obj.entries[name] = node + i = next + } + } + } + + return obj to i + } + + private fun parseProperty(lines: List, index: Int): Triple { + val line = lines[index].trim() + + val effective = line.substringBefore('#').trim() + + val typeKey = effective.firstOrNull() + ?: return Triple(null, null, index + 1) + + val nameStart = effective.indexOf('"') + val nameEnd = effective.indexOf('"', nameStart + 1) + if(nameStart < 0 || nameEnd < 0) return Triple(null, null, index + 1) + val name = effective.substring(nameStart + 1, nameEnd) + + val rest = effective.substring(nameEnd + 1).trim() + + return when { + rest.startsWith('=') -> { + val raw = rest.removePrefix("=") + val node = parseScalar(typeKey, raw) + Triple(name, node, index + 1) + } + + rest == "<" -> { + val items = mutableListOf() + var i = index + 1 + while(i < lines.size) { + val item = lines[i].trim().substringBefore('#').trim() + if(item == ">") { i++; break } + if(item.isNotEmpty()) items.add(parseScalar(typeKey, item)) + i++ + } + Triple(name, ConfigArray(items), i) + } + + else -> Triple(null, null, index + 1) + } + } + + private fun parseScalar(typeKey: Char, raw: String): ConfigNode = when (typeKey.uppercaseChar()) { + 'S' -> ConfigString(raw) + 'I' -> ConfigInt(raw.trim().toIntOrNull() ?: 0) + 'B' -> ConfigBool(raw.trim() == "true") + 'D' -> ConfigDouble(raw.trim().toDoubleOrNull() ?: 0.0) + else -> ConfigString(raw) + } + + override fun serialize(node: ConfigNode): String { + require(node is ConfigObj) { "Forge CFG root must be an object" } + return buildString { serializeObj(node, this, "") } + } + + fun serializeObj(obj: ConfigObj, sb: StringBuilder, indent: String) { + for((key, value) in obj.entries) { + when { + key.startsWith("__comment_") -> { + val c = value as ConfigComment + sb.appendLine("$indent# ${c.text}") + } + value is ConfigObj -> { + sb.appendLine("$indent\"$key\" {") + serializeObj(value, sb, "$indent\t") + sb.appendLine("$indent}") + } + value is ConfigArray -> { + val typeKey = inferTypeKey(value.items.firstOrNull()) + sb.appendLine("$indent$typeKey:\"$key\" <") + for (item in value.items) { + sb.appendLine("$indent\t${serializeScalar(item)}") + } + sb.appendLine("$indent>") + } + else -> { + val typeKey = inferTypeKey(value) + sb.appendLine("$indent$typeKey:\"$key\"=${serializeScalar(value)}") + } + } + } + } + + private fun inferTypeKey(node: ConfigNode?): Char = when (node) { + is ConfigString -> 'S' + is ConfigInt -> 'I' + is ConfigBool -> 'B' + is ConfigDouble -> 'D' + else -> 'S' + } + + private fun serializeScalar(node: ConfigNode): String = when (node) { + is ConfigString -> node.value + is ConfigInt -> node.value.toString() + is ConfigBool -> node.value.toString() + is ConfigDouble -> node.value.toString() + else -> "" + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/JsonConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/JsonConfigFormat.kt new file mode 100644 index 0000000..141be73 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/JsonConfigFormat.kt @@ -0,0 +1,301 @@ +package io.github.tritium_launcher.launcher.core.mod_config.formats + +import io.github.tritium_launcher.launcher.core.mod_config.* +import io.github.tritium_launcher.launcher.core.mod_config.formats.JsonConfigFormat.Variant.* +import kotlinx.serialization.json.* +import li.songe.json5.Json5 + +class JsonConfigFormat(private val variant: Variant): ConfigFormat { + enum class Variant { J, JC, J5 } + + private data class Scope( + val path: MutableList = mutableListOf(), + var pendingComments: MutableList = mutableListOf() + ) + + override val id: String = when(variant) { + J -> "json" + JC -> "jsonc" + J5 -> "json5" + } + + override val extensions: List = when(variant) { + J -> listOf("json") + JC -> listOf("jsonc") + J5 -> listOf("json5") + } + + private val strictJson = Json { + prettyPrint = true + allowComments = true + } + + private val jsonCJson = Json { + allowComments = true + allowTrailingComma = true + prettyPrint = true + } + + override fun parse(text: String): ConfigNode { + val elem = when (variant) { + J -> strictJson.parseToJsonElement(text) + JC -> jsonCJson.parseToJsonElement(text) + J5 -> Json5.parseToJson5Element(text) + } + val commentsByPath = when (variant) { + J -> emptyMap() + JC, J5 -> extractLeadingCommentsByPath(text) + } + return elementToNode(elem, emptyList(), commentsByPath) + } + + private fun elementToNode( + elem: JsonElement, + path: List, + commentsByPath: Map, List> + ): ConfigNode = when (elem) { + is JsonObject -> { + val map = linkedMapOf() + for((key, value) in elem) { + commentsByPath[path + key]?.forEach { comment -> + map["__comment_${map.size}"] = comment + } + map[key] = elementToNode(value, path + key, commentsByPath) + } + ConfigObj(map) + } + is JsonArray -> ConfigArray(elem.mapIndexed { index, child -> + elementToNode(child, path + index.toString(), commentsByPath) + }.toMutableList()) + is JsonNull -> ConfigNull() + is JsonPrimitive -> when { + elem.isString -> ConfigString(elem.content) + elem.booleanOrNull != null -> ConfigBool(elem.boolean) + elem.intOrNull != null -> ConfigInt(elem.int) + elem.doubleOrNull != null -> ConfigDouble(elem.double) + else -> ConfigString(elem.content) + } + } + + override fun serialize(node: ConfigNode): String { + return when (variant) { + J -> strictJson.encodeToString(nodeToElement(node)) + JC, J5 -> renderWithComments(node) + } + } + + // TODO: Long term, a JsonC parser is needed to preserve comments + private fun nodeToElement(node: ConfigNode): JsonElement = when (node) { + is ConfigObj -> JsonObject( + node.entries + .filter { !it.key.startsWith("__comment_") } + .mapValues { (_, v) -> nodeToElement(v) } + ) + is ConfigArray -> JsonArray(node.items.map { nodeToElement(it) }) + is ConfigString -> JsonPrimitive(node.value) + is ConfigInt -> JsonPrimitive(node.value) + is ConfigDouble -> JsonPrimitive(node.value) + is ConfigBool -> JsonPrimitive(node.value) + is ConfigNull -> JsonNull + is ConfigComment -> JsonNull + } + + private fun extractLeadingCommentsByPath(text: String): Map, List> { + val commentsByPath = linkedMapOf, MutableList>() + val scopeStack = mutableListOf(Scope()) + + for (rawLine in text.lines()) { + val trimmed = rawLine.trim() + if (trimmed.isEmpty()) continue + + if (trimmed.startsWith("//")) { + scopeStack.last().pendingComments += ConfigComment(trimmed.removePrefix("//").trim()) + continue + } + + if (trimmed.startsWith("#")) { + scopeStack.last().pendingComments += ConfigComment(trimmed.removePrefix("#").trim()) + continue + } + + val current = scopeStack.last() + val propertyMatch = PROPERTY_REGEX.find(trimmed) + if (propertyMatch != null) { + val key = propertyMatch.groupValues[1].takeIf { it.isNotBlank() }?.let(::unescapeJsonString) + ?: propertyMatch.groupValues[2] + val path = current.path + key + if (current.pendingComments.isNotEmpty()) { + commentsByPath.getOrPut(path) { mutableListOf() }.addAll(current.pendingComments) + current.pendingComments = mutableListOf() + } + + val remainder = trimmed.substring(propertyMatch.range.last + 1).trimStart() + if (remainder.startsWith("{")) { + scopeStack += Scope(current.path.toMutableList().apply { add(key) }) + } + } else if (current.pendingComments.isNotEmpty()) { + current.pendingComments = mutableListOf() + } + + closeScopes(trimmed, scopeStack) + } + + return commentsByPath + } + + private fun closeScopes(line: String, scopeStack: MutableList) { + if (scopeStack.isEmpty()) return + + val objectClosings = countStructural(line, '}') + repeat(objectClosings) { + if (scopeStack.size > 1) { + scopeStack.removeAt(scopeStack.lastIndex) + } + } + } + + private fun bracketDelta(text: String, open: Char, close: Char): Int = + countStructural(text, open) - countStructural(text, close) + + private fun countStructural(text: String, target: Char): Int { + var inString = false + var escaped = false + var count = 0 + + text.forEach { ch -> + when { + escaped -> escaped = false + ch == '\\' && inString -> escaped = true + ch == '"' -> inString = !inString + !inString && ch == target -> count++ + } + } + return count + } + + private fun unescapeJsonString(value: String): String = + buildString(value.length) { + var i = 0 + while (i < value.length) { + val ch = value[i] + if (ch == '\\' && i + 1 < value.length) { + val next = value[i + 1] + append( + when (next) { + '\\', '/', '"' -> next + 'b' -> '\b' + 'f' -> '\u000C' + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + 'u' -> { + val hex = value.substring(i + 2, (i + 6).coerceAtMost(value.length)) + if (hex.length == 4) { + i += 4 + hex.toIntOrNull(16)?.toChar() ?: next + } else next + } + else -> next + } + ) + i += 2 + continue + } + append(ch) + i++ + } + } + + private fun renderWithComments(node: ConfigNode): String = + buildString { append(renderNode(node, 0)) }.trimEnd() + "\n" + + private fun renderNode(node: ConfigNode, depth: Int): String = when (node) { + is ConfigObj -> renderObject(node, depth) + is ConfigArray -> renderArray(node, depth) + is ConfigString -> quote(node.value) + is ConfigInt -> node.value.toString() + is ConfigDouble -> formatDouble(node.value) + is ConfigBool -> node.value.toString() + is ConfigNull -> "null" + is ConfigComment -> "${indent(depth)}// ${node.text}" + } + + private fun renderObject(node: ConfigObj, depth: Int): String { + val indent = indent(depth) + val childIndent = indent(depth + 1) + val rendered = mutableListOf() + val entries = node.entries.entries.toList() + + for ((index, entry) in entries.withIndex()) { + val key = entry.key + val value = entry.value + if (key.startsWith("__comment_")) { + if (value is ConfigComment) rendered += "$childIndent// ${value.text}" + continue + } + + val renderedValue = renderNode(value, depth + 1) + val suffix = if (index == entries.lastIndex || noMoreProperties(entries, index + 1)) "" else "," + val formattedValue = if (value is ConfigObj || value is ConfigArray) { + renderedValue.prependIndent(childIndent).removePrefix(childIndent) + } else { + renderedValue + } + rendered += "$childIndent${renderKey(key)}: $formattedValue$suffix" + } + + return if (rendered.isEmpty()) { + "{}" + } else { + "{\n${rendered.joinToString("\n")}\n$indent}" + } + } + + private fun renderArray(node: ConfigArray, depth: Int): String { + if (node.items.isEmpty()) return "[]" + val indent = indent(depth) + val childIndent = indent(depth + 1) + val rendered = node.items.mapIndexed { index, item -> + val suffix = if (index == node.items.lastIndex) "" else "," + val value = renderNode(item, depth + 1) + "$childIndent$value$suffix" + } + return "[\n${rendered.joinToString("\n")}\n$indent]" + } + + private fun noMoreProperties(entries: List>, start: Int): Boolean = + entries.drop(start).none { !it.key.startsWith("__comment_") } + + private fun renderKey(key: String): String = + if (IDENTIFIER_REGEX.matches(key)) key else quote(key) + + private fun quote(value: String): String { + val escaped = buildString(value.length + 8) { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + } + return "\"$escaped\"" + } + + private fun formatDouble(value: Double): String { + val text = value.toString() + return if (text.contains('.') || text.contains('e', true)) text else "$text.0" + } + + private fun indent(depth: Int): String = " ".repeat(depth) + + private companion object { + val PROPERTY_REGEX = Regex("""^(?:"((?:\\.|[^"\\])*)"|([A-Za-z_\$][A-Za-z0-9_\-\$]*))\s*:""") + val IDENTIFIER_REGEX = Regex("""[A-Za-z_$][A-Za-z0-9_\-$]*""") + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/PropertiesConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/PropertiesConfigFormat.kt new file mode 100644 index 0000000..e8cdd37 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/PropertiesConfigFormat.kt @@ -0,0 +1,56 @@ +package io.github.tritium_launcher.launcher.core.mod_config.formats + +import io.github.tritium_launcher.launcher.core.mod_config.* + +class PropertiesConfigFormat : ConfigFormat { + override val id: String = "properties" + override val extensions: List = listOf("properties", "cfg") + + override fun parse(text: String): ConfigNode { + val map = linkedMapOf() + + for(line in text.lines()) { + val trimmed = line.trim() + + when { + trimmed.startsWith('#') -> { + val key = "__comment_${map.size}" + map[key] = ConfigComment(trimmed.removePrefix("#").trim()) + } + trimmed.contains('=') -> { + val (k, v) = trimmed.split('=', limit = 2) + map[k.trim()] = parseScalar(v.trim()) + } + } + } + + return ConfigObj(map) + } + + override fun serialize(node: ConfigNode): String { + require(node is ConfigObj) { "Properties root must be an object" } + return buildString { + for((k, v) in node.entries) { + when(v) { + is ConfigComment -> appendLine("# ${v.text}") + else -> appendLine("$k=${serializeScalar(v)}") + } + } + } + } + + private fun parseScalar(raw: String): ConfigNode = when { + raw == "true" || raw == "false" -> ConfigBool(raw.toBoolean()) + raw.toIntOrNull() != null -> ConfigInt(raw.toInt()) + raw.toFloatOrNull() != null -> ConfigDouble(raw.toDouble()) + else -> ConfigString(raw) + } + + private fun serializeScalar(node: ConfigNode): String = when (node) { + is ConfigString -> node.value + is ConfigInt -> node.value.toString() + is ConfigDouble -> node.value.toString() + is ConfigBool -> node.value.toString() + else -> "" + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/TomlConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/TomlConfigFormat.kt new file mode 100644 index 0000000..6a74cf7 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/TomlConfigFormat.kt @@ -0,0 +1,255 @@ +package io.github.tritium_launcher.launcher.core.mod_config.formats + +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.tree.nodes.* +import com.akuleshov7.ktoml.tree.nodes.pairs.values.* +import io.github.tritium_launcher.launcher.core.mod_config.* + +class TomlConfigFormat : ConfigFormat { + override val id: String = "toml" + override val extensions: List = listOf("toml") + + override fun parse(text: String): ConfigNode { + val file = Toml.tomlParser.parseString(text) + return nodesToObject(file) + } + + override fun serialize(node: ConfigNode): String { + require(node is ConfigObj) { "TOML root must be an object" } + return buildString { + writeObject(node, emptyList(), isRoot = true) + }.trimEnd() + "\n" + } + + private fun nodesToObject(file: TomlFile): ConfigObj = nodesToObject(file.children) + + private fun nodesToObject(nodes: List): ConfigObj { + val map = linkedMapOf() + nodes.forEach { node -> + appendComments(map, node.comments) + when (node) { + is TomlKeyValuePrimitive -> map[node.name] = valueToConfig(node.value) + is TomlKeyValueArray -> map[node.name] = valueToConfig(node.value) + is TomlTable -> map[node.name] = tableToConfig(node) + is TomlInlineTable -> map[node.name] = inlineTableToConfig(node) + is TomlStubEmptyNode -> {} + else -> {} + } + } + return ConfigObj(map) + } + + private fun tableToConfig(table: TomlTable): ConfigNode = when (table.type) { + TableType.PRIMITIVE -> nodesToObject(table.children.filterNot { it is TomlStubEmptyNode }) + TableType.ARRAY -> ConfigArray( + table.children + .filterIsInstance() + .map { nodesToObject(it.children.filterNot { child -> child is TomlStubEmptyNode }) } + .toMutableList() + ) + } + + private fun inlineTableToConfig(table: TomlInlineTable): ConfigNode { + val root = table.returnTable(TomlFile(), TomlFile()) + return tableToConfig(root) + } + + private fun appendComments(map: LinkedHashMap, comments: List) { + comments.forEach { comment -> + map["__comment_${map.size}"] = ConfigComment(comment) + } + } + + private fun valueToConfig(value: TomlValue): ConfigNode = when (value) { + is TomlBasicString -> ConfigString(value.content as String) + is TomlLiteralString -> ConfigString(value.content as String) + is TomlBoolean -> ConfigBool(value.content as Boolean) + is TomlDouble -> ConfigDouble(value.content as Double) + is TomlLong -> { + val longValue = value.content as Long + if (longValue in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + ConfigInt(longValue.toInt()) + } else { + ConfigDouble(longValue.toDouble()) + } + } + is TomlUnsignedLong -> { + val unsignedValue = value.content as ULong + if (unsignedValue <= Int.MAX_VALUE.toULong()) { + ConfigInt(unsignedValue.toInt()) + } else { + ConfigDouble(unsignedValue.toDouble()) + } + } + is TomlArray -> ConfigArray((value.content as List).map { arrayValueToConfig(it) }.toMutableList()) + is TomlNull -> ConfigNull() + is TomlDateTime -> ConfigString(value.content.toString()) + } + + private fun arrayValueToConfig(value: Any): ConfigNode = when (value) { + is TomlValue -> valueToConfig(value) + is TomlInlineTable -> inlineTableToConfig(value) + else -> ConfigString(value.toString()) + } + + private fun StringBuilder.writeObject(node: ConfigObj, path: List, isRoot: Boolean) { + val bodyLines = mutableListOf() + val sections = mutableListOf() + val pendingComments = mutableListOf() + + for ((key, value) in node.entries) { + if (key.startsWith("__comment_") && value is ConfigComment) { + pendingComments += value + continue + } + + if (value is ConfigObj || isArrayOfTables(value)) { + sections += DeferredSection(key, value, pendingComments.toList()) + pendingComments.clear() + continue + } + + bodyLines += renderComments(pendingComments) + pendingComments.clear() + bodyLines += "${renderKey(key)} = ${renderValue(value)}" + } + + bodyLines += renderComments(pendingComments) + + if (!isRoot) { + appendLine("[${path.joinToString(".") { renderKey(it) }}]") + } + + bodyLines.forEachIndexed { index, line -> + appendLine(line) + if (index == bodyLines.lastIndex && sections.isNotEmpty()) { + appendLine() + } + } + + sections.forEachIndexed { index, section -> + if (isNotEmpty() && !endsWith("\n\n")) { + appendLine() + } + writeSection(section, path + section.key) + if (index < sections.lastIndex && !endsWith("\n\n")) { + appendLine() + } + } + } + + private fun StringBuilder.writeSection(section: DeferredSection, path: List) { + renderComments(section.comments).forEach { appendLine(it) } + when (val node = section.node) { + is ConfigObj -> writeObject(node, path, isRoot = false) + is ConfigArray -> { + val objects = node.items.filterIsInstance() + objects.forEachIndexed { index, obj -> + if (index > 0 || section.comments.isNotEmpty()) { + if (isNotEmpty() && !endsWith("\n\n")) { + appendLine() + } + } + appendLine("[[${path.joinToString(".") { renderKey(it) }}]]") + writeArrayTableBody(obj, path) + } + } + else -> error("Unsupported TOML section node: ${node::class.simpleName}") + } + } + + private fun StringBuilder.writeArrayTableBody(node: ConfigObj, path: List) { + val bodyLines = mutableListOf() + val sections = mutableListOf() + val pendingComments = mutableListOf() + + for ((key, value) in node.entries) { + if (key.startsWith("__comment_") && value is ConfigComment) { + pendingComments += value + continue + } + + if (value is ConfigObj || isArrayOfTables(value)) { + sections += DeferredSection(key, value, pendingComments.toList()) + pendingComments.clear() + continue + } + + bodyLines += renderComments(pendingComments) + pendingComments.clear() + bodyLines += "${renderKey(key)} = ${renderValue(value)}" + } + + bodyLines += renderComments(pendingComments) + + bodyLines.forEachIndexed { index, line -> + appendLine(line) + if (index == bodyLines.lastIndex && sections.isNotEmpty()) { + appendLine() + } + } + + sections.forEachIndexed { index, section -> + if (isNotEmpty() && !endsWith("\n\n")) { + appendLine() + } + writeSection(section, path + section.key) + if (index < sections.lastIndex && !endsWith("\n\n")) { + appendLine() + } + } + } + + private fun renderComments(comments: List): List = + comments.map { "# ${it.text}" } + + private fun isArrayOfTables(node: ConfigNode): Boolean = + node is ConfigArray && node.items.isNotEmpty() && node.items.all { it is ConfigObj } + + private fun renderValue(node: ConfigNode): String = when (node) { + is ConfigString -> quote(node.value) + is ConfigInt -> node.value.toString() + is ConfigDouble -> formatDouble(node.value) + is ConfigBool -> node.value.toString() + is ConfigNull -> "null" + is ConfigArray -> "[${node.items.joinToString(", ") { renderValue(it) }}]" + is ConfigObj -> error("ConfigObj must be rendered as a table section") + is ConfigComment -> "# ${node.text}" + } + + private fun renderKey(key: String): String = + if (BARE_KEY.matches(key)) key else quote(key) + + private fun quote(value: String): String { + val escaped = buildString(value.length + 8) { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + } + return "\"$escaped\"" + } + + private fun formatDouble(value: Double): String { + val text = value.toString() + return if (text.contains('.') || text.contains('e', true)) text else "$text.0" + } + + private data class DeferredSection( + val key: String, + val node: ConfigNode, + val comments: List + ) + + private companion object { + val BARE_KEY = Regex("""[A-Za-z0-9_-]+""") + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/YamlConfigFormat.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/YamlConfigFormat.kt new file mode 100644 index 0000000..07e092f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/mod_config/formats/YamlConfigFormat.kt @@ -0,0 +1,256 @@ +package io.github.tritium_launcher.launcher.core.mod_config.formats + +import io.github.tritium_launcher.launcher.core.mod_config.* +import net.mamoe.yamlkt.Yaml +import net.mamoe.yamlkt.YamlNullableDynamicSerializer +import kotlin.math.max + +class YamlConfigFormat : ConfigFormat { + override val id: String = "yaml" + override val extensions: List = listOf("yaml", "yml") + + private val yaml = Yaml {} + + override fun parse(text: String): ConfigNode { + if (text.isBlank()) return ConfigObj() + val commentsByPath = extractLeadingCommentsByPath(text) + return anyToNode( + yaml.decodeFromString(YamlNullableDynamicSerializer, normalizePlainColonScalars(text)), + emptyList(), + commentsByPath + ) + } + + override fun serialize(node: ConfigNode): String = + buildString { append(renderNode(node, 0)) }.trimEnd() + "\n" + + private fun anyToNode( + value: Any?, + path: List, + commentsByPath: Map, List> + ): ConfigNode = when (value) { + null -> ConfigNull() + is String -> ConfigString(value) + is Int -> ConfigInt(value) + is Long -> { + if (value in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) ConfigInt(value.toInt()) + else ConfigDouble(value.toDouble()) + } + is Float -> ConfigDouble(value.toDouble()) + is Double -> ConfigDouble(value) + is Boolean -> ConfigBool(value) + is Map<*, *> -> ConfigObj(linkedMapOf().apply { + value.forEach { (key, child) -> + val keyText = key?.toString() ?: "null" + commentsByPath[path + keyText].orEmpty().forEach { comment -> + put("__comment_${size}", comment) + } + put(keyText, anyToNode(child, path + keyText, commentsByPath)) + } + }) + is List<*> -> ConfigArray(value.mapIndexedTo(mutableListOf()) { index, child -> + anyToNode(child, path + index.toString(), commentsByPath) + }) + else -> ConfigString(value.toString()) + } + + private fun normalizePlainColonScalars(text: String): String = + text.lineSequence() + .map(::normalizeLine) + .joinToString("\n") + + private fun normalizeLine(line: String): String { + val trimmedStart = line.trimStart() + if (trimmedStart.isEmpty() || trimmedStart.startsWith("#")) return line + + val listPrefix = LIST_ITEM_PREFIX.find(trimmedStart) + if (listPrefix != null) { + val value = trimmedStart.substring(listPrefix.value.length) + return line.take(line.length - trimmedStart.length) + listPrefix.value + normalizeScalar(value) + } + + val keyMatch = KEY_VALUE_PREFIX.find(trimmedStart) ?: return line + val value = trimmedStart.substring(keyMatch.value.length) + return line.take(line.length - trimmedStart.length) + keyMatch.value + normalizeScalar(value) + } + + private fun normalizeScalar(value: String): String { + val trimmed = value.trim() + if (trimmed.isEmpty()) return value + if (!needsQuotes(trimmed)) return value + + val leading = value.takeWhile { it.isWhitespace() } + val trailing = value.takeLastWhile { it.isWhitespace() } + return leading + quote(trimmed) + trailing + } + + private fun needsQuotes(value: String): Boolean { + if (':' !in value) return false + if (value.startsWith("\"") || value.startsWith("'")) return false + if (value.startsWith("{") || value.startsWith("[") || value.startsWith("|") || value.startsWith(">")) return false + if (value == "-" || value == "{}" || value == "[]") return false + return value.none { it == '#' } + } + + private fun quote(value: String): String { + val escaped = buildString(value.length + 8) { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + } + return "\"$escaped\"" + } + + private fun extractLeadingCommentsByPath(text: String): Map, List> { + val commentsByPath = linkedMapOf, MutableList>() + val pendingComments = mutableListOf() + val stack = mutableListOf() + + text.lines().forEach { rawLine -> + val trimmed = rawLine.trim() + if (trimmed.isEmpty()) return@forEach + + if (trimmed.startsWith("#")) { + pendingComments += ConfigComment(trimmed.removePrefix("#").trim()) + return@forEach + } + + val indent = rawLine.indexOfFirst { !it.isWhitespace() }.let { if (it < 0) 0 else it } + while (stack.isNotEmpty() && indent <= stack.last().indent) { + stack.removeAt(stack.lastIndex) + } + + val currentPath = stack.lastOrNull()?.path ?: emptyList() + val keyMatch = YAML_KEY.find(trimmed) + if (keyMatch != null) { + val key = keyMatch.groupValues[1].trim().removeSurrounding("\"").removeSurrounding("'") + val path = currentPath + key + if (pendingComments.isNotEmpty()) { + commentsByPath.getOrPut(path) { mutableListOf() }.addAll(pendingComments) + pendingComments.clear() + } + + val remainder = trimmed.substring(keyMatch.value.length).trim() + if (remainder.isEmpty()) { + stack += Scope(indent, path) + } + return@forEach + } + + if (pendingComments.isNotEmpty()) { + pendingComments.clear() + } + } + + return commentsByPath + } + + private fun renderNode(node: ConfigNode, depth: Int): String = when (node) { + is ConfigObj -> renderObject(node, depth) + is ConfigArray -> renderArray(node, depth) + is ConfigString -> renderScalar(node.value) + is ConfigInt -> node.value.toString() + is ConfigDouble -> formatDouble(node.value) + is ConfigBool -> node.value.toString() + is ConfigNull -> "null" + is ConfigComment -> "${indent(depth)}# ${node.text}" + } + + private fun renderObject(node: ConfigObj, depth: Int): String { + val lines = mutableListOf() + node.entries.forEach { (key, value) -> + if (key.startsWith("__comment_") && value is ConfigComment) { + lines += "${indent(depth)}# ${value.text}" + return@forEach + } + + val prefix = "${indent(depth)}${renderKey(key)}:" + when (value) { + is ConfigObj -> { + val rendered = renderObject(value, depth + 1) + lines += if (value.entries.isEmpty()) "$prefix {}" else "$prefix\n$rendered" + } + is ConfigArray -> { + val rendered = renderArray(value, depth + 1) + lines += if (value.items.isEmpty()) "$prefix []" else "$prefix\n$rendered" + } + else -> lines += "$prefix ${renderNode(value, depth + 1).trimStart()}" + } + } + return lines.joinToString("\n") + } + + private fun renderArray(node: ConfigArray, depth: Int): String { + val lines = mutableListOf() + node.items.forEach { item -> + when (item) { + is ConfigComment -> lines += "${indent(depth)}# ${item.text}" + is ConfigObj -> { + if (item.entries.isEmpty()) { + lines += "${indent(depth)}- {}" + } else { + val rendered = renderObjectWithListPrefix(item, depth) + lines += rendered + } + } + is ConfigArray -> { + val rendered = renderArray(item, depth + 1) + lines += if (item.items.isEmpty()) "${indent(depth)}- []" else "${indent(depth)}-\n$rendered" + } + else -> lines += "${indent(depth)}- ${renderNode(item, depth + 1).trimStart()}" + } + } + return lines.joinToString("\n") + } + + private fun renderObjectWithListPrefix(node: ConfigObj, depth: Int): String { + val rendered = renderObject(node, depth + 1).lines() + if (rendered.isEmpty()) return "${indent(depth)}- {}" + val firstMeaningful = rendered.indexOfFirst { it.isNotBlank() && !it.trimStart().startsWith("#") } + if (firstMeaningful < 0) return "${indent(depth)}-\n${rendered.joinToString("\n")}" + + val lines = rendered.toMutableList() + lines[firstMeaningful] = "${indent(depth)}- ${lines[firstMeaningful].trimStart()}" + return lines.joinToString("\n") + } + + private fun renderKey(key: String): String = + if (BARE_KEY.matches(key)) key else quote(key) + + private fun renderScalar(value: String): String = + if (needsQuotesWhenWriting(value)) quote(value) else value + + private fun needsQuotesWhenWriting(value: String): Boolean { + if (value.isEmpty()) return true + if (value == "null" || value == "true" || value == "false") return true + if (value.toIntOrNull() != null || value.toDoubleOrNull() != null) return true + if (value.first().isWhitespace() || value.last().isWhitespace()) return true + return value.any { it == ':' || it == '#' || it == '"' || it == '\'' || it == '{' || it == '}' || it == '[' || it == ']' } + } + + private fun formatDouble(value: Double): String { + val text = value.toString() + return if (text.contains('.') || text.contains('e', true)) text else "$text.0" + } + + private fun indent(depth: Int): String = " ".repeat(max(0, depth)) + + private data class Scope( + val indent: Int, + val path: List + ) + + private companion object { + val LIST_ITEM_PREFIX = Regex("""^-\s+""") + val KEY_VALUE_PREFIX = Regex("""^[^:#\[\]\{\},][^:]*:\s+""") + val YAML_KEY = Regex("""^([^:#][^:]*)\s*:\s*""") + val BARE_KEY = Regex("""[A-Za-z0-9_.-]+""") + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/CurseForge.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/CurseForge.kt deleted file mode 100644 index bc48f76..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/CurseForge.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack - -import io.github.tritium_launcher.launcher.registry.Registrable -import io.github.tritium_launcher.launcher.ui.theme.TIcons -import io.qt.gui.QPixmap - -data class CurseForge( - override val id: String = "curseforge", - override val displayName: String = "CurseForge", - override val icon: QPixmap = TIcons.CurseForge, - override val webpage: String = "https://www.curseforge.com/", - override val order: Int = 1 -) : ModSource(), Registrable { - - override fun toString(): String = id -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ModSource.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ModSource.kt deleted file mode 100644 index 1f206dc..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ModSource.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack - -import io.github.tritium_launcher.launcher.registry.Registrable -import io.qt.gui.QPixmap - -/** - * Mod Sources are web APIs that provide Mods and other content to users. Examples are [CurseForge] and [Modrinth]. - */ -abstract class ModSource: Registrable { - abstract override val id: String - abstract val displayName: String - abstract val icon: QPixmap - abstract val webpage: String - abstract val order: Int - - override fun toString(): String = id -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/Modrinth.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/Modrinth.kt deleted file mode 100644 index b708805..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/Modrinth.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack - -import io.github.tritium_launcher.launcher.registry.Registrable -import io.github.tritium_launcher.launcher.ui.theme.TIcons -import io.qt.gui.QPixmap - -data class Modrinth( - override val id: String = "modrinth", - override val displayName: String = "Modrinth", - override val icon: QPixmap = TIcons.Modrinth, - override val webpage: String = "https://modrinth.com/", - override val order: Int = 2 -) : ModSource(), Registrable { - override fun toString(): String = id -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ReleaseType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ReleaseType.kt deleted file mode 100644 index 84bf006..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/ReleaseType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack - -enum class ReleaseType { - ALPHA, - BETA, - RELEASE -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseChangelogType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseChangelogType.kt deleted file mode 100644 index 48c0c7e..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseChangelogType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.curseforge - -enum class CurseChangelogType { - TEXT, - HTML, - MARKDOWN; -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseFileUpdate.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseFileUpdate.kt deleted file mode 100644 index ab0af8c..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseFileUpdate.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.curseforge - -import io.github.tritium_launcher.launcher.core.modpack.ReleaseType -import kotlinx.serialization.Serializable - -@Serializable -data class CurseFileUpdate( - val id: Int, - - val changelog: String = "", - val changelogType: CurseChangelogType = CurseChangelogType.TEXT, - val displayName: String? = null, - val parentFileID: Int? = null, - val gameVersions: List = emptyList(), - val releaseType: ReleaseType = ReleaseType.RELEASE, - val isMarkedForManualRelease: Boolean = false, - val relations: List = emptyList(), -) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelatedProjectType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelatedProjectType.kt deleted file mode 100644 index 4dd2061..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelatedProjectType.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.curseforge - -enum class CurseRelatedProjectType { - EMBEDDED, - INCOMPATIBLE, - OPTIONAL, - REQUIRED, - TOOL; -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelation.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelation.kt deleted file mode 100644 index 618e2fe..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseRelation.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.curseforge - -import kotlinx.serialization.Serializable - -@Serializable -data class CurseRelation( - val relations: CurseRelations -) - -@Serializable -data class CurseRelations( - val projects: List -) - -@Serializable -data class CurseRelatedProject( - val slug: String, - val type: List -) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseUpload.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseUpload.kt deleted file mode 100644 index a3156d6..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/curseforge/CurseUpload.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.curseforge - -import io.github.tritium_launcher.launcher.core.modpack.ReleaseType -import kotlinx.serialization.Serializable - -@Serializable -data class CurseUpload( - val changelog: String = "", - val changelogType: CurseChangelogType = CurseChangelogType.TEXT, - val displayName: String? = null, - val parentFileID: Int? = null, - val gameVersions: List = emptyList(), - val releaseType: ReleaseType = ReleaseType.RELEASE, - val isMarkedForManualRelease: Boolean = false, - val relations: List = emptyList() -) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DependencyType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DependencyType.kt deleted file mode 100644 index 9f568d5..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DependencyType.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api - -enum class DependencyType { - REQUIRED, - OPTIONAL, - INCOMPATIBLE, - EMBEDDED -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/RequestedStatus.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/RequestedStatus.kt deleted file mode 100644 index 6988c1f..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/RequestedStatus.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api - -enum class RequestedStatus { - LISTED, - ARCHIVED, - DRAFT, - UNLISTED -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Side.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Side.kt deleted file mode 100644 index df46eaf..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Side.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api - -enum class Side { - REQUIRED, - OPTIONAL, - UNSUPPORTED, - UNKNOWN -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Status.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Status.kt deleted file mode 100644 index 75fb3c4..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Status.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api - -enum class Status { - LISTED, - ARCHIVED, - DRAFT, - UNLISTED, - SCHEDULED, - UNKNOWN; -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackProject.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackProject.kt index d178699..a4cee4c 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackProject.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackProject.kt @@ -15,8 +15,6 @@ import io.github.tritium_launcher.launcher.git.Git import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.platform.Platform import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread -import io.github.tritium_launcher.launcher.ui.notifications.NotificationMngr -import io.github.tritium_launcher.launcher.ui.project.ProjectTaskMngr import io.github.tritium_launcher.launcher.ui.project.menu.builtin.BuiltinMenuItems import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.setStyle @@ -30,13 +28,14 @@ import io.qt.gui.QPixmap import io.qt.widgets.* import kotlinx.coroutines.* import kotlinx.serialization.json.* +import java.net.URL import java.nio.file.Path /** * Project type for creating Modpack projects. */ class ModpackProjectType : ProjectType { - override val id: String = "modpack" + override val id: String = "source" override val displayName: String = "Modpack" // TODO: Localization override val description: String = "Create a ModPack project" override val icon: QIcon = QIcon(TIcons.TrMeta) @@ -57,6 +56,14 @@ class ModpackProjectType : ProjectType { private val modLoaders = BuiltinRegistries.ModLoader private val modSources = BuiltinRegistries.ModSource + private data class CompanionModEntry( + val loader: String, + val mcVersion: String + ) + + private var companionModVersion: String = "" + private var companionModEntries: List = emptyList() + override fun createSetupWidget( projectRootHint: Path?, initialVars: MutableMap @@ -158,7 +165,7 @@ class ModpackProjectType : ProjectType { } val iconRow = QWidget() - val iconRowLayout = hBoxLayout(iconRow) { + hBoxLayout(iconRow) { contentsMargins = 0.m setSpacing(8) addWidget(iconPathField) @@ -209,6 +216,19 @@ class ModpackProjectType : ProjectType { } form.addRow(separatorLabel) + // MARK: Companion Mod checkbox + val companionLabel = label("Include Companion Mod:") + val companionCheckbox = QCheckBox().apply { + isChecked = true + visible = false + toggled.connect { checked -> + initialVars["includeCompanionMod"] = if(checked) "true" else "false" + } + minimumWidth = 50 + } + initialVars["includeCompanionMod"] = "true" + form.addRow(companionLabel, companionCheckbox) + // MARK: Set if Git Repository should be initialized val gitLabel = label("Create Git Repository:") val gitCheckbox = QCheckBox().apply { @@ -328,12 +348,48 @@ class ModpackProjectType : ProjectType { return a.compareTo(b) } + fun updateCompanionModVisibility() { + val loaderId = modLoaderCombo.currentData as? String + val mcVersion = mcCombo.currentData as? String + val hasMatch = loaderId != null && mcVersion != null && + companionModEntries.any { it.loader == loaderId && it.mcVersion == mcVersion } + companionCheckbox.visible = hasMatch + } + + fun fetchCompanionModVersions() { + CoroutineScope(Dispatchers.IO).launch { + try { + val content = URL("https://raw.githubusercontent.com/Tritium-Launcher/Tritium-Companion/main/versions.json") + .openStream().bufferedReader().use { it.readText() } + val root = Json.parseToJsonElement(content).jsonObject + val modVersion = root["modVersion"]?.jsonPrimitive?.contentOrNull ?: return@launch + val supported = root["supported"]?.jsonArray ?: return@launch + val entries = supported.flatMap { element -> + val obj = element.jsonObject + val mcVersion = obj["mcVersion"]?.jsonPrimitive?.contentOrNull ?: return@flatMap emptyList() + val loaders = obj["loaders"]?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: return@flatMap emptyList() + loaders.map { CompanionModEntry(loader = it, mcVersion = mcVersion) } + } + companionModVersion = modVersion + companionModEntries = entries + runOnGuiThread { updateCompanionModVisibility() } + } catch (t: Throwable) { + logger.info("Failed to fetch companion mod versions", t) + } + } + } + fun updateCompatibleVersions() { val loaderId = modLoaderCombo.currentData as? String val mcVersion = mcCombo.currentData as? String if (loaderId == null || mcVersion == null) { - runOnGuiThread { modLoaderVerCombo.clear() } + runOnGuiThread { + modLoaderVerCombo.clear() + updateCompanionModVisibility() + } return } @@ -354,13 +410,14 @@ class ModpackProjectType : ProjectType { modLoaderVerCombo.currentIndex = 0 (modLoaderVerCombo.currentData as? String)?.let { initialVars["modLoaderVersion"] = it } } + updateCompanionModVisibility() } } } fun fetchAndPopulateMcVersions() { CoroutineScope(Dispatchers.IO).launch { - val includePreReleases = CoreSettingValues.includePreReleaseMinecraftVersions() + val includePreReleases = CoreSettingValues.includePreReleaseMinecraftVersions val releaseTypes = if (includePreReleases) { listOf(MCVersionType.Release, MCVersionType.Snapshot) } else { @@ -391,12 +448,13 @@ class ModpackProjectType : ProjectType { modLoaderCombo.currentIndexChanged.connect { updateCompatibleVersions() } fetchAndPopulateMcVersions() + fetchCompanionModVersions() return panel } /** - * Create the project on disk and write `trproj.json` plus modpack metadata. + * Create the project on disk and write `trproj.json` plus source metadata. */ override suspend fun createProject( vars: Map @@ -459,74 +517,39 @@ class ModpackProjectType : ProjectType { val manifest = json.encodeToString(ModpackMeta.serializer(), modpackMeta) val steps = mutableListOf() - steps += GeneratorStepDescriptor( - "create-modpack-meta", - "createFile", - JsonObject(mapOf( - "path" to JsonPrimitive("trmodpack.json"), - "template" to JsonPrimitive(manifest), - "overwrite" to JsonPrimitive(true) - )), - affects = listOf("trmodpack.json") - ) - - steps += GeneratorStepDescriptor( - "create-export-rules", - "createFile", - JsonObject(mapOf( - "path" to JsonPrimitive("trexportrules.json"), - "template" to JsonPrimitive("{}"), - "overwrite" to JsonPrimitive(false) - )), - affects = listOf("trexportrules.json") - ) - - // Ensure standard instance directories exist - fun placeholder(path: String) = GeneratorStepDescriptor( - "placeholder-$path", - "createFile", - JsonObject(mapOf( - "path" to JsonPrimitive("$path/.placeholder"), - "template" to JsonPrimitive("# placeholder to keep folder in VCS"), - "overwrite" to JsonPrimitive(false) - )), - affects = listOf("$path/**") - ) - listOf("mods", "config", "defaultconfigs", "logs", "saves").forEach { dir -> - steps += placeholder(dir) - } - - if(iconPath.isNotBlank()) { - val normalizedFileUrl = if(iconPath.startsWith("file://")) iconPath else "file://$iconPath" - steps += GeneratorStepDescriptor( - "copy-icon", - "fetch", - JsonObject(mapOf( - "url" to JsonPrimitive(normalizedFileUrl), - "dest" to JsonPrimitive("icon.png") - )), - affects = listOf("icon.png") - ) + steps += StandardProjectSteps.metadataStep("create-source-meta", manifest) + steps += StandardProjectSteps.exportRulesStep() + steps += StandardProjectSteps.placeholderSteps() + StandardProjectSteps.iconStep(iconPath)?.let { steps += it } + + val includeCompanion = vars["includeCompanionMod"]?.toBoolean() ?: false + if(includeCompanion && companionModVersion.isNotBlank()) { + val mcVer = vars["minecraftVersion"] ?: "" + val loaderId = vars["modLoader"] ?: "" + if(companionModEntries.any { it.loader == loaderId && it.mcVersion == mcVer }) { + val downloadUrl = "https://github.com/Tritium-Launcher/Tritium-Companion/releases/download/v${companionModVersion}/tritiumcompanion-${loaderId}-${companionModVersion}.jar" + val destFilename = downloadUrl.substringAfterLast('/') + steps += GeneratorStepDescriptor( + "companion-mod", + "fetch", + JsonObject(mapOf( + "url" to JsonPrimitive(downloadUrl), + "dest" to JsonPrimitive("mods/$destFilename") + )), + affects = listOf("mods/$destFilename") + ) + } } if(gitInit) { - steps += GeneratorStepDescriptor( - "gitignore", - "createFile", - JsonObject(mapOf( - "path" to JsonPrimitive(".gitignore"), - "template" to JsonPrimitive(".tr/\ntr*.json\n"), - "overwrite" to JsonPrimitive(false) - )), - affects = listOf(".gitignore") - ) + steps += StandardProjectSteps.gitignoreStep() } // Ensure project root exists before executing steps projectRoot.mkdirs() val execResult = ProjectTemplateExecutor.run( - templateId = "builtin.modpack:$packName", + templateId = "builtin.source:$packName", projectRoot = projectRoot.toJPath(), variables = vars, steps = steps @@ -568,111 +591,7 @@ class ModpackProjectType : ProjectType { } // Kick heavy downloads to background - CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { - val bootstrapTaskId = ProjectTaskMngr.start( - projectPath = projectRoot, - title = "Bootstrapping $packName", - detail = "Preparing runtime files", - progressPercent = 5.0 - ) - var bootstrapSucceeded = false - var failureDetail: String? = null - logger.info("Background bootstrap start for {} (MC {}, loader {} {})", packName, mcVer, loader.id, loaderVersion) - try { - coroutineScope { - val mcJob = async { - ProjectTaskMngr.update(bootstrapTaskId, detail = "Setting up Minecraft $mcVer") - ProjectTaskMngr.updateProgress(bootstrapTaskId, 20.0) - val mcStart = System.currentTimeMillis() - val ok = MicrosoftAuth.setupMinecraftInstance(mcVer, projectRoot) - logger.info("Minecraft setup {} ({})", if(ok) "ok" else "failed", formatDurationMs(System.currentTimeMillis() - mcStart)) - ProjectTaskMngr.updateProgress(bootstrapTaskId, if (ok) 55.0 else 40.0) - ok - } - - val gitJob = async { - if(gitInit) { - try { - logger.info("Initializing git repo in {}", projectRoot.toString().redactUserPath()) - Git.initRepo(projectRoot) - } catch (t: Throwable) { - logger.warn("Git init failed in {}", projectRoot.toString().redactUserPath(), t) - } - } - } - - val mcOk = mcJob.await() - if (mcOk) { - ProjectTaskMngr.update( - bootstrapTaskId, - detail = "Installing ${loader.displayName} $loaderVersion" - ) - ProjectTaskMngr.updateProgress(bootstrapTaskId, 70.0) - val loaderStart = System.currentTimeMillis() - logger.info( - "Installing loader {} {} into {}", - loader.id, - loaderVersion, - projectRoot.toString().redactUserPath() - ) - val ok = loader.installClient(loaderVersion, mcVer, projectRoot) - logger.info("Loader install {} ({})", if(ok) "ok" else "failed", formatDurationMs(System.currentTimeMillis() - loaderStart)) - if (ok) { - val merged = MicrosoftAuth.writeMergedVersionJson(mcVer, loader.id, loaderVersion, projectRoot) - logger.info( - "Merged version json written to {}", - merged?.toAbsolute()?.toString()?.redactUserPath() ?: "null" - ) - ProjectTaskMngr.update(bootstrapTaskId, detail = "Finalizing bootstrap") - ProjectTaskMngr.updateProgress(bootstrapTaskId, 95.0) - bootstrapSucceeded = true - } else { - failureDetail = "Failed to install ${loader.displayName}." - } - } else { - logger.warn("Skipping loader install; Minecraft setup failed for {}", packName) - failureDetail = "Failed to set up Minecraft runtime." - } - - gitJob.await() - } - } catch (t: Throwable) { - logger.warn("Background bootstrap failed for {}", packName, t) - failureDetail = t.message?.trim().takeUnless { it.isNullOrEmpty() } - ?: "Unexpected bootstrap error." - } finally { - if (bootstrapSucceeded) { - ProjectTaskMngr.update(bootstrapTaskId, detail = "Bootstrap finished") - ProjectTaskMngr.updateProgress(bootstrapTaskId, 100.0) - } - ProjectTaskMngr.finish(bootstrapTaskId) - - val projectRef = ProjectMngr.getProject(projectRoot) - if (bootstrapSucceeded) { - NotificationMngr.post( - id = "bootstrap_success", - project = projectRef, - description = "Project '$packName' is ready.", - metadata = mapOf( - "source" to "modpack.bootstrap", - "result" to "success", - ) - ) - } else { - val reason = failureDetail ?: "Unknown error." - NotificationMngr.post( - id = "bootstrap_failure", - project = projectRef, - description = "Project '$packName' bootstrap failed: $reason", - metadata = mapOf( - "source" to "modpack.bootstrap", - "result" to "failed", - ) - ) - } - } - logger.info("BACKGROUND BOOTSTRAP FINISHED for {} (success={})", packName, bootstrapSucceeded) - } + ProjectBootstrap.launch(projectRoot, packName, mcVer, loader, loaderVersion, gitInit) } logger.info("Modpack createProject finished: success={}", execResult.successful) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackTemplateDescriptor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackTemplateDescriptor.kt index d183918..3e5d9fe 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackTemplateDescriptor.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ModpackTemplateDescriptor.kt @@ -12,14 +12,14 @@ import kotlinx.serialization.json.* object ModpackTemplateDescriptor : TemplateDescriptor, ProjectFileLoader { private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } - override val typeId: String = "modpack" + override val typeId: String = "source" override val serializer = ModpackMeta.serializer() override val projectName: String = "Modpack" override val defaultIcon: String = TIcons.defaultProjectIcon override val currentSchema: Int = 1 /** - * Create a typed project from modpack metadata. + * Create a typed project from source metadata. */ override fun createProjectFromMeta(meta: ModpackMeta, schemaVersion: Int, projectDir: VPath): ProjectBase { val rawMeta: JsonObject = json.encodeToJsonElement(serializer, meta).jsonObject diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/Project.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/Project.kt index af3c316..dbb2650 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/Project.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/Project.kt @@ -39,6 +39,10 @@ open class ProjectBase( val path: VPath get() = projectDir.toAbsolute() + fun fromProject(path: String): VPath = projectDir.resolve(path) + + fun fromProject(path: VPath): VPath = projectDir.resolve(path) + /** * Resolve the icon path, expanding a leading "~" to the user home directory. */ diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectBootstrap.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectBootstrap.kt new file mode 100644 index 0000000..69bb50c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectBootstrap.kt @@ -0,0 +1,131 @@ +package io.github.tritium_launcher.launcher.core.project + +import io.github.tritium_launcher.launcher.accounts.MicrosoftAuth +import io.github.tritium_launcher.launcher.core.modloader.ModLoader +import io.github.tritium_launcher.launcher.formatDurationMs +import io.github.tritium_launcher.launcher.git.Git +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.redactUserPath +import io.github.tritium_launcher.launcher.ui.notifications.NotificationMngr +import io.github.tritium_launcher.launcher.ui.project.ProjectTaskMngr +import kotlinx.coroutines.* + +object ProjectBootstrap { + private val logger = logger() + + suspend fun launch( + projectRoot: VPath, + packName: String, + mcVer: String, + loader: ModLoader, + loaderVersion: String, + gitInit: Boolean = false + ) { + CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + val bootstrapTaskId = ProjectTaskMngr.start( + projectPath = projectRoot, + title = "Bootstrapping $packName", + detail = "Preparing runtime files", + progressPercent = 5.0 + ) + var bootstrapSucceeded = false + var failureDetail: String? = null + logger.info("Background bootstrap start for {} (MC {}, loader {} {})", packName, mcVer, loader.id, loaderVersion) + try { + coroutineScope { + val mcJob = async { + ProjectTaskMngr.update(bootstrapTaskId, detail = "Setting up Minecraft $mcVer") + ProjectTaskMngr.updateProgress(bootstrapTaskId, 20.0) + val mcStart = System.currentTimeMillis() + val ok = MicrosoftAuth.setupMinecraftInstance(mcVer, projectRoot) + logger.info("Minecraft setup {} ({})", if(ok) "ok" else "failed", formatDurationMs(System.currentTimeMillis() - mcStart)) + ProjectTaskMngr.updateProgress(bootstrapTaskId, if (ok) 55.0 else 40.0) + ok + } + + val gitJob = async { + if(gitInit) { + try { + logger.info("Initializing git repo in {}", projectRoot.toString().redactUserPath()) + Git.initRepo(projectRoot) + } catch (t: Throwable) { + logger.warn("Git init failed in {}", projectRoot.toString().redactUserPath(), t) + } + } + } + + val mcOk = mcJob.await() + if (mcOk) { + ProjectTaskMngr.update( + bootstrapTaskId, + detail = "Installing ${loader.displayName} $loaderVersion" + ) + ProjectTaskMngr.updateProgress(bootstrapTaskId, 70.0) + val loaderStart = System.currentTimeMillis() + logger.info( + "Installing loader {} {} into {}", + loader.id, + loaderVersion, + projectRoot.toString().redactUserPath() + ) + val ok = loader.installClient(loaderVersion, mcVer, projectRoot) + logger.info("Loader install {} ({})", if(ok) "ok" else "failed", formatDurationMs(System.currentTimeMillis() - loaderStart)) + if (ok) { + val merged = MicrosoftAuth.writeMergedVersionJson(mcVer, loader.id, loaderVersion, projectRoot) + logger.info( + "Merged version json written to {}", + merged?.toAbsolute()?.toString()?.redactUserPath() ?: "null" + ) + ProjectTaskMngr.update(bootstrapTaskId, detail = "Finalizing bootstrap") + ProjectTaskMngr.updateProgress(bootstrapTaskId, 95.0) + bootstrapSucceeded = true + } else { + failureDetail = "Failed to install ${loader.displayName}." + } + } else { + logger.warn("Skipping loader install; Minecraft setup failed for {}", packName) + failureDetail = "Failed to set up Minecraft runtime." + } + + gitJob.await() + } + } catch (t: Throwable) { + logger.warn("Background bootstrap failed for {}", packName, t) + failureDetail = t.message?.trim().takeUnless { it.isNullOrEmpty() } + ?: "Unexpected bootstrap error." + } finally { + if (bootstrapSucceeded) { + ProjectTaskMngr.update(bootstrapTaskId, detail = "Bootstrap finished") + ProjectTaskMngr.updateProgress(bootstrapTaskId, 100.0) + } + ProjectTaskMngr.finish(bootstrapTaskId) + + val projectRef = ProjectMngr.getProject(projectRoot) + if (bootstrapSucceeded) { + NotificationMngr.post( + id = "bootstrap_success", + project = projectRef, + description = "Project '$packName' is ready.", + metadata = mapOf( + "source" to "source.bootstrap", + "result" to "success", + ) + ) + } else { + val reason = failureDetail ?: "Unknown error." + NotificationMngr.post( + id = "bootstrap_failure", + project = projectRef, + description = "Project '$packName' bootstrap failed: $reason", + metadata = mapOf( + "source" to "source.bootstrap", + "result" to "failed", + ) + ) + } + } + logger.info("BACKGROUND BOOTSTRAP FINISHED for {} (success={})", packName, bootstrapSucceeded) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectGenerator.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectGenerator.kt index b78b352..8064373 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectGenerator.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectGenerator.kt @@ -4,6 +4,8 @@ import io.github.tritium_launcher.launcher.core.project.templates.TemplateExecut import io.github.tritium_launcher.launcher.coroutines.UIDispatcher import io.github.tritium_launcher.launcher.logger import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow /** * Runs project creation on a background dispatcher and reports progress to UI. @@ -13,7 +15,40 @@ class ProjectGenerator(private val uiCtx: CoroutineDispatcher = UIDispatcher) { private val logger = logger() /** - * Create a project asynchronously. + * Events emitted during project generation. + */ + sealed class ProjectGeneratorEvent { + data class Progress(val message: String) : ProjectGeneratorEvent() + data class Success(val result: TemplateExecutionResult) : ProjectGeneratorEvent() + data class Error(val throwable: Throwable) : ProjectGeneratorEvent() + } + + /** + * Create a project and returns a [Flow] of [ProjectGeneratorEvent]. + */ + fun createProject( + projectType: ProjectType, + vars: Map + ): Flow = flow { + emit(ProjectGeneratorEvent.Progress("Generating Project...")) + logger.info("Started generating project '{}'", projectType.id) + + try { + val result = projectType.createProject(vars) + logger.info("Finished generating project '{}'", projectType.id) + emit(ProjectGeneratorEvent.Progress("Finished")) + emit(ProjectGeneratorEvent.Success(result)) + } catch (c: CancellationException) { + logger.info("Cancelled generating project '{}'", projectType.id) + throw c + } catch (t: Throwable) { + logger.warn("Failed to generate project '{}'", projectType.id, t) + emit(ProjectGeneratorEvent.Error(t)) + } + } + + /** + * Create a project asynchronously (Legacy callback-based API). * * @param projectType The project type to create. * @param vars Variables collected from the setup UI. @@ -26,25 +61,16 @@ class ProjectGenerator(private val uiCtx: CoroutineDispatcher = UIDispatcher) { onProgress: (String) -> Unit = {}, onComplete: (Result) -> Unit ): Job = scope.launch { - withContext(uiCtx) { onProgress("Generating Project...") } - logger.info("Started generating project '{}'", projectType.id) - - val result = try { - projectType.createProject(vars) - } catch (c: CancellationException) { - logger.info("Cancelled generating project '{}'", projectType.id) - withContext(NonCancellable + uiCtx) { onComplete(Result.failure(c)) } - return@launch - } catch (t: Throwable) { - logger.warn("Failed to generate project '{}'", projectType.id, t) - withContext(NonCancellable + uiCtx) { onComplete(Result.failure(t)) } - return@launch - } - - logger.info("Finished generating project '{}'", projectType.id) - withContext(NonCancellable + uiCtx) { - onProgress("Finished") - onComplete(Result.success(result)) + createProject(projectType, vars).collect { event -> + when (event) { + is ProjectGeneratorEvent.Progress -> withContext(uiCtx) { onProgress(event.message) } + is ProjectGeneratorEvent.Success -> withContext(NonCancellable + uiCtx) { + onComplete(Result.success(event.result)) + } + is ProjectGeneratorEvent.Error -> withContext(NonCancellable + uiCtx) { + onComplete(Result.failure(event.throwable)) + } + } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectMngr.kt index cfb6cf4..19c127c 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/ProjectMngr.kt @@ -1,6 +1,8 @@ package io.github.tritium_launcher.launcher.core.project import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus import io.github.tritium_launcher.launcher.core.project.templates.MigrationRegistry import io.github.tritium_launcher.launcher.core.project.templates.ProjectFileLoader import io.github.tritium_launcher.launcher.core.project.templates.TemplateDescriptor @@ -14,6 +16,7 @@ import io.github.tritium_launcher.launcher.ui.project.ProjectWindows import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.qt.widgets.QApplication import io.qt.widgets.QMessageBox +import kotlinx.coroutines.flow.* import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -22,9 +25,18 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import java.io.IOException import java.nio.file.Files -import java.util.concurrent.CopyOnWriteArrayList import java.util.prefs.Preferences +/** + * Events emitted by [ProjectMngr]. + */ +sealed class ProjectMngrEvent { + data class Created(val project: ProjectBase) : ProjectMngrEvent() + data class FailedToGenerate(val project: ProjectBase, val errorMsg: String, val exception: Exception?) : ProjectMngrEvent() + data class Opened(val project: ProjectBase) : ProjectMngrEvent() + data class FinishedLoading(val projects: List) : ProjectMngrEvent() +} + /** * Central manager for loading and tracking cataloged projects on disk. */ @@ -38,16 +50,19 @@ object ProjectMngr { private val logger = logger() @Volatile var generationActive: Boolean = false - private val listeners = CopyOnWriteArrayList() + private val _activeProject = MutableStateFlow(null) + val activeProjectFlow: StateFlow = _activeProject.asStateFlow() + val activeProject: ProjectBase? + get() = _activeProject.value + + private val _projectEvents = MutableSharedFlow(replay = 0) + val projectEvents: SharedFlow = _projectEvents.asSharedFlow() private val _projectsLock = Any() private val _projects = mutableListOf() val projects: List get() = synchronized(_projectsLock) { _projects.toList() } - @Volatile - var activeProject: ProjectBase? = null - private set val projectsDir = fromTR(TConstants.Dirs.PROJECTS) private val catalogFile = fromTR(VPath.get("projects/catalog.json")).toAbsolute() @@ -68,27 +83,23 @@ object ProjectMngr { val projects: List = emptyList() ) - /** - * Register a listener for project events. - */ - fun addListener(listener: ProjectMngrListener) { listeners.add(listener) } - /** - * Unregister a listener. - */ - fun removeListener(listener: ProjectMngrListener) { listeners.remove(listener) } - private fun notifyProjectCreated(project: ProjectBase) { - listeners.forEach { it.onProjectCreated(project) } + _projectEvents.tryEmit(ProjectMngrEvent.Created(project)) + TritiumEventBus.publish(TritiumEvent.ProjectCreated(project)) } private fun notifyProjectFailedToGenerate(project: ProjectBase, errorMsg: String, exception: Exception?) { - listeners.forEach { it.onProjectFailedToGenerate(project, errorMsg, exception) } + _projectEvents.tryEmit(ProjectMngrEvent.FailedToGenerate(project, errorMsg, exception)) + TritiumEventBus.publish(TritiumEvent.ProjectFailedToGenerate(project, errorMsg)) } private fun notifyProjectOpened(project: ProjectBase) { - listeners.forEach { it.onProjectOpened(project) } + _activeProject.value = project + _projectEvents.tryEmit(ProjectMngrEvent.Opened(project)) + TritiumEventBus.publish(TritiumEvent.ProjectOpened(project)) } private fun notifyFinishedLoading() { val snapshot = synchronized(_projectsLock) { _projects.toList() } - listeners.forEach { it.onProjectsFinishedLoading(snapshot) } + _projectEvents.tryEmit(ProjectMngrEvent.FinishedLoading(snapshot)) + TritiumEventBus.publish(TritiumEvent.ProjectFinishedLoading(snapshot)) } private fun loadProjectFromDir(dir: VPath): ProjectBase? { @@ -103,7 +114,7 @@ object ProjectMngr { val schemaVersion = trMeta.schemaVersion val metaElem = trMeta.meta.jsonObjectOrEmpty() - val descriptor = TemplateRegistry.get(typeId) + val descriptor = resolveTemplateDescriptor(typeId) if(descriptor is ProjectFileLoader) { return try { descriptor.loadFromProjectFile(trMeta, dir) @@ -146,6 +157,17 @@ object ProjectMngr { private fun JsonElement.jsonObjectOrEmpty(): JsonObject = (this as? JsonObject) ?: JsonObject(emptyMap()) + /** + * Resolves a project template descriptor. + */ + private fun resolveTemplateDescriptor(typeId: String): TemplateDescriptor<*>? { + TemplateRegistry.get(typeId)?.let { return it } + return when (typeId) { + "modpack" -> TemplateRegistry.get(ModpackTemplateDescriptor.typeId) + else -> null + } + } + /** Converts a path into a stable absolute catalog key. */ private fun normalizeCatalogPath(path: VPath): String { val abs = path.expandHome().toAbsolute() @@ -356,9 +378,9 @@ object ProjectMngr { _projects.removeAll { normalizeCatalogPath(it.projectDir) == normalized } } - val currentActive = activeProject + val currentActive = _activeProject.value if (currentActive != null && normalizeCatalogPath(currentActive.projectDir) == normalized) { - activeProject = null + _activeProject.value = null } return true } @@ -462,13 +484,14 @@ object ProjectMngr { * Open a project in the UI and mark it as active. */ fun openProject(project: ProjectBase) { - logger.info("Loading project {}", project.name) addProjectToCatalog(project.projectDir, project.name) val previousActive = activeProject - val openMode = resolveOpenMode(project) ?: return + val openMode = resolveOpenMode(project) ?: run { + return + } + val wasDifferent = previousActive !== project - activeProject = project - val closeDashboard = CoreSettingValues.closeDashboardOnProjectOpen() && wasDifferent + val closeDashboard = CoreSettingValues.closeDashboardOnProjectOpen && wasDifferent try { ProjectWindows.openProject( @@ -477,7 +500,7 @@ object ProjectMngr { mode = openMode ) } catch (e: Exception) { - logger.debug("Failed to open project", e) + logger.error("ProjectWindows.openProject failed for '{}'", project.name, e) } notifyProjectOpened(project) @@ -531,17 +554,24 @@ object ProjectMngr { } private fun resolveOpenMode(project: ProjectBase): ProjectWindows.OpenMode? { - val existing = ProjectWindows.anyOpenWindow() ?: return ProjectWindows.OpenMode.NEW_WINDOW + val existing = ProjectWindows.anyOpenWindow() ?: run { + return ProjectWindows.OpenMode.NEW_WINDOW + } + val targetCanonical = project.path.toString().trim() if (existing.projectCanonicalPath() == targetCanonical) { return ProjectWindows.OpenMode.NEW_WINDOW } - return when (CoreSettingValues.projectOpenPromptMode()) { + val promptMode = CoreSettingValues.projectOpenPromptMode + return when (promptMode) { CoreSettingValues.ProjectOpenPromptMode.Always -> promptOpenMode(project) - CoreSettingValues.ProjectOpenPromptMode.Never -> when (CoreSettingValues.projectOpenDefaultTarget()) { - CoreSettingValues.ProjectOpenDefaultTarget.Current -> ProjectWindows.OpenMode.CURRENT_WINDOW - CoreSettingValues.ProjectOpenDefaultTarget.New -> ProjectWindows.OpenMode.NEW_WINDOW + CoreSettingValues.ProjectOpenPromptMode.Never -> { + val defaultTarget = CoreSettingValues.projectOpenDefaultTarget + when (defaultTarget) { + CoreSettingValues.ProjectOpenDefaultTarget.Current -> ProjectWindows.OpenMode.CURRENT_WINDOW + CoreSettingValues.ProjectOpenDefaultTarget.New -> ProjectWindows.OpenMode.NEW_WINDOW + } } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/StandardProjectSteps.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/StandardProjectSteps.kt new file mode 100644 index 0000000..0d28dc8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/StandardProjectSteps.kt @@ -0,0 +1,69 @@ +package io.github.tritium_launcher.launcher.core.project + +import io.github.tritium_launcher.launcher.core.project.templates.generation.GeneratorStepDescriptor +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object StandardProjectSteps { + + fun metadataStep(id: String, manifest: String): GeneratorStepDescriptor = GeneratorStepDescriptor( + id, + "createFile", + JsonObject(mapOf( + "path" to JsonPrimitive("trmodpack.json"), + "template" to JsonPrimitive(manifest), + "overwrite" to JsonPrimitive(true) + )), + affects = listOf("trmodpack.json") + ) + + fun exportRulesStep(id: String = "create-export-rules"): GeneratorStepDescriptor = GeneratorStepDescriptor( + id, + "createFile", + JsonObject(mapOf( + "path" to JsonPrimitive("trexportrules.json"), + "template" to JsonPrimitive("{}"), + "overwrite" to JsonPrimitive(false) + )), + affects = listOf("trexportrules.json") + ) + + fun placeholderSteps(): List { + fun placeholder(dir: String) = GeneratorStepDescriptor( + "placeholder-$dir", + "createFile", + JsonObject(mapOf( + "path" to JsonPrimitive("$dir/.placeholder"), + "template" to JsonPrimitive("# placeholder to keep folder in VCS"), + "overwrite" to JsonPrimitive(false) + )), + affects = listOf("$dir/**") + ) + return listOf("mods", "config", "defaultconfigs", "logs", "saves").map { placeholder(it) } + } + + fun iconStep(iconPath: String): GeneratorStepDescriptor? { + if (iconPath.isBlank()) return null + val normalizedFileUrl = if (iconPath.startsWith("file://")) iconPath else "file://$iconPath" + return GeneratorStepDescriptor( + "copy-icon", + "fetch", + JsonObject(mapOf( + "url" to JsonPrimitive(normalizedFileUrl), + "dest" to JsonPrimitive("icon.png") + )), + affects = listOf("icon.png") + ) + } + + fun gitignoreStep(id: String = "gitignore"): GeneratorStepDescriptor = GeneratorStepDescriptor( + id, + "createFile", + JsonObject(mapOf( + "path" to JsonPrimitive(".gitignore"), + "template" to JsonPrimitive(".tr/\ntr*.json\n"), + "overwrite" to JsonPrimitive(false) + )), + affects = listOf(".gitignore") + ) +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/ProjectTemplateExecutor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/ProjectTemplateExecutor.kt index d1fdde0..5d3aa2f 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/ProjectTemplateExecutor.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/ProjectTemplateExecutor.kt @@ -24,12 +24,14 @@ object ProjectTemplateExecutor { * @param projectRoot Root directory for generated files. * @param variables Variables available to steps. * @param steps Generator step descriptors to run in order. + * @param onStep Optional callback invoked before each step, with (stepId, index, total). */ suspend fun run( templateId: String, projectRoot: Path, variables: Map, - steps: List + steps: List, + onStep: (suspend (stepId: String, index: Int, total: Int) -> Unit)? = null ): TemplateExecutionResult = withContext(Dispatchers.IO) { val start = Instant.now() val ctx = GeneratorContext( @@ -40,7 +42,8 @@ object ProjectTemplateExecutor { snapshotDir = projectRoot.resolve(".tr/snapshots") ) val results = mutableListOf() - for (desc in steps) { + for ((i, desc) in steps.withIndex()) { + onStep?.invoke(desc.id, i, steps.size) val step = StepRegistry.create(desc) logger.info("Executing template step {} type={}", desc.id, desc.type) val res = step.execute(ctx) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/StepRegistry.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/StepRegistry.kt index 60c4af1..ffc4db6 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/StepRegistry.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/StepRegistry.kt @@ -18,6 +18,8 @@ object StepRegistry { register("createFile") { desc -> CreateFileStep.fromDescriptor(desc) } register("patchFile") { desc -> PatchFileStep.fromDescriptor(desc) } register("runCommand") { desc -> RunCommandStep.fromDescriptor(desc) } + register("importMods") { desc -> ImportModsStep.fromDescriptor(desc) } + register("importFiles") { desc -> ImportFilesStep.fromDescriptor(desc) } } /** diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/builtin/ImportSteps.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/builtin/ImportSteps.kt new file mode 100644 index 0000000..b59918e --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/project/templates/generation/builtin/ImportSteps.kt @@ -0,0 +1,165 @@ +package io.github.tritium_launcher.launcher.core.project.templates.generation.builtin + +import io.github.tritium_launcher.launcher.core.mod.InstalledMod +import io.github.tritium_launcher.launcher.core.mod.ModDatabase +import io.github.tritium_launcher.launcher.core.mod.ModSide +import io.github.tritium_launcher.launcher.core.mod.readModJarIcon +import io.github.tritium_launcher.launcher.core.project.templates.generation.GeneratorContext +import io.github.tritium_launcher.launcher.core.project.templates.generation.GeneratorStep +import io.github.tritium_launcher.launcher.core.project.templates.generation.GeneratorStepDescriptor +import io.github.tritium_launcher.launcher.core.project.templates.generation.StepExecutionResult +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Clock + +private val logger = logger("io.github.tritium_launcher.launcher.templates.generation.builtin.import") + +/** + * Copies selected mod jars into the project's mods/ directory and registers them + * in the ModDatabase. + */ +class ImportModsStep( + override val id: String, + override val type: String = "importMods", + private val sourceId: String, + private val mods: List +) : GeneratorStep { + data class ImportModEntry( + val jarPath: String, + val modId: String, + val displayName: String, + val fileName: String, + val side: String, + val sourceProjectId: String?, + val dependencyIds: List + ) + + companion object { + fun fromDescriptor(desc: GeneratorStepDescriptor): ImportModsStep { + val sourceId = desc.meta["sourceId"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalArgumentException("importMods step missing 'sourceId'") + val modsArray = desc.meta["mods"]?.jsonArray + ?: throw IllegalArgumentException("importMods step missing 'mods'") + val mods = modsArray.map { elem -> + val obj = elem.jsonObject + ImportModEntry( + jarPath = obj["jarPath"]?.jsonPrimitive?.contentOrNull ?: "", + modId = obj["modId"]?.jsonPrimitive?.contentOrNull ?: "", + displayName = obj["displayName"]?.jsonPrimitive?.contentOrNull ?: "", + fileName = obj["fileName"]?.jsonPrimitive?.contentOrNull ?: "", + side = obj["side"]?.jsonPrimitive?.contentOrNull ?: "BOTH", + sourceProjectId = obj["sourceProjectId"]?.jsonPrimitive?.contentOrNull, + dependencyIds = obj["dependencyIds"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + ) + } + return ImportModsStep(desc.id, sourceId = sourceId, mods = mods) + } + } + + override suspend fun execute(ctx: GeneratorContext): StepExecutionResult = withContext(Dispatchers.IO) { + val projectRoot = VPath.get(ctx.projectRoot.toString()) + try { + ModDatabase(projectRoot).use { db -> + for (mod in mods) { + val srcPath = VPath.get(mod.jarPath) + val destJar = projectRoot.resolve("mods/${mod.fileName}") + val bytes = srcPath.bytesOrNull() + if (bytes != null) { + destJar.parent().mkdirs() + destJar.writeBytesAtomic(bytes) + val hash = ModDatabase.sha1(bytes) + + val iconFile = run { + val iconBytes = readModJarIcon(destJar) + if (iconBytes != null) { + val f = ModDatabase.iconPathFor(mod.sourceProjectId ?: mod.modId) + f.writeBytesAtomic(iconBytes) + f + } else null + } + + val projectId = mod.sourceProjectId ?: mod.modId + val installedMod = InstalledMod( + projectId = projectId, + modId = mod.modId, + fileName = mod.fileName, + displayName = mod.displayName, + side = ModSide.valueOf(mod.side), + releaseType = "release", + source = sourceId, + versionId = mod.sourceProjectId ?: mod.modId, + versionLabel = "", + iconPath = iconFile?.toAbsolute()?.toString(), + projectUrl = null, + fileHash = hash, + installedAt = Clock.System.now(), + enabled = true, + excludedFromRelease = false, + requiresManualDownload = false, + dependencies = mod.dependencyIds + ) + db.install(installedMod) + if (mod.dependencyIds.isNotEmpty()) { + db.setDependencies(projectId, mod.dependencyIds) + } + } + } + } + StepExecutionResult(id, type, success = true, message = "Imported ${mods.size} mod(s)") + } catch (t: Throwable) { + logger.error("ImportModsStep failed", t) + StepExecutionResult(id, type, success = false, message = t.message) + } + } +} + +/** + * Copies checked files from the source instance into the project, + * preserving relative paths under the source instance's minecraft dir. + */ +class ImportFilesStep( + override val id: String, + override val type: String = "importFiles", + private val sourceMinecraftDir: String, + private val files: List +) : GeneratorStep { + companion object { + fun fromDescriptor(desc: GeneratorStepDescriptor): ImportFilesStep { + val sourceDir = desc.meta["sourceMinecraftDir"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalArgumentException("importFiles step missing 'sourceMinecraftDir'") + val filesArray = desc.meta["files"]?.jsonArray + ?: throw IllegalArgumentException("importFiles step missing 'files'") + val files = filesArray.mapNotNull { it.jsonPrimitive.contentOrNull } + return ImportFilesStep(desc.id, sourceMinecraftDir = sourceDir, files = files) + } + } + + override suspend fun execute(ctx: GeneratorContext): StepExecutionResult = withContext(Dispatchers.IO) { + val projectRoot = VPath.get(ctx.projectRoot.toString()) + val sourceRoot = VPath.get(sourceMinecraftDir) + try { + var copied = 0 + for (filePath in files) { + val srcFile = VPath.get(filePath) + val relative = sourceRoot.relativize(srcFile) + val dest = projectRoot.resolve(relative.toString()) + dest.parent().mkdirs() + val bytes = srcFile.bytesOrNull() + if (bytes != null) { + dest.writeBytesAtomic(bytes) + copied++ + } + } + StepExecutionResult(id, type, success = true, message = "Copied $copied file(s)") + } catch (t: Throwable) { + logger.error("ImportFilesStep failed", t) + StepExecutionResult(id, type, success = false, message = t.message) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/CurseForge.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/CurseForge.kt new file mode 100644 index 0000000..e516d2f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/CurseForge.kt @@ -0,0 +1,550 @@ +package io.github.tritium_launcher.launcher.core.source + +import io.github.tritium_launcher.launcher.core.source.curseforge.* +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.registry.Registrable +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.http.HttpStatusCode.Companion.TooManyRequests +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.serialization.kotlinx.json.* +import io.qt.gui.QPixmap +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlin.time.Duration.Companion.milliseconds + +class CurseForge : ModSource(), Registrable { + override val id: String = "curseforge" + override val displayName: String = "CurseForge" + override val icon: QPixmap = TIcons.CurseForge + override val webpage: String = "https://www.curseforge.com/" + override val order: Int = 1 + override val descriptionFormat: DescriptionFormat = DescriptionFormat.HTML + + // If you fork or modify this launcher, you may NOT use this key. + private val apiKey = "$2a$10\$P7GPNqahxcijWPXRG2HES.CCvjTAxfWEjQJ4WF42/CcVP8ksyIus." + private val apiUrl = "https://api.curseforge.com/v1/" + private var cachedCategories: List? = null + + private val json = Json { ignoreUnknownKeys = true } + private val client = HttpClient(CIO) { + install(ContentNegotiation) { json(json) } + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 30_000 + } + + defaultRequest { + url(apiUrl) + header("x-api-key", apiKey) + header("User-Agent", ClientIdentity.userAgent) + header("X-Client-Info", ClientIdentity.clientInfoHeader) + } + } + + private val logger = logger() + + override suspend fun getCategories(context: ModBrowserContext): List { + if (cachedCategories != null) return cachedCategories!! + try { + val response = retryOnThrottle { + client.get("categories") { + parameter("gameId", 432) + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge returned {}", response.status) + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge categories deserialization failed. Raw response (first 2KB): {}", raw.take(2048)) + throw e + } + } + + val result = response.data + .filterNot { it.isClass } + .filter { it.classId == 6 } + .map { ModCategory(it.id.toString(), it.name, iconUrl = it.iconUrl) } + cachedCategories = result + return result + } catch (e: ClientRequestException) { + when(e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge categories failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + throw e + } catch (e: Exception) { + logger.error("CurseForge categories deserialization failed: {}", e.message) + throw e + } + } + + override suspend fun search(context: ModBrowserContext, query: ModSearchQuery): ModSearchPage { + logger.info( + "CurseForge search: mc={} loader={} text='{}' offset={} limit={}", + context.minecraftVersion, context.modLoaderId, + query.text, query.offset, query.limit + ) + try { + val response = retryOnThrottle { + client.get("mods/search") { + parameter("gameId", 432) + parameter("classId", 6) // Mods only + parameter("sortField", 2) // Sort by Popularity + parameter("sortOrder", "desc") + parameter("searchFilter", query.text) + parameter("index", query.offset) + parameter("pageSize", query.limit) + context.minecraftVersion?.let { parameter("gameVersion", it) } + context.modLoaderId?.let { curseLoaderType(it) }?.let { parameter("modLoaderType", it) } + query.includedCategories.firstOrNull()?.let { parameter("classId", it) } + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge returned {}", response.status) + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge search deserialization failed. Raw response (first 2KB): {}", raw.take(2048)) + throw e + } + } + + return ModSearchPage( + results = response.data.map { mod -> + ModSearchResult( + id = mod.id.toString(), + title = mod.name, + summary = mod.summary, + author = mod.authors.firstOrNull()?.name, + downloads = mod.downloadCount, + categories = mod.categories.map { it.name }, + versions = mod.latestFiles.flatMap { it.gameVersions }, + iconUrl = mod.logo?.url, + slug = mod.slug, + ) + }, + total = response.pagination.totalCount + ) + } catch (e: ClientRequestException) { + when(e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge search failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + throw e + } catch (e: Exception) { + logger.error("CurseForge search deserialization failed: {}", e.message) + throw e + } + } + + override suspend fun details(context: ModBrowserContext, projectId: String): ModDetails { + try { + val response = retryOnThrottle { + client.get("mods/$projectId") + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge returned {}", response.status) + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge mod deserialization failed. Raw response (first 2KB): {}", raw.take(2048)) + throw e + } + } + + val mod = response.data + val description = runCatching { + retryOnThrottle { + client.get("mods/$projectId/description") + }.body().data + }.getOrNull() ?: mod.description ?: mod.summary + + return ModDetails( + id = mod.id.toString(), + title = mod.name, + summary = mod.summary, + description = description, + author = mod.authors.firstOrNull()?.name, + downloads = mod.downloadCount, + categories = mod.categories.map { it.name }, + website = mod.links?.websiteUrl ?: "$webpage/mc-mods/${mod.name}", + latestVersion = mod.latestFiles.firstOrNull()?.gameVersions?.lastOrNull(), + iconUrl = mod.logo?.url + ) + } catch (e: ClientRequestException) { + when(e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge mod detail failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + throw e + } catch (e: Exception) { + logger.error("CurseForge mod deserialization failed: {}", e.message) + throw e + } + } + + override suspend fun versions(context: ModBrowserContext, projectId: String): List { + logger.debug("CurseForge versions: projectId={} mc={} loader={}", projectId, context.minecraftVersion, context.modLoaderId) + try { + val allFiles = mutableListOf() + var index = 0 + val pageSize = 50 + val maxPages = 4 + val loaderType = context.modLoaderId?.let { curseLoaderType(it) } + + run pagination@{ + repeat(maxPages) { + val page = retryOnThrottle { + client.get("mods/$projectId/files") { + parameter("pageSize", pageSize) + parameter("index", index) + context.minecraftVersion?.let { parameter("gameVersion", it) } + loaderType?.let { parameter("modLoaderType", it) } + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge returned {}", response.status) + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge files deserialization failed. Raw response (first 2KB): {}", raw.take(2048)) + throw e + } + } + + allFiles.addAll(page.data) + + val total = page.pagination.totalCount + index += pageSize + if (index >= total || page.data.isEmpty()) return@pagination + } + logger.warn("CurseForge versions hit page limit for projectId={}, total files may be truncated", projectId) + } + + return allFiles + .filter { it.isAvailable } + .filter { file -> + (context.minecraftVersion == null || file.gameVersions.any { it == context.minecraftVersion }) && + (context.modLoaderId == null || file.modLoaders == null || file.modLoaders.any { it.id.lowercase().startsWith(context.modLoaderId.lowercase()) }) + } + .sortedByDescending { it.fileDate } + .map { file -> + val loaders = file.modLoaders?.map { it.id } + ?: file.gameVersions.filter { gv -> + knownLoaderIdentifiers.any { gv.contains(it, ignoreCase = true) } + } + val sha1Hash = file.hashes?.firstOrNull { it.algo == 1 }?.value + ModVersionOption( + id = file.id.toString(), + label = file.displayName.ifBlank { file.fileName }, + fileName = file.fileName, + fileHash = sha1Hash, + gameVersions = file.gameVersions, + loaders = loaders, + featured = false, + downloads = file.downloadCount, + releaseType = when (file.releaseType) { + 1 -> ReleaseType.RELEASE + 2 -> ReleaseType.BETA + 3 -> ReleaseType.ALPHA + else -> null + } + ) + } + } catch (e: ClientRequestException) { + when (e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge versions failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + throw e + } catch (e: Exception) { + logger.error("CurseForge versions failed: {}", e.message) + throw e + } + } + + override suspend fun resolveProjectInfoByFingerprint(fingerprint: Long): HashProjectInfo? { + return try { + val response = retryOnThrottle { + client.post("fingerprints/432") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { put("fingerprints", buildJsonArray { add(JsonPrimitive(fingerprint)) }) }) + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge fingerprint lookup returned {}", response.status) + return@let null + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge fingerprint deserialization failed. Raw response: {}", raw, e) + null + } + } ?: return null + + val match = (response.data.exactMatches + response.data.partialMatches).firstOrNull() ?: return null + val modId = match.file.modId ?: return null + HashProjectInfo(modId.toString(), match.file.displayName) + } catch (e: ClientRequestException) { + when (e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge fingerprint lookup failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + null + } catch (e: Exception) { + logger.error("CurseForge fingerprint lookup failed: {}", e.message) + null + } + } + + override suspend fun resolveProjectInfoByJarContents(bytes: ByteArray): HashProjectInfo? { + val fingerprint = curseFingerprint(bytes) + return resolveProjectInfoByFingerprint(fingerprint) + } + + override fun computeFileFingerprint(bytes: ByteArray): Long = curseFingerprint(bytes) + + override suspend fun resolveProjectInfosByFingerprints(fingerprints: List): Map { + if (fingerprints.isEmpty()) return emptyMap() + return try { + val response = retryOnThrottle { + client.post("fingerprints/432") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("fingerprints", buildJsonArray { + fingerprints.forEach { add(JsonPrimitive(it)) } + }) + }) + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge batch fingerprint lookup returned {}", response.status) + return@let null + } + response.body() + } ?: return emptyMap() + + (response.data.exactMatches + response.data.partialMatches).mapNotNull { match -> + val modId = match.file.modId ?: return@mapNotNull null + match.id to HashProjectInfo(modId.toString(), match.file.displayName) + }.toMap() + } catch (e: Exception) { + logger.error("CurseForge batch fingerprint lookup failed: {}", e.message) + emptyMap() + } + } + + private fun curseLoaderType(id: String): Int? { + return when (id.lowercase()) { + "forge" -> 1 + "fabric" -> 4 + "quilt" -> 5 + "neoforge" -> 6 + else -> null + } + } + + /** + * Resolves mod names and icon URLs for a list of CurseForge project IDs. + * + * @param projectIds CurseForge project IDs to look up. + * @return Map of project ID to [CurseModBrief] with the mod's name and icon URL. + */ + suspend fun batchModDetails(projectIds: List): Map { + if (projectIds.isEmpty()) return emptyMap() + return try { + val response = retryOnThrottle { + client.post("mods") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("modIds", buildJsonArray { + projectIds.forEach { add(JsonPrimitive(it)) } + }) + }) + } + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge batch mod lookup returned {}: {}", response.status, response.bodyAsText().take(500)) + return@let null + } + response.body() + } ?: return emptyMap() + + response.data.associate { mod -> + mod.id.toLong() to CurseModBrief(mod.name, mod.logo?.url) + } + } catch (e: Exception) { + logger.error("CurseForge batch mod lookup failed: {}", e.message) + emptyMap() + } + } + + override suspend fun resolveInstall( + context: ModBrowserContext, + projectId: String, + versionId: String + ): ModInstallPlan { + logger.info("CurseForge install resolve: projectId={} fileId={}", projectId, versionId) + try { + val file = retryOnThrottle { + client.get("mods/$projectId/files/$versionId") + }.let { response -> + if (!response.status.isSuccess()) { + logger.error("CurseForge returned {}", response.status) + } + try { + response.body() + } catch (e: Exception) { + val raw = response.bodyAsText() + logger.error("CurseForge file deserialization failed. Raw response (first 2KB): {}", raw.take(2048)) + throw e + } + }.data + + val hash = file.hashes?.firstOrNull { it.algo == 1 }?.value + + return ModInstallPlan( + projectId = projectId, + versionId = file.id.toString(), + versionLabel = file.displayName.ifBlank { file.fileName }, + fileName = file.fileName, + downloadUrl = file.downloadUrl, + releaseType = when (file.releaseType) { + 1 -> ReleaseType.RELEASE + 2 -> ReleaseType.BETA + 3 -> ReleaseType.ALPHA + else -> null + }, + fileHash = hash, + ) + } catch (e: ClientRequestException) { + when (e.response.status) { + Unauthorized, Forbidden -> + logger.error("CurseForge API Key Rejected ({}): {}", e.response.status, e.response.bodyAsText()) + TooManyRequests -> { + logger.warn("CurseForge rate limited") + throw e + } + else -> logger.error("CurseForge install resolve failed ({}): {}", e.response.status, e.response.bodyAsText()) + } + throw e + } catch (e: Exception) { + logger.error("CurseForge install resolve failed: {}", e.message) + throw e + } + } + + private suspend fun retryOnThrottle(block: suspend () -> T): T { + repeat(3) { attempt -> + try { + return block() + } catch (e: ClientRequestException) { + if (e.response.status.value != 429 || attempt == 2) throw e + val retryAfter = e.response.headers["Retry-After"]?.toLongOrNull() + ?: (10L * (1 shl attempt)) + delay((retryAfter * 1000).milliseconds) + } + } + error("unreachable") + } + + companion object { + private val knownLoaderIdentifiers = setOf( + "Forge", "NeoForge", "Fabric", "Quilt", "Fabric like", "Rift" + ) + + private const val MURMUR_MULTIPLEX = 1540483477 + + fun curseFingerprint(bytes: ByteArray): Long { + var normLen = 0 + for (b in bytes) { + if (b !in whitespaceSet) normLen++ + } + + var h = 1 xor normLen + var k = 0 + var offset = 0 + + for (b in bytes) { + if (b in whitespaceSet) continue + k = k or ((b.toInt() and 0xFF) shl offset) + offset += 8 + if (offset == 32) { + var k2 = k * MURMUR_MULTIPLEX + k2 = (k2 xor (k2 ushr 24)) * MURMUR_MULTIPLEX + h = h * MURMUR_MULTIPLEX xor k2 + k = 0 + offset = 0 + } + } + + if (offset > 0) { + h = (h xor k) * MURMUR_MULTIPLEX + } + + h = (h xor (h ushr 13)) * MURMUR_MULTIPLEX + h = h xor (h ushr 15) + + return h.toLong() and 0xFFFFFFFFL + } + + private val whitespaceSet = setOf( + 0x09, 0x0A, 0x0D, 0x20 + ) + } +} + +data class CurseModBrief( + val name: String, + val iconUrl: String? +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ModSource.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ModSource.kt new file mode 100644 index 0000000..4dac071 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ModSource.kt @@ -0,0 +1,192 @@ +package io.github.tritium_launcher.launcher.core.source + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.registry.Registrable +import io.qt.gui.QPixmap + +enum class DescriptionFormat { MARKDOWN, HTML } + +/** + * Mod Sources are web APIs that provide Mods and other content to users. Examples are [CurseForge] and [Modrinth]. + */ +abstract class ModSource: Registrable { + abstract override val id: String + abstract val displayName: String + abstract val icon: QPixmap + abstract val webpage: String + abstract val order: Int + open val descriptionFormat: DescriptionFormat = DescriptionFormat.MARKDOWN + + open fun support(context: ModBrowserContext): ModSourceSupport = ModSourceSupport() + + open suspend fun getCategories(context: ModBrowserContext): List = emptyList() + + abstract suspend fun search(context: ModBrowserContext, query: ModSearchQuery): ModSearchPage + + abstract suspend fun details(context: ModBrowserContext, projectId: String): ModDetails + + abstract suspend fun versions(context: ModBrowserContext, projectId: String): List + + abstract suspend fun resolveInstall(context: ModBrowserContext, projectId: String, versionId: String): ModInstallPlan + + /** + * Optionally resolve a project ID from a file hash. + * Sources that support hash-based lookup should override this. + */ + open suspend fun resolveProjectInfoByHash(hash: String): HashProjectInfo? = null + + open suspend fun resolveProjectInfoByFingerprint(fingerprint: Long): HashProjectInfo? = null + + /** + * Compute a file fingerprint from the raw jar bytes. + * Sources that use file fingerprint algorithms + * should override this to compute their fingerprint. + * Return `null` if not supported. + */ + open fun computeFileFingerprint(bytes: ByteArray): Long? = null + + /** + * Batch resolve project info for multiple fingerprints. + * Sources that support batch fingerprint lookup should override this. + * Returns a map of fingerprint -> project info; unmatched fingerprints are absent. + */ + open suspend fun resolveProjectInfosByFingerprints(fingerprints: List): Map = emptyMap() + + /** + * Optionally resolve a project from the raw jar file contents. + * Sources that use file fingerprint algorithms + * should override this to compute their fingerprint. + */ + open suspend fun resolveProjectInfoByJarContents(bytes: ByteArray): HashProjectInfo? = null + + override fun toString(): String = id +} + +data class ModBrowserContext( + val project: ProjectBase, + val minecraftVersion: String?, + val modLoaderId: String? +) + +data class ModSourceSupport( + val available: Boolean = true, + val message: String? = null +) + +data class ModCategory( + val id: String, + val displayName: String, + val iconUrl: String? = null +) + +data class ModSearchQuery( + val text: String, + val includedCategories: Set = emptySet(), + val excludedCategories: Set = emptySet(), + val offset: Int = 0, + val limit: Int = 25 +) + +data class ModSearchResult( + val id: String, + val title: String, + val summary: String, + val author: String? = null, + val downloads: Long? = null, + val categories: List = emptyList(), + val versions: List = emptyList(), + val iconUrl: String? = null, + val slug: String? = null, +) + +data class ModSearchPage( + val results: List, + val total: Int +) + +data class ModDetails( + val id: String, + val title: String, + val summary: String, + val description: String, + val author: String? = null, + val downloads: Long? = null, + val categories: List = emptyList(), + val website: String? = null, + val latestVersion: String? = null, + val iconUrl: String? = null +) + +data class ModVersionOption( + val id: String, + val label: String, + val fileName: String? = null, + val fileHash: String? = null, + val gameVersions: List = emptyList(), + val loaders: List = emptyList(), + val featured: Boolean = false, + val downloads: Long? = null, + val dependencies: List = emptyList(), + val releaseType: ReleaseType? = null, +) + +data class ModDependencyRef( + val projectId: String, + val required: Boolean = true, + val incompatible: Boolean = false +) + +data class ModInstallPlan( + val projectId: String, + val versionId: String, + val versionLabel: String, + val fileName: String, + val downloadUrl: String?, + val releaseType: ReleaseType? = null, + val fileHash: String? = null, +) + +data class HashProjectInfo( + val projectId: String, + val projectTitle: String, +) + +data class ResolvedFile( + val fileName: String, + val downloadUrl: String, + val fileHash: String, +) + +interface HashFallbackProvider { + val priority: Int + suspend fun resolveByHash(context: ModBrowserContext, hash: String): ResolvedFile? +} + +data class ResolvedInstall( + val plan: ModInstallPlan, + val downloadUrl: String?, + val fileName: String, + val requiresManualDownload: Boolean, +) + +suspend fun resolveInstallDownload( + context: ModBrowserContext, + source: ModSource, + projectId: String, + versionId: String, + fallbacks: List = emptyList(), +): ResolvedInstall { + val plan = source.resolveInstall(context, projectId, versionId) + if (plan.downloadUrl != null) { + return ResolvedInstall(plan, plan.downloadUrl, plan.fileName, requiresManualDownload = false) + } + if (plan.fileHash != null) { + for (fallback in fallbacks.sortedBy { it.priority }) { + val resolved = runCatching { fallback.resolveByHash(context, plan.fileHash) }.getOrNull() + if (resolved != null) { + return ResolvedInstall(plan, resolved.downloadUrl, resolved.fileName, requiresManualDownload = false) + } + } + } + return ResolvedInstall(plan, null, plan.fileName, requiresManualDownload = true) +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/Modrinth.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/Modrinth.kt new file mode 100644 index 0000000..7f46317 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/Modrinth.kt @@ -0,0 +1,294 @@ +package io.github.tritium_launcher.launcher.core.source + +import io.github.tritium_launcher.launcher.core.source.modrinth.api.* +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.registry.Registrable +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.qt.gui.QPixmap +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlin.time.Duration.Companion.milliseconds + +class Modrinth : ModSource(), HashFallbackProvider, Registrable { + override val id: String = "modrinth" + override val displayName: String = "Modrinth" + override val icon: QPixmap = TIcons.Modrinth + override val webpage: String = "https://modrinth.com/" + override val order: Int = 2 + + private val json = Json { ignoreUnknownKeys = true } + private val logger = logger() + private var cachedCategories: List? = null + private val client = HttpClient(CIO) { + install(ContentNegotiation) { json(json) } + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 30_000 + } + defaultRequest { + url("https://api.modrinth.com/v2/") + header("User-Agent", ClientIdentity.userAgent) + header("X-Client-Info", ClientIdentity.clientInfoHeader) + accept(ContentType.Application.Json) + } + } + + override suspend fun getCategories(context: ModBrowserContext): List { + if (cachedCategories != null) return cachedCategories!! + val categories = retryOnThrottle { + client.get("tag/category").body>() + } + val knownLoaderNames = loaderCategories.map { it.name.lowercase() }.toSet() + val result = categories + .filter { it.projectType == "mod" } + .filterNot { it.name.lowercase() in knownLoaderNames } + .map { category -> + val iconUrl = null + ModCategory( + id = category.name.lowercase().replace('_', '-'), + displayName = category.name.lowercase() + .split('_') + .joinToString(" ") { token -> token.replaceFirstChar(Char::uppercase) }, + iconUrl = iconUrl + ) + } + cachedCategories = result + return result + } + + private suspend fun retryOnThrottle(block: suspend () -> T): T { + repeat(3) { attempt -> + try { + return block() + } catch (e: ClientRequestException) { + if (e.response.status.value != 429 || attempt == 2) throw e + val retryAfter = e.response.headers["Retry-After"]?.toLongOrNull() + ?: (10L * (1 shl attempt)) + kotlinx.coroutines.delay((retryAfter * 1000).milliseconds) + } + } + error("unreachable") + } + + override suspend fun search(context: ModBrowserContext, query: ModSearchQuery): ModSearchPage { + logger.info( + "Modrinth search: mc={} loader={} text='{}' include={} exclude={} offset={} limit={}", + context.minecraftVersion, + context.modLoaderId, + query.text, + query.includedCategories, + query.excludedCategories, + query.offset, + query.limit + ) + val response = retryOnThrottle { + client.get("search") { + parameter("query", query.text) + parameter("facets", facetsFor(context, query.includedCategories)) + parameter("index", Search.Sorting.RELEVANCE.name.lowercase()) + parameter("offset", query.offset) + parameter("limit", query.limit) + }.body() + } + + return ModSearchPage( + results = response.hits + .filterNot(::isPluginLike) + .filterNot { hit -> + val categoryIds = (hit.categories + hit.display_categories).map(::normalizeCategoryId).toSet() + query.excludedCategories.any { it in categoryIds } + } + .map { hit -> + ModSearchResult( + id = hit.project_id, + title = hit.title, + summary = hit.description, + author = hit.author, + downloads = hit.downloads.toLong(), + categories = filterCategories(hit.display_categories.ifEmpty { hit.categories }), + versions = hit.versions, + iconUrl = hit.icon_url + ) + }, + total = response.total_hits + ) + } + + override suspend fun details(context: ModBrowserContext, projectId: String): ModDetails { + logger.debug("Modrinth details: projectId={}", projectId) + val project = retryOnThrottle { client.get("project/$projectId").body() } + return ModDetails( + id = project.id, + title = project.title, + summary = project.summary, + description = project.description, + downloads = project.downloads.toLong(), + categories = filterCategories(project.categories + project.additionalCategories), + website = project.sourceUrl ?: project.issuesUrl ?: "$webpage/mod/${project.slug}", + iconUrl = project.iconUrl + ) + } + + override suspend fun versions(context: ModBrowserContext, projectId: String): List { + logger.debug("Modrinth versions: projectId={} mc={} loader={}", projectId, context.minecraftVersion, context.modLoaderId) + return fetchVersions(context, projectId).map { version -> + ModVersionOption( + id = version.id, + label = version.name.ifBlank { version.version_number }, + gameVersions = version.game_versions, + loaders = version.loaders, + featured = version.featured, + downloads = version.downloads.toLong(), + dependencies = version.dependencies.mapNotNull { dependency -> + dependency.project_id?.let { projectId -> + when (dependency.dependency_type) { + io.github.tritium_launcher.launcher.core.source.modrinth.api.DependencyType.REQUIRED -> + ModDependencyRef(projectId = projectId, required = true, incompatible = false) + io.github.tritium_launcher.launcher.core.source.modrinth.api.DependencyType.INCOMPATIBLE -> + ModDependencyRef(projectId = projectId, required = false, incompatible = true) + else -> null + } + } + }.distinctBy { "${it.projectId}:${it.required}:${it.incompatible}" }, + releaseType = version.version_type, + ) + } + } + + override suspend fun resolveInstall(context: ModBrowserContext, projectId: String, versionId: String): ModInstallPlan { + val version = fetchVersions(context, projectId).firstOrNull { it.id == versionId } + ?: error("Selected version '$versionId' is no longer available") + + val file = version.files.firstOrNull { it.primary } ?: version.files.firstOrNull() + ?: error("No downloadable file available for ${version.name}") + + logger.info("Modrinth install resolve: projectId={} versionId={} file={}", projectId, versionId, file.filename) + return ModInstallPlan( + projectId = projectId, + versionId = version.id, + versionLabel = version.name.ifBlank { version.version_number }, + fileName = file.filename, + downloadUrl = file.url, + releaseType = version.version_type, + fileHash = file.hashes.sha1, + ) + } + + override val priority: Int get() = 10 + + override suspend fun resolveByHash(context: ModBrowserContext, hash: String): ResolvedFile? { + val version = runCatching { + retryOnThrottle { + client.get("version_file/$hash") { + parameter("algorithm", "sha1") + }.body() + } + }.getOrNull() ?: return null + val file = version.files.firstOrNull { it.primary } ?: version.files.firstOrNull() ?: return null + return ResolvedFile( + fileName = file.filename, + downloadUrl = file.url, + fileHash = file.hashes.sha1, + ) + } + + override suspend fun resolveProjectInfoByHash(hash: String): HashProjectInfo? { + return try { + val version = retryOnThrottle { + client.get("version_file/$hash") { + parameter("algorithm", "sha1") + }.body() + } + val project = retryOnThrottle { + client.get("project/${version.project_id}").body() + } + HashProjectInfo(projectId = project.id, projectTitle = project.title) + } catch (_: Exception) { null } + } + + private suspend fun fetchVersions(context: ModBrowserContext, projectId: String): List { + val versions = retryOnThrottle { + client.get("project/$projectId/version") { + context.minecraftVersion?.takeIf { it.isNotBlank() }?.let { version -> + parameter("game_versions", json.encodeToString(ListSerializer(String.serializer()), listOf(version))) + } + context.modLoaderId?.takeIf { it.isNotBlank() }?.let { loader -> + parameter("loaders", json.encodeToString(ListSerializer(String.serializer()), listOf(loader))) + } + }.body>() + } + return versions + .sortedWith(compareByDescending { it.featured }.thenByDescending { releaseRank(it.version_type) }) + } + + private fun facetsFor(context: ModBrowserContext, categories: Set): String { + val groups = mutableListOf() + groups += buildJsonArray { add(JsonPrimitive("project_type:mod")) } + context.minecraftVersion?.takeIf { it.isNotBlank() }?.let { version -> + groups += buildJsonArray { add(JsonPrimitive("versions:$version")) } + } + context.modLoaderId?.takeIf { it.isNotBlank() }?.let { loader -> + groups += buildJsonArray { add(JsonPrimitive("categories:$loader")) } + } + if (categories.isNotEmpty()) { + groups += buildJsonArray { + categories.sorted().forEach { category -> + add(JsonPrimitive("categories:$category")) + } + } + } + return Json.encodeToString(ListSerializer(JsonArray.serializer()), groups) + } + + private fun releaseRank(type: ReleaseType): Int = when (type) { + ReleaseType.RELEASE -> 3 + ReleaseType.BETA -> 2 + ReleaseType.ALPHA -> 1 + } + + private fun filterCategories(values: List): List = + values.filterNot { normalizeCategoryId(it) in excludedCategoryIds } + + private fun isPluginLike(hit: Search.Hit): Boolean { + if (!hit.project_type.equals("mod", ignoreCase = true)) return true + val allCategories = hit.categories + hit.display_categories + return allCategories.any { normalizeCategoryId(it) in pluginCategoryIds } + } + + private fun normalizeCategoryId(value: String): String = value + .trim() + .replace('_', '-') + .lowercase() + + private companion object { + val loaderCategories = setOf( + ModrinthCategories.FABRIC, + ModrinthCategories.NEOFORGE, + ModrinthCategories.QUILT, + ModrinthCategories.LITELOADER, + ModrinthCategories.RISUGAMIS, + ModrinthCategories.RIFT, + ModrinthCategories.FORGE + ) + val loaderCategoryIds = loaderCategories.map { it.name }.toSet() + val pluginCategoryIds = setOf( + "plugin", "plugins", "bukkit", "spigot", "paper", "purpur", "folia", "velocity", "waterfall", + "bungeecord", "sponge", "proxy" + ) + val excludedCategoryIds = loaderCategoryIds.map { it.lowercase().replace('_', '-') }.toSet() + pluginCategoryIds + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ReleaseType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ReleaseType.kt new file mode 100644 index 0000000..bb06a22 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/ReleaseType.kt @@ -0,0 +1,16 @@ +package io.github.tritium_launcher.launcher.core.source + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class ReleaseType { + @SerialName("alpha") + ALPHA, + + @SerialName("beta") + BETA, + + @SerialName("release") + RELEASE +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/curseforge/SearchResponse.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/curseforge/SearchResponse.kt new file mode 100644 index 0000000..d316af1 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/curseforge/SearchResponse.kt @@ -0,0 +1,145 @@ +package io.github.tritium_launcher.launcher.core.source.curseforge + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResponse( + val data: List, + val pagination: Pagination +) + +@Serializable +data class Pagination( + val index: Int, + val pageSize: Int, + val totalCount: Int +) + +@Serializable +data class CurseModSummary( + val id: Int, + val name: String, + val summary: String, + val slug: String, + val authors: List, + @SerialName("download_count") val downloadCount: Long = 0, + val categories: List, + @SerialName("latest_files") val latestFiles: List = emptyList(), + val logo: CurseImage? = null +) + +@Serializable +data class CurseAuthor(val name: String) + +@Serializable +data class CurseCategory(val name: String, val id: Int) + +@Serializable +data class CurseFileHash( + val value: String, + val algo: Int +) + +@Serializable +data class CurseFileInfo( + val id: Int, + @SerialName("mod_id") val modId: Int? = null, + @SerialName("game_id") val gameId: Int? = null, + val gameVersions: List, + val modLoaders: List? = null, + val fileName: String = "", + val displayName: String = "", + val downloadUrl: String? = null, + val description: String? = null, + @SerialName("download_count") val downloadCount: Long = 0, + val fileDate: String? = null, + val hashes: List? = null, + val releaseType: Int = 1, + val isAvailable: Boolean = true +) + +@Serializable +data class CurseImage(val url: String) + +@Serializable +data class CurseModLoader( + val id: String, + val primary: Boolean = false +) + +@Serializable +data class CurseCategoryResponse( + val data: List +) + +@Serializable +data class CurseCategoryItem( + val id: Int, + val name: String, + val slug: String, + val classId: Int? = null, + val parentCategoryId: Int? = null, + val isClass: Boolean = false, + val iconUrl: String? = null +) + +@Serializable +data class CurseModDetailResponse( + val data: CurseModDetail +) + +@Serializable +data class CurseModDetail( + val id: Int, + val name: String, + val summary: String, + val description: String? = null, + val authors: List, + val downloadCount: Long = 0, + val categories: List, + val links: CurseLinks? = null, + val latestFiles: List = emptyList(), + val logo: CurseImage? = null +) + +@Serializable +data class CurseLinks(val websiteUrl: String? = null) + +@Serializable +data class CurseFilesResponse( + val data: List, + val pagination: Pagination +) + +@Serializable +data class CurseFileResponse( + val data: CurseFileInfo +) + +@Serializable +data class CurseDescriptionResponse( + val data: String +) + +@Serializable +data class CurseFingerprintResponse( + val data: CurseFingerprintData +) + +@Serializable +data class CurseFingerprintData( + @SerialName("exact_matches") val exactMatches: List = emptyList(), + @SerialName("partial_matches") val partialMatches: List = emptyList(), +) + +@Serializable +data class CurseFingerprintMatch( + val id: Long, + val file: CurseFileInfo +) + +@Serializable +data class CurseModListResponse( + val data: List +) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Dependency.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Dependency.kt similarity index 74% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Dependency.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Dependency.kt index 680d3a7..3dd483f 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/Dependency.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Dependency.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DependencyType.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DependencyType.kt new file mode 100644 index 0000000..71ef514 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DependencyType.kt @@ -0,0 +1,19 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class DependencyType { + @SerialName("required") + REQUIRED, + + @SerialName("optional") + OPTIONAL, + + @SerialName("incompatible") + INCOMPATIBLE, + + @SerialName("embedded") + EMBEDDED +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DonationUrl.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DonationUrl.kt similarity index 68% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DonationUrl.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DonationUrl.kt index 4b92980..f24d384 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/DonationUrl.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/DonationUrl.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModRequests.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModRequests.kt similarity index 95% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModRequests.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModRequests.kt index 7975a23..012a628 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModRequests.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModRequests.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthCategories.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthCategories.kt similarity index 85% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthCategories.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthCategories.kt index 13bdbed..8ed59f7 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthCategories.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthCategories.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api enum class ModrinthCategories { ADVENTURE, diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthProject.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthProject.kt new file mode 100644 index 0000000..4473159 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthProject.kt @@ -0,0 +1,19 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ModrinthProject( + val id: String, + val slug: String, + val title: String, + @SerialName("description") val summary: String, + @SerialName("body") val description: String = "", + val categories: List = emptyList(), + @SerialName("additional_categories") val additionalCategories: List = emptyList(), + val downloads: Int = 0, + @SerialName("icon_url") val iconUrl: String? = null, + @SerialName("issues_url") val issuesUrl: String? = null, + @SerialName("source_url") val sourceUrl: String? = null +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthTagCategory.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthTagCategory.kt new file mode 100644 index 0000000..0f5061c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthTagCategory.kt @@ -0,0 +1,12 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ModrinthTagCategory( + val icon: String? = null, + val name: String, + @SerialName("project_type") val projectType: String, + val header: String +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthVersion.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthVersion.kt similarity index 91% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthVersion.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthVersion.kt index 0105176..440e7a8 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ModrinthVersion.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ModrinthVersion.kt @@ -1,6 +1,6 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api -import io.github.tritium_launcher.launcher.core.modpack.ReleaseType +import io.github.tritium_launcher.launcher.core.source.ReleaseType import kotlinx.serialization.Serializable /** diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ProjectRequests.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ProjectRequests.kt similarity index 98% rename from src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ProjectRequests.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ProjectRequests.kt index c5f821f..6c07d16 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/core/modpack/modrinth/api/ProjectRequests.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/ProjectRequests.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.core.modpack.modrinth.api +package io.github.tritium_launcher.launcher.core.source.modrinth.api import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/RequestedStatus.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/RequestedStatus.kt new file mode 100644 index 0000000..a18d50f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/RequestedStatus.kt @@ -0,0 +1,19 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class RequestedStatus { + @SerialName("listed") + LISTED, + + @SerialName("archived") + ARCHIVED, + + @SerialName("draft") + DRAFT, + + @SerialName("unlisted") + UNLISTED +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Side.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Side.kt new file mode 100644 index 0000000..e8348c9 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Side.kt @@ -0,0 +1,19 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class Side { + @SerialName("required") + REQUIRED, + + @SerialName("optional") + OPTIONAL, + + @SerialName("unsupported") + UNSUPPORTED, + + @SerialName("unknown") + UNKNOWN +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Status.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Status.kt new file mode 100644 index 0000000..2d908e8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/core/source/modrinth/api/Status.kt @@ -0,0 +1,25 @@ +package io.github.tritium_launcher.launcher.core.source.modrinth.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class Status { + @SerialName("listed") + LISTED, + + @SerialName("archived") + ARCHIVED, + + @SerialName("draft") + DRAFT, + + @SerialName("unlisted") + UNLISTED, + + @SerialName("scheduled") + SCHEDULED, + + @SerialName("unknown") + UNKNOWN; +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtDispatcher.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtDispatcher.kt new file mode 100644 index 0000000..4e18d9a --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtDispatcher.kt @@ -0,0 +1,63 @@ +package io.github.tritium_launcher.launcher.coroutines + +import io.qt.core.QCoreApplication +import io.qt.core.QMetaObject +import io.qt.core.QThread +import io.qt.core.Qt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlin.coroutines.CoroutineContext + +/** + * A Coroutine Dispatcher that schedules tasks on the Qt Event Loop. + * Provides integration with [Dispatchers.Main] via [QtMainDispatcherFactory]. + */ +internal class QtDispatcher : MainCoroutineDispatcher() { + override val immediate: MainCoroutineDispatcher = Immediate + + override fun dispatch(context: CoroutineContext, block: Runnable) { + val app = QCoreApplication.instance() + if (app == null) { + block.run() + } else { + val appThread = app.thread() + if (appThread != null && appThread == QThread.currentThread()) { + block.run() + } else { + QMetaObject.invokeMethod( + app, + QMetaObject.Slot0 { block.run() }, + Qt.ConnectionType.QueuedConnection + ) + } + } + } + + override fun isDispatchNeeded(context: CoroutineContext): Boolean { + val app = QCoreApplication.instance() ?: return false + val appThread = app.thread() ?: return false + return appThread != QThread.currentThread() + } + + private object Immediate : MainCoroutineDispatcher() { + override val immediate: MainCoroutineDispatcher get() = this + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = + (QCoreApplication.instance()?.thread() ?: return false) != QThread.currentThread() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + val app = QCoreApplication.instance() + val appThread = app?.thread() + if (app == null || appThread == null || appThread == QThread.currentThread()) { + block.run() + } else { + QMetaObject.invokeMethod( + app, + QMetaObject.Slot0 { block.run() }, + Qt.ConnectionType.QueuedConnection + ) + } + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtMainDispatcherFactory.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtMainDispatcherFactory.kt new file mode 100644 index 0000000..8099f9a --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/coroutines/QtMainDispatcherFactory.kt @@ -0,0 +1,18 @@ +package io.github.tritium_launcher.launcher.coroutines + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.internal.MainDispatcherFactory + +/** + * Factory for creating [QtDispatcher] for [kotlinx.coroutines.Dispatchers.Main]. + */ +@InternalCoroutinesApi +class QtMainDispatcherFactory : MainDispatcherFactory { + override val loadPriority: Int + get() = 0 + + override fun createDispatcher(allFactories: List): MainCoroutineDispatcher { + return QtDispatcher() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/Extension.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/Extension.kt index 392a5e5..468ef0e 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/Extension.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/Extension.kt @@ -1,5 +1,6 @@ package io.github.tritium_launcher.launcher.extension +import io.qt.gui.QIcon import org.koin.core.module.Module /** @@ -33,4 +34,10 @@ import org.koin.core.module.Module interface Extension { val namespace: String val modules: List + + val isBuiltin: Boolean get() = false + val requiresRestart: Boolean get() = true + val displayName: String get() = namespace + val description: String? get() = null + val icon: QIcon? get() = null } \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionDirectoryLoader.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionDirectoryLoader.kt index fef84f5..89f81c6 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionDirectoryLoader.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionDirectoryLoader.kt @@ -8,12 +8,13 @@ import java.util.* object ExtensionDirectoryLoader { - data class Result(val modules: List, val loaders: List) + data class Result(val modules: List, val extensions: List, val loaders: List) fun loadFrom(dir: VPath): Result { - if(!dir.exists() || !dir.isDir()) return Result(emptyList(), emptyList()) + if(!dir.exists() || !dir.isDir()) return Result(emptyList(), emptyList(), emptyList()) val modules = mutableListOf() + val extensions = mutableListOf() val loaders = mutableListOf() dir.listFiles { f -> f.isFile() && f.hasExtension("jar") }.forEach { jar -> @@ -22,6 +23,7 @@ object ExtensionDirectoryLoader { try { val sl = ServiceLoader.load(Extension::class.java, loader) val found = sl.iterator().asSequence().toList() + extensions += found found.forEach { modules += it.modules } loaders += loader } catch (_: Throwable) { @@ -29,6 +31,6 @@ object ExtensionDirectoryLoader { } } - return Result(modules, loaders) + return Result(modules, extensions, loaders) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionLoader.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionLoader.kt index 437f154..81a2aca 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionLoader.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionLoader.kt @@ -1,14 +1,26 @@ package io.github.tritium_launcher.launcher.extension +import io.github.tritium_launcher.launcher.logger import org.koin.core.module.Module import java.util.* object ExtensionLoader { + private val logger = logger() + private var cachedClasspath: List? = null - fun discover(): List = ServiceLoader.load(Extension::class.java) - .iterator() - .asSequence() - .toList() + var allExtensions: List = emptyList() + internal set + + fun discover(): List { + if (cachedClasspath == null) { + cachedClasspath = ServiceLoader.load(Extension::class.java) + .iterator() + .asSequence() + .toList() + logger.info("Discovered {} extensions: {}", cachedClasspath!!.size, cachedClasspath!!.joinToString { it.javaClass.simpleName }) + } + return cachedClasspath!! + } fun discoveredModules(): List = discover().flatMap { it.modules } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionStateManager.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionStateManager.kt new file mode 100644 index 0000000..4498ddc --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/ExtensionStateManager.kt @@ -0,0 +1,42 @@ +package io.github.tritium_launcher.launcher.extension + +import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.json.Json + +object ExtensionStateManager { + private val file: VPath = fromTR(TConstants.Dirs.SETTINGS, "extensions-state.json") + private val logger = logger() + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun load(): Map { + if (!file.exists()) return emptyMap() + return runCatching { + val text = file.readTextOrNull() ?: return emptyMap() + json.decodeFromString>(text) + }.getOrElse { + logger.error("Failed to load extension state from $file") + emptyMap() + } + } + + fun save(state: Map) { + runCatching { + file.parent().mkdirs() + file.writeTextAtomic(json.encodeToString(state)) + }.onFailure { + logger.error("Failed to save extension state to $file", it) + } + } + + fun setEnabled(namespace: String, enabled: Boolean) { + val state = load().toMutableMap() + state[namespace] = enabled + save(state) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinNotifications.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinNotifications.kt index d45c49f..1cd5858 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinNotifications.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinNotifications.kt @@ -16,7 +16,7 @@ internal object BuiltinNotifications { sendToOsByDefault = false ) - /** Posted when a modpack bootstrap completes successfully. */ + /** Posted when a source bootstrap completes successfully. */ val ModpackBootstrapSuccess = NotificationDefinition( id = "bootstrap_success", header = "Bootstrap Finished", @@ -25,7 +25,7 @@ internal object BuiltinNotifications { sendToOsByDefault = false ) - /** Posted when a modpack bootstrap fails. */ + /** Posted when a source bootstrap fails. */ val ModpackBootstrapFailed = NotificationDefinition( id = "bootstrap_failure", header = "Bootstrap Failed", @@ -34,9 +34,18 @@ internal object BuiltinNotifications { sendToOsByDefault = false ) + val LSPInstallPrompt = NotificationDefinition( + id = "lsp_install_prompt", + header = "LSP Server Missing", + description = "", + icon = TIcons.QuestionMark.icon, + sendToOsByDefault = false + ) + val All = listOf( Generic, ModpackBootstrapSuccess, - ModpackBootstrapFailed + ModpackBootstrapFailed, + LSPInstallPrompt ) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinRegistries.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinRegistries.kt index 75606ef..394245a 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinRegistries.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/BuiltinRegistries.kt @@ -1,10 +1,11 @@ package io.github.tritium_launcher.launcher.extension.core import io.github.tritium_launcher.launcher.accounts.AccountProvider +import io.github.tritium_launcher.launcher.core.mod_config.ConfigFormat import io.github.tritium_launcher.launcher.core.modloader.ModLoader -import io.github.tritium_launcher.launcher.core.modpack.ModSource import io.github.tritium_launcher.launcher.core.project.ProjectType import io.github.tritium_launcher.launcher.core.project.templates.generation.license.License +import io.github.tritium_launcher.launcher.core.source.ModSource import io.github.tritium_launcher.launcher.registry.RegistryMngr import io.github.tritium_launcher.launcher.ui.dashboard.ProjectListStyleProvider import io.github.tritium_launcher.launcher.ui.notifications.NotificationDefinition @@ -12,7 +13,7 @@ import io.github.tritium_launcher.launcher.ui.project.editor.EditorPaneProvider import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage import io.github.tritium_launcher.launcher.ui.project.menu.MenuItem -import io.github.tritium_launcher.launcher.ui.project.sidebar.SidePanelProvider +import io.github.tritium_launcher.launcher.ui.project.sidebar.* /** * Central registry handles for core and UI extension points. @@ -21,16 +22,23 @@ import io.github.tritium_launcher.launcher.ui.project.sidebar.SidePanelProvider * register their implementations in a consistent location. */ object BuiltinRegistries { - val AccountProvider = RegistryMngr.getOrCreateRegistry("account_provider") - val EditorPane = RegistryMngr.getOrCreateRegistry("editor_pane") - val FileType = RegistryMngr.getOrCreateRegistry("file_type") - val License = RegistryMngr.getOrCreateRegistry("license") - val MenuItem = RegistryMngr.getOrCreateRegistry("menu") - val ModLoader = RegistryMngr.getOrCreateRegistry("mod_loader") - val ModSource = RegistryMngr.getOrCreateRegistry("mod_source") - val Notification = RegistryMngr.getOrCreateRegistry("notification") - val ProjectType = RegistryMngr.getOrCreateRegistry("project_type") + val AccountProvider = RegistryMngr.getOrCreateRegistry("account_provider") + val EditorPane = RegistryMngr.getOrCreateRegistry("editor_pane") + val FileType = RegistryMngr.getOrCreateRegistry("file_type") + val License = RegistryMngr.getOrCreateRegistry("license") + val MenuItem = RegistryMngr.getOrCreateRegistry("menu") + val ModLoader = RegistryMngr.getOrCreateRegistry("mod_loader") + val ModSource = RegistryMngr.getOrCreateRegistry("mod_source") + val Notification = RegistryMngr.getOrCreateRegistry("notification") + val ProjectType = RegistryMngr.getOrCreateRegistry("project_type") val ProjectListStyle = RegistryMngr.getOrCreateRegistry("project_list_style") - val SidePanel = RegistryMngr.getOrCreateRegistry("side_panel") - val SyntaxLanguage = RegistryMngr.getOrCreateRegistry("syntax") + val SidePanel = RegistryMngr.getOrCreateRegistry("side_panel") + val SyntaxLanguage = RegistryMngr.getOrCreateRegistry("syntax") + val ConfigFormat = RegistryMngr.getOrCreateRegistry("config_format") + + val ProjectTreeDirectoryPresentation = RegistryMngr.getOrCreateRegistry("project_tree_directory_presentation") + val ProjectFilesViewMode = RegistryMngr.getOrCreateRegistry("project_files_view_mode") + val ProjectFilesAction = RegistryMngr.getOrCreateRegistry("project_files_action") + + val ProjectRootDirectory = RegistryMngr.getOrCreateRegistry("project_root_directory") } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreExtension.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreExtension.kt index b4f9520..f1f0491 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreExtension.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreExtension.kt @@ -1,29 +1,42 @@ package io.github.tritium_launcher.launcher.extension.core -import io.github.tritium_launcher.launcher.accounts.ui.MicrosoftAccountProvider +import io.github.tritium_launcher.launcher.accounts.CurseForgeAccount +import io.github.tritium_launcher.launcher.accounts.MicrosoftAccountProvider +import io.github.tritium_launcher.launcher.accounts.ModrinthAccount +import io.github.tritium_launcher.launcher.applyRainbowOverlay +import io.github.tritium_launcher.launcher.core.mod_config.ConfigFormat import io.github.tritium_launcher.launcher.core.modloader.Fabric import io.github.tritium_launcher.launcher.core.modloader.NeoForge -import io.github.tritium_launcher.launcher.core.modpack.CurseForge -import io.github.tritium_launcher.launcher.core.modpack.Modrinth import io.github.tritium_launcher.launcher.core.project.ModpackProjectType import io.github.tritium_launcher.launcher.core.project.ModpackTemplateDescriptor import io.github.tritium_launcher.launcher.core.project.templates.TemplateRegistry import io.github.tritium_launcher.launcher.core.project.templates.generation.license.* +import io.github.tritium_launcher.launcher.core.source.CurseForge +import io.github.tritium_launcher.launcher.core.source.Modrinth import io.github.tritium_launcher.launcher.extension.Extension -import io.github.tritium_launcher.launcher.registry.RegistryMngr +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.settings.CategoryPath +import io.github.tritium_launcher.launcher.settings.NamespacedId import io.github.tritium_launcher.launcher.settings.SettingsMngr import io.github.tritium_launcher.launcher.ui.dashboard.DvdStyleProvider +import io.github.tritium_launcher.launcher.ui.dashboard.ExtensionsManageList import io.github.tritium_launcher.launcher.ui.dashboard.GridStyleProvider import io.github.tritium_launcher.launcher.ui.dashboard.ListStyleProvider import io.github.tritium_launcher.launcher.ui.project.editor.file.builtin.BuiltinFileTypes -import io.github.tritium_launcher.launcher.ui.project.editor.pane.ImageViewerProvider -import io.github.tritium_launcher.launcher.ui.project.editor.pane.SettingsEditorPaneProvider +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ImageViewerProvider +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ModConfigPane +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ModDetailPaneProvider +import io.github.tritium_launcher.launcher.ui.project.editor.panes.SettingsEditorPaneProvider import io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin.JsonLanguage import io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin.PythonLanguage +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin.XmlLanguage import io.github.tritium_launcher.launcher.ui.project.menu.builtin.BuiltinMenuItems -import io.github.tritium_launcher.launcher.ui.project.sidebar.ProjectFilesSidePanelProvider -import io.github.tritium_launcher.launcher.ui.project.sidebar.ProjectLogsSidePanelProvider -import io.github.tritium_launcher.launcher.ui.project.sidebar.ProjectNotificationsSidePanelProvider +import io.github.tritium_launcher.launcher.ui.project.sidebar.* +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.util.SeasonalEvents +import io.qt.gui.QIcon +import io.qt.widgets.QApplication import org.koin.core.module.Module import org.koin.dsl.module @@ -33,7 +46,6 @@ import org.koin.dsl.module internal object CoreExtension : Extension { private val coreModule = module { single(createdAtStart = true) { - val rm: RegistryMngr = get() val settings: SettingsMngr = get() val modLoaders = BuiltinRegistries.ModLoader @@ -48,10 +60,30 @@ internal object CoreExtension : Extension { val syntax = BuiltinRegistries.SyntaxLanguage val editorPanes = BuiltinRegistries.EditorPane val projectListStyles = BuiltinRegistries.ProjectListStyle + val projectTreeDirectoryPresentations = BuiltinRegistries.ProjectTreeDirectoryPresentation + val projectFilesViewModes = BuiltinRegistries.ProjectFilesViewMode + val projectFilesActions = BuiltinRegistries.ProjectFilesAction + val configFormats = BuiltinRegistries.ConfigFormat + val rootDirectories = BuiltinRegistries.ProjectRootDirectory settings.register(this@CoreExtension.namespace, CoreSettings.registration) + settings.forNamespace("tritium").widget( + CategoryPath.root(NamespacedId("tritium", "extensions")), + "manage" + ) { + title = "Manage Extensions" + description = "Enable or disable extensions. Changes take effect after restart." + fullWidth = true + fullHeight = true + serializer = null + defaultValue = Unit + widgetFactory = { ExtensionsManageList() } + } + accountProviders.register(MicrosoftAccountProvider()) + accountProviders.register(ModrinthAccount()) + accountProviders.register(CurseForgeAccount()) modLoaders.register(Fabric()) modLoaders.register(NeoForge()) @@ -79,8 +111,12 @@ internal object CoreExtension : Extension { fileTypes.register(BuiltinFileTypes.all()) sidePanels.register(ProjectFilesSidePanelProvider()) + sidePanels.register(ProjectModpackSidePanelProvider()) + sidePanels.register(ProjectInstalledModsSidePanelProvider()) + sidePanels.register(ProjectRegistryBrowserSidePanelProvider()) sidePanels.register(ProjectLogsSidePanelProvider()) sidePanels.register(ProjectNotificationsSidePanelProvider()) + sidePanels.register(ModBrowserSidePanelProvider()) menuItems.register(BuiltinMenuItems.All) notifications.register(BuiltinNotifications.All) @@ -88,17 +124,133 @@ internal object CoreExtension : Extension { syntax.register(listOf( JsonLanguage(), PythonLanguage(), + XmlLanguage() )) TemplateRegistry.register(ModpackTemplateDescriptor) editorPanes.register(ImageViewerProvider()) + editorPanes.register(ModDetailPaneProvider) editorPanes.register(SettingsEditorPaneProvider()) + editorPanes.register(ModConfigPane.Provider) + projectListStyles.register(listOf(GridStyleProvider, ListStyleProvider, DvdStyleProvider)) + projectTreeDirectoryPresentations.register(ProjectTreeDirectoryPresentations.all()) + projectFilesViewModes.register(ProjectFilesViewModes.all()) + + rootDirectories.register(listOf( + projectRootDirectory("config", "config", "Configs"), + projectRootDirectory("defaultconfigs", "defaultconfigs", "Default Configs"), + projectRootDirectory("mods", "mods", "Mods"), + projectRootDirectory("resourcepacks", "resourcepacks", "Resource Packs"), + projectRootDirectory("shaderpacks", "shaderpacks", "Shader Packs"), + projectRootDirectory("scripts", "scripts", "Scripts") + )) + + projectFilesActions.register(listOf( + ProjectFilesContextAction.create( + id = "cut", + displayName = "Cut", + order = 0, + section = ProjectFilesContextAction.Section.CLIPBOARD, + execute = { _, _, tree -> + setClipboard(selectedPaths(tree).map { it.toAbsolute().toString() }, true) + } + ), + ProjectFilesContextAction.create( + id = "copy", + displayName = "Copy", + order = 1, + section = ProjectFilesContextAction.Section.CLIPBOARD, + execute = { _, _, tree -> + setClipboard(selectedPaths(tree).map { it.toAbsolute().toString() }, false) + } + ), + ProjectFilesContextAction.create( + id = "paste", + displayName = "Paste", + order = 2, + section = ProjectFilesContextAction.Section.CLIPBOARD, + matches = { _, _, _, _ -> clipboardSource().isNotEmpty() }, + needsRefresh = true, + execute = { path, _, _ -> + val isDir = runCatching { path.isDir() }.getOrDefault(false) + val targetDir = if (isDir) path else runCatching { path.parent() }.getOrNull() ?: path + pasteTo(targetDir) + } + ), + ProjectFilesContextAction.create( + id = "rename", + displayName = "Rename", + order = 0, + section = ProjectFilesContextAction.Section.RENAME, + needsRefresh = true, + execute = { path, _, tree -> + promptRename(path, tree) + } + ), + ProjectFilesContextAction.create( + id = "delete", + displayName = "Delete", + order = 0, + section = ProjectFilesContextAction.Section.DELETE, + needsRefresh = true, + execute = { path, _, tree -> + promptDelete(path, tree) + } + ), + ProjectFilesContextAction.create( + id = "reload_from_disk", + displayName = "Reload from Disk", + order = 0, + section = ProjectFilesContextAction.Section.RELOAD, + needsRefresh = true, + execute = { _, _, _ -> } + ), + ProjectFilesContextAction.create( + id = "copy_path", + displayName = "Copy Path", + order = 0, + section = ProjectFilesContextAction.Section.EXTENSIONS, + execute = { path, _, _ -> + QApplication.clipboard()?.setText(path.toAbsolute().toString()) + } + ), + ProjectFilesContextAction.create( + id = "copy_relative_path", + displayName = "Copy Relative Path", + order = 1, + section = ProjectFilesContextAction.Section.EXTENSIONS, + execute = { path, project, _ -> + val rel = path.toAbsolute().toString().removePrefix(project.projectDir.toAbsolute().toString().trimEnd('/')).trimStart('/') + QApplication.clipboard()?.setText(rel) + } + ), + ProjectFilesContextAction.create( + id = "open_in_file_manager", + displayName = "Open in File Manager", + order = 2, + section = ProjectFilesContextAction.Section.EXTENSIONS, + execute = { path, _, _ -> + val file = runCatching { path.toJFile() }.getOrNull() + if (file != null) { + val parent = file.parentFile ?: file + runCatching { Platform.openFile(parent) } + } + } + ) + )) + + configFormats.register(ConfigFormat.builtin) } } override val namespace: String = "tritium" + override val isBuiltin: Boolean = true + override val requiresRestart: Boolean = false + override val displayName: String = "Core" + override val description: String = "Core Tritium features" + override val icon: QIcon get() = if (SeasonalEvents.isPrideMonth()) TIcons.TritiumGrayscale.applyRainbowOverlay(opacity = 0.5f).icon else TIcons.Tritium.icon override val modules: List = listOf(coreModule) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettingValues.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettingValues.kt index 62471a1..24748e9 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettingValues.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettingValues.kt @@ -1,8 +1,8 @@ package io.github.tritium_launcher.launcher.extension.core +import io.github.tritium_launcher.launcher.font.FontMngr import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.settings.NamespacedId -import io.github.tritium_launcher.launcher.settings.SettingsMngr +import io.github.tritium_launcher.launcher.settings.* private val WINDOW_SIZE_REGEX = Regex("^([1-9][0-9]{0,4})x([1-9][0-9]{0,4})$") @@ -16,8 +16,8 @@ internal object CoreSettingKeys { val ProjectOpenWindowPrompt: NamespacedId = NamespacedId("tritium", "projects.open_window_prompt") val ProjectOpenWindowDefault: NamespacedId = NamespacedId("tritium", "projects.open_window_default") val CloseProjectConfirmation: NamespacedId = NamespacedId("tritium", "projects.close_confirmation") - val ModpackJvmArgs: NamespacedId = NamespacedId("tritium", "modpack.mc_args") - val ModpackMemoryMb: NamespacedId = NamespacedId("tritium", "modpack.mc_memory_mb") + val ModpackJvmArgs: NamespacedId = NamespacedId("tritium", "source.mc_args") + val ModpackMemoryMb: NamespacedId = NamespacedId("tritium", "source.mc_memory_mb") val DashboardWindowSize: NamespacedId = NamespacedId("tritium", "ui.dashboard.window_size") val ProjectWindowDefaultSize: NamespacedId = NamespacedId("tritium", "ui.project_window.default_size") val GameLaunchMaximized: NamespacedId = NamespacedId("tritium", "game.maximized") @@ -29,6 +29,21 @@ internal object CoreSettingKeys { val JavaPath25: NamespacedId = NamespacedId("tritium", "java.path.25") val CompanionWsHost: NamespacedId = NamespacedId("tritium", "companion.ws.host") val CompanionWsPort: NamespacedId = NamespacedId("tritium", "companion.ws.port") + val EditorAutoSave: NamespacedId = NamespacedId("tritium", "editor.auto_save") + val EditorAutoSaveInterval: NamespacedId = NamespacedId("tritium", "editor.auto_save_interval") + val EditorUnsavedIndicatorIntensity: NamespacedId = NamespacedId("tritium", "editor.unsaved_indicator_intensity") + val EditorRainbowBrackets: NamespacedId = NamespacedId("tritium", "editor.rainbow_brackets") + val ProjectFilesConfigSort: NamespacedId = NamespacedId("tritium", "projects.files.config_sort") + val UiGameTooltipStyle: NamespacedId = NamespacedId("tritium", "ui.tooltip_style") + val UiAnimateScrolling: NamespacedId = NamespacedId("tritium", "ui.animate_scrolling") + val SeasonalEventsEnabled: NamespacedId = NamespacedId("tritium", "ui.seasonal_events") + val UiBackgroundImage: NamespacedId = NamespacedId("tritium", "ui.background_image") + val KeymapActionsOverview: NamespacedId = NamespacedId("tritium", "keymap.actions_overview") + val ModCacheEnabled: NamespacedId = NamespacedId("tritium", "mods.cache_enabled") + + val GlobalFont: NamespacedId = NamespacedId("tritium", "ui.global_font") + val EditorFont: NamespacedId = NamespacedId("tritium", "ui.editor_font") + val SmartRerun: NamespacedId = NamespacedId("tritium", "game.smart_rerun") } /** @@ -58,80 +73,150 @@ internal object CoreSettingValues { Ask } + enum class UnsavedIndicatorIntensity { + Low, + High + } + + enum class ProjectFilesConfigSortMode { + Alphabetical, + FileType + } + + /** + * Optional background image path applied globally to main windows. + */ + val uiBackgroundImage by optionalTextSetting(CoreSettingKeys.UiBackgroundImage) + + /** + * Whether auto-save is enabled for modified editor files. + */ + val editorAutoSave by setting(CoreSettingKeys.EditorAutoSave, false) + + /** + * Interval in seconds for editor auto-save. + */ + fun editorAutoSaveInterval(): Int { + val fallback = 60 + val raw = readOptionalText(CoreSettingKeys.EditorAutoSaveInterval) ?: return fallback + return raw.toIntOrNull()?.coerceIn(1, 86400) ?: fallback + } + + /** + * Intensity of the unsaved changes indicator in editor tabs. + */ + val editorUnsavedIndicatorIntensity by enumSetting( + key = CoreSettingKeys.EditorUnsavedIndicatorIntensity, + fallback = UnsavedIndicatorIntensity.Low, + mapping = mapOf( + "low" to UnsavedIndicatorIntensity.Low, + "high" to UnsavedIndicatorIntensity.High + ) + ) + + /** + * Whether rainbow brackets are enabled in code editors. + */ + val editorRainbowBrackets by setting(CoreSettingKeys.EditorRainbowBrackets, false) + + /** + * Sort mode used for the project's /config directory in the files tree. + */ + val projectFilesConfigSortMode by enumSetting( + key = CoreSettingKeys.ProjectFilesConfigSort, + fallback = ProjectFilesConfigSortMode.Alphabetical, + mapping = mapOf( + "alphabetical" to ProjectFilesConfigSortMode.Alphabetical, + "file_type" to ProjectFilesConfigSortMode.FileType + ) + ) + + /** + * Whether Tritium tooltips should be styled like MC tooltips, or QT default. + */ + val uiGameTooltipStyle by setting(CoreSettingKeys.UiGameTooltipStyle, true) + + /** + * Whether wheel-driven scrolling should animate across scrollable UI. + */ + val uiAnimateScrolling by setting(CoreSettingKeys.UiAnimateScrolling, true) + + /** + * Whether seasonal events changes are active. + */ + val seasonalEventsEnabled by setting(CoreSettingKeys.SeasonalEventsEnabled, true) + + /** + * Whether downloaded mod jars are cached in a shared directory. + */ + val modCacheEnabled by setting(CoreSettingKeys.ModCacheEnabled, false) + /** * Whether dashboard should close when opening a project window. */ - fun closeDashboardOnProjectOpen(): Boolean = - (SettingsMngr.currentValueOrNull(CoreSettingKeys.CloseDashboardOnProjectOpen) as? Boolean) ?: true + val closeDashboardOnProjectOpen by setting(CoreSettingKeys.CloseDashboardOnProjectOpen, true) /** * Controls whether running game processes are closed when Tritium exits. */ - fun closeGameOnExitPolicy(): CloseGameOnExitPolicy { - return parseEnumSetting( - key = CoreSettingKeys.CloseGameOnExit, - fallback = CloseGameOnExitPolicy.Never, - mapping = mapOf( - "never" to CloseGameOnExitPolicy.Never, - "ask" to CloseGameOnExitPolicy.Ask, - "always" to CloseGameOnExitPolicy.Always - ) + val closeGameOnExitPolicy by enumSetting( + key = CoreSettingKeys.CloseGameOnExit, + fallback = CloseGameOnExitPolicy.Never, + mapping = mapOf( + "never" to CloseGameOnExitPolicy.Never, + "ask" to CloseGameOnExitPolicy.Ask, + "always" to CloseGameOnExitPolicy.Always ) - } + ) /** * Controls whether project opening should ask for current/new window target. */ - fun projectOpenPromptMode(): ProjectOpenPromptMode { - return parseEnumSetting( - key = CoreSettingKeys.ProjectOpenWindowPrompt, - fallback = ProjectOpenPromptMode.Always, - mapping = mapOf( - "always" to ProjectOpenPromptMode.Always, - "never" to ProjectOpenPromptMode.Never - ) + val projectOpenPromptMode by enumSetting( + key = CoreSettingKeys.ProjectOpenWindowPrompt, + fallback = ProjectOpenPromptMode.Always, + mapping = mapOf( + "always" to ProjectOpenPromptMode.Always, + "never" to ProjectOpenPromptMode.Never ) - } + ) /** * Default target used when project-open prompting is disabled. */ - fun projectOpenDefaultTarget(): ProjectOpenDefaultTarget { - return parseEnumSetting( - key = CoreSettingKeys.ProjectOpenWindowDefault, - fallback = ProjectOpenDefaultTarget.Current, - mapping = mapOf( - "current" to ProjectOpenDefaultTarget.Current, - "new" to ProjectOpenDefaultTarget.New - ) + val projectOpenDefaultTarget by enumSetting( + key = CoreSettingKeys.ProjectOpenWindowDefault, + fallback = ProjectOpenDefaultTarget.Current, + mapping = mapOf( + "current" to ProjectOpenDefaultTarget.Current, + "new" to ProjectOpenDefaultTarget.New ) - } + ) /** * Controls whether closing a project window asks for confirmation. */ - fun closeProjectConfirmationPolicy(): CloseProjectConfirmationPolicy { - return parseEnumSetting( - key = CoreSettingKeys.CloseProjectConfirmation, - fallback = CloseProjectConfirmationPolicy.Never, - mapping = mapOf( - "never" to CloseProjectConfirmationPolicy.Never, - "ask" to CloseProjectConfirmationPolicy.Ask - ) + val closeProjectConfirmationPolicy by enumSetting( + key = CoreSettingKeys.CloseProjectConfirmation, + fallback = CloseProjectConfirmationPolicy.Never, + mapping = mapOf( + "never" to CloseProjectConfirmationPolicy.Never, + "ask" to CloseProjectConfirmationPolicy.Ask ) - } + ) /** - * Optional extra JVM argument string for modpack launches. + * Optional extra JVM argument string for source launches. */ - fun modpackJvmArgs(): String? = readOptionalText(CoreSettingKeys.ModpackJvmArgs) + val modpackJvmArgs by optionalTextSetting(CoreSettingKeys.ModpackJvmArgs) /** - * Default modpack memory allocation in megabytes. + * Default source memory allocation in megabytes. */ fun modpackMemoryMb(): Int { val fallback = 6144 - val raw = readOptionalText(CoreSettingKeys.ModpackMemoryMb) ?: return fallback + val raw = (SettingsMngr.currentValueOrNull(CoreSettingKeys.ModpackMemoryMb) as? String)?.trim().orEmpty() + if (raw.isEmpty()) return fallback val parsed = raw.toIntOrNull() if (parsed == null || parsed !in 512..262_144) { logger.warn("Invalid memory value '{}' for {}. Falling back to {}", raw, CoreSettingKeys.ModpackMemoryMb, fallback) @@ -155,8 +240,9 @@ internal object CoreSettingValues { /** * Whether game launch should prefer maximized window behavior. */ - fun gameLaunchMaximized(): Boolean = - (SettingsMngr.currentValueOrNull(CoreSettingKeys.GameLaunchMaximized) as? Boolean) ?: false + val gameLaunchMaximized by setting(CoreSettingKeys.GameLaunchMaximized, false) + + val smartRerun by setting(CoreSettingKeys.SmartRerun, true) /** * Default WIDTHxHEIGHT resolution used by game launch token replacement. @@ -167,45 +253,45 @@ internal object CoreSettingValues { /** * Whether Minecraft snapshot/pre-release/RC versions should be included in version lists. */ - fun includePreReleaseMinecraftVersions(): Boolean = - (SettingsMngr.currentValueOrNull(CoreSettingKeys.IncludePreReleaseMinecraftVersions) as? Boolean) ?: false + val includePreReleaseMinecraftVersions by setting(CoreSettingKeys.IncludePreReleaseMinecraftVersions, false) /** * Configured Java path for Minecraft 1.16.5 and below. */ - fun javaPath8(): String? = readOptionalText(CoreSettingKeys.JavaPath8) + val javaPath8 by optionalTextSetting(CoreSettingKeys.JavaPath8) /** * Configured Java path for Minecraft 1.17 to 1.20. */ - fun javaPath17(): String? = readOptionalText(CoreSettingKeys.JavaPath17) + val javaPath17 by optionalTextSetting(CoreSettingKeys.JavaPath17) /** * Configured Java path for Minecraft 1.21 to 1.21.11. */ - fun javaPath21(): String? = readOptionalText(CoreSettingKeys.JavaPath21) + val javaPath21 by optionalTextSetting(CoreSettingKeys.JavaPath21) /** * Configured Java path for Minecraft 26.*. */ - fun javaPath25(): String? = readOptionalText(CoreSettingKeys.JavaPath25) + val javaPath25 by optionalTextSetting(CoreSettingKeys.JavaPath25) /** * Returns the configured Java path for the requested major runtime. */ fun javaPathForMajor(major: Int): String? = when (major) { - 8 -> javaPath8() - 17 -> javaPath17() - 21 -> javaPath21() - 25 -> javaPath25() + 8 -> javaPath8 + 17 -> javaPath17 + 21 -> javaPath21 + 25 -> javaPath25 else -> null } /** * Hostname used by Tritium when connecting to the Companion websocket. */ - fun companionWsHost(): String = - readOptionalText(CoreSettingKeys.CompanionWsHost) ?: "127.0.0.1" + val companionWsHost by setting(CoreSettingKeys.CompanionWsHost, "127.0.0.1") { + (it as? String)?.trim()?.takeIf { s -> s.isNotBlank() } + } /** * Port used by Tritium and the Companion websocket bridge. @@ -221,8 +307,31 @@ internal object CoreSettingValues { return parsed } + private val FONT_SETTING_REGEX = Regex("^(.*)\\|([1-9][0-9]{0,2})$") + + private fun parseFontSetting(key: NamespacedId, defaultFamily: String, defaultSize: Int): Pair { + val raw = readOptionalText(key) ?: return defaultFamily to defaultSize + val match = FONT_SETTING_REGEX.matchEntire(raw) + if (match == null) { + logger.warn("Invalid font setting '{}' for {}. Falling back to {}|{}", raw, key, defaultFamily, defaultSize) + return defaultFamily to defaultSize + } + val family = match.groupValues[1].takeIf { it.isNotBlank() } ?: defaultFamily + return family to match.groupValues[2].toInt() + } + + private fun encodeFontSetting(family: String, size: Int): String = "$family|$size" + + fun globalFont(): Pair = parseFontSetting( + CoreSettingKeys.GlobalFont, FontMngr.defaultFontFamily, 10 + ) + + fun editorFont(): Pair = parseFontSetting( + CoreSettingKeys.EditorFont, FontMngr.monoFontFamily, 10 + ) + private fun parseWindowSize(key: NamespacedId, fallbackWidth: Int, fallbackHeight: Int): Pair { - val raw = (SettingsMngr.currentValueOrNull(key) as? String)?.trim().orEmpty() + val raw = readOptionalText(key).orEmpty() if (raw.isEmpty()) return fallbackWidth to fallbackHeight val match = WINDOW_SIZE_REGEX.matchEntire(raw) @@ -244,16 +353,4 @@ internal object CoreSettingValues { val raw = (SettingsMngr.currentValueOrNull(key) as? String)?.trim().orEmpty() return raw.takeIf { it.isNotBlank() } } - - private fun parseEnumSetting( - key: NamespacedId, - fallback: T, - mapping: Map - ): T { - val raw = readOptionalText(key)?.lowercase() ?: return fallback - return mapping[raw] ?: run { - logger.warn("Invalid setting value '{}' for {}. Falling back to {}", raw, key, fallback) - fallback - } - } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettings.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettings.kt index b869465..bba7eba 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettings.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/core/CoreSettings.kt @@ -1,21 +1,37 @@ package io.github.tritium_launcher.launcher.extension.core import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent +import io.github.tritium_launcher.launcher.font.FontMngr +import io.github.tritium_launcher.launcher.keymap.* +import io.github.tritium_launcher.launcher.onClicked import io.github.tritium_launcher.launcher.settings.RefreshableSettingWidget import io.github.tritium_launcher.launcher.settings.SettingWidgetContext import io.github.tritium_launcher.launcher.settings.settingsDefinition import io.github.tritium_launcher.launcher.ui.widgets.InfoLineEditWidget import io.github.tritium_launcher.launcher.ui.widgets.TComboBox +import io.github.tritium_launcher.launcher.ui.widgets.TPushButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.pushButton +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.core.QEvent +import io.qt.core.QObject import io.qt.core.Qt -import io.qt.widgets.QComboBox -import io.qt.widgets.QSizePolicy -import io.qt.widgets.QWidget +import io.qt.gui.QKeyEvent +import io.qt.gui.QMouseEvent +import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json private val WINDOW_SIZE_REGEX = Regex("^([1-9][0-9]{0,4})x([1-9][0-9]{0,4})$") private val WINDOW_DIMENSION_REGEX = Regex("^[1-9][0-9]{0,4}$") +private val FONT_VALUE_REGEX = Regex("^(.*)\\|([1-9][0-9]{0,2})$") private data class WindowSizeParts( val width: String, @@ -212,6 +228,380 @@ private class ChoiceSettingWidget( } } +private class FileSettingWidget( + private val ctx: SettingWidgetContext, + private val dialogTitle: String = "Select File", + private val filter: String = "All Files (*)" +) : QWidget(), RefreshableSettingWidget { + private val pathInput = InfoLineEditWidget(ctx.descriptor.description.orEmpty()).apply { + objectName = "settingsInput" + } + private val browseBtn = TPushButton { + text = "..." + minimumWidth = 30 + maximumWidth = 36 + minimumHeight = 25 + textVerticalOffset = -4 + } + private var isRefreshing = false + + init { + hBoxLayout(this) { + setContentsMargins(0, 0, 0, 0) + widgetSpacing = 6 + setAlignment(Qt.AlignmentFlag.AlignVCenter) + addWidget(pathInput, 1) + addWidget(browseBtn, 0) + } + + pathInput.editingFinished.connect { commitInput() } + browseBtn.onClicked { + val res = QFileDialog.getOpenFileName(this, dialogTitle, pathInput.text, filter) + if (res != null && res.result.isNotBlank()) { + pathInput.text = res.result + commitInput() + } + } + + refreshFromSettingValue() + } + + override fun refreshFromSettingValue() { + isRefreshing = true + try { + pathInput.text = ctx.currentValue().trim() + } finally { + isRefreshing = false + } + } + + private fun commitInput() { + if (isRefreshing) return + ctx.updateValue(pathInput.text.trim()) + } +} + +private class FontSettingWidget( + private val ctx: SettingWidgetContext, + private val defaultFamily: String +) : QWidget(), RefreshableSettingWidget { + private val fontCombo = TComboBox() + private val sizeSpinner = QSpinBox().apply { + minimum = 8 + maximum = 32 + } + private var isRefreshing = false + + init { + hBoxLayout(this) { + setContentsMargins(0, 0, 0, 0) + widgetSpacing = 8 + setAlignment(Qt.AlignmentFlag.AlignVCenter) + addWidget(fontCombo, 1) + addWidget(label("Size:")) + addWidget(sizeSpinner) + } + + loadFonts() + + fontCombo.currentTextChanged.connect { commit() } + sizeSpinner.valueChanged.connect { commit() } + + refreshFromSettingValue() + } + + private fun loadFonts() { + val current = fontCombo.currentText + fontCombo.clear() + FontMngr.availableFontFamilies().forEach { fontCombo.addItem(it) } + if (current.isNotBlank()) { + val idx = (0 until fontCombo.count).indexOfFirst { fontCombo.itemText(it) == current } + if (idx >= 0) fontCombo.currentIndex = idx + } + } + + override fun refreshFromSettingValue() { + isRefreshing = true + try { + val raw = ctx.currentValue().trim() + val match = FONT_VALUE_REGEX.matchEntire(raw) + if (match != null) { + val family = match.groupValues[1] + val size = match.groupValues[2].toInt() + if (family.isNotBlank()) { + val idx = (0 until fontCombo.count).indexOfFirst { fontCombo.itemText(it) == family } + if (idx >= 0) fontCombo.currentIndex = idx + else fontCombo.currentText = family + } else { + val idx = (0 until fontCombo.count).indexOfFirst { fontCombo.itemText(it) == defaultFamily } + if (idx >= 0) fontCombo.currentIndex = idx + } + sizeSpinner.value = size.coerceIn(sizeSpinner.minimum, sizeSpinner.maximum) + } else { + val idx = (0 until fontCombo.count).indexOfFirst { fontCombo.itemText(it) == defaultFamily } + if (idx >= 0) fontCombo.currentIndex = idx + sizeSpinner.value = 10 + } + } finally { + isRefreshing = false + } + } + + private fun commit() { + if (isRefreshing) return + val family = fontCombo.currentText.takeIf { it.isNotBlank() } ?: return + ctx.updateValue("$family|${sizeSpinner.value}") + } +} + +private class KeymapActionsWidget( + private val ctx: SettingWidgetContext +) : QWidget(), RefreshableSettingWidget { + private val actionRole = Qt.ItemDataRole.UserRole + + private val tree = QTreeWidget().apply { + columnCount = 2 + setHeaderLabels(listOf("Action", "Shortcuts")) + rootIsDecorated = true + alternatingRowColors = true + sortingEnabled = true + header()?.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + header()?.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + contextMenuPolicy = Qt.ContextMenuPolicy.CustomContextMenu + } + + init { + hBoxLayout(this) { + setContentsMargins(0, 0, 0, 0) + widgetSpacing = 0 + addWidget(tree, 1) + } + tree.customContextMenuRequested.connect { pt -> + showContextMenu(pt.x(), pt.y()) + } + CoroutineScope(Dispatchers.Main).onEvent { event -> + val key = "${event.namespace}:${event.nodeKey}" + if (key == CoreSettingKeys.KeymapActionsOverview.toString()) { + val raw = (event.newValue as? String)?.trim().orEmpty() + val overrides = if (raw.isBlank()) { + emptyMap() + } else { + runCatching { + Json.decodeFromString( + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + raw + ) + }.getOrDefault(emptyMap()) + } + KeymapMngr.applyOverridesFromStrings(overrides) + refreshFromSettingValue() + } + } + refreshFromSettingValue() + } + + override fun refreshFromSettingValue() { + tree.clear() + val ids = (KeymapMngr.declaredActionIds() + ActionRegistry.actionIds()).toSortedSet() + if (ids.isEmpty()) return + val draftOverrides = decodeDraftOverrides() + + val groups = linkedMapOf>() + ids.forEach { id -> + val category = id.substringBefore('.', missingDelimiterValue = "other") + groups.getOrPut(category) { mutableListOf() }.add(id) + } + + groups.forEach { (category, actionIds) -> + val groupItem = QTreeWidgetItem(tree).apply { + setText(0, category.replaceFirstChar { it.uppercase() }) + } + + actionIds.sorted().forEach { actionId -> + val effectiveBindings = draftOverrides[actionId] + ?.mapNotNull { KeymapMngr.parseBindingString(it) } + ?: KeymapMngr.bindingsFor(actionId) + val bindingText = effectiveBindings + .joinToString(", ") { it.displayString() } + .ifBlank { "-" } + QTreeWidgetItem(groupItem).apply { + setText(0, ActionRegistry.actionLabel(actionId)) + setToolTip(0, actionId) + setText(1, bindingText) + setData(0, actionRole, actionId) + } + } + } + tree.expandAll() + tree.sortItems(0, Qt.SortOrder.AscendingOrder) + } + + private fun showContextMenu(x: Int, y: Int) { + val item = tree.itemAt(x, y) ?: return + val actionId = item.data(0, actionRole)?.toString()?.trim().orEmpty() + if (actionId.isBlank()) return + + val menu = QMenu(this) + val addKeyboard = menu.addAction("Add Keyboard Shortcut...") + val addMouse = menu.addAction("Add Mouse Shortcut...") + menu.addSeparator() + + val existing = KeymapMngr.bindingsFor(actionId) + existing.forEach { binding -> + val remove = menu.addAction("Remove ${binding.displayString()}") + remove?.triggered?.connect { + val updated = existing.filter { it != binding } + stageActionBindings(actionId, updated) + } + } + + addKeyboard?.triggered?.connect { + addShortcut(actionId, ShortcutKind.Keyboard) + } + addMouse?.triggered?.connect { + addShortcut(actionId, ShortcutKind.Mouse) + } + val globalPoint = tree.viewport()?.mapToGlobal(io.qt.core.QPoint(x, y)) ?: return + menu.exec(globalPoint) + } + + private fun addShortcut(actionId: String, kind: ShortcutKind) { + if (!ActionRegistry.allows(actionId, kind)) { + QMessageBox.warning(this, "Shortcut Not Allowed", "This action does not allow ${kind.name.lowercase()} shortcuts.") + return + } + + val parsed = captureShortcut(kind) ?: return + + val existing = currentBindingsForAction(actionId) + if (parsed in existing) return + + val updated = existing + parsed + val conflicts = KeymapMngr.findConflicts(actionId, updated) + if (conflicts.isNotEmpty()) { + val lines = conflicts.keys.sorted().joinToString("\n") + QMessageBox.warning( + this, + "Shortcut Conflict", + "Shortcut conflicts with:\n$lines" + ) + return + } + stageActionBindings(actionId, updated) + } + + private fun captureShortcut(kind: ShortcutKind): KeyBinding? { + val dialog = QDialog(this).apply { + windowTitle = when (kind) { + ShortcutKind.Keyboard -> "Capture Keyboard Shortcut" + ShortcutKind.Mouse -> "Capture Mouse Shortcut" + } + modal = true + minimumWidth = 420 + } + + var captured: KeyBinding? = null + val instruction = label( + when (kind) { + ShortcutKind.Keyboard -> "Press the keyboard shortcut now. Press Esc to cancel." + ShortcutKind.Mouse -> "Click the mouse shortcut now. Press Esc to cancel." + }, + dialog + ) + val cancel = pushButton("Cancel", dialog) { + clicked.connect { dialog.reject() } + } + vBoxLayout(dialog) { + addWidget(instruction) + addWidget(cancel) + } + + val filter = object : QObject(dialog) { + override fun eventFilter(watched: QObject?, event: QEvent?): Boolean { + event ?: return false + if (event.type() == QEvent.Type.KeyPress) { + val keyEvent = event as? QKeyEvent ?: return false + if (keyEvent.key() == Qt.Key.Key_Escape.value()) { + dialog.reject() + return true + } + if (kind == ShortcutKind.Keyboard) { + if (keyEvent.key() in setOf( + Qt.Key.Key_Control.value(), + Qt.Key.Key_Shift.value(), + Qt.Key.Key_Alt.value(), + Qt.Key.Key_Meta.value() + ) + ) return true + captured = KeyBinding.Single( + Keystroke( + key = keyEvent.key(), + modifiers = keyEvent.modifiers().value() + ) + ) + dialog.accept() + return true + } + } + if (event.type() == QEvent.Type.MouseButtonPress && kind == ShortcutKind.Mouse) { + val mouseEvent = event as? QMouseEvent ?: return false + captured = KeyBinding.Mouse( + MouseStroke( + button = mouseEvent.button().value(), + modifiers = mouseEvent.modifiers().value() + ) + ) + dialog.accept() + return true + } + return false + } + } + + dialog.installEventFilter(filter) + QApplication.instance()?.installEventFilter(filter) + try { + dialog.exec() + } finally { + QApplication.instance()?.removeEventFilter(filter) + dialog.removeEventFilter(filter) + } + return captured + } + + private fun stageActionBindings(actionId: String, updated: List) { + val draft = decodeDraftOverrides().toMutableMap() + draft[actionId] = updated.map { it.displayString() } + ctx.updateValue(encodeDraftOverrides(draft)) + refreshFromSettingValue() + } + + private fun currentBindingsForAction(actionId: String): List { + val draft = decodeDraftOverrides() + return draft[actionId] + ?.mapNotNull { KeymapMngr.parseBindingString(it) } + ?: KeymapMngr.bindingsFor(actionId) + } + + private fun decodeDraftOverrides(): Map> { + val raw = ctx.currentValue().trim() + if (raw.isBlank()) return KeymapMngr.activeLocalOverridesAsStrings() + return runCatching { + Json.decodeFromString( + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + raw + ) + }.getOrElse { KeymapMngr.activeLocalOverridesAsStrings() } + } + + private fun encodeDraftOverrides(overrides: Map>): String { + return Json.encodeToString( + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + overrides + ) + } +} + /** * Core settings schema declarations. * @@ -231,17 +621,106 @@ internal object CoreSettings { allowForeignSettings = true } + val keymap = category("keymap") { + title = "Keymap" + description = "View all declared actions and their active keyboard/mouse shortcuts." + allowForeignSettings = true + } + + widget(keymap.path, "keymap.actions_overview") { + title = "Declared Actions" + description = "Shows action ids grouped by category and their current shortcuts." + defaultValue = "" + serializer = null + fullWidth = true + fullHeight = true + widgetFactory = { ctx -> + KeymapActionsWidget(ctx) + } + } + + toggle(ui.path, "ui.tooltip_style") { + title = "Use Game Tooltip Style (Experimental)" + description = "Applies a Tooltip style similar to Minecraft (Requires Restart)." + defaultValue = false + } + + toggle(ui.path, "ui.animate_scrolling") { + title = "Animate Scrolling (Experimental)" + description = "Smoothly animate wheel scrolling across scrollable views." + defaultValue = false + } + + toggle(ui.path, "ui.seasonal_events") { + title = "Seasonal Events" + description = "Show seasonal visual effects and features." + defaultValue = true + } + + widget(ui.path, "ui.global_font") { + title = "Global Font" + description = "Font family and size used across the UI." + defaultValue = "|10" + serializer = String.serializer() + comments = listOf( + "The Themes panel applies font changes live.", + "The font must be installed on your system or bundled with Tritium." + ) + widgetFactory = { ctx -> FontSettingWidget(ctx, FontMngr.defaultFontFamily) } + } + + widget(ui.path, "ui.editor_font") { + title = "Editor Font" + description = "Font family and size used in the code editor." + defaultValue = "|10" + serializer = String.serializer() + comments = listOf( + "The Themes panel applies font changes live.", + "The font must be installed on your system or bundled with Tritium." + ) + widgetFactory = { ctx -> FontSettingWidget(ctx, FontMngr.monoFontFamily) } + } + + widget(ui.path, "ui.background_image") { + title = "Background Image" + description = "Applies a custom background image globally to the main windows." + defaultValue = "" + serializer = String.serializer() + comments = listOf( + "Absolute path to an image file (PNG, JPG, etc.)." + ) + widgetFactory = { ctx -> + FileSettingWidget( + ctx, + dialogTitle = "Choose Background Image", + filter = "Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)" + ) + } + } + val projects = category("projects") { title = "Projects" allowForeignSettings = true } + val projectFiles = category("project_files") { + title = "Project Files" + parent = projects + allowForeignSettings = true + } + val minecraft = category("minecraft") { title = "Minecraft" parent = projects allowForeignSettings = true } + toggle(minecraft.path, "game.smart_rerun") { + title = "Smart Rerun" + description = "When enabled, clicking the Play button while the game is running sends a server reload request to the Companion mod instead of restarting the game. Hold Shift while clicking to force a full restart. Falls back to normal restart if Companion mod is unavailable." + defaultValue = true + } + val companionBridge = category("companion_bridge") { title = "Companion Bridge" parent = projects @@ -254,6 +733,49 @@ internal object CoreSettings { allowForeignSettings = true } + val editor = category("editor") { + title = "Editor" + parent = projects + allowForeignSettings = true + } + + val autoSave = toggle(editor.path, "editor.auto_save") { + title = "Auto Save" + description = "Automatically save modified files after an interval." + defaultValue = false + } + + val autoSaveInterval = text(editor.path, "editor.auto_save_interval") { + title = "Auto Save Interval (seconds)" + description = "Interval in seconds to wait before auto-saving modified files." + defaultValue = "60" + disallow("^0$") + } + autoSave.addChild(autoSaveInterval) { it } + + val indicatorIntensity = widget(editor.path, "editor.unsaved_indicator_intensity") { + title = "Unsaved Indicator Intensity" + description = "How intense the unsaved changes indicator should be." + defaultValue = "low" + serializer = String.serializer() + widgetFactory = { ctx -> + ChoiceSettingWidget( + ctx, + options = listOf( + ChoiceSettingOption("low", "Low"), + ChoiceSettingOption("high", "High") + ) + ) + } + } + autoSave.addChild(indicatorIntensity) { !it } + + toggle(editor.path, "editor.rainbow_brackets") { + title = "Rainbow Brackets (Experimental)" + description = "Color code brackets, parentheses, and curly braces based on nesting depth." + defaultValue = false + } + toggle(projects.path, "projects.close_dashboard_on_open") { title = "Close Dashboard When Opening Project" description = "Automatically close the dashboard after opening a project window." @@ -340,23 +862,42 @@ internal object CoreSettings { } } - text(minecraft.path, "modpack.mc_args") { + widget(projectFiles.path, "projects.files.config_sort") { + title = "Config Directory Sort Mode" + description = "Controls how files are sorted inside the project's /config directory." + defaultValue = "alphabetical" + serializer = String.serializer() + comments = listOf( + "Allowed values: alphabetical, file_type." + ) + widgetFactory = { ctx -> + ChoiceSettingWidget( + ctx, + options = listOf( + ChoiceSettingOption("alphabetical", "Alphabetical"), + ChoiceSettingOption("file_type", "File Type") + ) + ) + } + } + + text(minecraft.path, "source.mc_args") { title = "Modpack JVM Args" - description = "Additional JVM arguments to append for modpack launches." + description = "Additional JVM arguments to append for source launches." defaultValue = "" placeholder = "-Dexample=true" comments = listOf( - "Space-separated extra JVM arguments for modpack launch." + "Space-separated extra JVM arguments for source launch." ) } - text(minecraft.path, "modpack.mc_memory_mb") { + text(minecraft.path, "source.mc_memory_mb") { title = "Modpack Memory (MB)" - description = "Default memory allocation in MB for modpack launches." + description = "Default memory allocation in MB for source launches." defaultValue = "6144" placeholder = "6144" comments = listOf( - "Memory allocation for modpack launches in megabytes." + "Memory allocation for source launches in megabytes." ) } @@ -369,6 +910,12 @@ internal object CoreSettings { ) } + toggle(minecraft.path, "mods.cache_enabled") { + title = "Mod Cache" + description = "Cache downloaded mod jars in a shared directory (~/.tritium/mod-cache/) for reuse across projects." + defaultValue = false + } + widget(ui.path, "ui.dashboard.window_size") { title = "Dashboard Window Size" description = "Fixed dashboard window size represented as WIDTH x HEIGHT." diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSExtension.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSExtension.kt new file mode 100644 index 0000000..d7ada93 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSExtension.kt @@ -0,0 +1,69 @@ +package io.github.tritium_launcher.launcher.extension.kubejs + +import io.github.tritium_launcher.launcher.extension.Extension +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.matches +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.TreeSitterService +import io.github.tritium_launcher.launcher.ui.project.sidebar.projectRootDirectory +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.qt.gui.QIcon +import org.koin.core.module.Module +import org.koin.dsl.module + +class KubeJSExtension : Extension { + override val namespace: String = "kubejs" + override val displayName: String = "KubeJS" + override val description: String = "KubeJS script editing — syntax highlighting, file type detection, project root directory" + override val requiresRestart: Boolean = true + override val icon: QIcon get() = TIcons.KubeScript.icon + + override val modules: List = listOf(module { + single(createdAtStart = true) { + KubeJSInitializer(this@KubeJSExtension).also { it.init() } + } + }) + + class KubeJSInitializer(private val ext: Extension) { + fun init() { + TreeSitterService.init() + with(ext) { + BuiltinRegistries.FileType.register(KubeScriptType) + BuiltinRegistries.ProjectRootDirectory.register(projectRootDirectory("kubejs", "kubejs", "KubeJS")) + BuiltinRegistries.SyntaxLanguage.register(KubeScriptLanguage) + } + } + } + + companion object { + val KubeScriptType = FileTypeDescriptor.create( + id = "kubescript", + displayName = "KubeJS Script", + icon = TIcons.KubeScript.icon, + matches = { file, _ -> + file.parent().fileName().matches("startup_scripts", "server_scripts", "client_scripts") && + file.extension().matches("js") + }, + order = -10, + canCreateIn = { directory, _ -> + directory.fileName().matches("startup_scripts", "server_scripts", "client_scripts") + }, + defaultFileName = { "" }, + createDefaultFile = { directory, name, _ -> + val fileName = "$name.js" + val file = directory.resolve(fileName) + runCatching { file.writeBytesAtomic(ByteArray(0)); file }.getOrNull() + } + ) + + val KubeScriptLanguage = SyntaxLanguage.create( + id = "kubescript", + displayName = "KubeJS Script", + predicate = { this.parent().fileName().matches("startup_scripts", "server_scripts", "client_scripts") && + this.extension().matches("js") + }, + ) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSIntelligenceService.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSIntelligenceService.kt new file mode 100644 index 0000000..08c8073 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/extension/kubejs/KubeJSIntelligenceService.kt @@ -0,0 +1,1878 @@ +package io.github.tritium_launcher.launcher.extension.kubejs + +import io.github.treesitter.ktreesitter.Node +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.kubejs.KubeJSIntelligenceService.getCompletions +import io.github.tritium_launcher.launcher.extension.kubejs.typings.KubeTypings +import io.github.tritium_launcher.launcher.extension.kubejs.typings.TypeKind +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.ui.project.editor.intelligence.CompletionItem +import io.github.tritium_launcher.launcher.ui.project.editor.intelligence.CompletionItemKind +import io.github.tritium_launcher.launcher.ui.project.editor.intelligence.HoverContent +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.ItemSlotInfo +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.TreeSitterService +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.sqlite.SQLiteConfig +import java.nio.ByteBuffer +import java.sql.Connection + +/** + * Provides KubeJS-aware editor intelligence from the per-project registry export. + * + * The service prefers the FlatBuffer snapshot at `registryObjs//typings.fb` + * because it can be loaded into compact immutable lookup structures, then falls + * back to the fallback SQLite typings database for completion and hover paths that + * still support that storage format. It also owns the Tree-sitter-based heuristics + * used to infer callback parameter types, call signatures, recipe schema argument + * types, and item string drop targets inside KubeJS scripts. + */ +object KubeJSIntelligenceService { + private val logger = logger() + private val json = Json { ignoreUnknownKeys = true } + + /** + * Deserializes `registryObjs/latest.json`, which points at the newest registry + * snapshot directory and identifies the snapshot that produced the typings data. + */ + @Serializable + private data class LatestPointer( + val path: String, + @SerialName("snapshotId") + val snapshotId: String + ) + + /** + * Represents a global JavaScript binding exposed by KubeJS, such as `Item`, + * `Block`, or an event group root object. + */ + private data class BindingInfo( + val name: String, + val type: String, + val documentation: String, + val side: String + ) + + /** + * Captures one callable or property member from a Java/KubeJS type, including + * enough parameter metadata to build completion details and signature help. + */ + private data class MemberInfo( + val name: String, + val type: String, + val isMethod: Boolean, + val isStatic: Boolean = false, + val parameters: List> = emptyList() + ) { + /** + * Formats a compact display label that includes parameters only when this + * member is a method and the registry export supplied parameter metadata. + */ + fun displayName(): String = if (isMethod && parameters.isNotEmpty()) { + "$name(${parameters.joinToString { "${it.first}: ${it.second}" }})" + } else name + + /** + * Formats the member as a TypeScript-like method or field signature for + * completion documentation and hover-style detail text. + */ + fun signature(): String = if (isMethod) { + val params = if (parameters.isEmpty()) "" else parameters.joinToString { "${it.first}: ${it.second}" } + "$name($params): $type" + } else "$name: $type" + } + + /** + * Describes a type exported by the KubeJS typings snapshot, including its + * inheritance edges so member lookup can walk superclasses and interfaces. + */ + private data class ClassInfo( + val fullName: String, + val simpleName: String, + val kind: Byte, + val typeParams: List, + val methods: List, + val fields: List, + val constructors: List, + val documentation: String, + val superClass: String, + val interfaces: List + ) + + /** + * Describes an event registration function such as `ServerEvents.recipes`, + * including the callback event class used to infer callback parameter members. + */ + private data class EventInfo( + val groupName: String, + val eventName: String, + val eventClass: String, + val side: String, + val extraType: String, + val targetRequired: Boolean, + val documentation: String + ) + + /** + * Stores a KubeJS recipe schema binding so recipe helper fields can expose + * useful argument signatures and item-drop validation. + */ + private data class RecipeSchemaInfo( + val namespace: String, + val schemaId: String, + val recipeClass: String, + val keys: List, + val documentation: String + ) + + /** + * Describes a single positional or named key from a recipe schema. + */ + private data class RecipeKeyInfo( + val name: String, + val type: String, + val optional: Boolean + ) + + /** + * Groups the top-level collections read from a FlatBuffer typings snapshot + * before they are indexed into the runtime cache. + */ + private data class ParseResult( + val bindings: List, + val classes: List, + val events: List, + val recipes: List + ) + + /** + * Holds the fully parsed FlatBuffer typings for one project plus the lookup + * maps needed by hot editor paths. + */ + private data class FlatCache( + val projectDir: String, + val bindings: List, + val classes: List, + val events: List, + val recipes: List, + val bindingByName: Map, + val classBySimpleName: Map, + val classByFullName: Map, + val eventsByGroup: Map> + ) + + /** + * Holds the read-only SQLite connection for one project's fallback typings + * database so repeated editor queries do not reopen the file. + */ + private data class SqliteCache( + val projectDir: String, + val connection: Connection + ) + + @Volatile + private var flatCache: FlatCache? = null + + @Volatile + private var sqliteCache: SqliteCache? = null + + /** + * Drops all cached KubeJS typing state and closes the SQLite connection. + */ + fun invalidateConnection() { + sqliteCache?.connection?.close() + flatCache = null + sqliteCache = null + } + + /** + * Loads and indexes the current project's FlatBuffer typings snapshot. + * + * Returns the in-memory cache when `latest.json` and `typings.fb` are present + * and parse successfully; otherwise returns `null` so callers can fall back to + * SQLite or suppress KubeJS-specific intelligence. + */ + private fun getData(project: ProjectBase): FlatCache? { + val projectDir = project.projectDir.toString() + val current = flatCache + if (current != null && current.projectDir == projectDir) { + return current + } + + val registryRoot = project.projectDir.resolve("registryObjs") + val latestPath = registryRoot.resolve("latest.json") + if (!latestPath.exists()) { + logger.info("getData: latest.json not found at {}", latestPath) + return null + } + + val pointer: LatestPointer = try { + json.decodeFromString(latestPath.readTextOrNull() ?: return null) + } catch (t: Throwable) { + logger.warn("getData: failed to read latest.json for project {}", project.name, t) + return null + } + + val typingsPath = registryRoot.resolve(pointer.path).resolve("typings.fb") + if (!typingsPath.exists()) { + logger.info("getData: typings.fb not found at {}", typingsPath) + return null + } + + val bytes = typingsPath.bytesOrNull() ?: return null + logger.info("getData: reading typings.fb ({} bytes) from {}", bytes.size, typingsPath) + val buf = ByteBuffer.wrap(bytes) + + val (bindings, classes, events, recipes) = try { + parseTypings(buf) + } catch (t: Throwable) { + logger.error("getData: FAILED to parse typings.fb", t) + return null + } + + logger.info("getData: loaded {} bindings, {} classes, {} events, {} recipes", + bindings.size, classes.size, events.size, recipes.size) + + val cache = FlatCache( + projectDir = projectDir, + bindings = bindings, + classes = classes, + events = events, + recipes = recipes, + bindingByName = bindings.associateBy { it.name }, + classBySimpleName = classes.associateBy { it.simpleName }, + classByFullName = classes.associateBy { it.fullName }, + eventsByGroup = events.groupBy { it.groupName } + ) + flatCache = cache + return cache + } + + /** + * Converts the generated FlatBuffer typings payload into Kotlin model objects. + * + * The generated FlatBuffer accessors expose nullable strings and indexed child + * collections, so this method normalizes missing values to empty strings and + * materializes lists that can be searched repeatedly by editor features. + */ + private fun parseTypings(buf: ByteBuffer): ParseResult { + val root = KubeTypings.getRootAsKubeTypings(buf) + + val bindings = mutableListOf() + val classes = mutableListOf() + val events = mutableListOf() + val recipes = mutableListOf() + + for (i in 0 until root.bindingsLength()) { + val b = root.bindings(i) ?: continue + bindings.add(BindingInfo( + name = b.name() ?: "", + type = b.type() ?: "", + documentation = b.documentation() ?: "", + side = b.side() ?: "" + )) + } + + for (i in 0 until root.classesLength()) { + val c = root.classes(i) ?: continue + val methods = mutableListOf() + for (j in 0 until c.methodsLength()) { + val m = c.methods(j) ?: continue + val params = mutableListOf>() + for (k in 0 until m.parametersLength()) { + val p = m.parameters(k) ?: continue + params.add((p.name() ?: "") to (p.type() ?: "")) + } + methods.add(MemberInfo( + name = m.name() ?: "", + type = m.returnType() ?: "", + isMethod = true, + isStatic = m.isStatic(), + parameters = params + )) + } + val fields = mutableListOf() + for (j in 0 until c.fieldsLength()) { + val f = c.fields(j) ?: continue + fields.add(MemberInfo( + name = f.name() ?: "", + type = f.type() ?: "", + isMethod = false, + isStatic = f.isStatic() + )) + } + val typeParams = mutableListOf() + for (j in 0 until c.typeParamsLength()) { + typeParams.add(c.typeParams(j) ?: "") + } + val constructors = mutableListOf() + for (j in 0 until c.constructorsLength()) { + val ct = c.constructors(j) ?: continue + val paramNames = mutableListOf() + for (k in 0 until ct.parametersLength()) { + val p = ct.parameters(k) ?: continue + paramNames.add("${p.name() ?: ""}: ${p.type() ?: ""}") + } + constructors.add("constructor(${paramNames.joinToString(", ")})") + } + val interfaces = mutableListOf() + for (j in 0 until c.interfacesLength()) { + interfaces.add(c.interfaces(j) ?: "") + } + classes.add(ClassInfo( + fullName = c.fullName() ?: "", + simpleName = c.simpleName() ?: "", + kind = c.kind(), + typeParams = typeParams, + methods = methods, + fields = fields, + constructors = constructors, + documentation = c.documentation() ?: "", + superClass = c.superClass() ?: "", + interfaces = interfaces + )) + } + + for (i in 0 until root.eventsLength()) { + val e = root.events(i) ?: continue + events.add(EventInfo( + groupName = e.groupName() ?: "", + eventName = e.eventName() ?: "", + eventClass = e.eventClass() ?: "", + side = e.side() ?: "", + extraType = e.extraType() ?: "", + targetRequired = e.targetRequired(), + documentation = e.documentation() ?: "" + )) + } + + for (i in 0 until root.recipesLength()) { + val r = root.recipes(i) ?: continue + val keys = mutableListOf() + for (j in 0 until r.keysLength()) { + val k = r.keys(j) ?: continue + keys.add(RecipeKeyInfo( + name = k.name() ?: "", + type = k.type() ?: "", + optional = k.optional() + )) + } + recipes.add(RecipeSchemaInfo( + namespace = r.namespace() ?: "", + schemaId = r.schemaId() ?: "", + recipeClass = r.recipeClass() ?: "", + keys = keys, + documentation = r.documentation() ?: "" + )) + } + + return ParseResult(bindings, classes, events, recipes) + } + + /** + * Opens the fallback per-project KubeJS typings database in read-only mode. + * + * The returned cache is reused while it still belongs to the same project + * directory, and any previous connection is closed before switching projects. + */ + private fun getSqlite(project: ProjectBase): SqliteCache? { + val projectDir = project.projectDir.toString() + val current = sqliteCache + if (current != null && current.projectDir == projectDir) { + return current + } + + val dbPath = project.projectDir.resolve("registryObjs/kubejs_typings.db") + if (!dbPath.exists()) return null + + return try { + sqliteCache?.connection?.close() + Class.forName("org.sqlite.JDBC") + val config = SQLiteConfig().apply { setReadOnly(true) } + val conn = config.createConnection("jdbc:sqlite:${dbPath.toAbsolute()}") + val cache = SqliteCache(projectDir, conn) + sqliteCache = cache + cache + } catch (t: Throwable) { + logger.warn("Failed to open KubeJS typings database for project {}", project.name, t) + null + } + } + + /** + * Builds completion items for all visible members of [className], walking + * superclasses and interfaces while de-duplicating inherited overloads. + */ + private fun membersOfClass(cache: FlatCache, className: String): List { + val seen = mutableSetOf() + val seenMembers = mutableSetOf() + val items = mutableListOf() + val queue = ArrayDeque() + queue.add(className) + while (queue.isNotEmpty()) { + val currentName = queue.removeFirst() + if (currentName in seen) continue + seen.add(currentName) + val cls = cache.classByFullName[currentName] ?: cache.classBySimpleName[currentName] ?: continue + for (m in cls.methods) { + val key = "${m.name}|${m.parameters.size}|${m.parameters.joinToString { it.second }}" + if (seenMembers.add(key)) { + val paramsStr = if (m.parameters.isNotEmpty()) "(${m.parameters.joinToString { "${it.first}: ${formatDisplayType(it.second)}" }})" else "()" + items.add(CompletionItem( + label = m.name, + kind = CompletionItemKind.Method, + detail = "$paramsStr: ${formatDisplayType(m.type)}", + documentation = m.signature() + )) + } + } + for (f in cls.fields) { + val key = "field:${f.name}" + if (seenMembers.add(key)) { + items.add(CompletionItem( + label = f.name, + kind = CompletionItemKind.Field, + detail = f.type, + documentation = f.signature() + )) + } + } + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue.add(superClass) + for (iface in cls.interfaces) { + queue.add(iface) + } + } + return items + } + + /** + * Returns event names registered under a KubeJS event group as callable + * completion items, for example members shown after `ServerEvents.`. + */ + private fun eventHandlersOfGroup(cache: FlatCache, groupName: String): List { + return cache.eventsByGroup[groupName]?.map { e -> + CompletionItem( + label = e.eventName, + kind = CompletionItemKind.Method, + detail = null, + documentation = e.eventClass + ) + } ?: emptyList() + } + + /** + * Returns line-oriented completions for the current cursor position. + * + * This path handles global names and simple dotted receivers from the current + * line only. It tries FlatBuffer-backed data first, then SQLite if the newer + * snapshot is unavailable. + */ + fun getCompletions(project: ProjectBase, line: String, column: Int): List { + val fb = getData(project) + if (fb != null) { + return getCompletionsFlat(fb, line, column) + } + + val sqlite = getSqlite(project) ?: return emptyList() + return getCompletionsSqlite(sqlite, line, column) + } + + /** + * Returns completions that may require the full document and AST context. + * + * This extends [getCompletions] by resolving callback parameter receivers such + * as `event.` inside `ServerEvents.recipes(event => ...)`. + */ + fun getContextualCompletions(project: ProjectBase, fullText: String, cursorPos: Int): List { + val fb = getData(project) + if (fb != null) { + return getContextualCompletionsFlat(fb, fullText, cursorPos) + } + val sqlite = getSqlite(project) ?: return emptyList() + return getContextualCompletionsSqlite(sqlite, fullText, cursorPos) + } + + /** + * Implements full-document contextual completion against the FlatBuffer cache. + * + * The method first attempts normal line completions, then uses Tree-sitter to + * infer the type of a dotted callback parameter receiver when no simple result + * is available. + */ + private fun getContextualCompletionsFlat(cache: FlatCache, fullText: String, cursorPos: Int): List { + val lineStart = fullText.lastIndexOf('\n', cursorPos - 1).let { if (it == -1) 0 else it + 1 } + val lineEnd = fullText.indexOf('\n', cursorPos).let { if (it == -1) fullText.length else it } + val line = fullText.substring(lineStart, lineEnd) + val column = cursorPos - lineStart + + val existing = getCompletionsFlat(cache, line, column) + logger.info("getContextualCompletionsFlat: line='{}' col={} existing={}", line, column, existing.size) + if (existing.isNotEmpty()) return existing + + val prefix = line.substring(0, column) + val dotPos = prefix.lastIndexOf('.') + if (dotPos == -1) { + logger.info("getContextualCompletionsFlat: no dot in prefix '{}'", prefix) + return existing + } + + val varName = prefix.substring(0, dotPos).trim().split(Regex("[^a-zA-Z0-9_]")).lastOrNull() ?: return existing.also { + logger.info("getContextualCompletionsFlat: varName is null") + } + logger.info("getContextualCompletionsFlat: dotPos={} varName='{}'", dotPos, varName) + val typeName = resolveCallbackParameterTypeFlat(cache, fullText, cursorPos, varName) + logger.info("getContextualCompletionsFlat: resolved typeName='{}'", typeName) + if (typeName == null) return existing + val members = membersOfClass(cache, typeName) + logger.info("getContextualCompletionsFlat: membersOfClass({}) = {} items", typeName, members.size) + return members + } + + /** + * Resolves the class of a callback parameter by walking from the cursor to the + * enclosing function and matching that callback to its event registration call. + */ + private fun resolveCallbackParameterTypeFlat(cache: FlatCache, fullText: String, cursorPos: Int, varName: String): String? { + if (!TreeSitterService.isAvailable()) { + logger.info("resolveCallbackParameterTypeFlat: TreeSitter not available") + return null + } + val result = TreeSitterService.parse(fullText) ?: return null.also { + logger.info("resolveCallbackParameterTypeFlat: parse returned null") + } + val node = result.findNodeAt(cursorPos) ?: return null.also { + logger.info("resolveCallbackParameterTypeFlat: findNodeAt({}) returned null", cursorPos) + } + logger.info("resolveCallbackParameterTypeFlat: deepest node type='{}' text='{}' pos={}-{}", + node.type, node.text().toString().replace("\n", "\\n"), node.startByte, node.endByte) + + var current: Node? = node + while (current?.parent != null) { + current = current.parent!! + logger.info("resolveCallbackParameterTypeFlat: visiting parent type='{}'", current.type) + if (current.type == "arrow_function" || current.type == "function") { + val paramOk = isParamOf(current, varName) + logger.info("resolveCallbackParameterTypeFlat: found {} isParamOf('{}')={}", current.type, varName, paramOk) + if (!paramOk) break + + val args = current.parent + logger.info("resolveCallbackParameterTypeFlat: args parent type='{}'", args?.type) + if (args == null || args.type != "arguments") break + val call = args.parent + logger.info("resolveCallbackParameterTypeFlat: call parent type='{}'", call?.type) + if (call == null || call.type != "call_expression") break + + val callTarget = extractCallTarget(call) + logger.info("resolveCallbackParameterTypeFlat: callTarget='{}'", callTarget) + if (callTarget == null) break + val dotIdx = callTarget.indexOf('.') + if (dotIdx == -1) break + val group = callTarget.substring(0, dotIdx) + val eventName = callTarget.substring(dotIdx + 1) + logger.info("resolveCallbackParameterTypeFlat: group='{}' eventName='{}'", group, eventName) + val event = cache.eventsByGroup[group]?.find { it.eventName == eventName } + logger.info("resolveCallbackParameterTypeFlat: event found={} class={}", event != null, event?.eventClass) + return event?.eventClass + } + } + logger.info("resolveCallbackParameterTypeFlat: reached root without finding arrow_function") + return null + } + + /** + * Checks whether [varName] is declared as a parameter of a Tree-sitter + * JavaScript function or arrow-function node. + */ + private fun isParamOf(fnNode: Node, varName: String): Boolean { + val namedChildren = fnNode.children.toList() + if (namedChildren.isEmpty()) return false + val first = namedChildren[0] + return when (first.type) { + "identifier" -> first.text().toString() == varName + "formal_parameters" -> { + first.children.any { it.type == "identifier" && it.text().toString() == varName } + } + else -> false + } + } + + /** + * Recovers a call target from a Tree-sitter `ERROR` node created while the + * user is typing an incomplete call expression. + */ + private fun resolveIncompleteCallFromError(errorNode: Node): String? { + for (child in errorNode.children.toList()) { + val found = findCallTargetRecursive(child) + if (found != null) return found + } + return null + } + + /** + * Searches a Tree-sitter subtree for the first plausible function or member + * expression target that can be used for signature recovery. + */ + private fun findCallTargetRecursive(node: Node): String? { + when (node.type) { + "member_expression" -> { + val parts = mutableListOf() + collectMemberParts(node, parts) + if (parts.size >= 2) return parts.joinToString(".") + } + "identifier" -> return node.text().toString() + "call_expression" -> { + val target = extractCallTarget(node) + if (target != null) return target + } + } + for (child in node.children.toList()) { + val found = findCallTargetRecursive(child) + if (found != null) return found + } + return null + } + + /** + * Extracts a dotted call target such as `ServerEvents.recipes` or + * `event.shaped` from a Tree-sitter `call_expression` node. + */ + private fun extractCallTarget(call: Node): String? { + val namedChildren = call.children.toList() + logger.info("extractCallTarget: {} named children, first type='{}' text='{}'", + namedChildren.size, namedChildren.firstOrNull()?.type, namedChildren.firstOrNull()?.text().toString().replace("\n", "\\n")) + namedChildren.forEachIndexed { i, n -> + logger.info("extractCallTarget: children[{}] type='{}' text='{}'", i, n.type, n.text().toString().replace("\n", "\\n")) + } + if (namedChildren.isEmpty()) return null + val func = namedChildren[0] + logger.info("extractCallTarget: func.type='{}'", func.type) + return if (func.type == "member_expression") { + val parts = mutableListOf() + collectMemberParts(func, parts) + logger.info("extractCallTarget: member_expression parts={}", parts) + if (parts.size >= 2) parts.joinToString(".") else null + } else if (func.type == "identifier") { + func.text().toString() + } else { + logger.info("extractCallTarget: unexpected func type '{}', returning null", func.type) + null + } + } + + /** + * Flattens a nested Tree-sitter member expression into identifier parts in + * source order. + */ + private fun collectMemberParts(node: Node, parts: MutableList) { + when (node.type) { + "identifier" -> parts.add(node.text().toString()) + "property_identifier" -> parts.add(node.text().toString()) + "member_expression" -> { + for (child in node.children.toList()) { + collectMemberParts(child, parts) + } + } + } + } + + /** + * Implements current-line completion against the FlatBuffer cache. + * + * Dotted prefixes resolve binding members, class members, or event handlers; + * non-dotted prefixes resolve global bindings, classes, and event groups. + */ + private fun getCompletionsFlat(cache: FlatCache, line: String, column: Int): List { + val prefix = line.substring(0, column) + val lastDotIndex = prefix.lastIndexOf('.') + + return if (lastDotIndex != -1) { + val objectName = prefix.substring(0, lastDotIndex).trim().split(Regex("[^a-zA-Z0-9_]")).lastOrNull() + if (objectName != null) { + val members = mutableListOf() + val binding = cache.bindingByName[objectName] + if (binding != null) { + members.addAll(membersOfClass(cache, binding.type)) + } + val cls = cache.classBySimpleName[objectName] ?: cache.classByFullName[objectName] + if (cls != null) { + members.addAll(membersOfClass(cache, cls.fullName)) + } + members.addAll(eventHandlersOfGroup(cache, objectName)) + members + } else { + emptyList() + } + } else { + val word = prefix.split(Regex("[^a-zA-Z0-9_$]")).lastOrNull()?.lowercase() ?: "" + getGlobalCompletionsFlat(cache, word) + } + } + + /** + * Builds global completion items from FlatBuffer bindings, classes, and event + * groups, optionally filtering by the lowercase [word] prefix. + */ + private fun getGlobalCompletionsFlat(cache: FlatCache, word: String = ""): List { + val items = mutableListOf() + val lowerWord = word.lowercase() + + for (b in cache.bindings) { + if (lowerWord.isNotEmpty() && !b.name.lowercase().startsWith(lowerWord)) continue + items.add(CompletionItem( + label = b.name, + kind = CompletionItemKind.Variable, + detail = null, + documentation = b.documentation.ifEmpty { b.type } + )) + } + + for (c in cache.classes) { + if (lowerWord.isNotEmpty() && !c.simpleName.lowercase().startsWith(lowerWord)) continue + items.add(CompletionItem( + label = c.simpleName, + kind = CompletionItemKind.Class, + detail = null, + documentation = c.fullName + )) + } + + for (groupName in cache.eventsByGroup.keys) { + if (lowerWord.isNotEmpty() && !groupName.lowercase().startsWith(lowerWord)) continue + items.add(CompletionItem( + label = groupName, + kind = CompletionItemKind.Module, + detail = null, + documentation = null + )) + } + + return items + } + + /** + * Implements current-line completion against the SQLite typings database. + */ + private fun getCompletionsSqlite(cache: SqliteCache, line: String, column: Int): List { + val conn = cache.connection + val prefix = line.substring(0, column) + val lastDotIndex = prefix.lastIndexOf('.') + + return if (lastDotIndex != -1) { + val objectName = prefix.substring(0, lastDotIndex).trim().split(Regex("[^a-zA-Z0-9_]")).lastOrNull() + if (objectName != null) { + val bindingType = getBindingTypeSqlite(conn, objectName) + if (bindingType != null) { + return getMembersSqlite(conn, bindingType) + } + val fullClassName = getFullClassNameSqlite(conn, objectName) + if (fullClassName != null) { + return getMembersSqlite(conn, fullClassName) + } + return getEventHandlersSqlite(conn, objectName) + } + emptyList() + } else { + val word = prefix.split(Regex("[^a-zA-Z0-9_$]")).lastOrNull()?.lowercase() ?: "" + getGlobalCompletionsSqlite(conn, word) + } + } + + /** + * Implements full-document contextual completion against the SQLite typings + * database for projects that do not have a FlatBuffer snapshot. + */ + private fun getContextualCompletionsSqlite(cache: SqliteCache, fullText: String, cursorPos: Int): List { + val conn = cache.connection + val lineStart = fullText.lastIndexOf('\n', cursorPos - 1).let { if (it == -1) 0 else it + 1 } + val lineEnd = fullText.indexOf('\n', cursorPos).let { if (it == -1) fullText.length else it } + val line = fullText.substring(lineStart, lineEnd) + val column = cursorPos - lineStart + + val existing = getCompletionsSqlite(cache, line, column) + if (existing.isNotEmpty()) return existing + + val prefix = line.substring(0, column) + val dotPos = prefix.lastIndexOf('.') + if (dotPos == -1) return existing + + val varName = prefix.substring(0, dotPos).trim().split(Regex("[^a-zA-Z0-9_]")).lastOrNull() ?: return existing + val typeName = resolveCallbackParameterTypeSqlite(conn, fullText, cursorPos, varName) ?: return existing + return getMembersSqlite(conn, typeName) + } + + /** + * Resolves the best signature-help string for the call surrounding [cursorPos]. + * + * Signature help currently requires the FlatBuffer cache because it depends on + * recipe schemas and class metadata that are most complete in that path. + */ + fun getSignatureHelp(project: ProjectBase, fullText: String, cursorPos: Int): String? { + logger.info("getSignatureHelp: called cursorPos={} fullTextLen={}", cursorPos, fullText.length) + val fb = getData(project) + logger.info("getSignatureHelp: fb={}", fb != null) + if (fb != null) { + val result = getSignatureHelpFlat(fb, fullText, cursorPos) + logger.info("getSignatureHelp: result='{}'", result) + return result + } + return null + } + + /** + * Implements signature help by combining Tree-sitter parent-chain lookup with + * text fallbacks for incomplete or temporarily invalid JavaScript. + */ + private fun getSignatureHelpFlat(cache: FlatCache, fullText: String, cursorPos: Int): String? { + if (!TreeSitterService.isAvailable()) return null + val result = TreeSitterService.parse(fullText) ?: return null.also { + logger.info("getSignatureHelpFlat: parse returned null") + } + val start = result.findNodeAt(cursorPos) ?: return null.also { + logger.info("getSignatureHelpFlat: findNodeAt({}) returned null", cursorPos) + } + logger.info("getSignatureHelpFlat: deepest node type='{}' text='{}'", start.type, start.text().toString().replace("\n", "\\n")) + var node: Node? = start + var passedCallback = false + + while (node != null) { + logger.info("getSignatureHelpFlat: visiting type='{}'", node.type) + if (node.type == "arrow_function" || node.type == "function") { + passedCallback = true + } + + if (node.type == "arguments") { + if (passedCallback) { + logger.info("getSignatureHelpFlat: skipping arguments (inside callback)") + } else { + val call = node.parent ?: break + logger.info("getSignatureHelpFlat: found arguments, call type='{}'", call.type) + if (call.type != "call_expression") break + val paramIndex = currentParameterIndex(fullText, node, cursorPos) + val sig = resolveSignatureForCall(cache, call, paramIndex) + if (sig != null) return sig + } + } + if (node.type == "call_expression") { + if (passedCallback) { + logger.info("getSignatureHelpFlat: skipping call_expression (inside callback)") + } else { + val argsChild = node.children.find { it.type == "arguments" } + val funcChild = node.children.find { it.type == "member_expression" || it.type == "identifier" } + val cursorInArgs = if (argsChild != null) { + cursorPos >= argsChild.startByte.toInt() + } else if (funcChild != null) { + cursorPos >= funcChild.endByte.toInt() + } else { + false + } + if (!cursorInArgs) { + logger.info("getSignatureHelpFlat: skipping call_expression (cursor not in argument area)") + } else { + logger.info("getSignatureHelpFlat: found call_expression directly") + val paramIndex = if (argsChild != null) { + currentParameterIndex(fullText, argsChild, cursorPos) + } else { + 0 + } + val sig = resolveSignatureForCall(cache, node, paramIndex) + if (sig != null) return sig + } + } + } + if (node.type == "ERROR") { + if (passedCallback) { + logger.info("getSignatureHelpFlat: skipping ERROR (inside callback, already passed relevant scope)") + } else { + logger.info("getSignatureHelpFlat: visiting ERROR node") + val target = resolveIncompleteCallFromError(node) + if (target != null) { + logger.info("getSignatureHelpFlat: resolved incomplete call target='{}'", target) + val parts = target.split('.') + if (parts.size >= 2) { + val varName = parts[0] + val methodName = parts.drop(1).joinToString(".") + val typeName = resolveVarTypeFromEnclosingCallback(cache, node, varName) + if (typeName != null) { + val paramIndex = estimateParamIndexInError(fullText, node, cursorPos) + val sig = formatMethodSignature(cache, typeName, methodName, target, paramIndex) + if (sig != null) return sig + val fieldSig = formatFieldSignature(cache, typeName, methodName, target, paramIndex) + if (fieldSig != null) return fieldSig + } + } + } + } + } + node = node.parent + } + logger.info("getSignatureHelpFlat: no arguments/call_expression node found in parent chain") + + val parenPos = fullText.lastIndexOf('(', cursorPos - 1) + if (parenPos >= 0) { + val beforeParen = fullText.substring(0, parenPos).trimEnd() + val dotIdx = beforeParen.lastIndexOf('.') + if (dotIdx > 0) { + val rawVar = beforeParen.substring(0, dotIdx).trim() + val varName = rawVar.split(Regex("[^a-zA-Z0-9_]")).lastOrNull() + val methodName = beforeParen.substring(dotIdx + 1).trim() + if (varName != null && methodName.isNotEmpty() && rawVar.contains(varName)) { + val target = "$varName.$methodName" + logger.info("getSignatureHelpFlat: text fallback target='{}'", target) + + val allParts = target.split('.') + if (allParts.size >= 2) { + val group = allParts[0] + val eventName = allParts.drop(1).joinToString(".") + val event = cache.eventsByGroup[group]?.find { it.eventName == eventName } + if (event != null) { + return "$target(callback: ${event.eventClass.substringAfterLast('.')})" + } + } + + var typeName = resolveVarTypeFromEnclosingCallback(cache, start, varName) + if (typeName == null) { + typeName = resolveVarTypeTextBased(cache, fullText, parenPos, varName) + logger.info("getSignatureHelpFlat: text fallback text-based typeName='{}'", typeName) + } + if (typeName != null) { + val argsText = fullText.substring(parenPos + 1, cursorPos.coerceAtMost(fullText.length)) + val paramIndex = countCommasInText(argsText) + val sig = formatMethodSignature(cache, typeName, methodName, target, paramIndex) + if (sig != null) return sig + val fieldSig = formatFieldSignature(cache, typeName, methodName, target, paramIndex) + if (fieldSig != null) return fieldSig + } + } + } + } + return null + } + + /** + * Resolves a formatted signature for a complete Tree-sitter call expression. + * + * Event registrations are handled before instance methods so calls such as + * `ServerEvents.recipes(...)` display the event callback type directly. + */ + private fun resolveSignatureForCall(cache: FlatCache, call: Node, paramIndex: Int = 0): String? { + val target = extractCallTarget(call) ?: return null.also { + logger.info("resolveSignatureForCall: extractCallTarget returned null") + } + logger.info("resolveSignatureForCall: target='{}'", target) + val parts = target.split('.') + + if (parts.size >= 2) { + val groupName = parts[0] + val eventLookup = parts.drop(1).joinToString(".") + val event = cache.eventsByGroup[groupName]?.find { it.eventName == eventLookup } + logger.info("resolveSignatureForCall: event registration check: group='{}' event='{}' found={}", groupName, eventLookup, event != null) + if (event != null) { + val callbackType = event.eventClass.substringAfterLast('.') + return "$target(callback: $callbackType)" + } + } + + if (parts.size >= 2) { + val varName = parts[0] + val methodName = parts.drop(1).joinToString(".") + logger.info("resolveSignatureForCall: method lookup: varName='{}' methodName='{}'", varName, methodName) + val typeName = resolveVarTypeFromEnclosingCallback(cache, call, varName) + logger.info("resolveSignatureForCall: resolved typeName='{}'", typeName) + if (typeName != null) { + val sig = formatMethodSignature(cache, typeName, methodName, target, paramIndex) + logger.info("resolveSignatureForCall: formatted sig='{}'", sig) + if (sig != null) return sig + + val fieldSig = formatFieldSignature(cache, typeName, methodName, target, paramIndex) + logger.info("resolveSignatureForCall: field sig='{}'", fieldSig) + if (fieldSig != null) return fieldSig + } + } + + val cls = cache.classByFullName[target] ?: cache.classBySimpleName[target] + if (cls != null && cls.constructors.isNotEmpty()) { + return "${cls.simpleName}${cls.constructors.first()}" + } + logger.info("resolveSignatureForCall: fallback returning null (no signature found)") + return null + } + + /** + * Infers the type of [varName] by locating an enclosing event callback whose + * registration metadata names the callback event class. + */ + private fun resolveVarTypeFromEnclosingCallback(cache: FlatCache, fromNode: Node, varName: String): String? { + var current: Node? = fromNode + while (current?.parent != null) { + current = current.parent!! + if (current.type == "arrow_function" || current.type == "function") { + if (!isParamOf(current, varName)) continue + val args = current.parent ?: break + if (args.type != "arguments") continue + val call = args.parent ?: break + if (call.type != "call_expression") continue + val callTarget = extractCallTarget(call) ?: continue + val dotIdx = callTarget.indexOf('.') + if (dotIdx == -1) continue + val group = callTarget.substring(0, dotIdx) + val eventName = callTarget.substring(dotIdx + 1) + return cache.eventsByGroup[group]?.find { it.eventName == eventName }?.eventClass + } + } + return null + } + + /** + * Pure text-based fallback to find the callback parameter type. + * Searches backwards from [searchEndPos] for the pattern + * `.(... =>` and returns the event class. + * Does NOT rely on tree-sitter AST parent chain, so it works even when + * tree-sitter has absorbed outer tokens and broken the parent chain. + */ + private fun resolveVarTypeTextBased(cache: FlatCache, fullText: String, searchEndPos: Int, varName: String): String? { + val textBefore = fullText.substring(0, searchEndPos) + for ((group, events) in cache.eventsByGroup) { + val groupStr = "$group." + var searchFrom = textBefore.length + while (true) { + val gPos = textBefore.lastIndexOf(groupStr, searchFrom - 1) + if (gPos == -1) break + val afterGroup = textBefore.substring(gPos + groupStr.length) + val parenPos = afterGroup.indexOf('(') + if (parenPos == -1) { searchFrom = gPos; continue } + val eventName = afterGroup.substring(0, parenPos).trim() + if (eventName.isEmpty()) { searchFrom = gPos; continue } + if (events.any { it.eventName == eventName }) { + val callbackSection = afterGroup.substring(parenPos + 1) + val arrowPos = callbackSection.indexOf("=>") + if (arrowPos > 0) { + val beforeArrow = callbackSection.substring(0, arrowPos).trim() + .removePrefix("(").removeSuffix(")") + if (beforeArrow == varName || beforeArrow.contains(varName)) { + return events.find { it.eventName == eventName }!!.eventClass + } + } else { + val funcPos = callbackSection.indexOf("function") + if (funcPos >= 0) { + val afterFunc = callbackSection.substring(funcPos + "function".length).trim() + if (afterFunc.startsWith('(')) { + val closeParen = afterFunc.indexOf(')') + if (closeParen > 0) { + val params = afterFunc.substring(1, closeParen).trim() + if (params == varName || params.contains(varName)) { + return events.find { it.eventName == eventName }!!.eventClass + } + } + } + } + } + } + searchFrom = gPos + } + } + return null + } + + /** + * Formats the matching method on [typeName] as a signature-help string and + * highlights the parameter at [paramIndex]. + */ + private fun formatMethodSignature(cache: FlatCache, typeName: String, methodName: String, displayTarget: String, paramIndex: Int = 0): String? { + val seen = mutableSetOf() + val queue = ArrayDeque() + queue.add(typeName) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (current in seen) continue + seen.add(current) + val cls = cache.classByFullName[current] ?: cache.classBySimpleName[current] ?: continue + val method = cls.methods.find { it.name == methodName } + if (method != null) { + val params = method.parameters.mapIndexed { idx, p -> + val text = "${escapeHtml(p.first)}: ${escapeHtml(formatDisplayType(p.second))}" + if (idx == paramIndex) "$text" else text + }.joinToString(", ") + val retType = escapeHtml(method.type.substringAfterLast('.')) + return "$displayTarget($params): $retType" + } + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue.add(superClass) + for (iface in cls.interfaces) { + queue.add(iface) + } + } + return null + } + + /** + * Formats recipe-schema backed field calls, such as recipe helper fields that + * behave like functions, as signature-help strings. + */ + private fun formatFieldSignature(cache: FlatCache, typeName: String, fieldName: String, displayTarget: String, paramIndex: Int = 0): String? { + val seen = mutableSetOf() + val queue = ArrayDeque() + queue.add(typeName) + var field: MemberInfo? = null + while (queue.isNotEmpty() && field == null) { + val current = queue.removeFirst() + if (current in seen) continue + seen.add(current) + val cls = cache.classByFullName[current] ?: cache.classBySimpleName[current] ?: continue + field = cls.fields.find { it.name == fieldName } + if (field == null) { + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue.add(superClass) + for (iface in cls.interfaces) { + queue.add(iface) + } + } + } + if (field == null) return null + val candidates = recipeSchemaCandidates(fieldName) + for (schemaId in candidates) { + val schemas = cache.recipes.filter { it.schemaId == schemaId } + if (schemas.isNotEmpty()) { + val schema = schemas.find { it.namespace == "minecraft" } ?: schemas.first() + val params = schema.keys.mapIndexed { idx, k -> + val text = "${escapeHtml(k.name)}: ${escapeHtml(formatDisplayType(k.type))}" + if (idx == paramIndex) "$text" else text + }.joinToString(", ") + return "$displayTarget($params)" + } + } + return null + } + + /** + * Converts a camelCase KubeJS helper field name into the snake_case recipe + * schema identifier used by generated recipe metadata. + */ + private fun fieldNameToSchemaId(name: String): String { + val result = StringBuilder() + for (ch in name) { + if (ch.isUpperCase() && result.isNotEmpty()) { + result.append('_') + result.append(ch.lowercaseChar()) + } else { + result.append(ch.lowercaseChar()) + } + } + return result.toString() + } + + /** + * Produces likely recipe schema IDs for a helper field, including the + * `vanilla` prefix convention used by some generated KubeJS helpers. + */ + private fun recipeSchemaCandidates(name: String): Set { + val candidates = mutableSetOf(name, fieldNameToSchemaId(name)) + if (name.startsWith("vanilla") && name.length > 7) { + val rest = name[7].lowercaseChar() + name.substring(8) + candidates.add(rest) + candidates.add(fieldNameToSchemaId(rest)) + } + return candidates + } + + /** + * Shortens fully qualified and generic type names for editor-facing display. + */ + private fun formatDisplayType(type: String): String { + val simple = stripPackages(type) + val angleStart = simple.indexOf('<') + if (angleStart == -1) return simple + val baseName = simple.substring(0, angleStart) + val inner = simple.substring(angleStart + 1, simple.length - 1) + val params = splitGenericParams(inner) + val simplified = params.joinToString(", ") { formatDisplayType(it.trim()) } + return "$baseName<$simplified>" + } + + /** + * Removes the top-level package prefix from a type name while preserving + * package-qualified generic arguments for later recursive formatting. + */ + private fun stripPackages(fqName: String): String { + var depth = 0 + var lastDotAtDepth0 = -1 + for (i in fqName.indices) { + when (fqName[i]) { + '<' -> depth++ + '>' -> depth-- + '.' -> if (depth == 0) lastDotAtDepth0 = i + } + } + return if (lastDotAtDepth0 >= 0) fqName.substring(lastDotAtDepth0 + 1) else fqName + } + + /** + * Splits a generic type argument list on commas that are not nested inside + * deeper generic parameter lists. + */ + private fun splitGenericParams(inner: String): List { + val parts = mutableListOf() + var depth = 0 + var start = 0 + for (i in inner.indices) { + when (inner[i]) { + '<' -> depth++ + '>' -> depth-- + ',' -> if (depth == 0) { + parts.add(inner.substring(start, i)) + start = i + 1 + } + } + } + parts.add(inner.substring(start)) + return parts + } + + /** + * Determines the active argument index inside an `arguments` node by counting + * top-level commas before [cursorPos]. + */ + private fun currentParameterIndex(fullText: String, argsNode: Node, cursorPos: Int): Int { + val argsStart = argsNode.startByte.toInt() + val argsEnd = argsNode.endByte.toInt().coerceAtMost(fullText.length) + if (argsStart >= argsEnd) return 0 + val argsText = fullText.substring(argsStart, argsEnd) + val cursorOffset = cursorPos - argsStart + if (cursorOffset < 0) return 0 + var depth = 0 + var count = 0 + val end = cursorOffset.coerceAtMost(argsText.length) + val startOffset = if (argsText.startsWith('(')) 1 else 0 + var i = startOffset + while (i < end) { + val c = argsText[i] + if (c == '\'' || c == '"') { + i++ + while (i < end && argsText[i] != c) { + if (argsText[i] == '\\') i++ + i++ + } + } else { + when (c) { + '(' -> depth++ + ')' -> depth-- + '[' -> depth++ + ']' -> depth-- + '{' -> depth++ + '}' -> depth-- + ',' -> if (depth == 0) count++ + } + } + i++ + } + return count + } + + /** + * Estimates the active argument index inside a Tree-sitter error node that + * represents a partially typed or syntactically incomplete call. + */ + private fun estimateParamIndexInError(fullText: String, errorNode: Node, cursorPos: Int): Int { + val errorStart = errorNode.startByte.toInt() + val errorEnd = errorNode.endByte.toInt().coerceAtMost(fullText.length) + if (errorStart >= errorEnd) return 0 + val errorText = fullText.substring(errorStart, errorEnd) + val parenPos = errorText.indexOf('(') + if (parenPos == -1) return 0 + val cursorOffset = cursorPos - errorStart + if (cursorOffset <= parenPos) return 0 + var depth = 0 + var count = 0 + val end = cursorOffset.coerceAtMost(errorText.length) + var i = parenPos + 1 + while (i < end) { + val c = errorText[i] + if (c == '\'' || c == '"') { + i++ + while (i < end && errorText[i] != c) { + if (errorText[i] == '\\') i++ + i++ + } + } else { + when (c) { + '(' -> depth++ + ')' -> depth-- + '[' -> depth++ + ']' -> depth-- + '{' -> depth++ + '}' -> depth-- + ',' -> if (depth == 0) count++ + } + } + i++ + } + return count + } + + /** + * Counts top-level commas in arbitrary argument text, ignoring commas nested + * in strings, arrays, objects, or parenthesized expressions. + */ + private fun countCommasInText(text: String): Int { + var depth = 0 + var count = 0 + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '\'' || c == '"') { + i++ + while (i < text.length && text[i] != c) { + if (text[i] == '\\') i++ + i++ + } + } else { + when (c) { + '(' -> depth++ + ')' -> depth-- + '[' -> depth++ + ']' -> depth-- + '{' -> depth++ + '}' -> depth-- + ',' -> if (depth == 0) count++ + } + } + i++ + } + return count + } + + /** + * Escapes a small signature fragment before it is embedded in tooltip HTML. + */ + private fun escapeHtml(text: String): String { + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + } + + /** + * SQLite-backed version of callback parameter type inference used by + * contextual completions when only the fallback database is available. + */ + private fun resolveCallbackParameterTypeSqlite(conn: Connection, fullText: String, cursorPos: Int, varName: String): String? { + if (!TreeSitterService.isAvailable()) return null + val result = TreeSitterService.parse(fullText) ?: return null + val node = result.findNodeAt(cursorPos) ?: return null + + var current = node + while (current.parent != null) { + current = current.parent!! + if (current.type == "arrow_function" || current.type == "function") { + if (!isParamOf(current, varName)) break + + val args = current.parent ?: break + if (args.type != "arguments") break + val call = args.parent ?: break + if (call.type != "call_expression") break + + val callTarget = extractCallTarget(call) ?: break + val dotIdx = callTarget.indexOf('.') + if (dotIdx == -1) break + val group = callTarget.substring(0, dotIdx) + val eventName = callTarget.substring(dotIdx + 1) + + val sql = "SELECT event_class FROM js_events WHERE group_name = ? AND event_name = ?" + return conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, group) + stmt.setString(2, eventName) + stmt.executeQuery().use { rs -> + if (rs.next()) rs.getString("event_class") else null + } + } + } + } + return null + } + + /** + * Reads event handler completions for a group from the SQLite typings tables. + */ + private fun getEventHandlersSqlite(conn: Connection, groupName: String): List { + val sql = "SELECT event_name, event_class, extra_type, documentation FROM js_events WHERE group_name = ?" + return conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, groupName) + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + val eventClass = rs.getString("event_class") + val extraType = rs.getString("extra_type") + val detail = if (extraType.isNotEmpty()) "$eventClass (target: $extraType)" else eventClass + add(CompletionItem( + label = rs.getString("event_name"), + kind = CompletionItemKind.Method, + detail = detail, + documentation = rs.getString("documentation") + )) + } + } + } + } + } + + /** + * Builds hover content for a global binding or class symbol. + * + * FlatBuffer metadata is preferred for richer type kind and side information; + * SQLite is used as a fallback for older project registry exports. + */ + fun getHover(project: ProjectBase, symbol: String): HoverContent? { + val fb = getData(project) + if (fb != null) { + val binding = fb.bindingByName[symbol] + if (binding != null) { + val doc = binding.documentation.ifEmpty { "No documentation available." } + val sideInfo = if (binding.side.isNotEmpty()) " (${binding.side})" else "" + return HoverContent("**Binding: ${binding.name}**${sideInfo} (${binding.type})\n\n$doc") + } + val cls = fb.classBySimpleName[symbol] + if (cls != null) { + val doc = cls.documentation.ifEmpty { "No documentation available." } + val kindName = when (cls.kind) { + TypeKind.Class -> "Class" + TypeKind.Interface -> "Interface" + TypeKind.Primitive -> "Primitive" + TypeKind.Array -> "Array" + TypeKind.Event -> "Event" + else -> "Class" + } + val typeParams = if (cls.typeParams.isNotEmpty()) "<${cls.typeParams.joinToString(", ")}>" else "" + return HoverContent("**$kindName: ${cls.fullName}$typeParams**\n\n$doc") + } + return null + } + + val sqlite = getSqlite(project) ?: return null + val conn = sqlite.connection + return getHoverSqlite(conn, symbol) + } + + /** + * Finds the item slot at [charPos] in [fullText], if the character is inside + * a string argument of a function call whose parameter type is an item type + * (ItemStack, Ingredient, etc.) or if the enclosing call cannot be resolved. + * @return the slot info, or null if the position is not a valid drop target. + */ + fun findItemSlotAt(project: ProjectBase, fullText: String, charPos: Int): ItemSlotInfo? { + val cache = getData(project) ?: return null + if (charPos < 0 || charPos > fullText.length) return null + val result = TreeSitterService.parse(fullText) ?: return null + var node = result.findNodeAt(charPos) ?: return null + while (node.type != "string" && node.parent != null) { + node = node.parent!! + } + if (node.type != "string") return null + return checkStringIsItemSlot(cache, fullText, node) + } + + /** + * Returns all string arguments in [fullText] that correspond to item-type + * parameters (ItemStack, Ingredient, etc.) or are in unresolvable calls. + * Used by drag-drop to highlight valid drop targets. + */ + fun findAllItemSlots(project: ProjectBase, fullText: String): List { + val cache = getData(project) ?: return emptyList() + val result = TreeSitterService.parse(fullText) ?: return emptyList() + val slots = mutableListOf() + findItemSlotsRecursive(cache, fullText, result.rootNode, slots) + return slots + } + + /** + * Returns all method names (static and non-static) for the class + * bound to the given binding name (e.g. "Item"). Used by drag-drop + * detection to recognise Item.xxx(...) calls in editor code. + */ + fun getMethodNamesForBinding(project: ProjectBase, bindingName: String): Set { + val cache = getData(project) ?: return emptySet() + val binding = cache.bindingByName[bindingName] ?: return emptySet() + val seen = mutableSetOf() + val seenMembers = mutableSetOf() + val queue = ArrayDeque() + queue.add(binding.type) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (current in seen) continue + seen.add(current) + val cls = cache.classByFullName[current] ?: cache.classBySimpleName[current] ?: continue + for (m in cls.methods) { + if (seenMembers.add(m.name)) { + + } + } + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue.add(superClass) + for (iface in cls.interfaces) { + queue.add(iface) + } + } + return seenMembers + } + + // --- SQLite helpers --- + + /** + * Looks up the declared type of a global binding in the SQLite typings table. + */ + private fun getBindingTypeSqlite(conn: Connection, name: String): String? { + val sql = "SELECT type FROM js_bindings WHERE name = ?" + return conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, name) + stmt.executeQuery().use { rs -> + if (rs.next()) rs.getString("type") else null + } + } + } + + /** + * Resolves a simple class name to its fully qualified class name in SQLite. + */ + private fun getFullClassNameSqlite(conn: Connection, simpleName: String): String? { + val sql = "SELECT full_name FROM js_classes WHERE simple_name = ?" + return conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, simpleName) + stmt.executeQuery().use { rs -> + if (rs.next()) rs.getString("full_name") else null + } + } + } + + /** + * Walks a SQLite-backed class hierarchy and returns the queried class followed + * by reachable superclasses and interfaces. + */ + private fun getClassHierarchySqlite(conn: Connection, className: String): List { + val seen = mutableSetOf() + val result = mutableListOf() + val queue = ArrayDeque() + queue.add(className) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (current in seen) continue + seen.add(current) + result.add(current) + val sql = "SELECT super_class, interfaces_json FROM js_classes WHERE full_name = ?" + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, current) + stmt.executeQuery().use { rs -> + if (rs.next()) { + val superClass = rs.getString("super_class") + if (superClass != null && superClass.isNotEmpty()) { + queue.add(superClass) + } + val interfacesJson = rs.getString("interfaces_json") + if (interfacesJson != null && interfacesJson.isNotEmpty() && interfacesJson != "[]") { + try { + val arr = json.parseToJsonElement(interfacesJson).jsonArray + for (elem in arr) { + queue.add(elem.jsonPrimitive.content) + } + } catch (_: Exception) {} + } + } + } + } + } + return result + } + + /** + * Reads methods and fields for a SQLite-backed class hierarchy and converts + * them into completion items. + */ + private fun getMembersSqlite(conn: Connection, className: String): List { + val hierarchy = getClassHierarchySqlite(conn, className) + val seenMembers = mutableSetOf() + val items = mutableListOf() + for (clsName in hierarchy) { + val sql = "SELECT name, kind, type, parameters_json, documentation FROM js_members WHERE class_full_name = ?" + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, clsName) + stmt.executeQuery().use { rs -> + while (rs.next()) { + val name = rs.getString("name") + val kind = rs.getString("kind") + if (kind == "method") { + val paramsJson = rs.getString("parameters_json") + val params = if (paramsJson != null && paramsJson.isNotEmpty() && paramsJson != "[]") { + try { + val arr = json.parseToJsonElement(paramsJson).jsonArray + arr.map { obj -> + val o = obj.jsonObject + (o["name"]?.jsonPrimitive?.content ?: "") to (o["type"]?.jsonPrimitive?.content ?: "") + } + } catch (_: Exception) { + emptyList() + } + } else emptyList() + val key = "$name|${params.size}|${params.joinToString("") { it.second }}" + if (seenMembers.add(key)) { + val paramsStr = if (params.isNotEmpty()) "(${params.joinToString { "${it.first}: ${formatDisplayType(it.second)}" }})" else "()" + items.add(CompletionItem( + label = name, + kind = CompletionItemKind.Method, + detail = "$paramsStr: ${formatDisplayType(rs.getString("type"))}", + documentation = rs.getString("documentation") + )) + } + } else { + val key = "field:$name" + if (seenMembers.add(key)) { + items.add(CompletionItem( + label = name, + kind = CompletionItemKind.Field, + detail = rs.getString("type"), + documentation = rs.getString("documentation") + )) + } + } + } + } + } + } + return items + } + + /** + * Builds SQLite-backed global completions for bindings and classes, optionally + * filtering by a lowercase prefix. + */ + private fun getGlobalCompletionsSqlite(conn: Connection, word: String = ""): List { + val items = mutableListOf() + val lowerWord = word.lowercase() + + conn.prepareStatement("SELECT name, type, documentation FROM js_bindings").use { stmt -> + stmt.executeQuery().use { rs -> + while (rs.next()) { + val name = rs.getString("name") + if (lowerWord.isNotEmpty() && !name.lowercase().startsWith(lowerWord)) continue + items.add(CompletionItem( + label = name, + kind = CompletionItemKind.Variable, + detail = rs.getString("type"), + documentation = rs.getString("documentation") + )) + } + } + } + + conn.prepareStatement("SELECT simple_name, full_name, documentation FROM js_classes").use { stmt -> + stmt.executeQuery().use { rs -> + while (rs.next()) { + val simpleName = rs.getString("simple_name") + if (lowerWord.isNotEmpty() && !simpleName.lowercase().startsWith(lowerWord)) continue + items.add(CompletionItem( + label = simpleName, + kind = CompletionItemKind.Class, + detail = rs.getString("full_name"), + documentation = rs.getString("documentation") + )) + } + } + } + + return items + } + + /** + * Builds SQLite-backed hover content for a binding or class symbol. + */ + private fun getHoverSqlite(conn: Connection, symbol: String): HoverContent? { + conn.prepareStatement("SELECT type, documentation FROM js_bindings WHERE name = ?").use { stmt -> + stmt.setString(1, symbol) + stmt.executeQuery().use { rs -> + if (rs.next()) { + val type = rs.getString("type") + val doc = rs.getString("documentation") ?: "No documentation available." + return HoverContent("**Binding: $symbol** ($type)\n\n$doc") + } + } + } + + conn.prepareStatement("SELECT full_name, documentation FROM js_classes WHERE simple_name = ?").use { stmt -> + stmt.setString(1, symbol) + stmt.executeQuery().use { rs -> + if (rs.next()) { + val fullName = rs.getString("full_name") + val doc = rs.getString("documentation") ?: "No documentation available." + return HoverContent("**Class: $fullName**\n\n$doc") + } + } + } + + return null + } + + // ---- Item slot detection for drag-drop (used by TextEditorPane.DragDropTextEdit) ---- + + /** + * Traverses a Tree-sitter subtree and accumulates every string literal that + * can be treated as an item drop slot. + */ + private fun findItemSlotsRecursive(cache: FlatCache, fullText: String, node: Node, slots: MutableList) { + if (node.type == "string") { + val slot = checkStringIsItemSlot(cache, fullText, node) + if (slot != null) slots.add(slot) + } + for (child in node.children) { + findItemSlotsRecursive(cache, fullText, child, slots) + } + } + + /** + * Determines whether a string literal is an acceptable item argument for its + * enclosing call and returns the editable string range when it is. + */ + private fun checkStringIsItemSlot(cache: FlatCache, fullText: String, stringNode: Node): ItemSlotInfo? { + if (stringNode.type != "string") return null + val callExpression = findEnclosingCallExpr(stringNode) ?: return null + val target = extractCallTarget(callExpression) ?: return null + val parts = target.split(".") + if (parts.size < 2) return null + val varName = parts[0] + val methodName = parts.drop(1).joinToString(".") + + val typeName = resolveVarTypeFromEnclosingCallback(cache, callExpression, varName) + ?: cache.bindingByName[varName]?.type + + if (typeName != null) { + val argsNode = findArgumentsAncestor(stringNode) ?: return null + val argIndex = currentParameterIndex(fullText, argsNode, stringNode.startByte.toInt() + 1) + val paramType = lookupParamType(cache, typeName, methodName, argIndex) + if (paramType != null && !isAcceptableItemType(paramType)) return null + } + + return ItemSlotInfo( + startByte = stringNode.startByte.toInt() + 1, + endByte = stringNode.endByte.toInt() - 1, + exprStartByte = callExpression.startByte.toInt(), + exprEndByte = callExpression.endByte.toInt() + ) + } + + /** + * Finds the call expression for a string argument while allowing the string to + * be nested in object, array, or pair syntax inside the argument list. + */ + private fun findEnclosingCallExpr(node: Node): Node? { + var current = node.parent + while (current != null) { + when (current.type) { + "arguments" -> { + val parent = current.parent + if (parent?.type == "call_expression") return parent + return null + } + "pair", "object", "array" -> { + current = current.parent + } + else -> return null + } + } + return null + } + + /** + * Finds the nearest Tree-sitter `arguments` ancestor for an expression node. + */ + private fun findArgumentsAncestor(node: Node): Node? { + var current = node.parent + while (current != null) { + if (current.type == "arguments") return current + current = current.parent + } + return null + } + + /** + * Looks up the parameter type at [argIndex] for [methodName] on [typeName]. + * Checks both regular methods and recipe-schema field patterns. + * Returns the type string (e.g. "ItemStack", "Ingredient") or null. + */ + private fun lookupParamType(cache: FlatCache, typeName: String, methodName: String, argIndex: Int): String? { + lookupRecipeSchemaParamType(cache, methodName, argIndex)?.let { return it } + + val seen = mutableSetOf() + val queue = ArrayDeque() + queue.add(typeName) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (current in seen) continue + seen.add(current) + val cls = cache.classByFullName[current] ?: cache.classBySimpleName[current] ?: continue + val method = cls.methods.find { it.name == methodName } + if (method != null) { + return method.parameters.getOrNull(argIndex)?.second + } + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue.add(superClass) + for (iface in cls.interfaces) { + queue.add(iface) + } + } + + val seen2 = mutableSetOf() + val queue2 = ArrayDeque() + queue2.add(typeName) + while (queue2.isNotEmpty()) { + val current = queue2.removeFirst() + if (current in seen2) continue + seen2.add(current) + val cls = cache.classByFullName[current] ?: cache.classBySimpleName[current] ?: continue + val field = cls.fields.find { it.name == methodName } + if (field != null) { + lookupRecipeSchemaParamType(cache, methodName, argIndex)?.let { return it } + } + val superClass = cls.superClass.takeIf { it.isNotEmpty() } + if (superClass != null) queue2.add(superClass) + for (iface in cls.interfaces) { + queue2.add(iface) + } + } + return null + } + + /** + * Resolves a recipe-schema argument type for the helper method or field name. + */ + private fun lookupRecipeSchemaParamType(cache: FlatCache, methodName: String, argIndex: Int): String? { + for (schemaId in recipeSchemaCandidates(methodName)) { + val schemas = cache.recipes.filter { it.schemaId == schemaId } + if (schemas.isNotEmpty()) { + val schema = schemas.find { it.namespace == "minecraft" } ?: schemas.first() + return schema.keys.getOrNull(argIndex)?.type + } + } + return null + } + + /** + * Returns whether a resolved parameter type can accept item identifiers + * dropped from the UI. + */ + private fun isAcceptableItemType(type: String): Boolean { + val trimmed = type.trim() + if (trimmed.endsWith("[]")) { + return isAcceptableItemType(trimmed.removeSuffix("[]")) + } + val genericStart = trimmed.indexOf('<') + if (genericStart >= 0 && trimmed.endsWith(">")) { + val outer = trimmed.substring(0, genericStart).substringAfterLast('.') + if (outer in setOf("List", "Collection", "Iterable", "ArrayList", "NonNullList")) { + val inner = trimmed.substring(genericStart + 1, trimmed.length - 1) + return splitGenericParams(inner).any { isAcceptableItemType(it) } + } + } + val simple = trimmed.substringAfterLast('.') + return simple == "ItemStack" || simple == "Ingredient" || simple == "IIngredient" + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/font/FontMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/font/FontMngr.kt new file mode 100644 index 0000000..c832a9d --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/font/FontMngr.kt @@ -0,0 +1,64 @@ +package io.github.tritium_launcher.launcher.font + +import io.github.tritium_launcher.launcher.logger +import io.qt.gui.QFont +import io.qt.gui.QFontDatabase + +object FontMngr { + private val logger = logger() + + private var _defaultFontFamily: String? = null + private var _monoFontFamily: String? = null + private var _initialized = false + private var _systemFontFamilies: List? = null + + val isInitialized: Boolean get() = _initialized + + val defaultFontFamily: String + get() = _defaultFontFamily + ?: error("FontMngr not initialized. Call FontMngr.init() first.") + + val monoFontFamily: String + get() = _monoFontFamily + ?: error("FontMngr not initialized. Call FontMngr.init() first.") + + fun init() { + if (_initialized) return + + _defaultFontFamily = loadFont("/fonts/Inter/InterVariable.ttf") + if (_defaultFontFamily != null) { + logger.info("Loaded Inter variable font: {}", _defaultFontFamily) + } else { + loadFont("/fonts/Inter/Inter.ttc")?.let { family -> + _defaultFontFamily = family + logger.info("Loaded Inter TTC font: {}", family) + } + } + + _monoFontFamily = loadFont("/fonts/JetBrainsMonoNL-Regular.ttf") + ?: loadFont("/fonts/JetBrainsMono-Regular.ttf") + if (_monoFontFamily != null) { + logger.info("Loaded JetBrains Mono font: {}", _monoFontFamily) + } + + _initialized = true + } + + fun defaultFont(size: Int = 10): QFont { + val family = _defaultFontFamily + return if (family != null) QFont(family, size) else QFont("sans-serif", size) + } + + fun availableFontFamilies(): List { + val builtin = listOfNotNull(_defaultFontFamily, _monoFontFamily) + val system = _systemFontFamilies ?: run { + try { + QFontDatabase.families().also { _systemFontFamilies = it } + } catch (e: Exception) { + logger.warn("Failed to query system fonts", e) + emptyList().also { _systemFontFamilies = it } + } + } + return (builtin + system).distinct().sorted() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/CurseForgeImporter.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/CurseForgeImporter.kt new file mode 100644 index 0000000..20a3f0c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/CurseForgeImporter.kt @@ -0,0 +1,291 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.core.mod.ModSide +import io.github.tritium_launcher.launcher.core.source.CurseForge +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.net.URLDecoder +import java.nio.file.Files +import java.util.zip.ZipFile + +/** + * A single file entry in the CurseForge modpack manifest. + * + * @param projectID CurseForge project ID. + * @param fileID Specific file ID for the version to download. + * @param downloadUrl Direct download URL for the jar. + * @param required Whether this file is required for the pack to function. + */ +@Serializable +data class CurseFile( + val projectID: Long, + val fileID: Long, + val downloadUrl: String? = null, + val required: Boolean = true +) + +/** + * Mod loader entry in the CurseForge manifest. + * + * @param id Loader identifier. + * @param primary Whether this is the primary loader for the pack. + */ +@Serializable +data class CurseModLoader( + val id: String, + val primary: Boolean = false +) + +/** + * Minecraft section of the CurseForge manifest. + * + * @param version Minecraft version string. + * @param modLoaders List of mod loaders configured for the pack. + */ +@Serializable +data class CurseMinecraft( + val version: String, + val modLoaders: List = emptyList() +) + +/** + * Root structure of a CurseForge modpack `manifest.json`. + * + * @param manifestType Always "minecraftModpack". + * @param manifestVersion Schema version of the manifest format. + * @param name Display name of the modpack. + * @param version Version string for this pack release. + * @param author Pack author name. + * @param overrides Directory name within the zip containing override files. + * @param minecraft Minecraft version and loader configuration. + * @param files List of mod files to download. + */ +@Serializable +data class CurseManifest( + val manifestType: String = "minecraftModpack", + val manifestVersion: Int = 1, + val name: String = "", + val version: String = "", + val author: String? = null, + val overrides: String = "overrides", + val minecraft: CurseMinecraft? = null, + val files: List = emptyList() +) + +/** + * Result of a successful CurseForge pack extraction. + * + * @param instance A [DetectedInstance] representing the extracted pack. + * @param tempDir The temporary directory containing extracted files. + * @param modEntries Pre-built [ImportableMod] entries derived from the manifest for preview. + */ +data class CursePackResult( + val instance: DetectedInstance, + val tempDir: VPath, + val modEntries: List +) + +val curseJson = Json { ignoreUnknownKeys = true } + +/** + * Extracts a CurseForge modpack zip archive into a temporary directory and returns a + * [CursePackResult]. + * + * The extraction process: + * 1. Reads `manifest.json` from the zip. + * 2. Extracts override files from the configured overrides directory, + * stripping that prefix so files land at the correct relative paths. + * 3. Saves the manifest as `curse-manifest.json` in the temp directory so it is + * available to [downloadCursePackMods] during project generation. + * + * @param path Absolute path to the CurseForge `.zip` file. + * @param curseForge Optional [CurseForge] source instance for resolving mod names + * and icons from the API. + * @return A [CursePackResult] with the parsed instance metadata and temp directory, or + * `null` when the manifest is missing or unreadable. + */ +suspend fun extractAndPrepareCursePack( + path: String, + curseForge: CurseForge? = null +): CursePackResult? { + val (manifest, tempVPath) = withContext(Dispatchers.IO) { + val zip = ZipFile(path) + zip.use { zip -> + val manifestEntry = zip.getEntry("manifest.json") ?: return@withContext null + val manifestContent = zip.getInputStream(manifestEntry).readAllBytes().decodeToString() + val mf = curseJson.decodeFromString(manifestContent) + + val tempDir = Files.createTempDirectory("tritium-curse-").toFile() + val tvp = VPath.get(tempDir.absolutePath) + + val overridePrefix = "${mf.overrides}/" + val entries = zip.entries() + val canonicalBase = tempDir.canonicalPath + File.separator + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory || entry.name == "manifest.json") continue + + val relativeName = entry.name.removePrefix(overridePrefix) + if (relativeName == entry.name) continue + if (relativeName.isBlank()) continue + + val outFile = File(tempDir, relativeName) + if (!outFile.canonicalPath.startsWith(canonicalBase)) continue + outFile.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> outFile.outputStream().use { input.copyTo(it) } } + } + + // Save manifest for later use during import + val manifestFile = File(tempDir, "curse-manifest.json") + manifestFile.writeText(manifestContent) + + Pair(mf, tvp) + } + } ?: return null + + val mcSection = manifest.minecraft + val gameVer = mcSection?.version + val primaryLoader = mcSection?.modLoaders?.firstOrNull { it.primary } ?: mcSection?.modLoaders?.firstOrNull() + val loaderId = primaryLoader?.id ?: "" + val (loaderName, loaderVersion) = parseLoaderString(loaderId) + + val instance = DetectedInstance( + launcher = KnownLauncher.BROWSE_FOLDER, + name = manifest.name, + instanceDir = tempVPath, + minecraftDir = tempVPath, + gameVersion = gameVer, + loader = loaderName, + loaderVersion = loaderVersion + ) + + val modEntries = manifest.files.map { file -> + val fileName = if (!file.downloadUrl.isNullOrBlank()) { + file.downloadUrl.substringAfterLast('/').substringBefore('?') + } else { + "mod-${file.projectID}-${file.fileID}.jar" + } + ImportableMod( + jarPath = tempVPath.resolve("mods/$fileName"), + modId = fileName.removeSuffix(".jar"), + displayName = fileName.removeSuffix(".jar"), + fileName = fileName, + side = ModSide.BOTH, + iconBytes = null, + checked = file.required + ) + }.toMutableList() + + if (curseForge != null && modEntries.isNotEmpty()) { + val projectIds = manifest.files.map { it.projectID }.distinct() + val batchInfo = curseForge.batchModDetails(projectIds) + for (i in modEntries.indices) { + val file = manifest.files.getOrNull(i) ?: continue + val brief = batchInfo[file.projectID] + if (brief != null) { + val entry = modEntries[i] + modEntries[i] = entry.copy( + displayName = brief.name, + modId = file.projectID.toString(), + sourceProjectId = file.projectID.toString(), + sourceIconUrl = brief.iconUrl, + sourceAvailable = true, + sourceStatus = "Available" + ) + } + } + } + + return CursePackResult(instance, tempVPath, modEntries) +} + +/** + * Downloads mod files from a list of [CurseFile] entries into the temp directory's `mods/` + * folder. + * + * Call this during the import phase for only the files the user has checked. + * + * @param cursePackTempDir The temp directory returned by [extractAndPrepareCursePack]. + * @param httpClient HTTP client used for downloading mod files. + * @param files The manifest file entries to download. If empty, downloads nothing. + */ +suspend fun downloadCursePackMods(cursePackTempDir: VPath, httpClient: HttpClient, files: List) { + val downloadSemaphore = Semaphore(6) + coroutineScope { + files + .map { file -> + async(Dispatchers.IO) { + val url = file.downloadUrl?.takeIf { it.isNotBlank() } + ?: "https://www.curseforge.com/api/v1/mods/${file.projectID}/files/${file.fileID}/download" + + downloadSemaphore.withPermit { + try { + val response = httpClient.get(url) + val bytes = response.bodyAsBytes() + val fileName = if (!file.downloadUrl.isNullOrBlank()) { + file.downloadUrl.substringAfterLast('/').substringBefore('?') + } else { + val finalPath = response.call.request.url.encodedPath + val raw = finalPath.substringAfterLast('/').substringBefore('?') + URLDecoder.decode(raw, "UTF-8").ifEmpty { "mod-${file.projectID}-${file.fileID}.jar" } + } + if (fileName.isNotBlank()) { + val outFile = File(cursePackTempDir.toJFile(), "mods/$fileName") + outFile.parentFile?.mkdirs() + outFile.outputStream().use { it.write(bytes) } + } + } catch (_: Exception) { + curseLog.warn("Failed to download mod file: {}", url) + } + } + } + } + .awaitAll() + } +} + +/** + * Deletes the temporary directory created during CurseForge pack extraction. + * + * Safe to call with `null` (no-op). + * + * @param cursePackTempDir The temp directory to remove, or `null`. + */ +fun cleanupCursePackTemp(cursePackTempDir: VPath?) { + cursePackTempDir?.toJFile()?.deleteRecursively() +} + +/** + * Parses a CurseForge loader string into a display name and version. + * + * @param loaderId The raw loader identifier from the manifest. + * @return A pair of (loader display name, loader version), or (null, null) if unparseable. + */ +private fun parseLoaderString(loaderId: String): Pair { + if (loaderId.isBlank()) return null to null + val known = mapOf( + "forge" to "Forge", + "neoforge" to "NeoForge", + "fabric" to "Fabric", + "quilt" to "Quilt" + ) + val lower = loaderId.lowercase() + for ((key, display) in known) { + if (lower.startsWith(key)) { + val version = loaderId.removePrefix(key).removePrefix("-").ifBlank { null } + return display to version + } + } + return null to null +} + +private val curseLog = logger("CurseForgeImporter") diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/FileTreeHelper.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/FileTreeHelper.kt new file mode 100644 index 0000000..db6620e --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/FileTreeHelper.kt @@ -0,0 +1,119 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.io.VPath +import io.qt.core.Qt +import io.qt.widgets.QTreeWidget +import io.qt.widgets.QTreeWidgetItem + +/** + * A single node in the file tree displayed in the import dialog. + * + * @param path Absolute path to the file or directory. + * @param isDirectory Whether this entry is a directory. + * @param parent Absolute path of the parent directory, or `null` for the root entry. + */ +data class FileTreeEntry(val path: VPath, val isDirectory: Boolean, val parent: VPath?) + +/** + * Recursively collects all files and directories under [dir] into a flat list of [FileTreeEntry]. + * + * Entries are sorted with directories first, then alphabetically by filename. + * + * @param dir The root directory to scan. + * @return A flat list of [FileTreeEntry] representing all descendants. + */ +fun collectFileTreeEntries(dir: VPath): List { + val result = mutableListOf() + fun walk(current: VPath, parent: VPath?) { + if (!current.isDir()) return + val children = current.list() + .sortedWith(compareBy { !it.isDir() }.thenBy { it.fileName().lowercase() }) + for (child in children) { + val childIsDir = child.isDir() + result.add(FileTreeEntry(child, childIsDir, parent)) + if (childIsDir) { + walk(child, child) + } + } + } + walk(dir, null) + return result +} + +/** + * Persists the expanded state of the file tree for a given instance. + * + * @param fileTree The tree widget to snapshot. + * @param instance The instance associated with this tree. + * @param expandedState Map to write the expanded paths into, keyed by instance path. + */ +fun saveExpandedState(fileTree: QTreeWidget, instance: DetectedInstance, expandedState: MutableMap>) { + val path = instance.minecraftDir.toAbsolute().toString() + val expanded = mutableSetOf() + fun walk(item: QTreeWidgetItem) { + val data = item.data(0, Qt.ItemDataRole.UserRole) as? String + if (data != null && item.isExpanded) expanded.add(data) + for (i in 0 until item.childCount()) { + item.child(i)?.let { walk(it) } + } + } + val root = fileTree.invisibleRootItem() ?: return + for (i in 0 until root.childCount()) { + root.child(i)?.let { walk(it) } + } + expandedState[path] = expanded +} + +/** + * Restores a previously saved expanded state onto the file tree. + * + * @param fileTree The tree widget to restore. + * @param instancePath The instance path that was used as the save key. + * @param expandedState The persisted expanded state map. + */ +fun restoreExpandedState(fileTree: QTreeWidget, instancePath: String, expandedState: Map>) { + val saved = expandedState[instancePath] ?: return + fun walk(item: QTreeWidgetItem) { + val data = item.data(0, Qt.ItemDataRole.UserRole) as? String + if (data != null && data in saved) item.isExpanded = true + for (i in 0 until item.childCount()) { + item.child(i)?.let { walk(it) } + } + } + val root = fileTree.invisibleRootItem() ?: return + for (i in 0 until root.childCount()) { + root.child(i)?.let { walk(it) } + } +} + +/** + * Collects all file paths that are currently checked in the tree widget. + * + * Directories are skipped; only leaf nodes are returned. A partially checked parent is + * considered checked and its leaf children are collected recursively. + * + * @param fileTree The tree widget to read check states from. + * @return List of [VPath] for checked files. + */ +fun collectCheckedFiles(fileTree: QTreeWidget): List { + val result = mutableListOf() + fun walk(item: QTreeWidgetItem) { + if (item.checkState(0) == Qt.CheckState.Checked || item.checkState(0) == Qt.CheckState.PartiallyChecked) { + for (i in 0 until item.childCount()) { + item.child(i)?.let { walk(it) } + } + if (item.childCount() == 0) { + val pathStr = item.data(0, Qt.ItemDataRole.UserRole) as? String + if (pathStr != null) { + val vpath = VPath.get(pathStr) + if (!vpath.isDir()) result.add(vpath) + } + } + } + } + val root = fileTree.invisibleRootItem() ?: return result + for (i in 0 until root.childCount()) { + root.child(i)?.let { walk(it) } + } + return result +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportModCache.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportModCache.kt new file mode 100644 index 0000000..d9dafc8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportModCache.kt @@ -0,0 +1,203 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.security.MessageDigest + +private val cacheLog = logger("ImportCache") +private val cacheJson = Json { prettyPrint = true } + +/** + * Cache of imported mod metadata for a specific instance and source combination. + * + * Written to disk after source validation completes so that subsequent scans of the same + * instance can skip re-querying the source. The cache is invalidated when the jar set or + * any jar content (SHA-1) changes. + * + * @param cacheVersion Schema version for forward-compatibility (currently 1). + * @param instancePath Absolute path of the instance's minecraft directory. + * @param instanceGameVersion Minecraft version at cache time. + * @param instanceLoader Loader display name at cache time. + * @param instanceLoaderVersion Loader version at cache time. + * @param sourceId ID of the mod source the cache applies to. + * @param mods Per-mod cache entries. + * @param cachedAt Timestamp (epoch millis) when this cache was written. + * @see tryLoadImportCache + * @see saveImportCache + */ +@Serializable +data class ImportModCache( + val cacheVersion: Int = 1, + val instancePath: String, + val instanceGameVersion: String?, + val instanceLoader: String?, + val instanceLoaderVersion: String?, + val sourceId: String, + val mods: List, + val cachedAt: Long, +) + +/** + * Per-mod snapshot stored in [ImportModCache]. + * + * @param jarFile Jar filename (e.g. "my-mod-1.0.jar"). + * @param modId Mod identifier from metadata. + * @param displayName Human-readable mod name. + * @param sha1Hash SHA-1 digest used for cache validation. + * @param fileFingerprint Source-specific fingerprint for fast matching. + * @param sourceProjectId ID of the matched source project. + * @param sourceIconUrl Project icon URL on the source. + * @param sourceAvailable Whether the mod was found on the source. + * @param sourceStatus Status at cache time ("Available", "Matched by file hash", etc.). + * @param dependencyIds Project IDs of required dependencies from the matched version. + */ +@Serializable +data class CachedMod( + val jarFile: String, + val modId: String, + val displayName: String, + val sha1Hash: String?, + val fileFingerprint: Long? = null, + val sourceProjectId: String?, + val sourceIconUrl: String?, + val sourceAvailable: Boolean?, + val sourceStatus: String?, + val dependencyIds: List = emptyList(), +) + +/** + * Derives a cache key from an instance + source combination. + * + * @param instance The detected instance. + * @param sourceId The mod source identifier. + * @return SHA-256 hex string used as the cache filename. + */ +fun cacheKey(instance: DetectedInstance, sourceId: String): String { + val input = instance.minecraftDir.toAbsolute().toString() + "|" + sourceId + val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } +} + +/** + * Resolves the cache file path for a given instance + source. + * + * @param instance The detected instance. + * @param sourceId The mod source identifier. + * @return Path under `cache/mod-import/.json`. + */ +fun cacheFilePath(instance: DetectedInstance, sourceId: String): VPath { + return fromTR("cache", "mod-import").resolve("${cacheKey(instance, sourceId)}.json") +} + +/** + * Attempts to load and validate a previously saved import cache. + * + * The cache is considered valid only when: + * - The cache schema version matches. + * - The number of cached mods matches the scanned mod count. + * - Every scanned mod has a corresponding cache entry with a matching SHA-1 hash. + * + * When valid, the cached source metadata (fingerprints, project IDs, status) is applied + * back onto the scanned mod list so that source queries can be skipped. + * + * @param instance The detected instance. + * @param sourceId The mod source identifier. + * @param scanned Currently scanned mods to validate against. + * @return A copy of [scanned] with source fields populated from cache, or `null` if the + * cache is missing, outdated, or corrupted. + */ +fun tryLoadImportCache( + instance: DetectedInstance, + sourceId: String, + scanned: List +): List? { + val file = cacheFilePath(instance, sourceId) + if (!file.exists()) return null + return try { + val jsonBytes = file.bytesOrNull() ?: return null + val cache = cacheJson.decodeFromString(jsonBytes.decodeToString()) + if (cache.cacheVersion != 1) return null + if (cache.mods.size != scanned.size) return null + val cacheModMap = cache.mods.associateBy { it.jarFile } + for (mod in scanned) { + val cached = cacheModMap[mod.fileName] ?: return null + if (cached.sha1Hash != mod.sha1Hash) return null + } + scanned.map { original -> + val cached = cacheModMap[original.fileName]!! + original.copy( + fileFingerprint = cached.fileFingerprint, + sourceProjectId = cached.sourceProjectId, + sourceIconUrl = cached.sourceIconUrl, + sourceAvailable = cached.sourceAvailable, + sourceStatus = cached.sourceStatus, + dependencyIds = cached.dependencyIds, + ) + } + } catch (t: Throwable) { + cacheLog.warn("Failed to load import cache: {}", t.message) + null + } +} + +/** + * Persists source validation results to disk for later reuse. + * + * @param instance The detected instance. + * @param sourceId The mod source identifier. + * @param mods The fully resolved mod list. + */ +fun saveImportCache(instance: DetectedInstance, sourceId: String, mods: List) { + val cache = ImportModCache( + instancePath = instance.minecraftDir.toAbsolute().toString(), + instanceGameVersion = instance.gameVersion, + instanceLoader = instance.loader, + instanceLoaderVersion = instance.loaderVersion, + sourceId = sourceId, + mods = mods.map { m -> + CachedMod( + jarFile = m.fileName, + modId = m.modId, + displayName = m.displayName, + sha1Hash = m.sha1Hash, + fileFingerprint = m.fileFingerprint, + sourceProjectId = m.sourceProjectId, + sourceIconUrl = m.sourceIconUrl, + sourceAvailable = m.sourceAvailable, + sourceStatus = m.sourceStatus, + dependencyIds = m.dependencyIds, + ) + }, + cachedAt = System.currentTimeMillis() + ) + try { + val jsonStr = cacheJson.encodeToString(ImportModCache.serializer(), cache) + val file = cacheFilePath(instance, sourceId) + file.parent().mkdirs() + file.writeBytesAtomic(jsonStr.toByteArray()) + cacheLog.warn("Saved import cache for {} -> {} ({} mods)", instance.name, sourceId, mods.size) + } catch (t: Throwable) { + cacheLog.warn("Failed to save import cache: {}", t.message) + } +} + +/** + * Removes the cached import data for a given instance + source. + * + * @param instance The detected instance. + * @param sourceId The mod source identifier. + */ +fun deleteImportCache(instance: DetectedInstance, sourceId: String) { + val file = cacheFilePath(instance, sourceId) + try { + if (file.exists()) { + file.delete() + cacheLog.warn("Deleted import cache for {} -> {}", instance.name, sourceId) + } + } catch (t: Throwable) { + cacheLog.warn("Failed to delete import cache: {}", t.message) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportProjectCreator.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportProjectCreator.kt new file mode 100644 index 0000000..819497d --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportProjectCreator.kt @@ -0,0 +1,183 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.core.project.* +import io.github.tritium_launcher.launcher.core.project.templates.ProjectTemplateExecutor +import io.github.tritium_launcher.launcher.core.project.templates.TemplateExecutionResult +import io.github.tritium_launcher.launcher.core.project.templates.generation.GeneratorStepDescriptor +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.import.ui.ImportProjectDialog +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* + +/** + * Assembles and runs the project creation pipeline for importing instances. + * + * This is the entry point used by [ImportProjectDialog] to convert a detected instance + * into a Tritium project. It orchestrates: + * - Metadata and export-rule file generation via [StandardProjectSteps]. + * - Optional mod import via the `"importMods"` step. + * - Optional file import via the `"importFiles"` step. + * - Project metadata (`trproj.json`) and icon writing. + * - Minecraft and loader bootstrapping via [ProjectBootstrap]. + * - Import cache cleanup on success. + * + * All work is executed through [ProjectTemplateExecutor] so that the same pipeline handles + * both freshly-created and imported projects, differing only in the step composition. + */ +object ImportProjectCreator { + private val json = Json { prettyPrint = true } + private val logger = logger() + + /** + * Creates a Tritium project from a detected Minecraft instance. + * + * @param projectRoot Destination directory for the new project. + * @param instance The detected instance to import from. + * @param instanceMinecraftDir The instance's minecraft directory (mods, config, etc.). + * @param sourceId Identifier of the mod source (e.g. "modrinth", "curseforge", "unknown"). + * @param iconPath Optional path to an icon file to use as the project icon. + * @param selectedMods Mods the user has checked for import. + * @param selectedFiles Files the user has checked for import. + * @param sourceInstance The instance used for cache cleanup (usually the same as [instance]). + * @param sourceIdForCache The source ID used for cache cleanup (usually the same as [sourceId]). + * @param onProgress Optional suspend callback invoked with status messages during creation. + * @return A [TemplateExecutionResult] describing the outcome of the generation pipeline. + * @throws IllegalArgumentException If [projectRoot] already exists and is non-empty. + */ + suspend fun createProject( + projectRoot: VPath, + instance: DetectedInstance, + instanceMinecraftDir: VPath, + sourceId: String, + iconPath: VPath?, + selectedMods: List, + selectedFiles: List, + sourceInstance: DetectedInstance?, + sourceIdForCache: String?, + onProgress: (suspend (String) -> Unit)? = null + ): TemplateExecutionResult { + val packName = instance.name + val loaderId = mapLoaderId(instance.loader) + val loaderVer = instance.loaderVersion ?: "" + val mcVer = instance.gameVersion ?: "unknown" + + logger.info("Import createProject start: name={} source={}", packName, sourceId) + + withContext(Dispatchers.IO) { + if (projectRoot.existsNotEmpty()) { + logger.warn("Aborting import, project directory already exists: {}", projectRoot) + throw IllegalArgumentException("Project directory already exists: $projectRoot") + } + } + + onProgress?.invoke("Building project metadata...") + + val modpackMeta = ModpackMeta( + id = packName, + minecraftVersion = mcVer, + loader = loaderId ?: "unknown", + loaderVersion = loaderVer, + source = sourceId, + license = null, + icon = if (iconPath != null) "icon.png" else null + ) + val manifest = json.encodeToString(ModpackMeta.serializer(), modpackMeta) + + val steps = mutableListOf() + steps += StandardProjectSteps.metadataStep("import-source-meta", manifest) + steps += StandardProjectSteps.exportRulesStep("import-export-rules") + steps += StandardProjectSteps.placeholderSteps() + StandardProjectSteps.iconStep(iconPath?.toString().orEmpty())?.let { steps += it } + + if (selectedMods.isNotEmpty()) { + onProgress?.invoke("Preparing ${selectedMods.size} mod(s)...") + val modsJson = buildJsonArray { + selectedMods.forEach { mod -> + addJsonObject { + put("jarPath", mod.jarPath.toString()) + put("modId", mod.modId) + put("displayName", mod.displayName) + put("fileName", mod.fileName) + put("side", mod.side.name) + put("sourceProjectId", mod.sourceProjectId?.let { JsonPrimitive(it) } ?: JsonNull) + put("dependencyIds", buildJsonArray { + mod.dependencyIds.forEach { add(it) } + }) + } + } + } + steps += GeneratorStepDescriptor( + "import-mods", + "importMods", + JsonObject(mapOf( + "sourceId" to JsonPrimitive(sourceId), + "mods" to modsJson + )), + affects = listOf("mods/*.jar") + ) + } + + if (selectedFiles.isNotEmpty()) { + onProgress?.invoke("Preparing ${selectedFiles.size} file(s)...") + val filesJson = buildJsonArray { + selectedFiles.forEach { file -> add(file.toString()) } + } + steps += GeneratorStepDescriptor( + "import-files", + "importFiles", + JsonObject(mapOf( + "sourceMinecraftDir" to JsonPrimitive(instanceMinecraftDir.toString()), + "files" to filesJson + )), + affects = listOf("**") + ) + } + + projectRoot.mkdirs() + + onProgress?.invoke("Writing project files...") + + val execResult = ProjectTemplateExecutor.run( + templateId = "import:$packName", + projectRoot = projectRoot.toJPath(), + variables = emptyMap(), + steps = steps, + onStep = { stepId, _, total -> + onProgress?.invoke("Running step $stepId ($total total)...") + } + ) + + if (execResult.successful) { + onProgress?.invoke("Finalizing project...") + + val iconValue = if (iconPath != null) "icon.png" else TIcons.defaultProjectIcon + val rawMeta = buildJsonObject { put("metaPath", "trmodpack.json") } + val trMeta = ProjectFiles.buildMeta( + type = "source", + name = packName, + icon = iconValue, + schemaVersion = ModpackTemplateDescriptor.currentSchema, + meta = rawMeta + ) + ProjectFiles.writeTrProject(projectRoot, trMeta) + + val loader = loaderId?.let { id -> + BuiltinRegistries.ModLoader.all().find { it.id == id } + } + if (loader != null && mcVer != "unknown" && loaderVer.isNotBlank()) { + onProgress?.invoke("Bootstrapping Minecraft and loader...") + ProjectBootstrap.launch(projectRoot, packName, mcVer, loader, loaderVer) + } + + if (sourceInstance != null && sourceIdForCache != null) { + deleteImportCache(sourceInstance, sourceIdForCache) + } + } + + return execResult + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportUtils.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportUtils.kt new file mode 100644 index 0000000..7df0283 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportUtils.kt @@ -0,0 +1,70 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.qt.gui.QIcon +import io.qt.gui.QPixmap +import java.security.MessageDigest + +/** + * Computes the SHA-1 hex digest of a byte array. + * + * @param bytes Input data. + * @return Lowercase hex string, or `null` if the algorithm is unavailable. + */ +fun computeSha1(bytes: ByteArray): String? { + return try { + val digest = MessageDigest.getInstance("SHA-1") + digest.update(bytes) + digest.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { null } +} + +/** + * Returns a scaled [QPixmap] of the icon associated with a [KnownLauncher]. + * + * @param launcher The launcher whose icon to fetch. + * @param size Desired width and height in pixels. + * @return Pixmap of the launcher icon. + */ +fun iconForLauncher(launcher: KnownLauncher, size: Int): QPixmap { + val icon = launcher.icon + return icon.pixmap(size, size) +} + +/** + * Returns the [QIcon] that best represents a file path, using [FileTypeDescriptor]. + * + * @param path The file to look up an icon for. + * @param dummyProject A lightweight project instance required for descriptor lookups. + * @return Determined file-type icon, falling back to a generic file icon. + */ +fun iconForFile(path: VPath, dummyProject: ProjectBase): QIcon { + val descriptor = FileTypeDescriptor.primary(path, dummyProject) + return descriptor?.icon ?: TIcons.File.icon +} + +/** + * Converts a loader display name (e.g. "Fabric", "NeoForge") to its registry ID. + * + * Falls back to matching against [BuiltinRegistries.ModLoader] by id or display name. + * + * @param displayName Loader display name (might be `null`). + * @return Normalized loader ID such as "fabric", "neoforge", "forge", "quilt", or `null`. + */ +fun mapLoaderId(displayName: String?): String? { + if (displayName == null) return null + val loaderNameToId = mapOf( + "Fabric" to "fabric", + "NeoForge" to "neoforge", + "Forge" to "forge", + "Quilt" to "quilt" + ) + return loaderNameToId[displayName] + ?: BuiltinRegistries.ModLoader.all().firstOrNull { it.id.equals(displayName, ignoreCase = true) }?.id + ?: BuiltinRegistries.ModLoader.all().firstOrNull { it.displayName.equals(displayName, ignoreCase = true) }?.id +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportableMod.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportableMod.kt new file mode 100644 index 0000000..41d14b4 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ImportableMod.kt @@ -0,0 +1,44 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.core.mod.ModSide +import io.github.tritium_launcher.launcher.io.VPath + +/** + * Represents a mod discovered during instance scanning that can be imported. + * + * Each instance maintains a mutable copy so that UI state (checked, source metadata) can be + * updated without copying the entire list on every change. Fields like [sha1Hash] and + * [fileFingerprint] are populated during scanning; source-related fields are filled in later + * by [ModSearchHelper] when the user selects a mod source. + * + * @param jarPath Absolute path to the .jar file on disk. + * @param modId Identifier extracted from the jar metadata. + * @param displayName Human-readable name from metadata, falling back to [modId]. + * @param fileName Jar filename (e.g. "my-mod-1.0.jar"). + * @param side Side constraint from the jar manifest. + * @param iconBytes Raw PNG bytes of the jar icon, or null. + * @param sha1Hash SHA-1 digest of the jar contents for cache matching. + * @param fileFingerprint Source-specific fingerprint (e.g. Modrinth FP). + * @param sourceProjectId ID of the matching project on the chosen mod source. + * @param sourceIconUrl URL to the project icon on the source. + * @param sourceAvailable Whether a matching version was found on the source. + * @param sourceStatus Human-readable status string ("Available", "Not Available", etc.). + * @param checked Whether the user has checked this mod for import. + * @param dependencyIds Project IDs of required dependencies from the matched source version. + */ +data class ImportableMod( + val jarPath: VPath, + val modId: String, + val displayName: String, + val fileName: String, + val side: ModSide, + val iconBytes: ByteArray?, + var sha1Hash: String? = null, + var fileFingerprint: Long? = null, + var sourceProjectId: String? = null, + var sourceIconUrl: String? = null, + var sourceAvailable: Boolean? = null, + var sourceStatus: String? = null, + var checked: Boolean = true, + var dependencyIds: List = emptyList() +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/LauncherDetector.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/LauncherDetector.kt new file mode 100644 index 0000000..9328b14 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/LauncherDetector.kt @@ -0,0 +1,470 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.qt.gui.QIcon +import kotlinx.serialization.json.* +import java.nio.file.Files + +/** + * Metadata parsed from a launcher's instance configuration files. + * + * @param name Instance display name (might be `null` if only the folder name is available). + * @param gameVersion Minecraft version of the instance. + * @param loader Mod loader display name ("Fabric", "Forge", "NeoForge", "Quilt"). + * @param loaderVersion Version string of the mod loader. + */ +data class InstanceMeta( + val name: String?, + val gameVersion: String?, + val loader: String?, + val loaderVersion: String? +) + +/** + * Describes a known third-party launcher whose instances can be discovered and imported. + * + * Each [KnownLauncher] defines the platform-specific directories to scan, how to resolve + * the minecraft subdirectory within an instance, and a parser function that reads the + * instance metadata from its config files. + * + * @param id Unique identifier, used for matching. + * @param displayName Human-readable name for the UI. + * @param icon [QIcon] badge shown in the launcher selection cards. + * @param instanceDirs Directories to scan for instances. + * @param minecraftSubdirName Subdirectory within an instance folder that contains the + * Minecraft files (e.g. "minecraft" for PrismLauncher, "" for CurseForge). + * @param parser Function that reads [InstanceMeta] from an instance directory. + */ +data class KnownLauncher( + val id: String, + val displayName: String, + val icon: QIcon, + val instanceDirs: List, + val minecraftSubdirName: String, + val parser: (VPath) -> InstanceMeta? +) { + companion object { + private fun windowsAppData(vararg subdirs: String) = + VPath.get(System.getenv("APPDATA") ?: "${Platform.userHome}/AppData/Roaming") + .resolve(subdirs.joinToString("/")) + + private fun windowsLocalAppData(vararg subdirs: String) = + VPath.get(System.getenv("LOCALAPPDATA") ?: "${Platform.userHome}/AppData/Local") + .resolve(subdirs.joinToString("/")) + + private fun xdgData(vararg subdirs: String) = + VPath.get(System.getenv("XDG_DATA_HOME") ?: "${Platform.userHome}/.local/share") + .resolve(subdirs.joinToString("/")) + + private fun macSupport(vararg subdirs: String) = + VPath.get("${Platform.userHome}/Library/Application Support") + .resolve(subdirs.joinToString("/")) + + private fun flatpakDir(appId: String, vararg subdirs: String): VPath? { + if (Platform.current != Platform.Linux) return null + val p = VPath.get("${Platform.userHome}/.var/app/$appId/data") + .resolve(subdirs.joinToString("/")) + return p.takeIf { it.exists() } + } + + val all: List = listOf( + KnownLauncher( + id = "prismlauncher", + displayName = "PrismLauncher", + icon = TIcons.Prism.icon, + instanceDirs = buildList { + addAll(when (Platform.current) { + Platform.Windows -> listOf(windowsAppData("PrismLauncher", "instances")) + Platform.MacOSX -> listOf(macSupport("PrismLauncher", "instances")) + else -> listOf( + xdgData("PrismLauncher", "instances"), + VPath.get("${Platform.userHome}/.local/share/PrismLauncher/instances") + ) + }) + addAll(when (Platform.current) { + Platform.Windows -> listOf(windowsAppData("MultiMC", "instances")) + Platform.MacOSX -> listOf(macSupport("MultiMC", "instances")) + else -> listOf( + xdgData("multimc", "instances"), + VPath.get("${Platform.userHome}/.local/share/multimc/instances") + ) + }) + addAll(when (Platform.current) { + Platform.Windows -> listOf(windowsAppData("PolyMC", "instances")) + Platform.MacOSX -> listOf(macSupport("PolyMC", "instances")) + else -> listOf( + xdgData("PolyMC", "instances"), + VPath.get("${Platform.userHome}/.local/share/PolyMC/instances") + ) + }) + flatpakDir("org.prismlauncher.PrismLauncher", "PrismLauncher", "instances")?.let { add(it) } + flatpakDir("io.github.PolyMC.PolyMC", "PolyMC", "instances")?.let { add(it) } + VPath.get("${Platform.userHome}/Applications/PrismLauncher/instances").takeIf { it.exists() }?.let { add(it) } + }, + minecraftSubdirName = "minecraft", + parser = ::parsePrismInstance + ), + KnownLauncher( + id = "atlauncher", + displayName = "ATLauncher", + icon = TIcons.ATL.icon, + instanceDirs = buildList { + addAll(when (Platform.current) { + Platform.Windows -> listOf(windowsAppData("ATLauncher", "instances")) + Platform.MacOSX -> listOf(macSupport("ATLauncher", "instances")) + else -> listOf( + xdgData("ATLauncher", "instances"), + VPath.get("${Platform.userHome}/.local/share/atlauncher/instances") + ) + }) + flatpakDir("com.atlauncher.ATLauncher", "ATLauncher", "instances")?.let { add(it) } + }, + minecraftSubdirName = "", + parser = ::parseATLauncherInstance + ), + KnownLauncher( + id = "curseforge", + displayName = "CurseForge", + icon = TIcons.CurseForge.icon, + instanceDirs = when (Platform.current) { + Platform.Windows -> listOf( + windowsLocalAppData("CurseForge", "Minecraft", "Instances"), + VPath.get("${Platform.userHome}/curseforge/minecraft/Instances") + ) + Platform.MacOSX -> listOf(macSupport("CurseForge", "Minecraft", "Instances")) + else -> listOf( + xdgData("CurseForge", "Minecraft", "Instances"), + VPath.get("${Platform.userHome}/Documents/curseforge/minecraft/Instances") + ) + }, + minecraftSubdirName = "", + parser = ::parseCurseForgeInstance + ), + KnownLauncher( + id = "gdlauncher", + displayName = "GDLauncher", + icon = TIcons.GDL.icon, + instanceDirs = buildList { + addAll(when (Platform.current) { + Platform.Windows -> listOf( + windowsLocalAppData("gdlauncher", "instances"), + VPath.get("${Platform.userHome}/AppData/Roaming/gdlauncher_next/instances") + ) + Platform.MacOSX -> listOf(macSupport("gdlauncher", "instances")) + else -> listOf( + xdgData("gdlauncher", "instances"), + xdgData("gdlauncher_carbon", "instances"), + VPath.get("${Platform.userHome}/.local/share/gdlauncher_carbon/data/instances") + ) + }) + }, + minecraftSubdirName = "instance", + parser = ::parseGDLauncherInstance + ), + ) + + /** + * Sentinel launcher used when browsing a directory manually. Returns no instances + * and the parser always returns `null`. + */ + val BROWSE_FOLDER = KnownLauncher( + id = "_browse", + displayName = "Existing Project", + icon = TIcons.Tritium.icon, + instanceDirs = emptyList(), + minecraftSubdirName = "", + parser = { null } + ) + + /** + * Sentinel launcher for importing CurseForge modpack archives. + */ + val CURSEFORGE_PACK = KnownLauncher( + id = "_cursepack", + displayName = "CurseForge ZIP", + icon = TIcons.CFPack.icon, + instanceDirs = emptyList(), + minecraftSubdirName = "", + parser = { null } + ) + + /** + * Sentinel launcher for importing Modrinth modpack archives (.mrpack). + */ + val MODRINTH_PACK = KnownLauncher( + id = "_modrinthpack", + displayName = "Modrinth ZIP", + icon = TIcons.MRPack.icon, + instanceDirs = emptyList(), + minecraftSubdirName = "", + parser = { null } + ) + + // --- Parser implementations --- + + /** + * Parses a PrismLauncher / MultiMC / PolyMC instance from its `mmc-pack.json`. + */ + private fun parsePrismInstance(dir: VPath): InstanceMeta? { + val pack = dir.resolve("mmc-pack.json") + val root = parseJsonFile(pack) ?: return null + val components = root["components"]?.jsonArray ?: return null + var gv: String? = null; var ln: String? = null; var lv: String? = null + for (c in components) { + val o = c.jsonObject; val uid = o["uid"]?.jsonPrimitive?.contentOrNull ?: continue + when (uid) { + "net.minecraft" -> gv = o["cachedVersion"]?.jsonPrimitive?.contentOrNull + "net.minecraftforge" -> { ln = "Forge"; lv = o["cachedVersion"]?.jsonPrimitive?.contentOrNull } + "net.neoforged" -> { ln = "NeoForge"; lv = o["cachedVersion"]?.jsonPrimitive?.contentOrNull } + "net.fabricmc.fabric-loader" -> { ln = "Fabric"; lv = o["cachedVersion"]?.jsonPrimitive?.contentOrNull } + "org.quiltmc.quilt-loader" -> { ln = "Quilt"; lv = o["cachedVersion"]?.jsonPrimitive?.contentOrNull } + } + } + return InstanceMeta(null, gv, ln, lv) + } + + /** + * Parses an ATLauncher instance from its `instance.json`. + */ + private fun parseATLauncherInstance(dir: VPath): InstanceMeta? { + val file = dir.resolve("instance.json") + if (!file.exists()) return null + val root = parseJsonFile(file) ?: return null + val launcher = root["launcher"]?.jsonObject ?: return null + val name = launcher["name"]?.jsonPrimitive?.contentOrNull + val gv = launcher["version"]?.jsonPrimitive?.contentOrNull + val lvObj = launcher["loaderVersion"]?.jsonObject + val ln = lvObj?.get("type")?.jsonPrimitive?.contentOrNull + val lv = lvObj?.get("rawVersion")?.jsonPrimitive?.contentOrNull + return InstanceMeta(name, gv, ln, lv) + } + + /** + * Parses a CurseForge instance from its `minecraftinstance.json`. + */ + private fun parseCurseForgeInstance(dir: VPath): InstanceMeta? { + val file = dir.resolve("minecraftinstance.json") + if (!file.exists()) return null + val root = parseJsonFile(file) ?: return null + val name = root["name"]?.jsonPrimitive?.contentOrNull ?: dir.fileName() + val gv = root["gameVersion"]?.jsonPrimitive?.contentOrNull + val bml = root["baseModLoader"]?.jsonObject + val ln = bml?.get("name")?.jsonPrimitive?.contentOrNull + ?.substringBefore('-') + ?.replaceFirstChar { it.uppercase() } + ?.let { if (it == "Neoforge") "NeoForge" else if (it == "Fabric") "Fabric" else it } + val lv = bml?.get("forgeVersion")?.jsonPrimitive?.contentOrNull + return InstanceMeta(name, gv, ln, lv) + } + + /** + * Parses a GDLauncher instance from its `instance.json`. + */ + private fun parseGDLauncherInstance(dir: VPath): InstanceMeta? { + val file = dir.resolve("instance.json") + if (!file.exists()) return null + val root = parseJsonFile(file) ?: return null + val name = root["name"]?.jsonPrimitive?.contentOrNull + val gc = root["game_configuration"]?.jsonObject + val ver = gc?.get("version")?.jsonObject + val gv = ver?.get("release")?.jsonPrimitive?.contentOrNull + val loaders = ver?.get("modloaders")?.jsonArray + var ln: String? = null; var lv: String? = null + if (!loaders.isNullOrEmpty()) { + val first = loaders[0].jsonObject + ln = first["type"]?.jsonPrimitive?.contentOrNull + lv = first["version"]?.jsonPrimitive?.contentOrNull + } + return InstanceMeta(name, gv, ln, lv) + } + } +} + +/** + * A detected Minecraft instance discovered by scanning a [KnownLauncher]'s directories. + * + * @param launcher The launcher this instance was found under. + * @param name Display name, from metadata or folder name. + * @param instanceDir Root directory of the instance. + * @param minecraftDir Directory containing the actual Minecraft files (mods, config, etc.). + * @param gameVersion Minecraft version, or `null` if unknown. + * @param loader Mod loader display name, or `null`. + * @param loaderVersion Mod loader version, or `null`. + */ +data class DetectedInstance( + val launcher: KnownLauncher, + val name: String, + val instanceDir: VPath, + val minecraftDir: VPath, + val gameVersion: String?, + val loader: String?, + val loaderVersion: String? +) + +/** + * Discovers launcher installations and resolves instance metadata. + */ +object LauncherDetector { + private val log = logger() + + /** + * Returns the subset of [KnownLauncher.all] that have at least one existing instance + * directory on the current machine. + * + * @return List of launchers with detectable installations. + */ + fun detectInstalled(): List = + KnownLauncher.all.filter { launcher -> + launcher.instanceDirs.any { dir -> + try { dir.exists() && dir.toJPath().let { Files.isDirectory(it) } } + catch (_: Exception) { false } + } + } + + /** + * Scans all instance directories for a given [launcher] and returns parsed [DetectedInstance]s. + * + * Deduplicates directories by real path to handle overlapping paths between launchers + * (e.g. PrismLauncher includes MultiMC directories). + * + * @param launcher The launcher to scan instances for. + * @return List of detected instances. + */ + fun scanInstances(launcher: KnownLauncher): List { + val results = mutableListOf() + val seenDirs = mutableSetOf() + for (dir in launcher.instanceDirs) { + if (!dir.exists()) continue + try { + val jPath = dir.toJPath() + if (!Files.isDirectory(jPath)) continue + val canonical = jPath.toRealPath().toString() + if (!seenDirs.add(canonical)) continue + val entries = Files.list(jPath).toList() + for (instancePath in entries) { + val instanceDir = VPath.get(instancePath.toString()) + val meta = launcher.parser(instanceDir) + if (meta != null) { + val name = meta.name ?: instanceDir.fileName() + val minecraftDir = instanceDir.resolve(launcher.minecraftSubdirName) + results.add( + DetectedInstance( + launcher = launcher, + name = name, + instanceDir = instanceDir, + minecraftDir = minecraftDir, + gameVersion = meta.gameVersion, + loader = meta.loader, + loaderVersion = meta.loaderVersion + ) + ) + } + } + } catch (e: Exception) { + log.warn("Failed to scan instances in {}", dir, e) + } + } + return results + } + + /** + * Inspects a user-selected directory and returns a [DetectedInstance] if it looks + * like a valid Minecraft instance. If a `.minecraft` subdirectory exists, it is + * treated as the minecraft dir; otherwise the directory itself is used. + * + * @param dir The directory to inspect. + * @return A [DetectedInstance] with unknown game version and loader, or `null`. + */ + fun inspectDirectory(dir: VPath): DetectedInstance? { + if (!dir.exists()) return null + try { + val jPath = dir.toJPath() + if (!Files.isDirectory(jPath)) return null + + val dotMc = dir.resolve(".minecraft") + if (dotMc.exists()) { + val name = dir.fileName() + return DetectedInstance( + launcher = KnownLauncher.BROWSE_FOLDER, + name = name, + instanceDir = dir, + minecraftDir = dotMc, + gameVersion = null, + loader = null, + loaderVersion = null + ) + } + + val name = dir.fileName() + return DetectedInstance( + launcher = KnownLauncher.BROWSE_FOLDER, + name = name, + instanceDir = dir, + minecraftDir = dir, + gameVersion = null, + loader = null, + loaderVersion = null + ) + } catch (_: Exception) { + return null + } + } + + /** + * Resolves the icon file path for a [DetectedInstance]. + * + * Priority: + * 1. CurseForge's `profileImagePath` field from `minecraftinstance.json`. + * 2. `icon.png` directly in the instance directory. + * 3. `icon.png` in the minecraft directory. + * 4. Any `.png` file found in the instance directory. + * + * @param instance The instance to find an icon for. + * @return Path to the icon file, or `null` if none was found. + */ + fun resolveInstanceIcon(instance: DetectedInstance): VPath? { + if (instance.launcher.id == "curseforge") { + val cfFile = instance.instanceDir.resolve("minecraftinstance.json") + if (cfFile.exists()) { + val root = parseJsonFile(cfFile) + val path = root?.get("profileImagePath")?.jsonPrimitive?.contentOrNull + if (path != null) { + val iconFile = VPath.get(path) + if (iconFile.exists()) return iconFile + } + } + } + val directIcon = instance.instanceDir.resolve("icon.png") + if (directIcon.exists()) return directIcon + val mcIcon = instance.minecraftDir.resolve("icon.png") + if (mcIcon.exists()) return mcIcon + try { + val jDir = instance.instanceDir.toJPath() + if (Files.isDirectory(jDir)) { + val pngFiles = Files.list(jDir).toList() + .filter { it.fileName.toString().lowercase().endsWith(".png") } + if (pngFiles.isNotEmpty()) { + return VPath.get(pngFiles.first().toString()) + } + } + } catch (_: Exception) {} + return null + } +} + +private val json = Json { ignoreUnknownKeys = true } + +/** + * Reads and parses a JSON file into a [JsonObject]. + * + * @param file The file to read. + * @return Parsed object, or `null` if the file is missing or malformed. + */ +private fun parseJsonFile(file: VPath): JsonObject? { + val text = file.readTextOrNull() ?: return null + return try { json.parseToJsonElement(text).jsonObject } catch (_: Exception) { null } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ModSearchHelper.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ModSearchHelper.kt new file mode 100644 index 0000000..23c8499 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ModSearchHelper.kt @@ -0,0 +1,300 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.core.source.ModBrowserContext +import io.github.tritium_launcher.launcher.core.source.ModSearchQuery +import io.github.tritium_launcher.launcher.core.source.ModSource +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.CancellationException + +object ModSearchHelper +private val searchLog = logger(ModSearchHelper::class) + +/** + * Result of a source lookup for a single mod. + * + * @param projectId ID of the matching project on the mod source. + * @param iconUrl URL of the project icon, if available. + * @param isFileMatch Whether the match came from a precise file fingerprint/hash lookup. + * @param status Human-readable status string ("Available", "Matched by file hash", etc.). + */ +data class SourceMatch( + val projectId: String, + val iconUrl: String?, + val isFileMatch: Boolean = true, + val status: String = "Available" +) + +private val loaderNameToId = mapOf( + "Fabric" to "fabric", + "NeoForge" to "neoforge", + "Forge" to "forge", + "Quilt" to "quilt" +) + +/** + * Normalizes a string for loose comparison: lowercased, non-alphanumeric characters removed. + */ +fun normalize(str: String): String = + str.lowercase().replace(Regex("[^a-z0-9]"), "") + +/** + * Generates an ordered list of search query strings for a mod, from most precise to most + * general. Queries are derived from the mod's ID, display name, filename, and various + * heuristic splits (camelCase separators, version-stripping, spacing alternatives). + * + * Queries that match a registered [ModSource] name are excluded. + * + * @param mod The mod to build queries for. + * @return A deduplicated list of non-blank query strings. + */ +fun buildSearchQueries(mod: ImportableMod): List { + val sourceNames = BuiltinRegistries.ModSource.all().flatMap { listOf(it.id, it.displayName) }.map { it.lowercase() }.toSet() + if (mod.modId.lowercase() in sourceNames || mod.displayName.lowercase() in sourceNames) { + return emptyList() + } + + val loaderIds = loaderNameToId.values.map { it.lowercase() }.toSet() + val skipModId = mod.modId.lowercase() in loaderIds + + val result = mutableListOf() + if (!skipModId) result.add(mod.modId) + if (mod.displayName != mod.modId || skipModId) result.add(mod.displayName) + val fileName = mod.fileName.removeSuffix(".jar") + if ((fileName != mod.modId || skipModId) && fileName != mod.displayName) result.add(fileName) + + val camel = Regex("([a-z])([A-Z])") + val camelDigit = Regex("([a-zA-Z])([0-9])") + for (s in (if (skipModId) listOf(mod.displayName, fileName) else listOf(mod.modId, mod.displayName, fileName)).distinct()) { + val split = s.replace(camel, "$1 $2").replace(camelDigit, "$1 $2") + if (split != s) result.add(split) + } + + val stripped = mod.displayName.replace(Regex("\\s*\\(.*?\\)\\s*$"), "").trim() + if (stripped != mod.displayName && stripped.isNotBlank()) result.add(stripped) + + val spacedModId = mod.modId.replace(Regex("[_\\-]"), " ") + if (spacedModId != mod.modId) result.add(spacedModId) + val spacedDisplayName = mod.displayName.replace(Regex("[_\\-]"), " ") + if (spacedDisplayName != mod.displayName && spacedDisplayName != spacedModId && spacedDisplayName !in result) result.add(spacedDisplayName) + + val strippedVersion = mod.modId + .replace(Regex("[-_][\\d.]+[-_].*$"), "") + .replace(Regex("[-_][\\d.]+$"), "") + if (strippedVersion != mod.modId && strippedVersion.isNotBlank()) { + result.add(strippedVersion) + val spacedVersion = strippedVersion.replace(Regex("[_\\-]"), " ") + if (spacedVersion != strippedVersion) result.add(spacedVersion) + } + + val fileNameCamel = fileName.replace(camel, "$1 $2").replace(camelDigit, "$1 $2") + val strippedFileName = fileNameCamel + .replace(Regex("(?<=\\S)[-_.]+\\w+[-_][\\d.]+.*$"), "").trim() + .replace(Regex("(?<=\\S)[-_.]+[\\d.]+.*$"), "").trim() + if (strippedFileName != fileName && strippedFileName != fileNameCamel && strippedFileName.isNotBlank() + && strippedFileName != mod.modId && strippedFileName != mod.displayName) { + result.add(strippedFileName) + } + + val base = (if (strippedVersion != mod.modId) strippedVersion else mod.modId).lowercase() + val loaded = mutableSetOf() + fun insertIfNew(s: String) { if (s.isNotBlank() && s !in loaded) { loaded.add(s); result.add(s) } } + val knownParts = listOf("neoforge", "forge", "fabric", "quilt", "api", "core", "lib", "util", "mod", "config", "for") + for (part in knownParts.sortedByDescending { it.length }) { + var idx = base.indexOf(part) + while (idx > 0) { + val candidate = base.substring(0, idx) + " " + base.substring(idx) + insertIfNew(candidate) + val prefix = base.substring(0, idx) + for (innerPart in knownParts.sortedByDescending { it.length }) { + val innerIdx = prefix.indexOf(innerPart) + if (innerIdx > 0) { + insertIfNew(prefix.substring(0, innerIdx) + " " + prefix.substring(innerIdx) + " " + base.substring( + idx + )) + } + } + idx = base.indexOf(part, idx + 1) + } + } + + return result.distinct().filter { it.isNotBlank() } +} + +/** + * Searches for a mod on a given [ModSource] using multiple lookup strategies: + * + * 1. **File fingerprint** — fastest, most precise match using the source's fingerprint API. + * 2. **SHA-1 hash** — fallback hash lookup when fingerprinting is unavailable. + * 3. **Text search** — iterates through [buildSearchQueries] results, first with a narrow + * context (matching MC version + loader), then with a broad context (any version/loader). + * + * When a text search result is found, the mod's version is verified against the source via + * [verifyVersionOnSource] before returning. + * + * @param mod The mod to locate on the source. + * @param source The mod source to query. + * @param context Search context containing MC version and loader for filtering. + * @return A [SourceMatch] with the project ID and status, or `null` if no match was found. + * @throws CancellationException Propagated from the source API if the coroutine is cancelled. + */ +suspend fun findModOnSource( + mod: ImportableMod, + source: ModSource, + context: ModBrowserContext +): SourceMatch? { + if (mod.fileFingerprint != null) { + try { + val fpInfo = source.resolveProjectInfoByFingerprint(mod.fileFingerprint!!) + if (fpInfo != null) { + return SourceMatch(fpInfo.projectId, iconUrl = null, isFileMatch = true, status = "Available") + } + } catch (_: Exception) { } + } + + if (mod.sha1Hash != null) { + try { + val hashInfo = source.resolveProjectInfoByHash(mod.sha1Hash!!) + if (hashInfo != null) { + return SourceMatch(hashInfo.projectId, iconUrl = null, status = "Matched by file hash") + } + } catch (_: Exception) { } + } + + val queries = buildSearchQueries(mod) + var bestNameMatch: SourceMatch? = null + + for (narrow in listOf(true, false)) { + val searchContext = if (narrow) context else context.copy(minecraftVersion = null, modLoaderId = null) + for (q in queries) { + try { + val page = source.search(searchContext, ModSearchQuery(text = q, limit = 30)) + if (page.results.isEmpty()) { + searchLog.warn("[{}] Search '{}': EMPTY", mod.modId, q) + continue + } + var matched = false + for (r in page.results) { + val normTitle = normalize(r.title) + val normModId = normalize(mod.modId) + val normDisplayName = normalize(mod.displayName) + val normQ = normalize(q) + val normSlug = r.slug?.let { normalize(it) } + val isExact = r.id.equals(mod.modId, ignoreCase = true) || + normSlug != null && (normSlug == normModId || normSlug == normDisplayName || normSlug == normQ) || + normTitle == normModId || normTitle == normDisplayName || normTitle == normQ + val isContains = !isExact && ( + normTitle.contains(normDisplayName) || + normDisplayName.contains(normTitle) || + normTitle.contains(normQ) || + normQ.contains(normTitle) + ) + if (isExact || isContains) { + matched = true + val verified = verifyVersionOnSource(source, searchContext, context, r.id, mod) + if (verified) { + return SourceMatch(r.id, r.iconUrl, isFileMatch = true, status = "Available") + } + if (bestNameMatch == null || isExact) { + bestNameMatch = SourceMatch(r.id, r.iconUrl, isFileMatch = false, status = "Available (name match)") + } + } + } + if (!matched) { + searchLog.warn("[{}] Search '{}': {} results, no candidate matched modId='{}' displayName='{}'. First 5: [{}]", + mod.modId, q, page.results.size, mod.modId, mod.displayName, + page.results.take(5).joinToString { it.title }) + } + } catch (t: Throwable) { + if (t is CancellationException) throw t + searchLog.warn("[{}] Search '{}' failed: {}", mod.modId, q, t.message) + } + } + } + + return bestNameMatch +} + +/** + * Verifies whether a project on a mod source has a version compatible with a given mod. + * + * Strategies used in order: + * 1. **MC version + loader match** — checks if any version matches both the mod's MC version + * and loader. Includes compatibility mappings (Forge ↔ NeoForge, Fabric ↔ Quilt). + * 2. **SHA-1 hash match** — checks if a version's file hash matches the mod jar's hash. + * 3. **Filename fallback** — checks if the version filename or label contains the mod's filename. + * + * @param source The mod source to query. + * @param fetchContext Context for fetching versions (MC version + loader). + * @param matchContext Context for filtering matched versions. + * @param projectId The source project ID to check. + * @param mod The mod being verified. + * @return `true` if a compatible version exists. + */ +suspend fun verifyVersionOnSource( + source: ModSource, + fetchContext: ModBrowserContext, + matchContext: ModBrowserContext, + projectId: String, + mod: ImportableMod +): Boolean { + return try { + val versions = source.versions(fetchContext, projectId) + val mcVersion = matchContext.minecraftVersion + val loaderId = matchContext.modLoaderId + + if (mcVersion != null || loaderId != null) { + val matched = versions.any { v -> + val mcMatch = mcVersion == null || v.gameVersions.any { gv -> + gv == mcVersion || gv.startsWith("$mcVersion.") + } + val loaderMatch = loaderId == null || + v.loaders.any { it.equals(loaderId, ignoreCase = true) } || + (v.loaders.any { it.equals("forge", ignoreCase = true) } && loaderId.equals("neoforge", ignoreCase = true)) || + (v.loaders.any { it.equals("fabric", ignoreCase = true) } && loaderId.equals("quilt", ignoreCase = true)) || + (v.loaders.any { it.equals("quilt", ignoreCase = true) } && loaderId.equals("fabric", ignoreCase = true)) + mcMatch && loaderMatch + } + if (matched) return true + } + + if (mod.sha1Hash != null) { + val byHash = versions.any { v -> v.fileHash?.equals(mod.sha1Hash, ignoreCase = true) == true } + if (byHash) { + searchLog.warn("verifyVersionOnSource: projectId={} matched by SHA-1 hash", projectId) + return true + } + } + + val jarName = mod.fileName.removeSuffix(".jar").lowercase() + val byFilename = versions.any { v -> + v.fileName.equals(mod.fileName, ignoreCase = true) || + v.fileName?.lowercase()?.contains(jarName) == true || + v.label.lowercase().contains(jarName) + } + if (byFilename) { + searchLog.warn("verifyVersionOnSource: projectId={} matched by filename '{}'", projectId, mod.fileName) + return true + } + + searchLog.warn("verifyVersionOnSource: projectId={} mc={} loader={} versions={} no match. jar='{}'", projectId, mcVersion, loaderId, versions.size, mod.fileName) + if (versions.isNotEmpty()) { + searchLog.warn(" First 5 version details:") + versions.take(5).forEachIndexed { i, v -> + val mcOk = mcVersion == null || v.gameVersions.any { gv -> gv == mcVersion || gv.startsWith("$mcVersion.") } + val loaderOk = loaderId == null || v.loaders.any { it.equals(loaderId, ignoreCase = true) } || + (v.loaders.any { it.equals("forge", ignoreCase = true) } && loaderId.equals("neoforge", ignoreCase = true)) + val fnOk = v.fileName.equals(mod.fileName, ignoreCase = true) || + v.fileName?.lowercase()?.contains(jarName) == true || + v.label.lowercase().contains(jarName) + val hashOk = mod.sha1Hash != null && v.fileHash?.equals(mod.sha1Hash, ignoreCase = true) == true + searchLog.warn(" [{}] gv={} loaders={} label='{}' fileName='{}' hash='{}' mcOk={} loaderOk={} fnOk={} hashOk={}", + i, v.gameVersions, v.loaders, v.label, v.fileName, v.fileHash, mcOk, loaderOk, fnOk, hashOk) + } + } + false + } catch (e: Exception) { + searchLog.warn("verifyVersionOnSource: projectId={} mc={} loader={} failed: {}", projectId, matchContext.minecraftVersion, matchContext.modLoaderId, e.message) + false + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/MrpackImporter.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/MrpackImporter.kt new file mode 100644 index 0000000..ef2a757 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/MrpackImporter.kt @@ -0,0 +1,150 @@ +package io.github.tritium_launcher.launcher.import + +import io.github.tritium_launcher.launcher.io.VPath +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipFile + +/** + * Describes a single file entry in the Modrinth pack index. + * + * @param path Relative path within the pack (e.g. "mods/example.jar"). + * @param downloads List of download URLs for the file. + */ +@Serializable +data class MrpackFile( + val path: String, + val downloads: List = emptyList() +) + +/** + * Root structure of `modrinth.index.json`. + * + * @param name Display name of the modpack. + * @param summary Short description. + * @param versionId Version string for this pack release. + * @param dependencies Map of dependency type to version (e.g. "minecraft" -> "1.20.1"). + * @param files List of files to download from the source. + */ +@Serializable +data class MrpackIndex( + val name: String = "", + val summary: String? = null, + @SerialName("versionId") val versionId: String = "", + val dependencies: Map = emptyMap(), + val files: List = emptyList() +) + +/** + * Result of a successful mrpack extraction. + * + * @param instance A [DetectedInstance] representing the extracted pack. + * @param tempDir The temporary directory containing extracted files. + */ +data class MrpackResult( + val instance: DetectedInstance, + val tempDir: VPath +) + +private val mrpackJson = Json { ignoreUnknownKeys = true } + +/** + * Extracts a `.mrpack` archive into a temporary directory and returns a [MrpackResult]. + * + * The extraction process: + * 1. Reads `modrinth.index.json` from the zip. + * 2. Extracts override files (`overrides/` and `client-overrides/`), stripping those + * prefixes so files land at the correct relative paths. + * 3. Downloads index-referenced files from their URLs. + * + * @param path Absolute path to the `.mrpack` file. + * @param httpClient HTTP client used for downloading index-referenced files. + * @return A [MrpackResult] with the parsed instance metadata and temp directory, or `null` + * when the index is missing or unreadable. + */ +suspend fun extractAndPrepareMrpack( + path: String, + httpClient: HttpClient +): MrpackResult? { + val (index, tempVPath) = withContext(Dispatchers.IO) { + val zip = ZipFile(path) + zip.use { zip -> + val indexEntry = zip.getEntry("modrinth.index.json") ?: return@withContext null + val indexContent = zip.getInputStream(indexEntry).readAllBytes().decodeToString() + val idx = mrpackJson.decodeFromString(indexContent) + + val tempDir = Files.createTempDirectory("tritium-mrpack-").toFile() + val tvp = VPath.get(tempDir.absolutePath) + + val entries = zip.entries() + val canonicalBase = tempDir.canonicalPath + File.separator + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory || entry.name == "modrinth.index.json") continue + + val relativeName = entry.name + .removePrefix("overrides/") + .removePrefix("client-overrides/") + if (relativeName == entry.name) continue + if (relativeName.isBlank()) continue + + val outFile = File(tempDir, relativeName) + if (!outFile.canonicalPath.startsWith(canonicalBase)) continue + outFile.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> outFile.outputStream().use { input.copyTo(it) } } + } + + Pair(idx, tvp) + } + } ?: return null + + val gameVer = index.dependencies["minecraft"] + val loaderKey = listOf("fabric-loader", "neoforge", "forge", "quilt-loader").firstOrNull { it in index.dependencies } + val loaderVer = if (loaderKey != null) index.dependencies[loaderKey] else null + val displayLoader = mapOf( + "fabric-loader" to "Fabric", "neoforge" to "NeoForge", + "forge" to "Forge", "quilt-loader" to "Quilt" + )[loaderKey] + + for (file in index.files) { + val url = file.downloads.firstOrNull() ?: continue + try { + val bytes = httpClient.get(url).bodyAsBytes() + val resolved = VPath.get(tempVPath, file.path).normalize() + if (!resolved.startsWith(tempVPath)) continue + val outFile = resolved.toJFile() + outFile.parentFile?.mkdirs() + withContext(Dispatchers.IO) { + outFile.outputStream().use { it.write(bytes) } + } + } catch (_: Exception) {} + } + + val instance = DetectedInstance( + launcher = KnownLauncher.BROWSE_FOLDER, + name = index.name, + instanceDir = tempVPath, + minecraftDir = tempVPath, + gameVersion = gameVer, + loader = displayLoader, + loaderVersion = loaderVer + ) + return MrpackResult(instance, tempVPath) +} + +/** + * Deletes the temporary directory created during mrpack extraction. + * + * @param mrpackTempDir The temp directory to remove, or `null`. + */ +fun cleanupMrpackTemp(mrpackTempDir: VPath?) { + mrpackTempDir?.toJFile()?.deleteRecursively() +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportProjectDialog.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportProjectDialog.kt new file mode 100644 index 0000000..2bb5284 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportProjectDialog.kt @@ -0,0 +1,1984 @@ +package io.github.tritium_launcher.launcher.import.ui + +import io.github.tritium_launcher.launcher.accounts.AccountDescriptor +import io.github.tritium_launcher.launcher.accounts.ModrinthAccount +import io.github.tritium_launcher.launcher.accounts.ModrinthProject +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.mod.ModSide +import io.github.tritium_launcher.launcher.core.mod.readModJarIcon +import io.github.tritium_launcher.launcher.core.mod.readModJarInfo +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.project.ProjectMngr +import io.github.tritium_launcher.launcher.core.source.CurseForge +import io.github.tritium_launcher.launcher.core.source.ModBrowserContext +import io.github.tritium_launcher.launcher.core.source.ModSource +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.import.* +import io.github.tritium_launcher.launcher.import.ui.ImportProjectDialog.Companion.iconCache +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.qs +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.TComboBox +import io.github.tritium_launcher.launcher.ui.widgets.TPushButton +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.* +import io.github.tritium_launcher.launcher.userHome +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.qt.core.QTimer +import io.qt.core.Qt +import io.qt.gui.QCursor +import io.qt.gui.QIcon +import io.qt.gui.QPixmap +import io.qt.widgets.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.json.JsonObject +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.sync.Semaphore as CoroutineSemaphore + +/** + * Multipage dialog for importing Minecraft instances into Tritium projects. + * + * The dialog guides the user through: + * 1. **Select page** — choose a launcher and pick an instance, or browse for a folder / + * Modrinth / CurseForge pack. + * 2. **Review page** — review instance metadata, choose mods to import (with source + * validation), select config files to copy, and set the destination path. + * 3. **Modrinth info page** — review a Modrinth pack project before importing. + */ +class ImportProjectDialog(parent: QWidget? = null) : QDialog(parent) { + private val stacked = QStackedWidget() + private val pageSelect = QWidget() + private val pageReview = QWidget() + + // Import Sources (select page) + private val launcherScroll = QScrollArea() + private val launcherCards = mutableListOf() + private var selectedCard: ImportOption? = null + private val instanceList = QListWidget() + private val instanceListStack = QStackedWidget() + private val instanceListPlaceholder = QLabel("Select a launcher to see instances.") + + // Instance Info and File Tree (review page) + private val instanceIconLabel = QLabel() + private val instanceNameLabel = QLabel() + private val instanceGameVerLabel = QLabel() + private val instanceLoaderLabel = QLabel() + private val instanceLoaderVerLabel = QLabel() + + // Destination (review page) + private val destPathField = QLineEdit() + private val destBrowseBtn = TPushButton() + + // Mod Source + Search (review page) + private val modSourceCombo = TComboBox() + private val searchField = QLineEdit() + private val refreshBtn = TPushButton { + text = "↻" + toolTip = "Re-validate all mods against source" + maximumWidth = 32 + } + + // Mod List (review page) + private val modListWidget = QListWidget() + private val modListStack = QStackedWidget() + private val modListPlaceholder = QLabel("Scanning mods...") + private val importableMods = mutableListOf() + private val modListGuard = Any() + + // Tabbed content (review page) + private val importTabWidget = QTabWidget() + private val modsTabPage = QWidget() + private val filesTabPage = QWidget() + + // File Tree (review page) + private val fileTree = QTreeWidget() + private val fileTreeStack = QStackedWidget() + private val fileTreeLoading = QLabel("Scanning files...") + + // Footer + private val backBtn = TPushButton(tint = TColors.Warning) { text = "Back" } + private val importBtn = TPushButton(tint = TColors.Green) { text = "Import" } + private val statusLabel = QLabel() + + // Modrinth pack account selector + private val accountCombo = TComboBox() + private val browseMrpackCard = QWidget() + + // Modrinth pack info page + private val pageModrinthInfo = QWidget() + private val modrinthIconLabel = QLabel() + private val modrinthTitleLabel = QLabel() + private val modrinthDescLabel = QLabel() + private val modrinthPackVerLabel = QLabel() + private val modrinthMetaRow = QWidget() + private val modrinthExternalBtn = TPushButton() + private var currentModrinthProject: ModrinthProject? = null + + // State + private val detectedLaunchers = mutableListOf() + private var currentLauncher: KnownLauncher? = null + private var currentInstance: DetectedInstance? = null + private var instances: List = emptyList() + private var modrinthPackMode = false + private val modrinthPackProjects = mutableListOf() + private val modrinthAccounts = mutableListOf() + private var currentValidationJob: Job? = null + private var currentScanJob: Job? = null + private var currentFileTreeJob: Job? = null + private var currentIconJob: Job? = null + private var currentModrinthFetchJob: Job? = null + + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val log = logger() + + private var cursePackPrebuiltMods: List? = null + + companion object { + private val expandedState = mutableMapOf>() + private val dummyProject = ProjectBase("dummy", VPath.get("/tmp"), "dummy", "", JsonObject(emptyMap())) + private val iconSemaphore = Semaphore(4) + private val depSemaphore = CoroutineSemaphore(2) + private val iconCache = ConcurrentHashMap() + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 30_000 + } + } + } + + init { + windowTitle = "Import Project" + minimumSize = qs(800, 520) + objectName = "ImportDialog" + + buildPageSelect() + buildPageReview() + buildPageModrinthInfo() + stacked.addWidget(pageSelect) + stacked.addWidget(pageReview) + stacked.addWidget(pageModrinthInfo) + + hBoxLayout(this) { + setContentsMargins(0, 0, 0, 0) + addWidget(stacked) + } + + connectSignals() + populateLaunchers() + populateModSources() + applyStyles() + } + + /** + * Applies the custom Qt stylesheet for all import dialog widgets using [setThemedStyle]. + */ + private fun applyStyles() { + setThemedStyle { + selector("#ImportDialog") { + backgroundColor(TColors.Surface0) + } + selector("#instanceList") { + border() + background("transparent") + padding(4) + } + selector("QListView::item") { + border() + borderRadius(6) + background("transparent") + color(TColors.Text) + padding(0) + } + selector("QFrame#launcherCard") { + border(1, TColors.Surface1) + borderRadius(8) + background("transparent") + padding(0) + } + selector("QFrame#launcherCard:hover") { + backgroundColor(TColors.Surface1) + } + selector("QFrame#launcherCard[selected=\"true\"]") { + backgroundColor(TColors.SelectedUI) + border(1, TColors.Accent) + } + selector("QLabel#launcherCardName") { + fontSize(13) + fontWeight(600) + color(TColors.Text) + } + selector("QLabel#launcherCardSub") { + fontSize(10) + color(TColors.Subtext) + } + selector("#instanceInfoPanel") { + backgroundColor(TColors.Surface1) + padding(12) + } + selector("QLabel#instanceName") { + fontSize(14) + fontWeight(700) + color(TColors.Text) + } + selector("QLabel#instanceMeta") { + fontSize(11) + color(TColors.Subtext) + } + selector("QTreeWidget#fileTree") { + border() + background("transparent") + color(TColors.Text) + } + selector("QTreeWidget#fileTree::item") { + padding(2, 4) + color(TColors.Text) + } + selector("QTreeWidget#fileTree::item:selected") { + backgroundColor(TColors.SelectedUI) + color(TColors.SelectedText) + } + selector("QTreeWidget#fileTree::item:hover") { + backgroundColor(TColors.Surface2) + } + selector("#importFooter") { + backgroundColor(TColors.Surface0) + padding(8, 12) + } + selector("#sidebar") { + backgroundColor(TColors.Surface1) + } + selector("QScrollArea#launcherScroll") { + background("transparent") + } + selector("QScrollArea#launcherScroll > QWidget") { + backgroundColor("transparent") + } + selector("#sectionHeader") { + color(TColors.Subtext) + fontSize(12) + fontWeight(700) + padding(8, 12) + } + selector("#treeHeader") { + color(TColors.Subtext) + fontSize(10) + padding(8, 12) + } + selector("#emptyHint") { + color(TColors.Subtext) + } + + selector("#destBar") { + backgroundColor(TColors.Surface1) + padding(8, 12) + border(1, TColors.Surface2, "bottom") + } + selector("#modListWidget") { + border() + background("transparent") + padding(4) + } + selector("QListView#modListWidget::item") { + padding(4) + } + selector("QTabWidget#importTabWidget > QTabBar") { + background("transparent") + color(TColors.Subtext) + padding(0, 4) + } + selector("QTabWidget#importTabWidget > QTabBar::tab") { + padding(8, 20, 8, 20) + color(TColors.Subtext) + background("transparent") + margin(0, 0) + border() + border(2, "transparent", "bottom") + } + selector("QTabWidget#importTabWidget > QTabBar::tab:selected") { + color(TColors.Text) + border(2, TColors.Accent, "bottom") + } + selector("QTabWidget#importTabWidget > QTabBar::tab:hover:!selected") { + color(TColors.Text) + } + selector("#statusLabel") { + color(TColors.Subtext) + fontSize(10) + padding(0, 12) + } + + selector("QFrame#importableModRow") { + border() + borderRadius(6) + background("transparent") + padding(2) + } + selector("QFrame#importableModRow:hover") { + backgroundColor(TColors.Surface2) + } + selector("QLabel#importableModName") { + fontSize(12) + fontWeight(600) + color(TColors.Text) + } + selector("QLabel#importableModMeta") { + fontSize(10) + color(TColors.Subtext) + } + selector("QLabel#badgeAvailable") { + fontSize(10) + color(TColors.Green) + padding(2, 6) + borderRadius(4) + } + selector("QLabel#badgeNameMatch") { + fontSize(10) + color(TColors.Warning) + padding(2, 6) + borderRadius(4) + } + selector("QLabel#badgeUnavailable") { + fontSize(10) + color(TColors.Error) + padding(2, 6) + borderRadius(4) + } + selector("QLabel#badgeChecking") { + fontSize(10) + color(TColors.Warning) + padding(2, 6) + borderRadius(4) + } + selector("QLabel#badgeUnknown") { + fontSize(10) + color(TColors.Subtext) + padding(2, 6) + borderRadius(4) + } + } + } + + /** + * Builds the source selection page with a sidebar of launcher cards and a list of + * detected instances on the right. + */ + private fun buildPageSelect() { + hBoxLayout(pageSelect) { + setContentsMargins(0, 0, 0, 0) + + val sidebar = QWidget() + sidebar.objectName = "sidebar" + sidebar.minimumWidth = 220 + sidebar.maximumWidth = 260 + vBoxLayout(sidebar) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + addWidget(label("Import from...") { objectName = "sectionHeader"; setAlignment(Qt.AlignmentFlag.AlignCenter) }) + + launcherScroll.apply { + widgetResizable = true + frameShape = QFrame.Shape.NoFrame + objectName = "launcherScroll" + verticalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + } + + val scrollContent = QWidget() + vBoxLayout(scrollContent) { + setContentsMargins(8, 8, 8, 8) + setSpacing(4) + } + launcherScroll.setWidget(scrollContent) + launcherScroll.viewport()?.autoFillBackground = false + scrollContent.autoFillBackground = false + addWidget(launcherScroll, 1) + } + addWidget(sidebar, 0) + + val rightSide = qWidget() + vBoxLayout(rightSide) { + setSpacing(0) + + val headerRow = qWidget() + hBoxLayout(headerRow) { + setContentsMargins(0, 0, 0, 0) + addWidget(label("Instances") { objectName = "sectionHeader" }) + addStretch() + accountCombo.apply { + sizeAdjustPolicy = QComboBox.SizeAdjustPolicy.AdjustToContents + visible = false + } + addWidget(accountCombo) + } + addWidget(headerRow) + + instanceListPlaceholder.objectName = "emptyHint" + instanceListPlaceholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + + instanceList.apply { + objectName = "instanceList" + selectionMode = QAbstractItemView.SelectionMode.SingleSelection + iconSize = qs(32, 32) + frameShape = QFrame.Shape.NoFrame + spacing = 0 + uniformItemSizes = true + verticalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + } + + instanceListStack.apply { + addWidget(instanceListPlaceholder) + addWidget(instanceList) + currentIndex = 0 + } + addWidget(instanceListStack, 1) + + browseMrpackCard.apply { + visible = false + hBoxLayout(this) { + setContentsMargins(12, 4, 12, 4) + setSpacing(8) + addStretch() + addWidget(label("From File:") { + styleSheet = "color: ${TColors.Text}; font-size: 12px;" + }) + val browseBtn = TPushButton { + text = "Browse" + minimumWidth = 80 + minimumHeight = 40 + cursor = QCursor(Qt.CursorShape.PointingHandCursor) + clicked.connect { onMrpackBrowse() } + } + addWidget(browseBtn) + addStretch() + } + } + addWidget(browseMrpackCard) + } + addWidget(rightSide, 1) + } + } + + /** + * Builds the review page showing instance info, mod source selection, mod list, file + * tree, destination path. + */ + private fun buildPageReview() { + vBoxLayout(pageReview) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + + // Destination bar + val destBar = widget { objectName = "destBar" } + hBoxLayout(destBar) { + setContentsMargins(12, 8, 12, 8) + setSpacing(8) + addWidget(label("Import to:")) + destPathField.apply { + text = "~/tritium/projects/" + minimumWidth = 300 + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + } + addWidget(destPathField, 1) + destBrowseBtn.apply { + icon = QIcon(TIcons.Folder) + text = "Browse" + minimumWidth = 80 + sizePolicy = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + } + addWidget(destBrowseBtn, 0) + } + addWidget(destBar) + + // Main content: info panel + mods/files + val contentArea = QWidget() + hBoxLayout(contentArea) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + + // Left: instance info panel + val infoPanel = widget { + objectName = "instanceInfoPanel" + minimumWidth = 180 + maximumWidth = 180 + } + vBoxLayout(infoPanel) { + setContentsMargins(12, 12, 12, 12) + setSpacing(8) + + instanceIconLabel.apply { + setFixedSize(48, 48) + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + addWidget(instanceIconLabel, 0, Qt.AlignmentFlag.AlignCenter) + + instanceNameLabel.apply { objectName = "instanceName"; wordWrap = true } + addWidget(instanceNameLabel, 0, Qt.AlignmentFlag.AlignCenter) + + fun metaRow(key: String, label: QLabel): QWidget { + val row = qWidget() + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(8) + addWidget(label("$key:") { styleSheet = "color: ${TColors.Subtext}; font-weight: bold;"; setFixedWidth(80) }) + addWidget(label.apply { objectName = "instanceMeta"; wordWrap = true }, 1) + } + return row + } + + addWidget(metaRow("Game", instanceGameVerLabel)) + addWidget(metaRow("Loader", instanceLoaderLabel)) + addWidget(metaRow("Version", instanceLoaderVerLabel)) + addStretch(1) + } + addWidget(infoPanel, 0) + + // Right: mod source + search + mod list + files + val rightPanel = qWidget() + vBoxLayout(rightPanel) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + + // Mod source + search bar (with bottom border) + val sourceSearchBar = widget { + objectName = "modSourceSearchBar" + styleSheet = "border-bottom: 1px solid ${TColors.Surface2};" + } + hBoxLayout(sourceSearchBar) { + setContentsMargins(12, 8, 12, 8) + setSpacing(8) + addWidget(label("Mod Source:")) + modSourceCombo.apply { + minimumWidth = 140 + sizeAdjustPolicy = QComboBox.SizeAdjustPolicy.AdjustToContents + } + addWidget(modSourceCombo, 0) + addStretch(1) + searchField.apply { + placeholderText = "Search mods..." + minimumWidth = 200 + } + addWidget(searchField, 1) + refreshBtn.apply { + objectName = "refreshBtn" + } + addWidget(refreshBtn, 0) + } + addWidget(sourceSearchBar) + + // Tabbed content: Mods / Files + importTabWidget.apply { + objectName = "importTabWidget" + tabBarAutoHide = false + documentMode = true + + // --- Mods tab --- + vBoxLayout(modsTabPage) { + setContentsMargins(12, 0, 12, 0) + setSpacing(0) + + modListPlaceholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + modListPlaceholder.objectName = "emptyHint" + + modListWidget.apply { + objectName = "modListWidget" + selectionMode = QAbstractItemView.SelectionMode.NoSelection + focusPolicy = Qt.FocusPolicy.NoFocus + spacing = 2 + frameShape = QFrame.Shape.NoFrame + } + + modListStack.apply { + addWidget(modListPlaceholder) + addWidget(modListWidget) + currentIndex = 0 + } + addWidget(modListStack, 1) + } + addTab(modsTabPage, "Mods") + + // --- Files tab --- + vBoxLayout(filesTabPage) { + setContentsMargins(12, 0, 12, 0) + setSpacing(0) + + fileTree.apply { + objectName = "fileTree" + header()?.isVisible = false + rootIsDecorated = true + animated = true + indentation = 16 + } + fileTreeLoading.setAlignment(Qt.AlignmentFlag.AlignCenter) + fileTreeLoading.objectName = "emptyHint" + + fileTreeStack.apply { + addWidget(fileTreeLoading) + addWidget(fileTree) + currentIndex = 0 + } + addWidget(fileTreeStack, 1) + } + addTab(filesTabPage, "Files") + } + addWidget(importTabWidget, 1) + } + addWidget(rightPanel, 1) + } + addWidget(contentArea, 1) + + // Status label + statusLabel.apply { + objectName = "statusLabel" + text = "" + } + addWidget(statusLabel) + + // Footer + val footer = widget { objectName = "importFooter" } + hBoxLayout(footer) { + addStretch(1) + addWidget(importBtn.apply { minimumHeight = 36 }) + addWidget(backBtn.apply { minimumHeight = 36 }) + } + addWidget(footer, 0) + } + } + + /** + * Builds the Modrinth pack info page showing project icon, title, description, + * version, metadata. + */ + private fun buildPageModrinthInfo() { + vBoxLayout(pageModrinthInfo) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + + val centerArea = qWidget() + vBoxLayout(centerArea) { + setContentsMargins(24, 24, 24, 24) + setSpacing(12) + + addStretch(1) + + modrinthIconLabel.apply { + setFixedSize(64, 64) + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + addWidget(modrinthIconLabel, 0, Qt.AlignmentFlag.AlignCenter) + + modrinthTitleLabel.apply { + styleSheet = "font-size: 18px; font-weight: 700; color: ${TColors.Text};" + wordWrap = true + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + addWidget(modrinthTitleLabel, 0, Qt.AlignmentFlag.AlignCenter) + + modrinthDescLabel.apply { + styleSheet = "font-size: 12px; color: ${TColors.Subtext};" + wordWrap = true + setAlignment(Qt.AlignmentFlag.AlignCenter) + maximumWidth = 600 + minimumHeight = 48 + } + addWidget(modrinthDescLabel, 0, Qt.AlignmentFlag.AlignCenter) + + addSpacing(8) + + hBoxLayout(modrinthMetaRow) { + setContentsMargins(0, 0, 0, 0) + setSpacing(16) + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + addWidget(modrinthMetaRow, 0, Qt.AlignmentFlag.AlignCenter) + + addSpacing(16) + + val packVerCol = qWidget() + vBoxLayout(packVerCol) { + setContentsMargins(0, 0, 0, 0) + setSpacing(4) + setAlignment(Qt.AlignmentFlag.AlignCenter) + addWidget(label("Pack Version") { + styleSheet = "font-size: 10px; font-weight: 700; color: ${TColors.Subtext}; text-transform: uppercase; letter-spacing: 1px;" + setAlignment(Qt.AlignmentFlag.AlignCenter) + }) + modrinthPackVerLabel.apply { + styleSheet = "font-size: 13px; color: ${TColors.Text};" + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + addWidget(modrinthPackVerLabel) + } + addWidget(packVerCol, 0, Qt.AlignmentFlag.AlignCenter) + + addSpacing(12) + + modrinthExternalBtn.apply { + objectName = "modrinthExtBtn" + text = "View on Modrinth" + minimumWidth = 200 + minimumHeight = 36 + styleSheet = qtStyle { + selector("modrinthExtBtn") { + fontSize(13) + fontWeight(600) + background("transparent") + border(1, TColors.Accent) + color(TColors.Accent) + borderRadius(6) + } + }.toStyleSheet() + cursor = QCursor(Qt.CursorShape.PointingHandCursor) + clicked.connect { + val project = currentModrinthProject ?: return@connect + val url = if (project.slug != null) "https://modrinth.com/modpack/${project.slug}" + else "https://modrinth.com/project/${project.id}" + Platform.openBrowser(url) + } + } + addWidget(modrinthExternalBtn, 0, Qt.AlignmentFlag.AlignCenter) + + addStretch(1) + } + addWidget(centerArea, 1) + + // Footer + val footer = widget { objectName = "importFooter" } + hBoxLayout(footer) { + addStretch(1) + val modrinthBackBtn = TPushButton { + text = "Back" + minimumHeight = 36 + clicked.connect { + currentModrinthProject = null + stacked.currentIndex = 0 + } + } + addWidget(modrinthBackBtn) + val modrinthImportBtn = TPushButton { + text = "Import" + minimumHeight = 36 + clicked.connect { + val project = currentModrinthProject ?: return@connect + statusLabel.text = "Modpack '${project.title}' import coming soon..." + } + } + addWidget(modrinthImportBtn) + } + addWidget(footer, 0) + } + } + + /** + * Set up all Qt signal connections: instance selection, button clicks, search text + * changes, source combo changes, and refresh button. + */ + private fun connectSignals() { + instanceList.itemDoubleClicked.connect { + if (modrinthPackMode) { + val idx = instanceList.row(it) + if (idx >= 0 && idx < modrinthPackProjects.size) { + onModrinthPackSelected(modrinthPackProjects[idx]) + } + return@connect + } + val idx = instanceList.row(it) + if (idx >= 0 && idx < instances.size) onInstanceSelected(instances[idx]) + } + + backBtn.clicked.connect { + currentFileTreeJob?.cancel() + currentScanJob?.cancel() + currentValidationJob?.cancel() + currentIconJob?.cancel() + currentModrinthFetchJob?.cancel() + currentInstance?.let { saveExpandedState(fileTree, it, expandedState) } + stacked.currentIndex = 0 + currentInstance = null + importableMods.clear() + modrinthPackMode = false + modrinthPackProjects.clear() + cleanupMrpackTemp(mrpackTempDir) + mrpackTempDir = null + cleanupCursePackTemp(cursePackTempDir) + cursePackTempDir = null + } + + importBtn.clicked.connect { onImport() } + + destBrowseBtn.clicked.connect { + val chosen = QFileDialog.getExistingDirectory(this, "Select Destination Directory", destPathField.text) + if (!chosen.isNullOrBlank()) { + destPathField.text = chosen + } + } + + searchField.textChanged.connect { filterModsBySearch(it) } + + refreshBtn.clicked.connect { + val source = modSourceCombo.currentData as? ModSource + val instance = currentInstance + if (source != null && instance != null) { + deleteImportCache(instance, source.id) + validateModsAgainstSource(source) + } + } + + modSourceCombo.currentIndexChanged.connect { + val source = modSourceCombo.currentData as? ModSource + if (source != null && currentInstance != null) { + validateModsAgainstSource(source) + } + } + + accountCombo.currentIndexChanged.connect { + if (!modrinthPackMode) return@connect + val provider = BuiltinRegistries.AccountProvider.get("modrinth_account") as? ModrinthAccount ?: return@connect + val accountId = accountCombo.currentData as? String ?: return@connect + currentModrinthFetchJob?.cancel() + fetchProjectsForSelectedAccount(provider, accountId) + } + } + + // --- Launcher selection --- + + /** + * Scans for installed launchers via [LauncherDetector.detectInstalled] and populates + * the sidebar with [ImportOption] cards. Also adds special cards for CurseForge and + * Modrinth modpack archives, and a "Browse" card for manual directory selection. + */ + private fun populateLaunchers() { + detectedLaunchers.clear() + launcherCards.clear() + selectedCard = null + + val content = launcherScroll.widget() + val layout = content?.layout() as? QVBoxLayout ?: return + + fun sectionLabel(text: String) = label(text) { objectName = "sectionHeader" } + + // ---- Launchers ---- + layout.addWidget(sectionLabel("Launchers")) + + detectedLaunchers.addAll(LauncherDetector.detectInstalled().sortedBy { it.displayName }) + + for (launcher in detectedLaunchers) { + val card = createImportOption(launcher, withSubtitle = true) + card.onClick = { l -> + selectCard(card) + onLauncherSelected(l) + } + layout.addWidget(card) + launcherCards.add(card) + } + + // ---- Modpacks ---- + layout.addWidget(sectionLabel("Modpack")) + + val cursePackCard = ImportOption(KnownLauncher.CURSEFORGE_PACK).apply { + setIcon(iconForLauncher(KnownLauncher.CURSEFORGE_PACK, 32)) + nameLabel.text = "CurseForge Pack" + subtitleLabel.text = "Select a modpack archive..." + onClick = { + selectCard(this) + onLauncherSelected(launcher) + } + } + layout.addWidget(cursePackCard) + launcherCards.add(cursePackCard) + + val modrinthPackCard = ImportOption(KnownLauncher.MODRINTH_PACK).apply { + setIcon(iconForLauncher(KnownLauncher.MODRINTH_PACK, 32)) + nameLabel.text = "Modrinth Pack" + subtitleLabel.text = "Select a modpack archive..." + onClick = { + selectCard(this) + onLauncherSelected(launcher) + } + } + layout.addWidget(modrinthPackCard) + launcherCards.add(modrinthPackCard) + + // ---- Tritium ---- + layout.addWidget(sectionLabel("Tritium")) + + val browseCard = ImportOption(KnownLauncher.BROWSE_FOLDER).apply { + setIcon(iconForLauncher(KnownLauncher.BROWSE_FOLDER, 32)) + nameLabel.text = "Existing Project" + subtitleLabel.text = "Select a folder..." + onClick = { selectCard(this); onLauncherSelected(launcher) } + } + layout.addWidget(browseCard) + launcherCards.add(browseCard) + + layout.addStretch(1) + + if (detectedLaunchers.isNotEmpty() && launcherCards.isNotEmpty()) { + val first = launcherCards[0] + selectCard(first) + onLauncherSelected(first.launcher) + } + } + + /** + * Creates an [ImportOption] card for a known launcher. + */ + private fun createImportOption(launcher: KnownLauncher, withSubtitle: Boolean = false): ImportOption { + val card = ImportOption(launcher) + card.setIcon(iconForLauncher(launcher, 32)) + card.nameLabel.text = launcher.displayName + if (withSubtitle) { + val existingDirs = launcher.instanceDirs.count { it.exists() } + card.subtitleLabel.text = "$existingDirs location${if (existingDirs != 1) "s" else ""}" + } + return card + } + + /** + * Fills the mod source combo box with all registered [ModSource] implementations. + */ + private fun populateModSources() { + modSourceCombo.clear() + modSourceCombo.addItem("Choose...", null) + val sources = BuiltinRegistries.ModSource.all().sortedBy { it.order } + for (source in sources) { + modSourceCombo.addItem(source.displayName, source) + } + modSourceCombo.currentIndex = 0 + } + + /** + * Marks a card as selected, unselecting the previous one. + */ + private fun selectCard(card: ImportOption) { + selectedCard?.setSelected(false) + card.setSelected(true) + selectedCard = card + } + + /** + * Handles selection of a launcher from the sidebar. For recognized launchers, scans + * and displays their instances. For special launchers ([BROWSE_FOLDER], [CURSEFORGE_PACK], + * [MODRINTH_PACK]), triggers the appropriate file-browse or account-based flow. + */ + private fun onLauncherSelected(launcher: KnownLauncher) { + modrinthPackMode = false + modrinthPackProjects.clear() + accountCombo.visible = false + browseMrpackCard.visible = false + if (launcher.id == "_browse") { openBrowseDialog(); return } + if (launcher.id == "_cursepack") { + onCursePackBrowse() + return + } + if (launcher.id == "_modrinthpack") { + currentModrinthFetchJob?.cancel() + modrinthPackMode = true + instances = emptyList() + modrinthPackProjects.clear() + modrinthAccounts.clear() + instanceList.clear() + instanceListPlaceholder.text = "" + instanceListStack.currentIndex = 0 + accountCombo.visible = false + browseMrpackCard.visible = false + currentModrinthFetchJob = ioScope.launch { + val provider = BuiltinRegistries.AccountProvider.get("modrinth_account") as? ModrinthAccount + if (provider == null) { + withContext(Dispatchers.Main) { onMrpackBrowse() } + return@launch + } + val accounts = withContext(Dispatchers.IO) { provider.listAccounts() } + if (accounts.isEmpty()) { + withContext(Dispatchers.Main) { onMrpackBrowse() } + return@launch + } + val accountIcons = mutableMapOf() + for (acc in accounts) { + if (acc.avatarUrl != null) { + try { + val bytes = httpClient.get(acc.avatarUrl).bodyAsBytes() + val pix = QPixmap() + if (pix.loadFromData(bytes)) { + val mode = if (pix.width() <= 64 || pix.height() <= 64) + Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation + accountIcons[acc.id] = QIcon(pix.scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio, mode)) + } + } catch (_: Exception) {} + } + } + withContext(Dispatchers.Main) { + modrinthAccounts.clear() + modrinthAccounts.addAll(accounts) + accountCombo.clear() + accountCombo.blockSignals(true) + accountCombo.visible = true + browseMrpackCard.visible = true + for (acc in accounts) { + val icon = accountIcons[acc.id] ?: QIcon() + accountCombo.addItem(icon, acc.label ?: acc.username ?: acc.id, acc.id) + } + accountCombo.minimumWidth = 200 + accountCombo.currentIndex = 0 + accountCombo.blockSignals(false) + fetchProjectsForSelectedAccount(provider, accounts[0].id) + } + } + return + } + currentLauncher = launcher + + val items = LauncherDetector.scanInstances(launcher).sortedWith(compareBy({ it.name.firstOrNull()?.isLetter() != true }, { it.name.lowercase() })) + instances = items + instanceList.clear() + + if (items.isEmpty()) { + instanceListPlaceholder.text = "No instances found for ${launcher.displayName}." + instanceListStack.currentIndex = 0 + return + } + + for (i in items) { + val widget = InstanceItemWidget(i) + val item = QListWidgetItem().apply { + setSizeHint(widget.sizeHint()) + } + instanceList.addItem(item) + instanceList.setItemWidget(item, widget) + } + instanceListStack.currentIndex = 1 + } + + /** + * Fetches modpack projects for a selected Modrinth account and displays them in the + * instance list. + */ + private fun fetchProjectsForSelectedAccount(provider: ModrinthAccount, accountId: String) { + currentModrinthFetchJob?.cancel() + modrinthPackProjects.clear() + instanceList.clear() + instanceListPlaceholder.text = "Loading Modrinth projects..." + instanceListStack.currentIndex = 0 + currentModrinthFetchJob = ioScope.launch { + val projects = withContext(Dispatchers.IO) { + provider.fetchModpackProjectsForAccount(accountId) + } + withContext(Dispatchers.Main) { + if (projects.isEmpty()) { + onMrpackBrowse() + return@withContext + } + modrinthPackProjects.clear() + modrinthPackProjects.addAll(projects) + instanceList.clear() + for (project in projects) { + val widget = ModrinthPackItemWidget(project) + val item = QListWidgetItem().apply { + setSizeHint(widget.sizeHint()) + } + instanceList.addItem(item) + instanceList.setItemWidget(item, widget) + if (project.iconUrl != null) { + fetchModrinthPackIcon(widget, project.iconUrl) + } + } + instanceListStack.currentIndex = 1 + } + } + } + + private var mrpackTempDir: VPath? = null + private var cursePackTempDir: VPath? = null + + /** + * Opens a file dialog for selecting `.mrpack` files and triggers extraction via + * [extractAndPrepareMrpack]. + */ + private fun onMrpackBrowse() { + val chosen = QFileDialog.getOpenFileName(this, "Select Modrinth Pack", userHome.toString(), "Modrinth Pack (*.mrpack)") + @Suppress("UNNECESSARY_SAFE_CALL") + val path = chosen?.result?.trim() + if (path.isNullOrBlank()) return + statusLabel.text = "Extracting modpack..." + ioScope.launch { + val result = withContext(Dispatchers.IO) { + extractAndPrepareMrpack(path, httpClient) + } + withContext(Dispatchers.Main) { + if (result != null) { + mrpackTempDir = result.tempDir + onMrpackInstanceReady(result.instance) + } else { + statusLabel.text = "" + QMessageBox.warning(this@ImportProjectDialog, "Invalid File", "Could not read modrinth.index.json from the selected file.") + } + } + } + } + + /** + * Called when a mrpack has been successfully extracted and parsed. Treats the + * resulting instance as a normal instance and triggers the review page. + */ + private fun onMrpackInstanceReady(instance: DetectedInstance) { + currentLauncher = KnownLauncher.BROWSE_FOLDER + instances = listOf(instance) + onInstanceSelected(instance) + } + + /** + * Opens a file dialog for selecting CurseForge `.zip` pack files and triggers + * extraction via [extractAndPrepareCursePack]. + */ + private fun onCursePackBrowse() { + val chosen = QFileDialog.getOpenFileName(this, "Select CurseForge Pack", userHome.toString(), "CurseForge Pack (*.zip)") + @Suppress("UNNECESSARY_SAFE_CALL") + val path = chosen?.result?.trim() + if (path.isNullOrBlank()) { + restoreSelection(null) + return + } + statusLabel.text = "Extracting modpack..." + val curseForge = BuiltinRegistries.ModSource.get("curseforge") as? CurseForge + ioScope.launch { + val result = withContext(Dispatchers.IO) { + extractAndPrepareCursePack(path, curseForge) + } + withContext(Dispatchers.Main) { + if (result != null) { + cursePackTempDir = result.tempDir + cursePackPrebuiltMods = result.modEntries + onMrpackInstanceReady(result.instance) + } else { + statusLabel.text = "" + QMessageBox.warning(this@ImportProjectDialog, "Invalid File", "Could not read manifest.json from the selected file.") + restoreSelection(null) + } + } + } + } + + /** + * Opens a directory picker for manual instance folder selection. If the chosen directory + * is recognized, proceeds to the review page. + */ + private fun openBrowseDialog() { + val prev = currentLauncher + val chosen = QFileDialog.getExistingDirectory(this, "Select Instance Directory", userHome.toString()) + if (chosen.isNullOrBlank()) { restoreSelection(prev); return } + + val dir = VPath.get(chosen) + val instance = LauncherDetector.inspectDirectory(dir) + if (instance != null) { + onInstanceSelected(instance) + } else { + QMessageBox.warning(this, "Invalid Directory", "Selected directory does not contain a recognizable instance.") + restoreSelection(prev) + } + } + + /** + * Restores the previously selected launcher card, or clears the selection if the + * launcher is no longer known. + */ + private fun restoreSelection(launcher: KnownLauncher?) { + val card = launcherCards.firstOrNull { it.launcher.id == launcher?.id } + if (card != null) { + selectCard(card) + onLauncherSelected(launcher!!) + } else { + selectedCard?.setSelected(false) + selectedCard = null + } + } + + // --- Instance selected: scan mods + show review page --- + + /** + * Called when an instance is selected. Switches to the review page, populates the + * instance info panel, scans mods, and builds the file tree. + */ + private fun onInstanceSelected(instance: DetectedInstance) { + currentScanJob?.cancel() + currentValidationJob?.cancel() + currentIconJob?.cancel() + currentInstance?.let { saveExpandedState(fileTree, it, expandedState) } + currentInstance = instance + instanceNameLabel.text = instance.name + instanceGameVerLabel.text = instance.gameVersion ?: "Unknown" + instanceLoaderLabel.text = instance.loader ?: "Unknown" + instanceLoaderVerLabel.text = instance.loaderVersion ?: "Unknown" + + val cleanName = instance.name.replace(Regex("[^a-zA-Z0-9_\\- ]"), "").trim() + destPathField.text = "~/tritium/projects/$cleanName" + + val iconPath = LauncherDetector.resolveInstanceIcon(instance) + val pixmap = if (iconPath != null) QPixmap(iconPath.toAbsolute().toString()) else QPixmap() + if (!pixmap.isNull) { + instanceIconLabel.pixmap = pixmap.scaled(qs(48, 48), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } else { + instanceIconLabel.pixmap = QPixmap() + instanceIconLabel.text = instance.name.take(2).uppercase() + } + + stacked.currentIndex = 1 + populateFileTreeAsync(instance) + scanInstanceMods(instance) + } + + /** + * Scans the instance's `mods/` directory for `.jar` files, reads their metadata, + * computes SHA-1 hashes, and populates the mod list. + */ + private fun scanInstanceMods(instance: DetectedInstance) { + // If we have pre-built mods from a CurseForge pack, use them directly + val prebuilt = cursePackPrebuiltMods + if (prebuilt != null) { + cursePackPrebuiltMods = null + synchronized(modListGuard) { + importableMods.clear() + importableMods.addAll(prebuilt) + importableMods.sortBy { it.displayName } + } + modListPlaceholder.text = "No mods found in this instance." + populateModList() + // Trigger icon fetches for mods with sourceIconUrl + for (i in 0 until modListWidget.count()) { + val item = modListWidget.item(i) + val dataIdx = item?.data(Qt.ItemDataRole.UserRole) as? Int ?: continue + val mod = synchronized(modListGuard) { importableMods.getOrNull(dataIdx) } ?: continue + if (!mod.sourceIconUrl.isNullOrBlank()) { + val row = modListWidget.itemWidget(item) as? ImportableModRow + row?.updateAvailability(mod.sourceAvailable, mod.sourceProjectId, mod.sourceIconUrl, mod.sourceStatus) + } + } + return + } + + importableMods.clear() + modListWidget.clear() + modListPlaceholder.text = "Scanning mods..." + modListStack.currentIndex = 0 + + val modsDir = instance.minecraftDir.resolve("mods") + if (!modsDir.exists() || !modsDir.isDir()) { + modListPlaceholder.text = "No mods directory found in this instance." + modListStack.currentIndex = 0 + populateModList() + return + } + + currentScanJob?.cancel() + currentScanJob = ioScope.launch { + val jars = withContext(Dispatchers.IO) { + modsDir.listFiles { f -> f.fileName().endsWith(".jar", ignoreCase = true) } + } + + val source = modSourceCombo.currentData as? ModSource + val defaultAvailable = source != null + val defaultStatus = if (source != null) "Not Available" else null + + val scanned = jars.mapNotNull { jarPath -> + try { + val bytes = withContext(Dispatchers.IO) { jarPath.toJFile().readBytes() } + val sha1Hash = computeSha1(bytes) + val fileFingerprint = source?.computeFileFingerprint(bytes) + val info = readModJarInfo(jarPath) + if (info == null) { + log.info("Could not read mod metadata from '{}', treating as generic jar", jarPath.fileName()) + ImportableMod( + jarPath = jarPath, + modId = jarPath.fileName().removeSuffix(".jar"), + displayName = jarPath.fileName().removeSuffix(".jar"), + fileName = jarPath.fileName(), + side = ModSide.BOTH, + iconBytes = null, + sha1Hash = sha1Hash, + fileFingerprint = fileFingerprint, + sourceAvailable = if (defaultAvailable) false else null, + sourceStatus = defaultStatus, + checked = true + ) + } else { + val iconBytes = readModJarIcon(jarPath) + ImportableMod( + jarPath = jarPath, + modId = info.modId, + displayName = info.displayName, + fileName = jarPath.fileName(), + side = info.side, + iconBytes = iconBytes, + sha1Hash = sha1Hash, + fileFingerprint = fileFingerprint, + sourceAvailable = if (defaultAvailable) false else null, + sourceStatus = defaultStatus, + checked = true + ) + } + } catch (t: Throwable) { + log.warn("Failed to scan mod jar '{}': {}", jarPath.fileName(), t.message) + null + } + } + + // Check cache before switching to Main + val cachedFromCache = if (source != null && scanned.isNotEmpty()) { + tryLoadImportCache(instance, source.id, scanned) + } else null + + withContext(Dispatchers.Main) { + synchronized(modListGuard) { + importableMods.clear() + if (cachedFromCache != null) { + log.warn("Using cached validation for {} mods (source: {})", cachedFromCache.size, source!!.id) + importableMods.addAll(cachedFromCache) + } else { + importableMods.addAll(scanned) + } + importableMods.sortBy { it.displayName } + } + populateModList() + + if (source != null && scanned.isNotEmpty() && cachedFromCache == null) { + validateModsAgainstSource(source) + } + } + } + } + + /** + * Updates the Mods tab label with the current checked/total count. + */ + private fun updateModsTabLabel(total: Int, checked: Int) { + importTabWidget.setTabText(0, "Mods $checked/$total") + } + + /** + * Fills the mod list widget with [ImportableModRow] items. If [filteredSubset] is + * provided, only those mods are shown (used for search filtering). + */ + private fun populateModList(filteredSubset: List? = null) { + val allMods: List + synchronized(modListGuard) { + allMods = filteredSubset ?: importableMods.toList() + } + + modListWidget.clear() + + if (allMods.isEmpty()) { + modListPlaceholder.text = if (filteredSubset != null) "No mods match your search." else "No mods found in this instance." + modListStack.currentIndex = 0 + return + } + + modListStack.currentIndex = 1 + + val checkCount = allMods.count { it.checked } + updateModsTabLabel(allMods.size, checkCount) + + allMods.forEachIndexed { index, mod -> + val item = QListWidgetItem() + item.setData(Qt.ItemDataRole.UserRole, index) + val row = ImportableModRow(mod, index, { idx, checked -> + synchronized(modListGuard) { + if (idx in importableMods.indices) { + importableMods[idx] = importableMods[idx].copy(checked = checked) + // If checked and source is active, fetch dependencies + if (checked) { + val src = modSourceCombo.currentData as? ModSource + val instance = currentInstance + if (src != null && instance != null) { + resolveDependenciesForMod(importableMods[idx], src, instance) + } + } + } + } + // Update count + val total = synchronized(modListGuard) { importableMods.size } + val checkedCount = synchronized(modListGuard) { importableMods.count { it.checked } } + updateModsTabLabel(total, checkedCount) + }, ::fetchOnlineIcon) + item.setSizeHint(row.sizeHint()) + modListWidget.addItem(item) + modListWidget.setItemWidget(item, row) + } + } + + /** + * Filters the mod list by display name, mod ID, or filename matching the given text. + */ + private fun filterModsBySearch(text: String) { + val allMods: List + synchronized(modListGuard) { + allMods = importableMods.toList() + } + + if (text.isBlank()) { + populateModList(allMods) + return + } + + val query = text.lowercase() + val filtered = allMods.filter { + it.displayName.lowercase().contains(query) || + it.modId.lowercase().contains(query) || + it.fileName.lowercase().contains(query) + } + populateModList(filtered) + } + + // --- Source validation --- + + /** + * Validates all mods against the chosen mod source. First attempts fast batch + * fingerprint resolution, then falls back to per-mod search via [findModOnSource]. + * Results are cached via [saveImportCache]. + */ + private fun validateModsAgainstSource(source: ModSource) { + currentValidationJob?.cancel() + val instance = currentInstance ?: return + val allMods: List + synchronized(modListGuard) { + allMods = importableMods.toList() + } + if (allMods.isEmpty()) return + + val context = ModBrowserContext( + project = dummyProject, + minecraftVersion = instance.gameVersion, + modLoaderId = mapLoaderId(instance.loader) + ) + + for (i in 0 until modListWidget.count()) { + val item = modListWidget.item(i) + (modListWidget.itemWidget(item) as? ImportableModRow)?.updateAvailability(null, null, null, null) + } + + val semaphore = CoroutineSemaphore(4) + val totalMods = allMods.size + val progressInterval = maxOf(1, totalMods / 20) + val completedCount = AtomicInteger(0) + + currentValidationJob = ioScope.launch { + log.warn("validateModsAgainstSource: starting validation for {} mods", totalMods) + + val fingerprintMatched = mutableSetOf() + try { + val fingerprints = allMods.mapNotNull { it.fileFingerprint }.distinct() + if (fingerprints.isNotEmpty()) { + val fpResults = source.resolveProjectInfosByFingerprints(fingerprints) + if (fpResults.isNotEmpty()) { + withContext(Dispatchers.Main) { + allMods.forEachIndexed { index, mod -> + val fp = mod.fileFingerprint + if (fp != null && fp in fpResults) { + val info = fpResults[fp]!! + fingerprintMatched.add(index) + synchronized(modListGuard) { + if (index in importableMods.indices) { + importableMods[index] = importableMods[index].copy( + sourceProjectId = info.projectId, + sourceAvailable = true, + sourceStatus = "Available" + ) + } + } + for (i in 0 until modListWidget.count()) { + val item = modListWidget.item(i) + val dataIdx = item?.data(Qt.ItemDataRole.UserRole) as? Int ?: continue + if (dataIdx == index) { + (modListWidget.itemWidget(item) as? ImportableModRow) + ?.updateAvailability(true, info.projectId, null, "Available") + break + } + } + completedCount.incrementAndGet() + } + } + } + } + } + } catch (_: Exception) { } + + val remainingMods = allMods.filterIndexed { index, _ -> index !in fingerprintMatched } + + coroutineScope { + remainingMods.mapIndexed { _, mod -> + // Find the original index in allMods + val originalIndex = allMods.indexOf(mod) + async { + semaphore.withPermit { + try { + ensureActive() + val result = findModOnSource(mod, source, context) + ensureActive() + withContext(Dispatchers.Main) { + if (!isActive) return@withContext + val status = result?.status ?: "Not Available" + synchronized(modListGuard) { + if (originalIndex in importableMods.indices) { + importableMods[originalIndex] = importableMods[originalIndex].copy( + sourceProjectId = result?.projectId, + sourceIconUrl = result?.iconUrl, + sourceAvailable = result != null, + sourceStatus = status + ) + } + } + // Update the row widget + for (i in 0 until modListWidget.count()) { + val item = modListWidget.item(i) + val dataIdx = item?.data(Qt.ItemDataRole.UserRole) as? Int ?: continue + if (dataIdx == originalIndex) { + (modListWidget.itemWidget(item) as? ImportableModRow) + ?.updateAvailability(result != null, result?.projectId, result?.iconUrl, status) + break + } + } + // If mod is checked and matching version was found, resolve dependencies + val currentMod = synchronized(modListGuard) { importableMods.getOrNull(originalIndex) } + } + val done = completedCount.incrementAndGet() + if (done % progressInterval == 0) { + log.warn("validateModsAgainstSource: progress {}/{}", done, totalMods) + } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + if (isActive) { + log.warn("Failed to validate mod '{}' against source '{}': {}", mod.displayName, source.id, t.message) + } + } + } + } + }.awaitAll() + } + val modsCopy = synchronized(modListGuard) { importableMods.toList() } + saveImportCache(instance, source.id, modsCopy) + log.warn("validateModsAgainstSource: completed {}/{} mods for {}", completedCount.get(), totalMods, source.id) + } + } + + /** + * Resolves and stores the dependency project IDs for a mod by querying the matched + * version on the source. Only runs when the validation job is still active. + */ + private fun resolveDependenciesForMod(mod: ImportableMod, source: ModSource, instance: DetectedInstance) { + val projectId = mod.sourceProjectId ?: return + if (projectId.isBlank()) return + + ioScope.launch { + // If validation was canceled, skip dependency resolution + if (currentValidationJob?.isActive == false) return@launch + depSemaphore.withPermit { + try { + val context = ModBrowserContext( + project = dummyProject, + minecraftVersion = instance.gameVersion, + modLoaderId = mapLoaderId(instance.loader) + ) + val versions = source.versions(context, projectId) + if (versions.isEmpty()) return@launch + + // Pick the best matching version (featured > release > newest) + val best = versions + .filter { v -> + val mcMatch = + instance.gameVersion == null || v.gameVersions.any { it == instance.gameVersion } + val loaderMatch = instance.loader == null || v.loaders.any { + it.equals(instance.loader, ignoreCase = true) || + mapLoaderId(instance.loader)?.equals(it, ignoreCase = true) == true + } + mcMatch && loaderMatch + }.maxByOrNull { it.featured } ?: versions.firstOrNull { it.featured } ?: versions.firstOrNull() + ?: return@launch + + val depIds = best.dependencies + .filter { it.required && !it.incompatible } + .map { it.projectId } + + withContext(Dispatchers.Main) { + synchronized(modListGuard) { + val idx = importableMods.indexOfFirst { it.jarPath == mod.jarPath } + if (idx >= 0) { + importableMods[idx] = importableMods[idx].copy(dependencyIds = depIds) + } + } + } + } catch (t: Throwable) { + log.warn("Failed to resolve dependencies for '{}': {}", mod.displayName, t.message) + } + } + } + } + + // --- Icon loading --- + + /** + * Fetches a mod icon from a URL and updates the corresponding row widget. Results are + * cached in [iconCache] for reuse. + */ + private fun fetchOnlineIcon(index: Int, url: String) { + if (currentIconJob?.isActive != true) currentIconJob = Job() + ioScope.launch(currentIconJob!!) { + iconSemaphore.acquire() + try { + val cached = iconCache[url] + if (cached != null) { + withContext(Dispatchers.Main) { + updateRowIcon(index, cached) + } + return@launch + } + + val bytes = httpClient.get(url).bodyAsBytes() + val pixmap = QPixmap() + if (pixmap.loadFromData(bytes)) { + val scaled = pixmap.scaled(qs(32, 32), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + val icon = QIcon(scaled) + iconCache[url] = icon + withContext(Dispatchers.Main) { + updateRowIcon(index, icon) + } + } + } finally { + iconSemaphore.release() + } + } + } + + /** + * Fetches and sets a Modrinth project icon on the given widget. + */ + private fun fetchModrinthPackIcon(widget: ModrinthPackItemWidget, iconUrl: String) { + ioScope.launch { + try { + val bytes = httpClient.get(iconUrl).bodyAsBytes() + val pixmap = QPixmap() + if (pixmap.loadFromData(bytes)) { + val mode = if (pixmap.width() <= 64 || pixmap.height() <= 64) + Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation + val scaled = pixmap.scaled(qs(32, 32), Qt.AspectRatioMode.KeepAspectRatio, mode) + withContext(Dispatchers.Main) { + widget.iconLabel.pixmap = scaled + } + } + } catch (_: Exception) { } + } + } + + /** + * Displays the Modrinth pack info page with project details and metadata. + */ + private fun onModrinthPackSelected(project: ModrinthProject) { + currentModrinthProject = project + modrinthTitleLabel.text = project.title + modrinthDescLabel.text = project.description ?: "" + val loaderName = project.latestLoaders.firstOrNull() + modrinthPackVerLabel.text = project.latestVersionName.ifBlank { project.versions.firstOrNull() ?: "Unknown" } + modrinthExternalBtn.visible = project.slug != null || project.id.isNotBlank() + + val metaLayout = modrinthMetaRow.layout() as? QHBoxLayout + if (metaLayout != null) { + while (metaLayout.count() > 0) { + metaLayout.takeAt(0)?.widget()?.let { it.hide(); it.setParent(null); it.dispose() } + } + } + val pills = buildMetaTagsWidget(project.latestGameVersion, loaderName) + modrinthMetaRow.layout()?.addWidget(pills ?: qWidget()) + + modrinthIconLabel.pixmap = QPixmap() + if (project.iconUrl != null) { + currentIconJob?.cancel() + currentIconJob = ioScope.launch { + try { + val bytes = httpClient.get(project.iconUrl).bodyAsBytes() + val pix = QPixmap() + if (pix.loadFromData(bytes)) { + val mode = if (pix.width() <= 64 || pix.height() <= 64) + Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation + val scaled = pix.scaled(qs(64, 64), Qt.AspectRatioMode.KeepAspectRatio, mode) + withContext(Dispatchers.Main) { + modrinthIconLabel.pixmap = scaled + } + } + } catch (_: Exception) {} + } + } + + stacked.currentIndex = 2 + } + + /** + * Finds the row widget for the given mod index and updates its icon. + */ + private fun updateRowIcon(index: Int, icon: QIcon) { + for (i in 0 until modListWidget.count()) { + val item = modListWidget.item(i) + val dataIdx = item?.data(Qt.ItemDataRole.UserRole) as? Int ?: continue + if (dataIdx == index) { + val row = modListWidget.itemWidget(item) as? ImportableModRow + row?.setIconFromQIcon(icon) + break + } + } + } + + /** + * Builds the file tree widget by asynchronously scanning the instance's minecraft + * directory and creating corresponding tree items with checkboxes. + */ + private fun populateFileTreeAsync(instance: DetectedInstance) { + fileTree.clear() + fileTreeStack.currentIndex = 0 + + if (!instance.minecraftDir.exists()) { + fileTreeLoading.text = "Minecraft directory not found." + return + } + + currentFileTreeJob?.cancel() + currentFileTreeJob = ioScope.launch { + try { + val instancePath = instance.minecraftDir.toAbsolute().toString() + val entries = withContext(Dispatchers.IO) { + collectFileTreeEntries(instance.minecraftDir) + } + + withContext(Dispatchers.Main) { + fileTree.blockSignals(true) + val root = QTreeWidgetItem(fileTree) + root.setText(0, instance.minecraftDir.fileName()) + root.setIcon(0, QIcon(TIcons.Folder)) + root.setFlags(Qt.ItemFlag.ItemIsEnabled, Qt.ItemFlag.ItemIsSelectable, Qt.ItemFlag.ItemIsUserCheckable, Qt.ItemFlag.ItemIsAutoTristate) + root.setCheckState(0, Qt.CheckState.Checked) + root.setData(0, Qt.ItemDataRole.UserRole, instancePath) + + val parentMap = mutableMapOf() + parentMap[instance.minecraftDir] = root + + for (entry in entries) { + val parentItem = entry.parent.let { parentMap[it] } ?: root + val item = QTreeWidgetItem(parentItem) + item.setText(0, entry.path.fileName()) + if (entry.isDirectory) { + item.setFlags(Qt.ItemFlag.ItemIsEnabled, Qt.ItemFlag.ItemIsSelectable, Qt.ItemFlag.ItemIsUserCheckable, Qt.ItemFlag.ItemIsAutoTristate) + item.setIcon(0, QIcon(TIcons.Folder)) + item.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) + } else { + item.setFlags(Qt.ItemFlag.ItemIsEnabled, Qt.ItemFlag.ItemIsSelectable, Qt.ItemFlag.ItemIsUserCheckable) + item.setIcon(0, iconForFile(entry.path, dummyProject)) + } + item.setCheckState(0, Qt.CheckState.Checked) + item.setData(0, Qt.ItemDataRole.UserRole, entry.path.toString()) + parentMap[entry.path] = item + } + + if (expandedState.containsKey(instancePath)) { + restoreExpandedState(fileTree, instancePath, expandedState) + } else { + root.isExpanded = true + } + fileTree.blockSignals(false) + fileTreeStack.currentIndex = 1 + } + } catch (_: Exception) { + withContext(Dispatchers.Main) { + fileTreeLoading.text = "Failed to read directory." + } + } + } + } + + // --- Import logic --- + + /** + * Validates the current state and triggers the import pipeline via + * [ImportProjectCreator.createProject]. Disables UI during import, handles success + * (loading and opening the project) and failure (cleanup and re-enabling UI). + * + * On success, the import cache is cleaned up automatically by the creator. On failure, + * the partial project directory is deleted. + */ + @OptIn(ExperimentalTime::class) + private fun onImport() { + val instance = currentInstance ?: run { + statusLabel.text = "No instance selected." + return + } + + val destRaw = destPathField.text.trim() + if (destRaw.isBlank()) { + statusLabel.text = "Please specify a destination path." + return + } + + val projectRoot = VPath.get(destRaw).expandHome().toAbsolute().normalize() + if (projectRoot.exists() && projectRoot.list().isNotEmpty()) { + val answer = QMessageBox.question( + this, "Directory Not Empty", + "The destination directory '${projectRoot.fileName()}' already exists and is not empty.\n\n" + + "Continue importing into this directory? Existing files may be overwritten.", + QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No + ) + if (answer != QMessageBox.StandardButton.Yes.value()) return + } + + // Disable UI during import + importBtn.isEnabled = false + backBtn.isEnabled = false + + ioScope.launch { + var cleanedUp = false + try { + // Collect checked state before any mutation + val allModsBefore: List + synchronized(modListGuard) { + allModsBefore = importableMods.toList() + } + + // If this is a CurseForge pack, download mods before importing + if (cursePackTempDir != null) { + statusLabel.text = "Downloading mods..." + val checkedFileNames = allModsBefore.filter { it.checked }.map { it.fileName }.toSet() + val manifestFile = cursePackTempDir!!.resolve("curse-manifest.json").toJFile() + val allProjectFiles = if (manifestFile.exists()) { + try { + curseJson.decodeFromString(manifestFile.readText()).files + } catch (_: Exception) { emptyList() } + } else emptyList() + val filesToDownload = allProjectFiles.filter { f -> + val fName = f.downloadUrl?.substringAfterLast('/')?.substringBefore('?') + ?: "mod-${f.projectID}-${f.fileID}.jar" + fName in checkedFileNames + } + downloadCursePackMods(cursePackTempDir!!, httpClient, filesToDownload) + importableMods.clear() + val modsDir = instance.minecraftDir.resolve("mods") + if (modsDir.exists() && modsDir.isDir()) { + val scanned = withContext(Dispatchers.IO) { + modsDir.listFiles { f -> f.fileName().endsWith(".jar", ignoreCase = true) }.mapNotNull { jarPath -> + try { + val bytes = jarPath.toJFile().readBytes() + val sha1Hash = computeSha1(bytes) + val info = readModJarInfo(jarPath) + if (info == null) { + ImportableMod( + jarPath = jarPath, + modId = jarPath.fileName().removeSuffix(".jar"), + displayName = jarPath.fileName().removeSuffix(".jar"), + fileName = jarPath.fileName(), + side = ModSide.BOTH, + iconBytes = readModJarIcon(jarPath), + sha1Hash = sha1Hash, + checked = true + ) + } else { + ImportableMod( + jarPath = jarPath, + modId = info.modId, + displayName = info.displayName, + fileName = jarPath.fileName(), + side = info.side, + iconBytes = readModJarIcon(jarPath), + sha1Hash = sha1Hash, + checked = true + ) + } + } catch (t: Throwable) { + log.warn("Failed to scan downloaded mod jar '{}': {}", jarPath.fileName(), t.message) + null + } + } + } + importableMods.addAll(scanned) + } + } + + // Collect selected mods and files + val allMods: List + synchronized(modListGuard) { + allMods = importableMods.toList() + } + val selectedMods = allMods.filter { it.checked } + val selectedFiles = collectCheckedFiles(fileTree) + val source = modSourceCombo.currentData as? ModSource + val sourceId = source?.id ?: "unknown" + val iconPath = LauncherDetector.resolveInstanceIcon(instance) + + val result = ImportProjectCreator.createProject( + projectRoot = projectRoot, + instance = instance, + instanceMinecraftDir = instance.minecraftDir, + sourceId = sourceId, + iconPath = iconPath, + selectedMods = selectedMods, + selectedFiles = selectedFiles, + sourceInstance = instance, + sourceIdForCache = sourceId, + onProgress = { msg -> + withContext(Dispatchers.Main) { statusLabel.text = msg } + } + ) + + if (!result.successful) { + withContext(Dispatchers.IO) { projectRoot.toJFile().deleteRecursively() } + cleanedUp = true + withContext(Dispatchers.Main) { + statusLabel.text = "Import failed." + importBtn.isEnabled = true + backBtn.isEnabled = true + } + return@launch + } + + // Register and open the project + val project = withContext(Dispatchers.IO) { + ProjectMngr.loadProject(projectRoot) + } + + if (project != null) { + withContext(Dispatchers.Main) { + ProjectMngr.notifyCreatedExternal(project) + try { + ProjectMngr.openProject(project) + } catch (t: Throwable) { + log.warn("Failed to open imported project window", t) + } + statusLabel.text = "Project imported successfully!" + QTimer.singleShot(1500) { accept() } + } + } else { + withContext(Dispatchers.Main) { + statusLabel.text = "Project imported, but failed to load." + QTimer.singleShot(2000) { accept() } + } + } + } catch (t: Throwable) { + log.warn("Import failed", t) + if (!cleanedUp) { + withContext(Dispatchers.IO) { projectRoot.toJFile().deleteRecursively() } + } + withContext(Dispatchers.Main) { + statusLabel.text = "Import failed: ${t.message}" + importBtn.isEnabled = true + backBtn.isEnabled = true + } + } + } + } + +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportWidgets.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportWidgets.kt new file mode 100644 index 0000000..9210c44 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/ImportWidgets.kt @@ -0,0 +1,314 @@ +package io.github.tritium_launcher.launcher.import.ui + +import io.github.tritium_launcher.launcher.accounts.ModrinthProject +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.mod.ModSide +import io.github.tritium_launcher.launcher.import.DetectedInstance +import io.github.tritium_launcher.launcher.import.ImportableMod +import io.github.tritium_launcher.launcher.import.KnownLauncher +import io.github.tritium_launcher.launcher.import.LauncherDetector +import io.github.tritium_launcher.launcher.loadScaledPixmap +import io.github.tritium_launcher.launcher.qs +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.core.QSize +import io.qt.core.Qt +import io.qt.gui.QCursor +import io.qt.gui.QIcon +import io.qt.gui.QMouseEvent +import io.qt.gui.QPixmap +import io.qt.widgets.QCheckBox +import io.qt.widgets.QFrame +import io.qt.widgets.QLabel +import io.qt.widgets.QWidget + +/** + * A clickable card representing a launcher or modpack source on the import source selection + * page. + * + * Clicking the card triggers [onClick] with the associated [KnownLauncher]. + * + * @param launcher The launcher this card represents. + */ +class ImportOption(val launcher: KnownLauncher) : QFrame() { + private val iconLabel = QLabel() + val nameLabel = QLabel() + val subtitleLabel = QLabel() + var onClick: ((KnownLauncher) -> Unit)? = null + + init { + objectName = "launcherCard" + frameShape = Shape.NoFrame + setFixedHeight(48) + cursor = QCursor(Qt.CursorShape.PointingHandCursor) + + val layout = hBoxLayout(this) { + setContentsMargins(8, 0, 8, 0) + setSpacing(8) + } + + iconLabel.setFixedSize(32, 32) + layout.addWidget(iconLabel, 0) + + val textCol = QWidget() + val textLayout = vBoxLayout(textCol) { + setContentsMargins(0, 0, 0, 0) + setSpacing(1) + } + nameLabel.objectName = "launcherCardName" + textLayout.addWidget(nameLabel) + subtitleLabel.objectName = "launcherCardSub" + textLayout.addWidget(subtitleLabel) + layout.addWidget(textCol, 1) + } + + override fun mouseReleaseEvent(ev: QMouseEvent?) { + if (ev?.button() == Qt.MouseButton.LeftButton) { + onClick?.invoke(launcher) + } + } + + /** + * Toggles the visual "selected" state of the card by setting the `selected` property + * and re-polishing the style. + */ + fun setSelected(sel: Boolean) { + setProperty("selected", sel) + style()?.unpolish(this) + style()?.polish(this) + update() + } + + /** + * Updates the card icon from a [QPixmap]. + */ + fun setIcon(pixmap: QPixmap) { + iconLabel.pixmap = loadScaledPixmap(pixmap.toImage(), qs(32, 32), this) + } +} + +/** + * A row widget in the mod list on the review page. Displays a checkbox, mod icon, mod name + * and ID, side badge, and availability status. + * + * @param mod The mod data to display. + * @param index Position index used for callback identification. + * @param onCheckedChanged Callback with (index, checked) when the checkbox is toggled. + * @param onFetchOnlineIcon Callback with (index, iconUrl) to fetch the online project icon. + */ +class ImportableModRow( + private val mod: ImportableMod, + private val index: Int, + private val onCheckedChanged: (Int, Boolean) -> Unit, + private val onFetchOnlineIcon: (Int, String) -> Unit +) : QFrame() { + private val checkbox = QCheckBox() + val iconLabel = QLabel() + private val nameLabel = QLabel() + private val metaLabel = QLabel() + private val badgeLabel = QLabel() + + override fun sizeHint(): QSize = QSize(200, 40) + + init { + objectName = "importableModRow" + setFixedHeight(40) + frameShape = Shape.NoFrame + + val layout = hBoxLayout(this) { + setContentsMargins(8, 2, 8, 2) + setSpacing(8) + } + + checkbox.isChecked = mod.checked + checkbox.stateChanged.connect { state: Int -> + onCheckedChanged(index, state == Qt.CheckState.Checked.value()) + } + layout.addWidget(checkbox, 0, Qt.AlignmentFlag.AlignVCenter) + + iconLabel.apply { + setFixedSize(32, 32) + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + var pix: QPixmap? = null + if (mod.iconBytes != null) { + val p = QPixmap() + if (p.loadFromData(mod.iconBytes)) { + pix = p.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } + } + if (pix == null) { + pix = TIcons.Search.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } + iconLabel.pixmap = pix + layout.addWidget(iconLabel, 0, Qt.AlignmentFlag.AlignVCenter) + + val textColumn = QWidget() + val textLayout = vBoxLayout(textColumn) { + setContentsMargins(0, 0, 0, 0) + setSpacing(2) + } + + nameLabel.apply { + text = mod.displayName + objectName = "importableModName" + } + textLayout.addWidget(nameLabel) + + val metaText = buildString { + append(mod.modId) + if (mod.side != ModSide.BOTH) append(" · ${mod.side.name}") + } + metaLabel.apply { + text = metaText + objectName = "importableModMeta" + } + textLayout.addWidget(metaLabel) + + layout.addWidget(textColumn, 1) + + badgeLabel.apply { + if (mod.sourceAvailable == null && mod.sourceStatus == null) { + visible = false + } else { + objectName = when (mod.sourceAvailable) { + true if mod.sourceStatus?.contains("name match") == true -> "badgeNameMatch" + true -> "badgeAvailable" + false -> "badgeUnavailable" + else -> "badgeChecking" + } + text = mod.sourceStatus ?: when (mod.sourceAvailable) { + true -> "Available" + false -> "Not Available" + null -> "Checking..." + } + } + } + layout.addWidget(badgeLabel, 0, Qt.AlignmentFlag.AlignVCenter) + } + + /** + * Updates the availability badge and optionally triggers online icon fetching. + * + * @param available Whether the mod is available on the source. + * @param projectId The matched source project ID (unused internally, provided for callers). + * @param iconUrl URL of the project icon to fetch. + * @param status Custom status text override. + */ + fun updateAvailability(available: Boolean?, projectId: String?, iconUrl: String?, status: String? = null) { + badgeLabel.visible = true + val displayStatus = status ?: when (available) { + true -> "Available" + false -> "Not Available" + null -> "Checking..." + } + badgeLabel.objectName = when (available) { + true if status?.contains("name match") == true -> "badgeNameMatch" + true -> "badgeAvailable" + false -> "badgeUnavailable" + else -> "badgeChecking" + } + badgeLabel.text = displayStatus + badgeLabel.style()?.unpolish(badgeLabel) + badgeLabel.style()?.polish(badgeLabel) + + if (!iconUrl.isNullOrBlank()) { + onFetchOnlineIcon(index, iconUrl) + } + } + + /** + * Updates the row icon from a [QIcon] (typically fetched online). + */ + fun setIconFromQIcon(icon: QIcon) { + val p = icon.pixmap(32, 32) + if (!p.isNull) { + iconLabel.pixmap = p + } + } +} + +/** + * A row widget representing a Modrinth modpack project in the instance list when the user + * selects a Modrinth account. Shows the project icon, title, and metadata pills. + * + * @param project The Modrinth project data. + */ +class ModrinthPackItemWidget(val project: ModrinthProject) : QFrame() { + val iconLabel = QLabel() + + override fun sizeHint(): QSize = QSize(200, 44) + + init { + objectName = "importableModRow" + setFixedHeight(44) + frameShape = Shape.NoFrame + + val layout = hBoxLayout(this) { + setContentsMargins(8, 4, 12, 4) + setSpacing(8) + } + + iconLabel.setFixedSize(32, 32) + iconLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(iconLabel, 0, Qt.AlignmentFlag.AlignVCenter) + + label(project.title) { + styleSheet = "font-size: 13px; font-weight: 600; color: ${TColors.Text};" + }.let { layout.addWidget(it, 1, Qt.AlignmentFlag.AlignVCenter) } + + val loaderName = project.latestLoaders.firstOrNull() + val pills = buildMetaTagsWidget(project.latestGameVersion, loaderName) + if (pills != null) { + layout.addWidget(pills, 0, Qt.AlignmentFlag.AlignVCenter) + } + } +} + +/** + * A row widget representing a detected Minecraft instance in the instance list. + * Shows the instance icon, name, and metadata (game version + loader). + * + * @param instance The detected instance data. + */ +class InstanceItemWidget(val instance: DetectedInstance) : QFrame() { + override fun sizeHint(): QSize = QSize(200, 44) + + init { + objectName = "importableModRow" + setFixedHeight(44) + frameShape = Shape.NoFrame + + val layout = hBoxLayout(this) { + setContentsMargins(8, 4, 12, 4) + setSpacing(8) + } + + val iconPath = LauncherDetector.resolveInstanceIcon(instance) + val iconLabel = label { + if (iconPath != null) { + val sourcePix = QPixmap(iconPath.toAbsolute().toString()) + val mode = if (sourcePix.width() <= 64 || sourcePix.height() <= 64) + Qt.TransformationMode.FastTransformation else Qt.TransformationMode.SmoothTransformation + pixmap = sourcePix.scaled(qs(32, 32), Qt.AspectRatioMode.KeepAspectRatio, mode) + } else { + pixmap = TIcons.Unknown.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation) + } + setFixedSize(32, 32) + setAlignment(Qt.AlignmentFlag.AlignCenter) + } + layout.addWidget(iconLabel, 0, Qt.AlignmentFlag.AlignVCenter) + + label(instance.name) { + styleSheet = "font-size: 13px; font-weight: 600; color: ${TColors.Text};" + }.let { layout.addWidget(it, 1, Qt.AlignmentFlag.AlignVCenter) } + + val pills = buildMetaTagsWidget(instance.gameVersion, instance.loader) + if (pills != null) { + layout.addWidget(pills, 0, Qt.AlignmentFlag.AlignVCenter) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/MetaTagsBuilder.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/MetaTagsBuilder.kt new file mode 100644 index 0000000..ebf4f5e --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/import/ui/MetaTagsBuilder.kt @@ -0,0 +1,74 @@ +package io.github.tritium_launcher.launcher.import.ui + +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.import.mapLoaderId +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.frame +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.qt.core.Qt +import io.qt.widgets.QFrame +import io.qt.widgets.QWidget + +/** + * Builds a small pill-style widget displaying the Minecraft version and mod loader for an + * instance or modpack project. + * + * @param gameVersion Minecraft version string, or `null` to skip the version pill. + * @param loaderName Loader display name, or `null` to skip the loader pill. + * @return A [QWidget] containing the pills, or `null` if neither [gameVersion] nor + * [loaderName] is provided. + */ +fun buildMetaTagsWidget(gameVersion: String?, loaderName: String?): QWidget? { + var hasAny = false + val metaWidget = QWidget() + val metaLayout = hBoxLayout(metaWidget) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + } + + if (gameVersion != null) { + val verFrame = frame { + frameShape = QFrame.Shape.NoFrame + setFixedHeight(22) + styleSheet = "border: 1px solid ${TColors.Green}; border-radius: 8px; background: transparent;" + } + val verLayout = hBoxLayout(verFrame) { + setContentsMargins(8, 2, 8, 2) + setSpacing(0) + } + verLayout.addWidget(label(gameVersion) { + styleSheet = "color: ${TColors.Green}; font-size: 11px; font-weight: 600; background: transparent; border: none;" + }) + metaLayout.addWidget(verFrame, 0, Qt.AlignmentFlag.AlignVCenter) + hasAny = true + } + + if (loaderName != null) { + val loaderId = mapLoaderId(loaderName) + val loader = if (loaderId != null) BuiltinRegistries.ModLoader.all().find { it.id == loaderId } else null + val loaderFrame = frame { + frameShape = QFrame.Shape.NoFrame + setFixedHeight(22) + styleSheet = "border: 1px solid ${TColors.Warning}; border-radius: 8px; background: transparent;" + } + val loaderLayout = hBoxLayout(loaderFrame) { + setContentsMargins(8, 2, 8, 2) + setSpacing(4) + } + if (loader != null) { + loaderLayout.addWidget(label { + pixmap = loader.icon + setFixedSize(16, 16) + styleSheet = "background: transparent; border: none;" + }, 0, Qt.AlignmentFlag.AlignVCenter) + } + loaderLayout.addWidget(label(loader?.displayName ?: loaderName) { + styleSheet = "color: ${TColors.Warning}; font-size: 11px; font-weight: 600; background: transparent; border: none;" + }, 0, Qt.AlignmentFlag.AlignVCenter) + metaLayout.addWidget(loaderFrame, 0, Qt.AlignmentFlag.AlignVCenter) + hasAny = true + } + + return if (hasAny) metaWidget else null +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/io/VPath.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/io/VPath.kt index e236c27..69dd541 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/io/VPath.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/io/VPath.kt @@ -2,6 +2,7 @@ package io.github.tritium_launcher.launcher.io import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.matches +import io.github.tritium_launcher.launcher.platform.Platform import io.github.tritium_launcher.launcher.userHome import io.qt.core.QUrl import kotlinx.coroutines.withContext @@ -12,6 +13,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import java.awt.Desktop import java.io.* import java.net.URI import java.nio.charset.Charset @@ -275,6 +277,8 @@ data class VPath( */ fun bytesOrNull(): ByteArray? = try { Files.readAllBytes(toJPath()) + } catch (_: NoSuchFileException) { + null } catch (e: Exception) { logger.warn("Exception reading bytes for path '$this'", e) null @@ -455,6 +459,24 @@ data class VPath( false } + /** + * Move this file to the OS recycling bin / trash. + * Uses [java.awt.Desktop.moveToTrash]; falls back to [delete] if trash is not supported. + * @return true if the file was moved to trash (or deleted in fallback), false otherwise. + */ + fun moveToTrash(): Boolean { + val file = toJFile() + if (!file.exists()) return false + + try { + Desktop.getDesktop().moveToTrash(file) + } catch (e: Exception) { + logger.error("Exception when moving $this to trash, using fallback method.", e) + } + + return Platform.linuxTrash(this) + } + /** * Return the parent path. */ @@ -675,6 +697,23 @@ data class VPath( operator fun div(other: VPath): VPath = this.resolve(other) operator fun div(other: String): VPath = this.resolve(parse(other)) + /** + * Append a suffix to the filename of this path. + * E.g. VPath("file") + ".txt" -> VPath("file.txt") + */ + operator fun plus(suffix: String): VPath { + if (segments.isEmpty()) return this + val last = segments.last() + val newSegments = segments.toMutableList() + newSegments[newSegments.size - 1] = last + suffix + return VPath(root, newSegments) + } + + /** + * Returns true if this path is a parent of (or equal to) [other]. + */ + operator fun contains(other: VPath): Boolean = other.startsWith(this) + companion object { /** Parse a path string into a VPath. */ diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/ActionRegistry.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/ActionRegistry.kt new file mode 100644 index 0000000..49f1500 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/ActionRegistry.kt @@ -0,0 +1,95 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qAction +import io.qt.gui.QAction +import io.qt.gui.QIcon + +typealias ActionId = String +typealias ActionHandler = () -> Unit + +enum class ShortcutKind { Keyboard, Mouse } + +object ActionRegistry { + private val actions = mutableMapOf() + + data class RegisteredAction( + val qAction: QAction?, + val label: String?, + val allowedShortcutKinds: Set, + val focusGroups: Set, + val handler: ActionHandler + ) + + fun register( + id: ActionId, + label: String, + icon: QIcon? = null, + allowKeyboardShortcuts: Boolean = true, + allowMouseShortcuts: Boolean = true, + focusGroups: Set = setOf(KeymapFocusMngr.GLOBAL), + handler: ActionHandler + ): QAction { + val qAction = qAction(label, icon) { + triggered.connect { handler() } + } + actions[id] = RegisteredAction( + qAction = qAction, + label = label, + allowedShortcutKinds = allowedKinds(allowKeyboardShortcuts, allowMouseShortcuts), + focusGroups = focusGroups.ifEmpty { setOf(KeymapFocusMngr.GLOBAL) }, + handler = handler + ) + return qAction + } + + fun registerHandler( + id: ActionId, + allowKeyboardShortcuts: Boolean = true, + allowMouseShortcuts: Boolean = true, + focusGroups: Set = setOf(KeymapFocusMngr.GLOBAL), + handler: ActionHandler + ) { + val existingAction = actions[id]?.qAction + val existingLabel = actions[id]?.label ?: existingAction?.text() + actions[id] = RegisteredAction( + qAction = existingAction, + label = existingLabel, + allowedShortcutKinds = allowedKinds(allowKeyboardShortcuts, allowMouseShortcuts), + focusGroups = focusGroups.ifEmpty { setOf(KeymapFocusMngr.GLOBAL) }, + handler = handler + ) + } + + operator fun get(id: ActionId): QAction? = actions[id]?.qAction + + fun contains(id: ActionId): Boolean = id in actions + + fun execute(id: ActionId) { actions[id]?.handler?.invoke() } + + fun allows(id: ActionId, kind: ShortcutKind): Boolean = + actions[id]?.allowedShortcutKinds?.contains(kind) ?: true + + fun focusGroups(id: ActionId): Set = + actions[id]?.focusGroups ?: setOf(KeymapFocusMngr.GLOBAL) + + fun actionIds(): Set = actions.keys + + fun actionLabel(id: ActionId): String = actions[id]?.label?.takeIf { it.isNotBlank() } ?: id + + fun syncShortcuts(keymap: Keymap) { + actions.forEach { (id, registered) -> + val sequences = keymap.getBindings(id).flatMap { it.toQKeySequences() } + if (sequences.isEmpty()) { + registered.qAction?.setShortcuts(emptyList()) + } else { + registered.qAction?.setShortcuts(sequences) + } + } + } + + private fun allowedKinds(allowKeyboard: Boolean, allowMouse: Boolean): Set = buildSet { + if (allowKeyboard) add(ShortcutKind.Keyboard) + if (allowMouse) add(ShortcutKind.Mouse) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeyBinding.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeyBinding.kt new file mode 100644 index 0000000..0703e34 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeyBinding.kt @@ -0,0 +1,24 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.qt.gui.QKeySequence + +sealed class KeyBinding { + data object None : KeyBinding() + data class Single(val stroke: Keystroke): KeyBinding() + data class Chord(val first: Keystroke, val second: Keystroke): KeyBinding() + data class Mouse(val stroke: MouseStroke): KeyBinding() + + fun toQKeySequences(): List = when(this) { + is None -> emptyList() + is Single -> listOf(stroke.toQKeySequence()) + is Chord -> listOf(QKeySequence("$first, $second")) + is Mouse -> emptyList() + } + + fun displayString(): String = when(this) { + is None -> "-" + is Single -> stroke.toString() + is Chord -> "$first $second" + is Mouse -> stroke.toString() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keymap.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keymap.kt new file mode 100644 index 0000000..2d7ea9f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keymap.kt @@ -0,0 +1,104 @@ +package io.github.tritium_launcher.launcher.keymap + +class Keymap( + val id: String, + val displayName: String, + val parent: Keymap? = null, + val isBuiltin: Boolean = false +) { + private val bindings = mutableMapOf>() + + fun getBindings(actionId: ActionId): List { + val local = bindings[actionId] + if (local != null) { + if (local.any { it is KeyBinding.None }) return emptyList() + return local + } + return parent?.getBindings(actionId) ?: emptyList() + } + + fun resolveAction(stroke: Keystroke): ActionId? { + bindings.forEach { (id, keys) -> + if (keys.any { it is KeyBinding.None }) return@forEach + if (keys.filterIsInstance().any { it.stroke == stroke }) { + return id + } + } + + val parentActionId = parent?.resolveAction(stroke) ?: return null + + val local = bindings[parentActionId] + if (local != null) { + if (local.isEmpty() || local.any { it is KeyBinding.None }) { + return null + } + return null + } + + return parentActionId + } + + fun resolveChordAction(first: Keystroke, second: Keystroke): ActionId? { + bindings.forEach { (id, keys) -> + if (keys.any { it is KeyBinding.None }) return@forEach + if (keys.filterIsInstance().any { it.first == first && it.second == second }) { + return id + } + } + val parentActionId = parent?.resolveChordAction(first, second) ?: return null + val local = bindings[parentActionId] + if (local != null) { + if (local.isEmpty() || local.any { it is KeyBinding.None }) return null + return null + } + return parentActionId + } + + fun resolveMouseAction(stroke: MouseStroke): ActionId? { + bindings.forEach { (id, keys) -> + if (keys.any { it is KeyBinding.None }) return@forEach + if (keys.filterIsInstance().any { it.stroke == stroke }) { + return id + } + } + val parentActionId = parent?.resolveMouseAction(stroke) ?: return null + val local = bindings[parentActionId] + if (local != null) { + if (local.isEmpty() || local.any { it is KeyBinding.None }) return null + return null + } + return parentActionId + } + + fun isChordPrefix(stroke: Keystroke): Boolean { + val localMatch = bindings.values.flatten() + .filterIsInstance() + .any { it.first == stroke } + return localMatch || (parent?.isChordPrefix(stroke) == true) + } + + fun allActions(): Map> { + val merged = parent?.allActions()?.toMutableMap() ?: mutableMapOf() + bindings.forEach { (id, keys) -> merged[id] = keys } + return merged + } + + fun setBindings(actionId: ActionId, keys: List) { + bindings[actionId] = keys.toMutableList() + } + + fun addBinding(actionId: ActionId, key: KeyBinding) { + bindings.getOrPut(actionId) { mutableListOf() }.add(key) + } + + fun removeBindings(actionId: ActionId) { + bindings[actionId] = mutableListOf() + } + + fun localOverrides(): Map> = bindings.toMap() + + fun applyOverrides(overrides: Map>) { + bindings.clear() + overrides.forEach { (id, keys) -> bindings[id] = keys.toMutableList() } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapBootstrap.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapBootstrap.kt new file mode 100644 index 0000000..e4f9d83 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapBootstrap.kt @@ -0,0 +1,23 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.ui.project.menu.MenuItemKind + +object KeymapBootstrap { + + private const val MENU_ACTION_PREFIX = "menu." + + fun initializeDefaults() { + BuiltinRegistries.MenuItem.all() + .asSequence() + .filter { it.kind == MenuItemKind.ACTION } + .forEach { item -> + if (!item.allowKeyboardShortcuts) return@forEach + val shortcut = item.shortcut?.trim().orEmpty() + if (shortcut.isBlank()) return@forEach + val binding = KeymapMngr.parseBindingString(shortcut) ?: return@forEach + val actionId = item.shortcutActionId ?: "$MENU_ACTION_PREFIX${item.id}" + KeymapMngr.declareDefault(actionId, binding) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapDispatcher.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapDispatcher.kt new file mode 100644 index 0000000..611c093 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapDispatcher.kt @@ -0,0 +1,124 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.qt.Nullable +import io.qt.core.QEvent +import io.qt.core.QObject +import io.qt.core.Qt +import io.qt.core.Qt.Key.* +import io.qt.gui.QKeyEvent +import io.qt.gui.QMouseEvent +import io.qt.widgets.QApplication +import io.qt.widgets.QLineEdit +import io.qt.widgets.QPlainTextEdit +import io.qt.widgets.QTextEdit + +/** + * Dispatches Key events + */ +class KeymapDispatcher( + private val registry: ActionRegistry +): QObject() { + + private var pendingChordFirst: Keystroke? = null + + override fun eventFilter( + watched: @Nullable QObject?, + event: @Nullable QEvent? + ): Boolean { + event ?: return false + + if(event.type() == QEvent.Type.ShortcutOverride) return false + if (event.type() == QEvent.Type.MouseButtonPress) { + return handleMousePress(event as? QMouseEvent ?: return false) + } + if(event.type() != QEvent.Type.KeyPress) return false + + val keyEvent = event as? QKeyEvent ?: return false + + val key = keyEvent.key() + if(isModifierKey(key)) return false + + if (isTextEditKeystroke(key) || keyEvent.modifiers().value() == Qt.KeyboardModifier.ControlModifier.value() && key in textEditCtrlKeys) { + val focusWidget = QApplication.focusWidget() + if (focusWidget is QLineEdit || focusWidget is QTextEdit || focusWidget is QPlainTextEdit) { + return false + } + } + + val stroke = Keystroke(key, keyEvent.modifiers().value()) + val keymap = KeymapMngr.activeKeymap + val activeFocusGroup = KeymapFocusMngr.currentGroup() + + val pending = pendingChordFirst + if(pending != null) { + pendingChordFirst = null + val chordActionId = keymap.resolveChordAction(pending, stroke) + if(chordActionId != null) { + if (activeFocusGroup !in registry.focusGroups(chordActionId)) return false + registry.execute(chordActionId) + return true + } + } + + if(keymap.isChordPrefix(stroke)) { + pendingChordFirst = stroke + return true + } + + val actionId = keymap.resolveAction(stroke) + if(actionId != null) { + if (!registry.allows(actionId, ShortcutKind.Keyboard)) return false + if (activeFocusGroup !in registry.focusGroups(actionId)) return false + val qAction = registry[actionId] + if(qAction == null || qAction.shortcuts().isEmpty) { + registry.execute(actionId) + return true + } + } + + return false + } + + fun cancelPendingChord() { pendingChordFirst = null } + + private fun handleMousePress(event: QMouseEvent): Boolean { + val keymap = KeymapMngr.activeKeymap + val activeFocusGroup = KeymapFocusMngr.currentGroup() + val stroke = MouseStroke( + button = event.button().value(), + modifiers = event.modifiers().value() + ) + val actionId = keymap.resolveMouseAction(stroke) ?: return false + if (!registry.allows(actionId, ShortcutKind.Mouse)) return false + if (activeFocusGroup !in registry.focusGroups(actionId)) return false + registry.execute(actionId) + return true + } + + private fun isModifierKey(key: Int): Boolean = key in setOf( + Key_Control.value(), + Key_Shift.value(), + Key_Alt.value(), + Key_Meta.value(), + ) + + private companion object { + private val textEditCtrlKeys = setOf( + Key_A.value(), // Select All + Key_C.value(), // Copy + Key_V.value(), // Paste + Key_X.value(), // Cut + Key_Z.value(), // Undo + Key_Y.value(), // Redo + Key_Slash.value(), + ) + + private val textEditStandaloneKeys = setOf( + Key_Delete.value(), + Key_Backspace.value(), + ) + } + + private fun isTextEditKeystroke(key: Int): Boolean = + key in textEditStandaloneKeys +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapFocusMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapFocusMngr.kt new file mode 100644 index 0000000..7d346dc --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapFocusMngr.kt @@ -0,0 +1,50 @@ +package io.github.tritium_launcher.launcher.keymap + +object KeymapFocusMngr { + const val GLOBAL = "global" + + private val listeners = mutableListOf<(String) -> Unit>() + private val resolvers = mutableMapOf String?>() + private val focusStack = ArrayDeque() + + private var explicitFocusGroup: String = GLOBAL + + fun currentGroup(): String { + focusStack.lastOrNull()?.let { return it } + resolvers.values.forEach { resolver -> + val resolved = resolver()?.trim() + if (!resolved.isNullOrBlank()) return resolved + } + return explicitFocusGroup + } + + fun set(group: String) { + val normalized = group.trim().ifBlank { GLOBAL } + explicitFocusGroup = normalized + listeners.forEach { it(normalized) } + } + + fun push(group: String) { + val normalized = group.trim().ifBlank { GLOBAL } + focusStack.addLast(normalized) + listeners.forEach { it(currentGroup()) } + } + + fun pop() { + if (focusStack.isNotEmpty()) { + focusStack.removeLast() + listeners.forEach { it(currentGroup()) } + } + } + + fun registerResolver(id: String, resolver: () -> String?) { + resolvers[id] = resolver + } + + fun unregisterResolver(id: String) { + resolvers.remove(id) + } + + fun addListener(listener: (String) -> Unit) { listeners += listener } + fun removeListener(listener: (String) -> Unit) { listeners -= listener } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapMngr.kt new file mode 100644 index 0000000..1ab87c1 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/KeymapMngr.kt @@ -0,0 +1,305 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.platform.Platform +import io.qt.gui.QKeySequence +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +object KeymapMngr { + + private val keymaps = mutableMapOf() + private val _activeKeymap = MutableStateFlow(Keymap("__empty__", "empty")) + val activeKeymapFlow: StateFlow = _activeKeymap.asStateFlow() + var activeKeymap: Keymap + get() = _activeKeymap.value + private set(value) { _activeKeymap.value = value } + private val declaredDefaults = mutableMapOf>() + private val persistedPath = fromTR(TConstants.Dirs.SETTINGS, "keymap.json") + + fun register(keymap: Keymap) { + keymaps[keymap.id] = keymap + } + + fun getAll(): List = keymaps.values.toList() + + fun get(id: String): Keymap? = keymaps[id] + + fun declareDefault(actionId: ActionId, binding: KeyBinding) { + declaredDefaults.getOrPut(actionId) { mutableListOf() }.add(binding) + } + + fun declareDefault(actionId: ActionId, bindings: List) { + declaredDefaults.getOrPut(actionId) { mutableListOf() }.addAll(bindings) + } + + fun declaredActionIds(): Set = declaredDefaults.keys + + fun bindingsFor(actionId: ActionId, keymap: Keymap = activeKeymap): List = + keymap.getBindings(actionId) + + fun activeLocalOverridesAsStrings(): Map> = + activeKeymap.localOverrides().mapValues { (_, bindings) -> + bindings.map { it.displayString() } + } + + fun applyOverridesFromStrings( + overrides: Map> + ) { + val parsed = overrides.mapValues { (_, displayStrings) -> + displayStrings.mapNotNull { parseBindingString(it) } + } + + saveActiveCustomOverrides(parsed) + } + + fun activate(id: String) { + val keymap = keymaps[id] ?: return + activeKeymap = keymap + ActionRegistry.syncShortcuts(keymap) + persistCurrent() + } + + /** Call once at startup after all built-in keymaps have been registered. */ + fun initPlatformDefault() { + ensureBuiltinKeymaps() + val defaultId = if (Platform.isMacOS) "tritium.mac" else "tritium.default" + activate(defaultId) + } + + fun initWithPersistence() { + ensureBuiltinKeymaps() + if (!restorePersisted()) { + initPlatformDefault() + persistCurrent() + } + } + + fun reloadWithPersistence() { + val declared = declaredDefaults.toMap() + keymaps.clear() + activeKeymap = Keymap("__empty__", "empty") + declaredDefaults.clear() + declared.forEach { (id, bindings) -> + declaredDefaults[id] = bindings.toMutableList() + } + initWithPersistence() + } + + fun findConflicts( + actionId: ActionId, + bindings: List, + keymap: Keymap = activeKeymap + ): Map> { + val actionGroups = ActionRegistry.focusGroups(actionId) + return keymap.allActions() + .filter { (otherId, _) -> otherId != actionId } + .filter { (otherId, _) -> + actionGroups.intersect(ActionRegistry.focusGroups(otherId)).isNotEmpty() + } + .mapValues { (_, otherBindings) -> + otherBindings.filter { it in bindings } + } + .filter { (_, conflicts) -> conflicts.isNotEmpty() } + } + + private val reservedMnemonics: Set = setOf( + Keystroke.alt(io.qt.core.Qt.Key.Key_F.value()), // &File + Keystroke.alt(io.qt.core.Qt.Key.Key_E.value()), // &Edit + Keystroke.alt(io.qt.core.Qt.Key.Key_V.value()), // &View + Keystroke.alt(io.qt.core.Qt.Key.Key_M.value()), // &Modpack + Keystroke.alt(io.qt.core.Qt.Key.Key_H.value()), // &Help + ) + + fun wouldConflictWithMnemonic(binding: KeyBinding): Boolean { + if (binding !is KeyBinding.Single) return false + return binding.stroke in reservedMnemonics + } + + @Serializable + data class KeymapSnapshot( + val id: String, + val displayName: String, + val parentId: String?, + // actionId → list of display strings, e.g. ["Ctrl+S", "Ctrl+K Ctrl+S"] + val overrides: Map> + ) + + @Serializable + private data class PersistedKeymapState( + val activeKeymapId: String, + val customKeymaps: List = emptyList() + ) + + fun save(keymap: Keymap, path: VPath) { + val snapshot = KeymapSnapshot( + id = keymap.id, + displayName = keymap.displayName, + parentId = keymap.parent?.id, + overrides = keymap.localOverrides().mapValues { (_, bindings) -> + bindings.map { it.displayString() } + } + ) + path.writeBytes(Json.encodeToString(KeymapSnapshot.serializer(), snapshot).toByteArray()) + } + + fun load(path: VPath): Keymap? { + val snapshot = runCatching { + Json.decodeFromString(KeymapSnapshot.serializer(), path.readTextOr("")) + }.getOrNull() ?: return null + + val parent = snapshot.parentId?.let { keymaps[it] } + val keymap = Keymap(snapshot.id, snapshot.displayName, parent, false) + + val overrides = snapshot.overrides.mapValues { (_, displayStrings) -> + displayStrings.mapNotNull { parseBindingString(it) } + } + keymap.applyOverrides(overrides) + + register(keymap) + activate(keymap.id) + return keymap + } + + private fun persistCurrent() { + val snapshots = keymaps.values + .filter { !it.isBuiltin } + .map { keymap -> + KeymapSnapshot( + id = keymap.id, + displayName = keymap.displayName, + parentId = keymap.parent?.id, + overrides = keymap.localOverrides().mapValues { (_, bindings) -> + bindings.map { it.displayString() } + } + ) + } + val state = PersistedKeymapState( + activeKeymapId = activeKeymap.id, + customKeymaps = snapshots + ) + persistedPath.writeBytes( + Json.encodeToString(PersistedKeymapState.serializer(), state).toByteArray() + ) + } + + private fun restorePersisted(): Boolean { + if (!persistedPath.exists()) return false + val state = runCatching { + Json.decodeFromString(PersistedKeymapState.serializer(), persistedPath.readTextOr("")) + }.getOrNull() ?: return false + + state.customKeymaps.forEach { snap -> + val parent = snap.parentId?.let { keymaps[it] } + val keymap = Keymap(snap.id, snap.displayName, parent, isBuiltin = false) + val overrides = snap.overrides.mapValues { (_, displayStrings) -> + displayStrings.mapNotNull { parseBindingString(it) } + } + keymap.applyOverrides(overrides) + register(keymap) + } + + if (get(state.activeKeymapId) == null) return false + activate(state.activeKeymapId) + return true + } + + fun parseBindingString(s: String): KeyBinding? { + // "Ctrl+K Ctrl+F" → Chord; "Ctrl+S" → Single; "-" → None + return runCatching { + val normalized = s.trim() + if (normalized == "-" || normalized.isEmpty()) return KeyBinding.None + val parts = when { + normalized.contains(",") -> normalized.split(",").map { it.trim() } + normalized.contains(" ") -> normalized.split(" ").map { it.trim() } + else -> normalized.split(" ").filter { it.isNotBlank() } + } + if (parts.size == 2) { + KeyBinding.Chord(parseKeystroke(parts[0]), parseKeystroke(parts[1])) + } else if (normalized.contains("Mouse", ignoreCase = true)) { + KeyBinding.Mouse(parseMouseStroke(normalized)) + } else { + KeyBinding.Single(parseKeystroke(normalized)) + } + }.getOrNull() + } + + fun sequencesFor(actionId: ActionId, keymap: Keymap = activeKeymap): List = + keymap.getBindings(actionId).flatMap { it.toQKeySequences() } + + private fun ensureBuiltinKeymaps() { + if ("tritium.default" !in keymaps) { + val default = Keymap("tritium.default", "Tritium Default", isBuiltin = true) + declaredDefaults.forEach { (actionId, bindings) -> + default.setBindings(actionId, bindings) + } + register(default) + } + if ("tritium.mac" !in keymaps) { + val mac = Keymap("tritium.mac", "Tritium Mac", parent = keymaps["tritium.default"], isBuiltin = true) + register(mac) + } + } + + fun saveActiveCustomOverrides(overrides: Map>) { + val current = activeKeymap + val target = if (current.isBuiltin) { + val userKeymap = Keymap( + id = "tritium.user", + displayName = "Tritium User", + parent = current, + isBuiltin = false + ) + register(userKeymap) + userKeymap + } else current + + val normalized = overrides.mapValues { (actionId, bindings) -> + bindings.filter { binding -> + when (binding) { + is KeyBinding.Mouse -> ActionRegistry.allows(actionId, ShortcutKind.Mouse) + else -> ActionRegistry.allows(actionId, ShortcutKind.Keyboard) + } + } + } + target.applyOverrides(normalized) + activate(target.id) + persistCurrent() + } + + private fun parseKeystroke(s: String): Keystroke { + var mods = 0 + var remaining = s + if (remaining.startsWith("Ctrl+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.ControlModifier.value(); remaining = remaining.removePrefix("Ctrl+") } + if (remaining.startsWith("Shift+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.ShiftModifier.value(); remaining = remaining.removePrefix("Shift+") } + if (remaining.startsWith("Alt+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.AltModifier.value(); remaining = remaining.removePrefix("Alt+") } + if (remaining.startsWith("Meta+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.MetaModifier.value(); remaining = remaining.removePrefix("Meta+") } + val key = QKeySequence(remaining).get(0).key().value() + return Keystroke(key, mods) + } + + private fun parseMouseStroke(s: String): MouseStroke { + var mods = 0 + var remaining = s + if (remaining.startsWith("Ctrl+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.ControlModifier.value(); remaining = remaining.removePrefix("Ctrl+") } + if (remaining.startsWith("Shift+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.ShiftModifier.value(); remaining = remaining.removePrefix("Shift+") } + if (remaining.startsWith("Alt+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.AltModifier.value(); remaining = remaining.removePrefix("Alt+") } + if (remaining.startsWith("Meta+")) { mods = mods or io.qt.core.Qt.KeyboardModifier.MetaModifier.value(); remaining = remaining.removePrefix("Meta+") } + + val button = when (val normalized = remaining.trim().lowercase()) { + "mouseleft" -> io.qt.core.Qt.MouseButton.LeftButton.value() + "mouseright" -> io.qt.core.Qt.MouseButton.RightButton.value() + "mousemiddle" -> io.qt.core.Qt.MouseButton.MiddleButton.value() + "mouseback" -> io.qt.core.Qt.MouseButton.BackButton.value() + "mouseforward" -> io.qt.core.Qt.MouseButton.ForwardButton.value() + else -> normalized.removePrefix("mousebutton").toIntOrNull() + ?: throw IllegalArgumentException("Unsupported mouse binding: $s") + } + return MouseStroke(button, mods) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keystroke.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keystroke.kt new file mode 100644 index 0000000..e6a3ee8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/Keystroke.kt @@ -0,0 +1,30 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.qt.core.Qt.KeyboardModifier.* +import io.qt.gui.QKeySequence + +data class Keystroke( + val key: Int, + val modifiers: Int +) { + fun toQKeySequence(): QKeySequence = QKeySequence(modifiers or key) + + override fun toString(): String = buildString { + if(modifiers and ControlModifier.value() != 0) append("Ctrl+") + if(modifiers and AltModifier.value() != 0) append("Alt+") + if(modifiers and ShiftModifier.value() != 0) append("Shift+") + if(modifiers and MetaModifier.value() != 0) append("Meta+") + append(QKeySequence(key).toString()) + } + + companion object { + fun ctrl(key: Int) = Keystroke(key, ControlModifier.value()) + fun ctrlShift(key: Int) = Keystroke(key, ControlModifier.value() or ShiftModifier.value()) + + fun meta(key: Int) = Keystroke(key, MetaModifier.value()) + fun metaShift(key: Int) = Keystroke(key, MetaModifier.value() or ShiftModifier.value()) + + fun plain(key: Int) = Keystroke(key, 0) + fun alt(key: Int) = Keystroke(key, AltModifier.value()) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/MouseStroke.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/MouseStroke.kt new file mode 100644 index 0000000..de39cc8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/keymap/MouseStroke.kt @@ -0,0 +1,25 @@ +package io.github.tritium_launcher.launcher.keymap + +import io.qt.core.Qt.KeyboardModifier.* + +data class MouseStroke( + val button: Int, + val modifiers: Int +) { + override fun toString(): String = buildString { + if(modifiers and ControlModifier.value() != 0) append("Ctrl+") + if(modifiers and AltModifier.value() != 0) append("Alt+") + if(modifiers and ShiftModifier.value() != 0) append("Shift+") + if(modifiers and MetaModifier.value() != 0) append("Meta+") + append( + when (button) { + io.qt.core.Qt.MouseButton.LeftButton.value() -> "MouseLeft" + io.qt.core.Qt.MouseButton.RightButton.value() -> "MouseRight" + io.qt.core.Qt.MouseButton.MiddleButton.value() -> "MouseMiddle" + io.qt.core.Qt.MouseButton.BackButton.value() -> "MouseBack" + io.qt.core.Qt.MouseButton.ForwardButton.value() -> "MouseForward" + else -> "MouseButton$button" + } + ) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/logging/Logs.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/logging/Logs.kt index b2953b7..24a43b5 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/logging/Logs.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/logging/Logs.kt @@ -6,13 +6,15 @@ import ch.qos.logback.core.AppenderBase import io.github.tritium_launcher.launcher.fromTR import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.sanitizeForLogs +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.io.BufferedOutputStream import java.nio.file.Files import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.util.concurrent.CopyOnWriteArrayList import java.util.zip.GZIPOutputStream /** @@ -27,7 +29,8 @@ object Logs { private val ARCHIVE_NAME_FMT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") private val lock = Any() - private val listeners = CopyOnWriteArrayList<(String) -> Unit>() + private val _entryFlow = MutableSharedFlow(replay = 0) + val entryFlow: SharedFlow = _entryFlow.asSharedFlow() private var prepared = false private val logsDirPath: VPath @@ -76,9 +79,7 @@ object Logs { } } - listeners.forEach { listener -> - runCatching { listener(sanitizedEntry) } - } + _entryFlow.tryEmit(sanitizedEntry) } /** @@ -94,16 +95,6 @@ object Logs { } } - /** - * Subscribes to live appended log entries. - * - * @return Function that unsubscribes the listener. - */ - fun addEntryListener(listener: (String) -> Unit): () -> Unit { - listeners.add(listener) - return { listeners.remove(listener) } - } - /** * Returns the absolute active log file path as a string. */ diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPInstaller.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPInstaller.kt new file mode 100644 index 0000000..914a305 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPInstaller.kt @@ -0,0 +1,207 @@ +package io.github.tritium_launcher.launcher.lsp + +import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.notifications.NotificationMngr +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.LSPServerDefinition +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.pushButton +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.ZipInputStream + +object LSPInstaller { + private val logger = logger() + private val downloading = ConcurrentHashMap.newKeySet() + private val httpClient = HttpClient(CIO) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + fun isInstalled(lang: SyntaxLanguage, server: LSPServerDefinition): Boolean { + val binary = getBinaryPath(lang, server) ?: return false + return binary.exists() && binary.toJFile().canExecute() + } + + fun getBinaryPath(lang: SyntaxLanguage, server: LSPServerDefinition): VPath? { + val spec = server.installSpec ?: return null + return TConstants.LSPS_DIR.resolve(lang.id).resolve(server.id).resolve(spec.binaryPath) + } + + fun checkAndPromptInstallation(project: ProjectBase, file: VPath) { + val lang = BuiltinRegistries.SyntaxLanguage.all().find { it.matches(file) } ?: return + val lsp = lang.lsp ?: return + + // Find the first server that has an install spec and is not installed + val installableServer = lsp.servers.find { server -> + server.installSpec != null && !isInstalled(lang, server) && !isOnPath(server) + } ?: return + + if (downloading.contains("${lang.id}:${installableServer.id}")) return + + NotificationMngr.post( + id = "lsp_install_prompt", + project = project, + header = "LSP Server Missing", + description = "An LSP server (${installableServer.id}) is available for ${lang.displayName} but not installed. Would you like to install it?", + customWidgetFactory = { context -> + pushButton("Install ${installableServer.id} LSP", null) { + clicked.connect { + install(project, lang, installableServer) + NotificationMngr.dismiss(context.entry.instanceId) + } + } + } + ) + } + + private fun isOnPath(server: LSPServerDefinition): Boolean { + val exe = server.command.firstOrNull() ?: return false + return isExecutableOnPath(exe) + } + + private fun isExecutableOnPath(exe: String): Boolean { + val direct = File(exe) + if (direct.isAbsolute || exe.contains(File.separator)) { + return direct.isFile && direct.canExecute() + } + val path = System.getenv("PATH") ?: return false + return path.split(File.pathSeparator).any { dir -> + val f = File(dir, exe) + f.isFile && f.canExecute() + } + } + + fun install(project: ProjectBase, lang: SyntaxLanguage, server: LSPServerDefinition) { + val spec = server.installSpec ?: return + val url = spec.downloadUrls[Platform.current] ?: spec.downloadUrls[Platform.Unknown] ?: return + + val downloadKey = "${lang.id}:${server.id}" + if (!downloading.add(downloadKey)) { + logger.info("Already downloading LSP for {}", downloadKey) + return + } + + scope.launch { + val notification = NotificationMngr.post( + id = "generic", + project = project, + header = "Installing ${server.id} LSP", + description = "Downloading from $url...", + icon = TIcons.Run.icon + ) + + try { + val tempFile = File.createTempFile("lsp-download", ".tmp") + logger.info("Downloading LSP for {} from {} to {}", downloadKey, url, tempFile.absolutePath) + + val response: HttpResponse = httpClient.get(url) + if (!response.status.isSuccess()) { + throw IllegalStateException("HTTP ${response.status.value} when downloading LSP") + } + + val channel: ByteReadChannel = response.body() + FileOutputStream(tempFile).use { output -> + channel.copyTo(output) + } + + notification?.let { NotificationMngr.dismiss(it.instanceId) } + + val extractingNotification = NotificationMngr.post( + id = "generic", + project = project, + header = "Installing ${server.id} LSP", + description = "Extracting...", + icon = TIcons.Run.icon + ) + + val installDir = TConstants.LSPS_DIR.resolve(lang.id).resolve(server.id) + if (installDir.exists()) { + installDir.toJFile().deleteRecursively() + } + installDir.mkdirs() + + logger.info("Extracting LSP for {} to {}", downloadKey, installDir.toAbsolute()) + extract(tempFile, installDir.toJFile()) + tempFile.delete() + + val binary = getBinaryPath(lang, server) + if (binary != null && binary.exists()) { + binary.toJFile().setExecutable(true) + logger.info("LSP for {} installed to {}", downloadKey, binary.toAbsolute()) + } else { + logger.warn("LSP for {} installed but binary not found at expected path: {}", downloadKey, binary?.toAbsolute()) + } + + extractingNotification?.let { NotificationMngr.dismiss(it.instanceId) } + NotificationMngr.post( + id = "generic", + project = project, + header = "${server.id} LSP Installed", + description = "The Language Server has been installed successfully. Please reopen the file to activate it.", + icon = TIcons.Run.icon + ) + } catch (t: Throwable) { + logger.error("Failed to install LSP for {}", downloadKey, t) + notification?.let { NotificationMngr.dismiss(it.instanceId) } + NotificationMngr.post( + id = "bootstrap_failure", + project = project, + header = "LSP Installation Failed", + description = "Failed to install LSP for ${server.id}: ${t.message}" + ) + } finally { + downloading.remove(downloadKey) + } + } + } + + private suspend fun extract(archive: File, destination: File) = withContext(Dispatchers.IO) { + if (archive.name.contains(".zip", ignoreCase = true)) { + ZipInputStream(archive.inputStream()).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + val file = File(destination, entry.name) + if (entry.isDirectory) { + file.mkdirs() + } else { + file.parentFile.mkdirs() + file.outputStream().use { output -> + zip.copyTo(output) + } + } + entry = zip.nextEntry + } + } + } else if (archive.name.contains(".tar.gz", ignoreCase = true) || archive.name.contains(".tgz", ignoreCase = true)) { + if (Platform.isWindows) { + throw UnsupportedOperationException("TAR extraction not yet supported on Windows") + } else { + val proc = ProcessBuilder("tar", "-xzf", archive.absolutePath, "-C", destination.absolutePath) + .inheritIO() + .start() + val exitCode = proc.waitFor() + if (exitCode != 0) { + throw IllegalStateException("tar command failed with exit code $exitCode") + } + } + } else { + throw UnsupportedOperationException("Unsupported archive format: ${archive.name}") + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPMngr.kt index c40e14b..26ca315 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/LSPMngr.kt @@ -5,10 +5,14 @@ import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage +import kotlinx.coroutines.* import org.eclipse.lsp4j.* import org.eclipse.lsp4j.launch.LSPLauncher import org.eclipse.lsp4j.services.LanguageServer +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger /** * Manages one LSP connection per project/language pair. @@ -18,7 +22,7 @@ import java.util.concurrent.ConcurrentHashMap */ object LSPMngr { private val connections = ConcurrentHashMap, LSPConnection>() - private val refCounts = ConcurrentHashMap, java.util.concurrent.atomic.AtomicInteger>() + private val refCounts = ConcurrentHashMap, AtomicInteger>() private val logger = logger() /** @@ -32,10 +36,19 @@ object LSPMngr { val cmd = resolveCmd(lang) ?: return null val key = project to lang.id - val connection = connections.computeIfAbsent(key) { - LSPConnection(project, lang.id, cmd).apply { start() } + val connection = connections.compute(key) { _, existing -> + if(existing == null || existing.isClosed || existing.ready.isCompletedExceptionally) { + existing?.stop() + LSPConnection(project, lang.id, cmd) { failed -> + connections.remove(key, failed) + refCounts.remove(key) + }.apply { start() } + } else { + existing + } } - refCounts.computeIfAbsent(key) { java.util.concurrent.atomic.AtomicInteger(0) }.incrementAndGet() + if(connection == null) return null + refCounts.computeIfAbsent(key) { AtomicInteger(0) }.incrementAndGet() return connection } @@ -54,26 +67,32 @@ object LSPMngr { } private fun resolveCmd(lang: SyntaxLanguage): List? { - val options = lang.lspCmds ?: lang.lspCmd?.let { listOf(it) } - if(options == null) { - logger.info("LSP disabled for '{}' (no lspCmd configured)", lang.id) + val lsp = lang.lsp + if(lsp == null) { + logger.info("LSP disabled for '{}' (no lsp definition)", lang.id) return null } - for(option in options) { - val exe = option.firstOrNull() ?: continue - if(isExecutableOnPath(exe)) { - if(options.size > 1) { - logger.info("LSP for '{}' selected cmd={}", lang.id, option.joinToString(" ")) - } - return option + for (server in lsp.servers) { + // 1. Check if the server is on PATH + val exe = server.command.firstOrNull() ?: continue + if (isExecutableOnPath(exe)) { + logger.info("LSP for '{}' selected server='{}' from PATH", lang.id, server.id) + return server.command + } + + // 2. Check if the server is installed in Tritium's lsps directory + val installedBinary = LSPInstaller.getBinaryPath(lang, server) + if (installedBinary != null && installedBinary.exists() && installedBinary.toJFile().canExecute()) { + logger.info("LSP for '{}' selected server='{}' from local install", lang.id, server.id) + return listOf(installedBinary.toString()) } } logger.warn( "No LSP executable found for '{}' (tried: {})", lang.id, - options.joinToString(" | ") { it.joinToString(" ") } + lsp.servers.joinToString(", ") { it.id } ) return null } @@ -97,11 +116,21 @@ object LSPMngr { * The [ready] future completes after initialize/initialized handshake finishes. * Editors should wait for it before sending didOpen/didChange. */ -class LSPConnection(val project: ProjectBase, val langId: String, val cmd: List) { +class LSPConnection( + val project: ProjectBase, + val langId: String, + val cmd: List, + private val onFailedStart: ((LSPConnection) -> Unit)? = null +) { private var process: Process? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var watcherJob: Job? = null lateinit var server: LanguageServer val client = TritiumLanguageClient() - val ready = java.util.concurrent.CompletableFuture() + val ready = CompletableFuture() + @Volatile var isClosed: Boolean = false + private set + var semanticTokensLegend: SemanticTokensLegend? = null private val logger = logger() @@ -115,8 +144,21 @@ class LSPConnection(val project: ProjectBase, val langId: String, val cmd: List< .directory(project.projectDir.toJFile()) process = pb.start() + // Watch for unexpected process exit so adapters can check isClosed + val proc = process!! + watcherJob = scope.launch { + try { + val exitCode = runInterruptible { proc.waitFor() } + if (!isClosed) { + logger.info("LSP '{}' process exited unexpectedly (code {})", langId, exitCode) + isClosed = true + } + } catch (_: InterruptedException) { + } + } + val launcher = LSPLauncher.createClientLauncher( - client, process!!.inputStream, process!!.outputStream + client, proc.inputStream, proc.outputStream ) launcher.startListening() server = launcher.remoteProxy @@ -137,19 +179,38 @@ class LSPConnection(val project: ProjectBase, val langId: String, val cmd: List< completion = CompletionCapabilities() hover = HoverCapabilities() publishDiagnostics = PublishDiagnosticsCapabilities() + + semanticTokens = SemanticTokensCapabilities( + SemanticTokensClientCapabilitiesRequests( + SemanticTokensClientCapabilitiesRequestsFull(false), + false + ), + tokenTypesList, + tokenModifiersList, + listOf(TokenFormat.Relative) + ) } } } - server.initialize(params).thenRun { + + server.initialize(params).thenAccept { result -> + if(isClosed) return@thenAccept + + semanticTokensLegend = result.capabilities?.semanticTokensProvider?.legend + server.initialized(InitializedParams()) ready.complete(Unit) }.exceptionally { t -> + onFailedStart?.invoke(this) ready.completeExceptionally(t) + stop() null } } catch (t: Throwable) { logger.error("Failed to start Language Server for '{}'", langId, t) + onFailedStart?.invoke(this) ready.completeExceptionally(t) + stop() } } @@ -157,12 +218,71 @@ class LSPConnection(val project: ProjectBase, val langId: String, val cmd: List< * Stops the underlying server process. */ fun stop() { + if(isClosed) { + return + } + isClosed = true + watcherJob?.cancel() + scope.cancel() try { - server.shutdown().thenRun { server.exit() } + val shutdown = if(this::server.isInitialized) { + server.shutdown() + } else { + CompletableFuture.completedFuture(null) + } + shutdown.orTimeout(2, TimeUnit.SECONDS).whenComplete { _, _ -> + try { + if(this::server.isInitialized) { + server.exit() + } + } catch (t: Throwable) { + logger.warn("LSP exit failed for '{}'", langId, t) + } finally { + process?.destroy() + } + } } catch (t: Throwable) { logger.warn("LSP shutdown failed for '{}'", langId, t) - } finally { process?.destroy() } } } + +private val tokenTypesList = listOf( + SemanticTokenTypes.Namespace, + SemanticTokenTypes.Type, + SemanticTokenTypes.Class, + SemanticTokenTypes.Enum, + SemanticTokenTypes.Interface, + SemanticTokenTypes.Struct, + SemanticTokenTypes.TypeParameter, + SemanticTokenTypes.Parameter, + SemanticTokenTypes.Variable, + SemanticTokenTypes.Property, + SemanticTokenTypes.EnumMember, + SemanticTokenTypes.Event, + SemanticTokenTypes.Function, + SemanticTokenTypes.Method, + SemanticTokenTypes.Macro, + SemanticTokenTypes.Keyword, + SemanticTokenTypes.Modifier, + SemanticTokenTypes.Comment, + SemanticTokenTypes.String, + SemanticTokenTypes.Number, + SemanticTokenTypes.Regexp, + SemanticTokenTypes.Operator, + SemanticTokenTypes.Decorator +) + +private val tokenModifiersList = listOf( + SemanticTokenModifiers.Declaration, + SemanticTokenModifiers.Definition, + SemanticTokenModifiers.Readonly, + SemanticTokenModifiers.Static, + SemanticTokenModifiers.Deprecated, + SemanticTokenModifiers.Abstract, + SemanticTokenModifiers.Async, + SemanticTokenModifiers.Modification, + SemanticTokenModifiers.Documentation, + SemanticTokenModifiers.DefaultLibrary +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/TritiumLanguageClient.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/TritiumLanguageClient.kt index 5ff17ff..397e36d 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/TritiumLanguageClient.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/lsp/TritiumLanguageClient.kt @@ -1,7 +1,9 @@ package io.github.tritium_launcher.launcher.lsp import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.lsp.LSPEventBus.publishDiagnostics +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import org.eclipse.lsp4j.* import org.eclipse.lsp4j.services.LanguageClient import java.util.concurrent.CompletableFuture @@ -41,38 +43,30 @@ class TritiumLanguageClient : LanguageClient { logger.debug("[Server Log] {}", it.message) } } + + override fun configuration(configurationParams: ConfigurationParams): CompletableFuture> { + return CompletableFuture.completedFuture(emptyList()) + } } /** - * Thread-safe diagnostics event bus. - * - * LSP4J calls [publishDiagnostics] on background threads, so this must be safe - * for concurrent subscribe/unsubscribe/publish. + * Thread-safe diagnostics event bus using Kotlin Flows. */ internal object LSPEventBus { - private val listeners = java.util.concurrent.ConcurrentHashMap Unit>() - private val nextId = java.util.concurrent.atomic.AtomicInteger(0) + private val _diagnostics = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) /** - * Registers a diagnostics listener and returns its subscription id. + * Flow of diagnostics published by the LSP server. */ - fun subscribe(l: (PublishDiagnosticsParams) -> Unit): Int { - val id = nextId.getAndIncrement() - listeners[id] = l - return id - } - - /** - * Removes a previously registered listener. - */ - fun unsubscribe(id: Int) { - listeners.remove(id) - } + val diagnostics = _diagnostics.asSharedFlow() /** - * Publishes diagnostics to all listeners. + * Publishes diagnostics to the flow. */ fun publishDiagnostics(p: PublishDiagnosticsParams) { - listeners.values.toList().forEach { it(p) } + _diagnostics.tryEmit(p) } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/CompanionBridge.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/CompanionBridge.kt index ffbe06d..2e1d4ec 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/CompanionBridge.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/CompanionBridge.kt @@ -6,13 +6,15 @@ import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.websocket.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.json.* import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds /** * Bridge response payload. @@ -33,9 +35,9 @@ data class CompanionBridgeResponse( * Websocket client for bridge requests. * * Communication model: - * - Opens a short-lived websocket per request. - * - Sends a single JSON request frame. - * - Reads one JSON response frame and closes. + * - Maintains a persistent websocket connection. + * - Dispatches incoming messages to a SharedFlow. + * - Correlates request/responses using unique ids. */ object CompanionBridge { private val logger = logger() @@ -43,6 +45,22 @@ object CompanionBridge { private val httpClient = HttpClient(CIO) { install(WebSockets) } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var connectionJob: Job? = null + + @Volatile + private var activeSession: DefaultClientWebSocketSession? = null + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + + /** + * Flow of all incoming messages from the bridge (both responses and spontaneous events). + */ + val events = _events.asSharedFlow() + + private val responseDeferreds = ConcurrentHashMap>() + @Volatile private var sessionToken: String? = null @@ -56,7 +74,61 @@ object CompanionBridge { private const val AUTH_HEADER = "X-Tritium-Token" /** Active websocket endpoint. */ - fun endpoint(): String = "ws://${CoreSettingValues.companionWsHost()}:${CoreSettingValues.companionWsPort()}/tritium" + fun endpoint(): String = "ws://${CoreSettingValues.companionWsHost}:${CoreSettingValues.companionWsPort()}/tritium" + + /** + * Ensures the persistent connection is active. + */ + fun ensureConnected() { + if (connectionJob?.isActive == true) return + connectionJob = scope.launch { + while (isActive) { + try { + logger.info("Connecting to Companion websocket at {}...", endpoint()) + httpClient.webSocket( + method = HttpMethod.Get, + host = CoreSettingValues.companionWsHost, + port = CoreSettingValues.companionWsPort(), + path = "/tritium", + request = { + sessionToken?.let { token -> + header(AUTH_HEADER, token) + } + } + ) { + activeSession = this + logger.info("Companion websocket connected.") + try { + for (frame in incoming) { + if (frame is Frame.Text) { + handleIncoming(frame.readText()) + } + } + } finally { + activeSession = null + logger.info("Companion websocket incoming stream closed.") + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger.warn("Companion websocket error, retrying in 5s: {}", t.message) + activeSession = null + delay(5000.milliseconds) + } + } + } + } + + private fun handleIncoming(text: String) { + val response = parseResponse(text, "") + val id = response.id + if (id != null) { + val deferred = responseDeferreds.remove(id) + deferred?.complete(response) + } + _events.tryEmit(response) + } /** * Sets the per-session auth token used for websocket handshakes. @@ -164,61 +236,31 @@ object CompanionBridge { put("payload", payload) } - var session: DefaultClientWebSocketSession? = null + ensureConnected() + + val deferred = CompletableDeferred() + responseDeferreds[requestId] = deferred return try { - session = withTimeout(effectiveTimeoutMs) { - httpClient.webSocketSession { - url(endpoint()) - sessionToken?.let { token -> - header(AUTH_HEADER, token) - } - } + val session = activeSession ?: withTimeout(5000.milliseconds) { + while (activeSession == null) delay(100.milliseconds) + activeSession!! } - withTimeout(effectiveTimeoutMs) { - session.send(Frame.Text(requestPayload.toString())) - } - val rawResponse = withTimeout(effectiveTimeoutMs) { - readSingleTextResponse(session) + session.send(Frame.Text(requestPayload.toString())) + + withTimeout(effectiveTimeoutMs.milliseconds) { + deferred.await() } - parseResponse(rawResponse, requestId) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - CompanionBridgeResponse( - ok = false, - message = "Request interrupted while talking to Companion websocket: ${e.message}." - ) + } catch (_: TimeoutCancellationException) { + responseDeferreds.remove(requestId) + CompanionBridgeResponse(ok = false, message = "Request timed out after ${effectiveTimeoutMs}ms") } catch (t: Throwable) { + responseDeferreds.remove(requestId) CompanionBridgeResponse( ok = false, - message = "Failed to reach Companion websocket at ${endpoint()}: ${t.message ?: t::class.simpleName.orEmpty()}" + message = "Failed to send request: ${t.message ?: t::class.simpleName.orEmpty()}" ) - } finally { - if (session != null) { - runCatching { - session.close(CloseReason(CloseReason.Codes.NORMAL, "done")) - } - } - } - } - - /** - * Reads frames until a single text response is received or the socket closes. - */ - private suspend fun readSingleTextResponse(session: DefaultClientWebSocketSession): String { - while (true) { - when (val frame = session.incoming.receive()) { - is Frame.Text -> return frame.readText() - is Frame.Close -> { - val reason = frame.readReason() - throw IllegalStateException( - "Companion websocket closed unexpectedly (${reason?.code?.toInt() ?: -1}): ${reason?.message.orEmpty()}" - ) - } - else -> { - } - } } } @@ -233,7 +275,7 @@ object CompanionBridge { ) val responseId = root["id"]?.jsonPrimitive?.contentOrNull - if (!responseId.isNullOrBlank() && responseId != expectedRequestId) { + if (!responseId.isNullOrBlank() && expectedRequestId.isNotBlank() && responseId != expectedRequestId) { logger.debug("Companion websocket response id mismatch: expected {}, received {}", expectedRequestId, responseId) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameLauncher.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameLauncher.kt index a5fd2d4..6888a72 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameLauncher.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameLauncher.kt @@ -3,6 +3,7 @@ package io.github.tritium_launcher.launcher.platform import io.github.tritium_launcher.launcher.TConstants import io.github.tritium_launcher.launcher.accounts.MicrosoftAuth import io.github.tritium_launcher.launcher.accounts.ProfileMngr +import io.github.tritium_launcher.launcher.core.mod.ModDatabase import io.github.tritium_launcher.launcher.core.modloader.LaunchContext import io.github.tritium_launcher.launcher.core.modloader.ModLoader import io.github.tritium_launcher.launcher.core.project.ModpackMeta @@ -15,12 +16,14 @@ import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.redactUserPath import io.qt.gui.QGuiApplication import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.json.* import java.io.File import java.nio.file.Files import java.util.* import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList import java.util.jar.JarFile /** @@ -36,10 +39,11 @@ object GameLauncher { private val pathSeparator = File.pathSeparator ?: ":" private val ansiRegex = Regex("\\u001B\\[[;\\d]*[ -/]*[@-~]") private const val MAX_MISSING_LIB_LOG = 12 - private const val MODPACK_PROJECT_TYPE_ID = "modpack" + private const val MODPACK_PROJECT_TYPE_ID = "source" private const val DEFAULT_MAX_HEAP_MB = 2_048 private val runtimePreparingScopes = ConcurrentHashMap() - private val runtimePreparationListeners = CopyOnWriteArrayList<(RuntimePreparationEvent) -> Unit>() + private val _runtimePreparationEvents = MutableSharedFlow(replay = 0) + val runtimePreparationEvents: SharedFlow = _runtimePreparationEvents.asSharedFlow() data class RuntimePreparationContext( val projectScope: String, @@ -47,14 +51,15 @@ object GameLauncher { val startedAtEpochMs: Long ) - data class RuntimePreparationEvent( - val type: Type, + /** + * Events emitted during the lifecycle of runtime preparation. + */ + sealed interface RuntimePreparationEvent { val context: RuntimePreparationContext - ) { - enum class Type { - Started, - Finished - } + + data class Started(override val context: RuntimePreparationContext) : RuntimePreparationEvent + data class Finished(override val context: RuntimePreparationContext) : RuntimePreparationEvent + data class Failed(override val context: RuntimePreparationContext, val error: Throwable) : RuntimePreparationEvent } private data class LaunchSpec( @@ -99,15 +104,25 @@ object GameLauncher { /** * Subscribe to game process lifecycle events. */ - fun addGameProcessListener(listener: (GameProcessMngr.GameProcessEvent) -> Unit): () -> Unit = - GameProcessMngr.addListener(listener) + fun addGameProcessListener(listener: (GameProcessMngr.GameProcessEvent) -> Unit): () -> Unit { + val job = CoroutineScope(Dispatchers.Default).launch { + GameProcessMngr.events.collect { event -> + listener(event) + } + } + return { job.cancel() } + } /** * Subscribe to runtime preparation state changes. */ fun addRuntimePreparationListener(listener: (RuntimePreparationEvent) -> Unit): () -> Unit { - runtimePreparationListeners += listener - return { runtimePreparationListeners -= listener } + val job = CoroutineScope(Dispatchers.Default).launch { + _runtimePreparationEvents.collect { event -> + listener(event) + } + } + return { job.cancel() } } /** @@ -132,10 +147,10 @@ object GameLauncher { scope.launch { try { prepareRuntimeInternal(project) + endRuntimePreparation(prepareCtx) } catch (t: Throwable) { logger.error("Runtime preparation failed for {}", project.name, t) - } finally { - endRuntimePreparation(prepareCtx) + failRuntimePreparation(prepareCtx, t) } } } @@ -148,16 +163,16 @@ object GameLauncher { scope.launch { try { launchInternal(project) + endRuntimePreparation(prepareCtx) } catch (t: Throwable) { logger.error("Launch failed for {}", project.name, t) - } finally { - endRuntimePreparation(prepareCtx) + failRuntimePreparation(prepareCtx, t) } } } /** - * Resolve metadata, ensure required files, and start the game process for a modpack project. + * Resolve metadata, ensure required files, and start the game process for a source project. */ private suspend fun launchInternal(project: ProjectBase) { val spec = resolveLaunchSpec(project, logFailures = true) ?: return @@ -252,12 +267,12 @@ object GameLauncher { gameDir = project.projectDir, assetsDir = assetsDir, assetIndexId = assetIndexId, - mcVersion = mcVersion, username = username, uuid = uuid, accessToken = accessToken, mergedId = mergedId, - launchMaximized = CoreSettingValues.gameLaunchMaximized() + launchMaximized = CoreSettingValues.gameLaunchMaximized + ) val jvmArgs = buildJvmArgs(versionObj, project.projectDir, nativesDir, classpath).toMutableList() loader.prepareLaunchJvmArgs(context, classpathEntries, jvmArgs) @@ -306,6 +321,7 @@ object GameLauncher { launchJvmArgs.size, gameArgs.size ) + val disabledModState = prepareDisabledMods(project.projectDir) try { val processBuilder = ProcessBuilder(command) .directory(project.projectDir.toJFile()) @@ -333,9 +349,11 @@ object GameLauncher { outputJob.join() logger.info("Minecraft exited with code {}", exit) CompanionBridge.clearSessionToken() + restoreDisabledMods(disabledModState) } } catch (t: Throwable) { CompanionBridge.clearSessionToken() + restoreDisabledMods(disabledModState) logger.error("Failed to start Minecraft process", t) } } @@ -376,7 +394,8 @@ object GameLauncher { if (project.typeId != MODPACK_PROJECT_TYPE_ID) { return emptyList() } - val raw = CoreSettingValues.modpackJvmArgs()?.trim().orEmpty() + val raw = CoreSettingValues.modpackJvmArgs?.trim().orEmpty() + if (raw.isEmpty()) { return emptyList() } @@ -479,7 +498,6 @@ object GameLauncher { gameDir: VPath, assetsDir: VPath, assetIndexId: String, - mcVersion: String, username: String, uuid: String, accessToken: String, @@ -1043,9 +1061,9 @@ object GameLauncher { } private fun resolveLaunchSpec(project: ProjectBase, logFailures: Boolean): LaunchSpec? { - if (project.typeId != "modpack") { + if (project.typeId != "source") { if (logFailures) { - logger.warn("Launch is only supported for modpack projects (type={})", project.typeId) + logger.warn("Launch is only supported for source projects (type={})", project.typeId) } return null } @@ -1200,23 +1218,58 @@ object GameLauncher { logger.debug("Runtime preparation already active for project '{}'", project.name) return null } - emitRuntimePreparation(RuntimePreparationEvent(RuntimePreparationEvent.Type.Started, ctx)) + emitRuntimePreparation(RuntimePreparationEvent.Started(ctx)) return ctx } private fun endRuntimePreparation(context: RuntimePreparationContext) { val removed = runtimePreparingScopes.remove(context.projectScope, context) if (removed) { - emitRuntimePreparation(RuntimePreparationEvent(RuntimePreparationEvent.Type.Finished, context)) + emitRuntimePreparation(RuntimePreparationEvent.Finished(context)) + } + } + + private fun failRuntimePreparation(context: RuntimePreparationContext, error: Throwable) { + val removed = runtimePreparingScopes.remove(context.projectScope, context) + if (removed) { + emitRuntimePreparation(RuntimePreparationEvent.Failed(context, error)) } } private fun emitRuntimePreparation(event: RuntimePreparationEvent) { - runtimePreparationListeners.forEach { listener -> - try { - listener(event) - } catch (t: Throwable) { - logger.warn("Runtime preparation listener failed", t) + _runtimePreparationEvents.tryEmit(event) + } + + private fun prepareDisabledMods(projectDir: VPath): List> { + val renamed = mutableListOf>() + try { + ModDatabase(projectDir).use { db -> + val allMods = db.getAll() + val disabled = allMods.filter { !it.enabled } + val modsDir = projectDir.resolve("mods").toJFile() + if (!modsDir.isDirectory) return@use + for (mod in disabled) { + if (mod.fileName.isBlank()) continue + val jar = File(modsDir, mod.fileName) + if (!jar.exists()) continue + val disabledFile = File(modsDir, "${mod.fileName}.disabled") + if (jar.renameTo(disabledFile)) { + renamed.add(jar to mod.fileName) + logger.info("Disabled mod: {} -> {}.disabled", mod.fileName, mod.fileName) + } + } + } + } catch (t: Throwable) { + logger.warn("Failed to prepare disabled mods for launch", t) + } + return renamed + } + + private fun restoreDisabledMods(state: List>) { + for ((originalFile, originalName) in state) { + val disabledFile = File(originalFile.parentFile, "$originalName.disabled") + if (disabledFile.exists() && disabledFile.renameTo(originalFile)) { + logger.info("Restored disabled mod: {} <- {}.disabled", originalName, originalName) } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameProcessMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameProcessMngr.kt index f778540..9f2f0bb 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameProcessMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/GameProcessMngr.kt @@ -1,10 +1,14 @@ package io.github.tritium_launcher.launcher.platform +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.nio.file.Files -import java.util.concurrent.CopyOnWriteArrayList /** * Tracks a single active game process per project path scope. @@ -13,7 +17,8 @@ object GameProcessMngr { private val logger = logger() private val lock = Any() private val trackedByScope = LinkedHashMap() - private val listeners = CopyOnWriteArrayList<(GameProcessEvent) -> Unit>() + private val _events = MutableSharedFlow(replay = 0) + val events: SharedFlow = _events.asSharedFlow() enum class Source { Launch, @@ -30,18 +35,17 @@ object GameProcessMngr { val attachedAtEpochMs: Long ) - data class GameProcessEvent( - val type: Type, - val context: GameProcessContext, - val exitCode: Int? = null - ) { - enum class Type { - Attached, - Detached, - Exited, - KillRequested, - KillFailed - } + /** + * Events emitted during the lifecycle of a tracked game process. + */ + sealed interface GameProcessEvent { + val context: GameProcessContext + + data class Attached(override val context: GameProcessContext) : GameProcessEvent + data class Detached(override val context: GameProcessContext) : GameProcessEvent + data class Exited(override val context: GameProcessContext, val exitCode: Int) : GameProcessEvent + data class KillRequested(override val context: GameProcessContext) : GameProcessEvent + data class KillFailed(override val context: GameProcessContext) : GameProcessEvent } /** @@ -80,7 +84,7 @@ object GameProcessMngr { fun detach(projectPath: VPath): Boolean { val scope = scopeOf(projectPath) val removed = synchronized(lock) { trackedByScope.remove(scope) } ?: return false - emit(GameProcessEvent(GameProcessEvent.Type.Detached, removed.toContext(isAttached = false))) + emit(GameProcessEvent.Detached(removed.toContext(isAttached = false))) return true } @@ -105,7 +109,7 @@ object GameProcessMngr { fun killByScope(projectScope: String, force: Boolean = true): Boolean { val scope = projectScope.trim() val tracked = synchronized(lock) { trackedByScope[scope] } ?: return false - emit(GameProcessEvent(GameProcessEvent.Type.KillRequested, tracked.toContext())) + emit(GameProcessEvent.KillRequested(tracked.toContext())) return try { if (tracked.process != null) { tracked.process.destroy() @@ -123,7 +127,7 @@ object GameProcessMngr { } } catch (t: Throwable) { logger.warn("Failed to kill tracked game process (pid={})", tracked.handle.pid(), t) - emit(GameProcessEvent(GameProcessEvent.Type.KillFailed, tracked.toContext())) + emit(GameProcessEvent.KillFailed(tracked.toContext())) false } } @@ -153,14 +157,6 @@ object GameProcessMngr { return synchronized(lock) { trackedByScope.values.map { it.toContext() } } } - /** - * Subscribe to process events. - */ - fun addListener(listener: (GameProcessEvent) -> Unit): () -> Unit { - listeners += listener - return { listeners -= listener } - } - private fun attachInternal( scope: String, projectName: String, @@ -180,10 +176,10 @@ object GameProcessMngr { val displaced = synchronized(lock) { trackedByScope.put(scope, tracked) } if (displaced != null && displaced !== tracked) { - emit(GameProcessEvent(GameProcessEvent.Type.Detached, displaced.toContext(isAttached = false))) + emit(GameProcessEvent.Detached(displaced.toContext(isAttached = false))) } - emit(GameProcessEvent(GameProcessEvent.Type.Attached, tracked.toContext())) + emit(GameProcessEvent.Attached(tracked.toContext())) tracked.handle.onExit().whenComplete { _, throwable -> if (throwable != null) { logger.debug("Game process exit watcher raised error (pid={})", tracked.handle.pid(), throwable) @@ -206,21 +202,26 @@ object GameProcessMngr { } if (!shouldEmit) return emit( - GameProcessEvent( - type = GameProcessEvent.Type.Exited, + GameProcessEvent.Exited( context = tracked.toContext(isRunning = false, isAttached = false), - exitCode = exitCode + exitCode = exitCode ?: 0 ) ) } private fun emit(event: GameProcessEvent) { - listeners.forEach { listener -> - try { - listener(event) - } catch (t: Throwable) { - logger.warn("Game process listener failed", t) - } + _events.tryEmit(event) + when (event) { + is GameProcessEvent.Attached -> TritiumEventBus.publish( + TritiumEvent.GameAttached(event.context.projectScope, event.context.projectName, event.context.pid) + ) + is GameProcessEvent.Detached -> TritiumEventBus.publish( + TritiumEvent.GameDetached(event.context.projectScope, event.context.projectName, event.context.pid) + ) + is GameProcessEvent.Exited -> TritiumEventBus.publish( + TritiumEvent.GameExited(event.context.projectScope, event.context.projectName, event.context.pid, event.exitCode) + ) + else -> {} } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/Platform.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/Platform.kt index 7419741..7211088 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/platform/Platform.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/platform/Platform.kt @@ -1,11 +1,13 @@ package io.github.tritium_launcher.launcher.platform +import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.mainLogger import io.github.tritium_launcher.launcher.toURI import kotlinx.io.IOException import org.slf4j.Logger import java.awt.Desktop import java.io.File +import java.lang.ProcessBuilder.Redirect.DISCARD import java.util.concurrent.TimeUnit enum class Platform { @@ -25,6 +27,9 @@ enum class Platform { val isMacOS = current == MacOSX val isLinux = current == Linux + /** + * @throws IOException + */ fun openBrowser(url: String): Boolean { try { val desktop = Desktop.getDesktop() @@ -51,7 +56,12 @@ enum class Platform { else -> { val candidates = listOf( listOf("/usr/bin/xdg-open", url), - listOf("gio", "open", url) + listOf("xdg-open", url), + listOf("gio", "open", url), + listOf("kioclient5", "exec", url), + listOf("kioclient", "exec", url), + listOf("kde-open5", url), + listOf("kde-open", url) ) candidates.forEach { cmd -> if(runAndLogProcess(cmd)) { @@ -68,6 +78,74 @@ enum class Platform { return false } + fun linuxTrash(path: VPath): Boolean { + if (current == Linux) { + return try { + val process = ProcessBuilder("gio", "trash", path.toAbsoluteString()) + .redirectError(DISCARD) + .redirectOutput(DISCARD) + .start() + + val exited = process.waitFor(5, TimeUnit.SECONDS) + exited && process.exitValue() == 0 + } catch (_: Exception) { + false + } + } + return false + } + + /** + * @throws IOException + */ + fun openFile(file: File): Boolean = openFile(file.path) + + /** + * @throws IOException + */ + fun openFile(file: VPath): Boolean = openFile(file.toString()) + + /** + * @throws IOException + */ + fun openFile(path: String): Boolean { + try { + when(current) { + Windows -> { + val start = listOf("start", path) + val process = runAndLogProcess(start) + if(!process) return false + } + MacOSX -> { + val open = listOf("open", path) + val process = runAndLogProcess(open) + if(!process) return false + } + else -> { + val candidates = listOf( + listOf("/usr/bin/xdg-open", path), + listOf("xdg-open", path), + listOf("gio", "open", path), + listOf("kioclient5", "exec", path), + listOf("kioclient", "exec", path), + listOf("kde-open5", path), + listOf("kde-open", path) + ) + candidates.forEach { cmd -> + if(runAndLogProcess(cmd)) { + return true + } + } + return false + } + } + } catch (e: IOException) { + throw IllegalStateException("Failed to open file for: $path", e) + } + + return false + } + private fun runAndLogProcess(cmd: List): Boolean { try { val commandName = cmd.firstOrNull().orEmpty() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/DeferredRegistryBuilder.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/registry/DeferredRegistryBuilder.kt index 00c6465..a1d042a 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/DeferredRegistryBuilder.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/registry/DeferredRegistryBuilder.kt @@ -3,7 +3,8 @@ package io.github.tritium_launcher.launcher.registry import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import kotlinx.coroutines.* -import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -23,26 +24,28 @@ import kotlin.time.toDuration */ class DeferredRegistryBuilder( private val registry: Registry, - scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob() + CoroutineName("DeferredRegistryBuilder")), private val pollInterval: Duration = 100.toDuration(DurationUnit.MILLISECONDS), private val onBuild: (List) -> Unit ) { private val logger = logger() - private val accumulated = CopyOnWriteArrayList() - private val listener = object : RegistryListener { - override fun onRegister(fullId: String, entry: T) { - accumulated.add(entry) - } - } + private val accumulated = mutableListOf() init { if(registry.isFrozen) { onBuild(registry.all().toList()) } else { - registry.addListener(listener) scope.launch { try { + registry.events + .onEach { event -> + if (event is RegistryEvent.Registered) { + accumulated.add(event.entry) + } + } + .launchIn(this) + while (!registry.isFrozen) { delay(pollInterval) } @@ -51,9 +54,7 @@ class DeferredRegistryBuilder( onBuild(snapshot) } } catch (t: Throwable) { - logger.warn("Polling failed", t) - } finally { - registry.removeListener(listener) + logger.warn("Flow collection failed", t) } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/Registry.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/registry/Registry.kt index 372c9eb..6783725 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/Registry.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/registry/Registry.kt @@ -5,7 +5,9 @@ import io.github.tritium_launcher.launcher.registry.exceptions.DuplicateRegistra import io.github.tritium_launcher.launcher.registry.exceptions.InvalidIdException import io.github.tritium_launcher.launcher.registry.exceptions.RegistryFrozenException import io.ktor.util.collections.* -import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.reflect.KClass @@ -13,10 +15,17 @@ import kotlin.reflect.KClass private val LOCAL_ID = Regex("^[a-z0-9_.-]+$") private val NAMESPACED_ID = Regex("^[a-z0-9_.-]+:[a-z0-9_.-]+$") +/** + * Events emitted by [Registry]. + */ +sealed class RegistryEvent { + data class Registered(val fullId: String, val entry: T) : RegistryEvent() +} + /** * Namespaced registry for extension-provided entries. * - * Entries are keyed by a local [Registrable.id] and namespaced with the registering + * Entries are keyed by a local [Registrable.id] and namespace with the registering * extension id. Registries can be frozen to prevent further changes once startup * completes. */ @@ -27,8 +36,10 @@ class Registry( ) { private val entries = ConcurrentMap() - private val listeners = CopyOnWriteArrayList>() + private val _events = MutableSharedFlow>(replay = 0) + val events: SharedFlow> = _events.asSharedFlow() private val frozen = AtomicBoolean(false) + private var cachedAll: List? = null val isFrozen: Boolean get() = frozen.load() @@ -55,7 +66,7 @@ class Registry( validateNamespacedId(namespacedId) val prev = entries.putIfAbsent(entry.id, entry) if(prev != null) throw DuplicateRegistrationException("Duplicate id '${entry.id}' in registry '$name'") - listeners.forEach { it.onRegister(namespacedId, entry) } + _events.tryEmit(RegistryEvent.Registered(namespacedId, entry)) } /** @@ -70,7 +81,7 @@ class Registry( validateNamespacedId(namespacedId) val prev = entries.putIfAbsent(entry.id, entry) if (prev != null) throw DuplicateRegistrationException("Duplicate id '${entry.id}' in registry '$name'") - listeners.forEach { it.onRegister(namespacedId, entry) } + _events.tryEmit(RegistryEvent.Registered(namespacedId, entry)) } } @@ -83,25 +94,20 @@ class Registry( val namespacedId = "${extId.trim()}:${entry.id}" validateNamespacedId(namespacedId) entries[entry.id] = entry - listeners.forEach { it.onRegister(namespacedId, entry) } + _events.tryEmit(RegistryEvent.Registered(namespacedId, entry)) } fun get(id: String): T? = entries[id] fun require(id: String): T = get(id) ?: throw NoSuchElementException("No entry '$id' in registry '$name'") - fun all(): Collection = entries.values.toList() + fun all(): Collection = cachedAll ?: entries.values.toList() fun contains(id: String): Boolean = entries.containsKey(id) fun size(): Int = entries.size - fun addListener(listener: RegistryListener) { - listeners += listener + fun freeze() { + frozen.store(true) + cachedAll = entries.values.toList() } - fun removeListener(listener: RegistryListener) { - listeners -= listener - } - - fun freeze() { frozen.store(true) } - fun clear() { if(isFrozen) throw RegistryFrozenException("Registry '$name' is frozen") entries.clear() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/RegistryListener.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/registry/RegistryListener.kt deleted file mode 100644 index 1816168..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/registry/RegistryListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.tritium_launcher.launcher.registry - -/** - * Listener notified when a registry entry is registered. - */ -interface RegistryListener { - fun onRegister(fullId: String, entry: T) -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryDatabase.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryDatabase.kt new file mode 100644 index 0000000..a9de8f5 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryDatabase.kt @@ -0,0 +1,1006 @@ +package io.github.tritium_launcher.launcher.registrydb + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sqlite.SQLiteConfig +import java.sql.Connection +import java.sql.ResultSet + +private const val REGISTRY_DB_SCHEMA_VERSION = 1L + +object RegistryDatabase { + private val logger = logger() + private val json = Json { ignoreUnknownKeys = true } + + private data class CachedDbState( + val status: RegistryDbStatus.Ready, + val connection: Connection + ) + + @Volatile + private var cachedState: CachedDbState? = null + @Volatile + private var cachedProjectDir: String? = null + + fun invalidateCachedConnection() { + cachedState?.connection?.close() + cachedState = null + cachedProjectDir = null + } + + fun status(project: ProjectBase): RegistryDbStatus { + val locations = resolveLocations(project) + if(!locations.rootDir.exists()) { + return RegistryDbStatus.MissingRoot(locations.rootDir) + } + if(!locations.latestPointer.exists()) { + return RegistryDbStatus.MissingLatestPointer(locations.latestPointer) + } + if(!locations.database.exists()) { + return RegistryDbStatus.MissingDatabase(locations.database) + } + + val latest = readLatestPointer(locations.latestPointer) + ?: return RegistryDbStatus.InvalidLatestPointer(locations.latestPointer) + val snapshotDir = locations.rootDir.resolve(latest.path).toAbsolute() + val manifestPath = snapshotDir.resolve("manifest.json") + if(!manifestPath.exists()) { + return RegistryDbStatus.MissingManifest(manifestPath) + } + + val manifest = readManifest(manifestPath) + ?: return RegistryDbStatus.InvalidManifest(manifestPath) + if(!manifest.complete) { + return RegistryDbStatus.IncompleteDump(snapshotDir) + } + + return runCatching { + openConnection(locations.database).use { conn -> + if (!tableExists(conn, "metadata")) { + return@runCatching RegistryDbStatus.InvalidDatabase( + locations.database, + "Missing metadata table." + ) + } + + val dbSchema = meta(conn, "schema_version")?.toLongOrNull() + if(dbSchema != REGISTRY_DB_SCHEMA_VERSION) { + return@runCatching RegistryDbStatus.SchemaMismatch( + locations.database, + expected = REGISTRY_DB_SCHEMA_VERSION, + actual = dbSchema + ) + } + + val dbSnapshot = meta(conn, "snapshot_id") + if(dbSnapshot.isNullOrBlank()) { + return@runCatching RegistryDbStatus.InvalidDatabase( + locations.database, + "Missing snapshot_id metadata." + ) + } + + if(dbSnapshot != latest.snapshotId) { + return@runCatching RegistryDbStatus.StaleDatabase( + locations.database, + expectedSnapshotId = latest.snapshotId, + actualSnapshotId = dbSnapshot + ) + } + + RegistryDbStatus.Ready( + database = locations.database, + snapshotId = dbSnapshot, + manifestPath = manifestPath + ) + } + }.getOrElse { t -> + logger.warn("Registry DB status check failed for '{}'", project.name, t) + RegistryDbStatus.InvalidDatabase(locations.database, t.message ?: t::class.simpleName.orEmpty()) + } + } + + fun registryCounts(project: ProjectBase): List = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT registry_type, entry_count + FROM v_registry_counts + ORDER BY registry_type + """.trimIndent() + ).use { stmt -> + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add( + RegistryTypeCount( + registryType = rs.getString("registry_type"), + entryCount = rs.getLong("entry_count") + ) + ) + } + } + } + } + } + + fun modCounts(project: ProjectBase): List = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sqlite + """ + SELECT namespace, item_count, recipe_count, recipe_type_count, tag_count, registry_entry_count, total_count + FROM v_mod_counts + ORDER BY total_count DESC, namespace ASC + """.trimIndent() + ).use { stmt -> + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add( + ModContentCount( + namespace = rs.getString("namespace"), + itemCount = rs.getLong("item_count"), + recipeCount = rs.getLong("recipe_count"), + recipeTypeCount = rs.getLong("recipe_type_count"), + tagCount = rs.getLong("tag_count"), + registryEntryCount = rs.getLong("registry_entry_count"), + totalCount = rs.getLong("total_count") + ) + ) + } + } + } + } + } + + fun searchItems(project: ProjectBase, query: String, limit: Int = 100): List = + searchItems(project, query, offset = 0, limit = limit) + + fun countItems(project: ProjectBase, query: String): Int = + withReadyDatabase(project) { conn -> + val trimmed = query.trim().lowercase() + val sql = if(trimmed.isBlank()) { + //language=sql + "SELECT COUNT(*) AS count FROM items" + } else { + //language=sql + """ + SELECT COUNT(*) AS count + FROM items + WHERE id LIKE ? OR lower(COALESCE(display_name, '')) LIKE ? + """.trimIndent() + } + + conn.prepareStatement(sql).use { stmt -> + if(trimmed.isNotBlank()) { + val pattern = "%$trimmed%" + stmt.setString(1, pattern) + stmt.setString(2, pattern) + } + stmt.executeQuery().use { rs -> + if(rs.next()) rs.getInt("count") else 0 + } + } + } + + fun searchItems(project: ProjectBase, query: String, offset: Int, limit: Int): List = + withReadyDatabase(project) { conn -> + val trimmed = query.trim().lowercase() + val sql = if(trimmed.isBlank()) { + //language=sql + """ + SELECT namespace, id, path, display_name, max_count, max_damage, rarity, enchantability, texture_path, tag_values + FROM v_item_browser + ORDER BY namespace ASC, COALESCE(display_name, id) ASC + LIMIT ? OFFSET ? + """.trimIndent() + } else { + //language=sql + """ + SELECT namespace, id, path, display_name, max_count, max_damage, rarity, enchantability, texture_path, tag_values + FROM v_item_browser + WHERE id LIKE ? OR lower(COALESCE(display_name, '')) LIKE ? + ORDER BY namespace ASC, COALESCE(display_name, id) ASC + LIMIT ? OFFSET ? + """.trimIndent() + } + + conn.prepareStatement(sql).use { stmt -> + if(trimmed.isBlank()) { + stmt.setInt(1, limit) + stmt.setInt(2, offset) + } else { + val pattern = "%$trimmed%" + stmt.setString(1, pattern) + stmt.setString(2, pattern) + stmt.setInt(3, limit) + stmt.setInt(4, offset) + } + + stmt.executeQuery().use { rs -> + buildList { + while(rs.next()) { + add(rs.toItemSummary()) + } + } + } + } + } + + fun itemDetail(project: ProjectBase, id: String): RegistryItemDetail? = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT id, namespace, path, display_name, max_count, max_damage, rarity, enchantability, texture_path, raw_json + FROM items + WHERE id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, id) + stmt.executeQuery().use { rs -> + if(!rs.next()) { + return@withReadyDatabase null + } + + RegistryItemDetail( + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + displayName = rs.getString("display_name"), + maxCount = rs.getNullableInt("max_count"), + maxDamage = rs.getNullableInt("max_damage"), + rarity = rs.getString("rarity"), + enchantability = rs.getNullableInt("enchantability"), + texturePath = rs.getString("texture_path"), + rawJson = rs.getString("raw_json"), + tags = loadTagsForValue(conn, registryType = "item", value = id) + ) + } + } + } + + data class ItemDetailWithRecipes( + val detail: RegistryItemDetail?, + val recipeUsage: RegistryItemRecipeUsage, + val recipeDetails: List + ) + + fun itemDetailWithRecipes(project: ProjectBase, id: String): ItemDetailWithRecipes = + withReadyDatabase(project) { conn -> + val detail = conn.prepareStatement( + //language=sql + """ + SELECT id, namespace, path, display_name, max_count, max_damage, rarity, enchantability, texture_path, raw_json + FROM items + WHERE id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, id) + stmt.executeQuery().use { rs -> + if (!rs.next()) { + null to emptyList() + } else { + RegistryItemDetail( + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + displayName = rs.getString("display_name"), + maxCount = rs.getNullableInt("max_count"), + maxDamage = rs.getNullableInt("max_damage"), + rarity = rs.getString("rarity"), + enchantability = rs.getNullableInt("enchantability"), + texturePath = rs.getString("texture_path"), + rawJson = rs.getString("raw_json"), + tags = loadTagsForValue(conn, registryType = "item", value = id) + ) to emptyList() + } + } + } + + val recipeUsage = RegistryItemRecipeUsage( + producedBy = recipesForItemRole(conn, id, "output"), + usedIn = recipesForItemRole(conn, id, "input") + ) + + val allRecipeIds = recipeUsage.producedBy.map { it.id } + recipeUsage.usedIn.map { it.id } + val recipeDetails = recipeDetailsFromIds(conn, allRecipeIds) + + ItemDetailWithRecipes( + detail = detail.first, + recipeUsage = recipeUsage, + recipeDetails = recipeDetails + ) + } + + private fun recipeDetailsFromIds(conn: Connection, recipeIds: Collection): List = + if (recipeIds.isEmpty()) { + emptyList() + } else { + val placeholders = recipeIds.joinToString(",") { "?" } + conn.prepareStatement( + //language=sql + """ + SELECT r.id, r.namespace, r.path, r.recipe_type, r.group_name, r.raw_json, + rt.input_slots, rt.output_slots, rt.fuel_slots, rt.raw_json AS recipe_type_raw_json + FROM recipes r + LEFT JOIN recipe_types rt ON rt.id = r.recipe_type + WHERE r.id IN ($placeholders) + """.trimIndent() + ).use { stmt -> + recipeIds.forEachIndexed { index, rid -> + stmt.setString(index + 1, rid) + } + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add( + RegistryRecipeDetail( + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + recipeType = rs.getString("recipe_type"), + groupName = rs.getString("group_name"), + inputSlots = rs.getNullableInt("input_slots"), + outputSlots = rs.getNullableInt("output_slots"), + fuelSlots = rs.getNullableInt("fuel_slots"), + rawJson = rs.getString("raw_json"), + recipeTypeRawJson = rs.getString("recipe_type_raw_json") + ) + ) + } + } + } + } + } + + fun itemTexturePath(project: ProjectBase, id: String): String? = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT texture_path + FROM items + WHERE id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, id) + stmt.executeQuery().use { rs -> + if (rs.next()) rs.getString("texture_path") else null + } + } + } + + fun itemIdsForTag(project: ProjectBase, tagId: String): List = + withReadyDatabase(project) { conn -> + resolveTagValues(conn, registryType = "item", tagId = tagId, visitedTags = linkedSetOf()) + } + + fun itemPreviewsForTag(project: ProjectBase, tagId: String): List = + withReadyDatabase(project) { conn -> + val itemIds = resolveTagValues(conn, registryType = "item", tagId = tagId, visitedTags = linkedSetOf()) + conn.prepareStatement( + //language=sql + """ + SELECT id, texture_path + FROM items + WHERE id = ? + """.trimIndent() + ).use { stmt -> + buildList { + itemIds.forEach { itemId -> + stmt.setString(1, itemId) + stmt.executeQuery().use { rs -> + if (rs.next()) { + add( + RegistryItemPreview( + id = rs.getString("id"), + texturePath = rs.getString("texture_path") + ) + ) + } + } + } + } + } + } + + fun browseRecipes(project: ProjectBase, query: String, limit: Int = 100): List = + withReadyDatabase(project) { conn -> + val trimmed = query.trim().lowercase() + val sql = if(trimmed.isBlank()) { + //language=sql + """ + SELECT namespace, id, path, recipe_type, group_name, input_slots, output_slots, fuel_slots + FROM v_recipe_browser + ORDER BY namespace ASC, id ASC + LIMIT ? + """.trimIndent() + } else { + //language=sql + """ + SELECT namespace, id, path, recipe_type, group_name, input_slots, output_slots, fuel_slots + FROM v_recipe_browser + WHERE id LIKE ? + OR lower(COALESCE(recipe_type, '')) LIKE ? + OR lower(COALESCE(group_name, '')) LIKE ? + ORDER BY namespace ASC, id ASC + LIMIT ? + """.trimIndent() + } + + conn.prepareStatement(sql).use { stmt -> + if(trimmed.isBlank()) { + stmt.setInt(1, limit) + } else { + val pattern = "%$trimmed%" + stmt.setString(1, pattern) + stmt.setString(2, pattern) + stmt.setString(3, pattern) + stmt.setInt(4, limit) + } + + stmt.executeQuery().use { rs -> + buildList { + while(rs.next()) { + add(rs.toRecipeSummary()) + } + } + } + } + } + + fun recipeDetail(project: ProjectBase, id: String): RegistryRecipeDetail? = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT r.id, r.namespace, r.path, r.recipe_type, r.group_name, r.raw_json, + rt.input_slots, rt.output_slots, rt.fuel_slots, rt.raw_json AS recipe_type_raw_json + FROM recipes r + LEFT JOIN recipe_types rt ON rt.id = r.recipe_type + WHERE r.id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, id) + stmt.executeQuery().use { rs -> + if(!rs.next()) { + return@withReadyDatabase null + } + + RegistryRecipeDetail( + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + recipeType = rs.getString("recipe_type"), + groupName = rs.getString("group_name"), + inputSlots = rs.getNullableInt("input_slots"), + outputSlots = rs.getNullableInt("output_slots"), + fuelSlots = rs.getNullableInt("fuel_slots"), + rawJson = rs.getString("raw_json"), + recipeTypeRawJson = rs.getString("recipe_type_raw_json") + ) + } + } + } + + fun recipeDetails(project: ProjectBase, recipeIds: Collection): List = + if (recipeIds.isEmpty()) { + emptyList() + } else { + withReadyDatabase(project) { conn -> + val placeholders = recipeIds.joinToString(",") { "?" } + conn.prepareStatement( + //language=sql + """ + SELECT r.id, r.namespace, r.path, r.recipe_type, r.group_name, r.raw_json, + rt.input_slots, rt.output_slots, rt.fuel_slots, rt.raw_json AS recipe_type_raw_json + FROM recipes r + LEFT JOIN recipe_types rt ON rt.id = r.recipe_type + WHERE r.id IN ($placeholders) + """.trimIndent() + ).use { stmt -> + recipeIds.forEachIndexed { index, id -> + stmt.setString(index + 1, id) + } + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add( + RegistryRecipeDetail( + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + recipeType = rs.getString("recipe_type"), + groupName = rs.getString("group_name"), + inputSlots = rs.getNullableInt("input_slots"), + outputSlots = rs.getNullableInt("output_slots"), + fuelSlots = rs.getNullableInt("fuel_slots"), + rawJson = rs.getString("raw_json"), + recipeTypeRawJson = rs.getString("recipe_type_raw_json") + ) + ) + } + } + } + } + } + } + + fun customValuePreview(project: ProjectBase, typeId: String, id: String): RegistryValuePreview? = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT cv.id, cv.display_name, cv.texture_path, vt.display_name AS type_display_name + FROM custom_values cv + LEFT JOIN value_types vt ON vt.id = cv.type_id + WHERE cv.type_id = ? AND cv.id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, typeId) + stmt.setString(2, id) + stmt.executeQuery().use { rs -> + if(!rs.next()) { + return@withReadyDatabase null + } + + RegistryValuePreview( + id = rs.getString("id"), + typeId = typeId, + displayName = rs.getString("display_name"), + typeDisplayName = rs.getString("type_display_name"), + texturePath = rs.getString("texture_path") + ) + } + } + } + + fun recipesForItem(project: ProjectBase, itemId: String): RegistryItemRecipeUsage = + withReadyDatabase(project) { conn -> + RegistryItemRecipeUsage( + producedBy = recipesForItemRole(conn, itemId, "output"), + usedIn = recipesForItemRole(conn, itemId, "input") + ) + } + + fun recipesForProduct(project: ProjectBase, itemId: String): List = + recipesForItem(project, itemId).producedBy + + fun recipesForIngredient(project: ProjectBase, itemId: String): List = + recipesForItem(project, itemId).usedIn + + fun recipeTypesForItem(project: ProjectBase, itemId: String): List = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT DISTINCT rt.id, rt.display_name, rt.catalysts + FROM recipe_links rl + JOIN recipes r ON r.id = rl.recipe_id + JOIN recipe_types rt ON rt.id = r.recipe_type + WHERE rl.value = ? + ORDER BY rt.id ASC + """.trimIndent() + ).use { stmt -> + stmt.setString(1, itemId) + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + val catalystsRaw = rs.getString("catalysts") + val catalystIds = runCatching { + kotlinx.serialization.json.Json.decodeFromString>(catalystsRaw ?: "[]") + }.getOrDefault(emptyList()) + add( + RecipeTypeCatalyst( + recipeTypeId = rs.getString("id"), + displayName = rs.getString("display_name"), + catalystIds = catalystIds + ) + ) + } + } + } + } + } + + fun itemSummariesByIds(project: ProjectBase, ids: Collection): List = + if (ids.isEmpty()) emptyList() else withReadyDatabase(project) { conn -> + val placeholders = ids.joinToString(",") { "?" } + conn.prepareStatement( + //language=sql + """ + SELECT namespace, id, path, display_name, max_count, max_damage, rarity, enchantability, texture_path, tag_values + FROM v_item_browser + WHERE id IN ($placeholders) + ORDER BY id ASC + """.trimIndent() + ).use { stmt -> + ids.forEachIndexed { index, id -> + stmt.setString(index + 1, id) + } + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add(rs.toItemSummary()) + } + } + } + } + } + + fun registryEntryDetail(project: ProjectBase, registryType: String, id: String): RegistryEntryDetail? = + withReadyDatabase(project) { conn -> + conn.prepareStatement( + //language=sql + """ + SELECT registry_type, id, namespace, path, raw_json + FROM registry_entries + WHERE registry_type = ? AND id = ? + """.trimIndent() + ).use { stmt -> + stmt.setString(1, registryType) + stmt.setString(2, id) + stmt.executeQuery().use { rs -> + if(!rs.next()) { + return@withReadyDatabase null + } + + RegistryEntryDetail( + registryType = rs.getString("registry_type"), + id = rs.getString("id"), + namespace = rs.getString("namespace"), + path = rs.getString("path"), + rawJson = rs.getString("raw_json") + ) + } + } + } + + private fun withReadyDatabase(project: ProjectBase, block: (Connection) -> T): T { + val projectDir = project.projectDir.toString().trim() + val current = cachedState + if (current != null && cachedProjectDir == projectDir) { + return block(current.connection) + } + + val state = status(project) + return when (state) { + is RegistryDbStatus.Ready -> { + cachedState?.connection?.close() + val conn = openConnection(state.database) + cachedState = CachedDbState(state, conn) + cachedProjectDir = projectDir + block(conn) + } + else -> throw IllegalStateException("Registry DB is not ready for '${project.name}': $state") + } + } + + private fun openConnection(db: VPath): Connection { + Class.forName("org.sqlite.JDBC") + val config = SQLiteConfig().apply { + setReadOnly(true) + } + return config.createConnection("jdbc:sqlite:${db.toAbsolute()}") + } + + private fun meta(conn: Connection, key: String): String? { + conn.prepareStatement("SELECT value FROM metadata WHERE key = ?").use { stmt -> + stmt.setString(1, key) + stmt.executeQuery().use { rs -> + return if(rs.next()) rs.getString("value") else null + } + } + } + + private fun tableExists(conn: Connection, tableName: String): Boolean { + conn.prepareStatement( + //language=sql + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = ? + LIMIT 1 + """.trimIndent() + ).use { stmt -> + stmt.setString(1, tableName) + stmt.executeQuery().use { rs -> + return rs.next() + } + } + } + + private fun resolveTagValues( + conn: Connection, + registryType: String, + tagId: String, + visitedTags: MutableSet + ): List { + if (!visitedTags.add(tagId)) return emptyList() + + conn.prepareStatement( + """ + SELECT value + FROM tag_values + WHERE registry_type = ? AND tag_id = ? + ORDER BY ordinal ASC + """.trimIndent() + ).use { stmt -> + stmt.setString(1, registryType) + stmt.setString(2, tagId) + stmt.executeQuery().use { rs -> + val resolved = linkedSetOf() + while (rs.next()) { + val value = rs.getString("value").orEmpty() + if (value.startsWith("#")) { + resolved += resolveTagValues( + conn, + registryType = registryType, + tagId = value.removePrefix("#"), + visitedTags = visitedTags + ) + } else if (value.isNotBlank()) { + resolved += value + } + } + return resolved.toList() + } + } + } + + private fun loadTagsForValue(conn: Connection, registryType: String, value: String): List { + conn.prepareStatement( + """ + SELECT tag_id + FROM tag_values + WHERE registry_type = ? AND value = ? + ORDER BY tag_id ASC + """.trimIndent() + ).use { stmt -> + stmt.setString(1, registryType) + stmt.setString(2, value) + stmt.executeQuery().use { rs -> + return buildList { + while(rs.next()) { + add(rs.getString("tag_id")) + } + } + } + } + } + + private fun resolveLocations(project: ProjectBase): RegistryDbLocations { + val root = project.projectDir.resolve("registryObjs").toAbsolute() + return RegistryDbLocations( + rootDir = root, + latestPointer = root.resolve("latest.json"), + database = root.resolve("game_registry.db") + ) + } + + private fun readLatestPointer(path: VPath): RegistryLatestPointer? = runCatching { + json.decodeFromString(path.readTextOrNull() ?: return null) + }.onFailure { t -> + logger.warn("Failed reading registry latest pointer '{}'", path.toAbsolute(), t) + }.getOrNull() + + private fun readManifest(path: VPath): RegistryDumpManifest? = runCatching { + json.decodeFromString(path.readTextOrNull() ?: return null) + }.onFailure { t -> + logger.warn("Failed reading registry manifest '{}'", path.toAbsolute(), t) + }.getOrNull() + + private fun recipesForItemRole(conn: Connection, itemId: String, role: String): List = + conn.prepareStatement( + //language=sql + """ + SELECT DISTINCT + r.namespace, + r.id, + r.path, + r.recipe_type, + r.group_name, + rt.input_slots, + rt.output_slots, + rt.fuel_slots + FROM recipe_links rl + JOIN recipes r ON r.id = rl.recipe_id + LEFT JOIN recipe_types rt ON rt.id = r.recipe_type + WHERE rl.role = ? + AND ( + (rl.value_kind = 'item' AND rl.value = ?) + OR ( + rl.value_kind = 'tag' + AND EXISTS ( + SELECT 1 + FROM tag_values tv + WHERE tv.registry_type = 'item' + AND tv.tag_id = rl.value + AND tv.value = ? + ) + ) + ) + ORDER BY r.namespace ASC, r.id ASC + """.trimIndent() + ).use { stmt -> + stmt.setString(1, role) + stmt.setString(2, itemId) + stmt.setString(3, itemId) + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add(rs.toRecipeSummary()) + } + } + } + } + + private fun ResultSet.toItemSummary(): RegistryItemSummary { + val rawTags = getString("tag_values").orEmpty() + return RegistryItemSummary( + id = getString("id"), + namespace = getString("namespace"), + path = getString("path"), + displayName = getString("display_name"), + maxCount = getNullableInt("max_count"), + maxDamage = getNullableInt("max_damage"), + rarity = getString("rarity"), + enchantability = getNullableInt("enchantability"), + texturePath = getString("texture_path"), + tags = rawTags.lines().mapNotNull { it.trim().takeIf(String::isNotBlank) } + ) + } + + private fun ResultSet.toRecipeSummary() = RegistryRecipeSummary( + id = getString("id"), + namespace = getString("namespace"), + path = getString("path"), + recipeType = getString("recipe_type"), + groupName = getString("group_name"), + inputSlots = getNullableInt("input_slots"), + outputSlots = getNullableInt("output_slots"), + fuelSlots = getNullableInt("fuel_slots") + ) +} + +data class RegistryDbLocations( + val rootDir: VPath, + val latestPointer: VPath, + val database: VPath +) + +sealed interface RegistryDbStatus { + data class MissingRoot(val path: VPath): RegistryDbStatus + data class MissingLatestPointer(val path: VPath): RegistryDbStatus + data class MissingDatabase(val path: VPath): RegistryDbStatus + data class MissingManifest(val path: VPath): RegistryDbStatus + data class InvalidLatestPointer(val path: VPath): RegistryDbStatus + data class InvalidManifest(val path: VPath): RegistryDbStatus + data class IncompleteDump(val snapshotDir: VPath): RegistryDbStatus + data class SchemaMismatch(val path: VPath, val expected: Long, val actual: Long?): RegistryDbStatus + data class StaleDatabase(val path: VPath, val expectedSnapshotId: String, val actualSnapshotId: String): RegistryDbStatus + data class InvalidDatabase(val path: VPath, val reason: String): RegistryDbStatus + data class Ready(val database: VPath, val snapshotId: String, val manifestPath: VPath): RegistryDbStatus +} + +data class RegistryTypeCount( + val registryType: String, + val entryCount: Long +) + +data class ModContentCount( + val namespace: String, + val itemCount: Long, + val recipeCount: Long, + val recipeTypeCount: Long, + val tagCount: Long, + val registryEntryCount: Long, + val totalCount: Long +) + +data class RegistryItemSummary( + val id: String, + val namespace: String, + val path: String, + val displayName: String?, + val maxCount: Int?, + val maxDamage: Int?, + val rarity: String?, + val enchantability: Int?, + val texturePath: String?, + val tags: List +) + +data class RegistryItemDetail( + val id: String, + val namespace: String, + val path: String, + val displayName: String?, + val maxCount: Int?, + val maxDamage: Int?, + val rarity: String?, + val enchantability: Int?, + val texturePath: String?, + val rawJson: String, + val tags: List +) + +data class RegistryRecipeSummary( + val id: String, + val namespace: String, + val path: String, + val recipeType: String?, + val groupName: String?, + val inputSlots: Int?, + val outputSlots: Int?, + val fuelSlots: Int? +) + +data class RegistryItemRecipeUsage( + val producedBy: List, + val usedIn: List +) + +data class RegistryItemPreview( + val id: String, + val texturePath: String? +) + +data class RecipeTypeCatalyst( + val recipeTypeId: String, + val displayName: String?, + val catalystIds: List +) + +data class RegistryRecipeDetail( + val id: String, + val namespace: String, + val path: String, + val recipeType: String?, + val groupName: String?, + val inputSlots: Int?, + val outputSlots: Int?, + val fuelSlots: Int?, + val rawJson: String, + val recipeTypeRawJson: String? +) + +data class RegistryEntryDetail( + val registryType: String, + val id: String, + val namespace: String, + val path: String, + val rawJson: String +) + +data class RegistryValuePreview( + val id: String, + val typeId: String, + val displayName: String?, + val typeDisplayName: String?, + val texturePath: String? +) + +@Serializable +private data class RegistryLatestPointer( + val path: String, + @SerialName("snapshotId") + val snapshotId: String +) + +@Serializable +private data class RegistryDumpManifest( + val complete: Boolean +) + +private fun ResultSet.getNullableInt(column: String): Int? { + val value = getInt(column) + return if(wasNull()) null else value +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryRefreshService.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryRefreshService.kt new file mode 100644 index 0000000..40b0fcd --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/registrydb/RegistryRefreshService.kt @@ -0,0 +1,267 @@ +package io.github.tritium_launcher.launcher.registrydb + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.io.VWatchEvent +import io.github.tritium_launcher.launcher.io.watch +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.platform.CompanionBridge +import io.github.tritium_launcher.launcher.ui.project.ProjectTaskMngr +import io.ktor.utils.io.core.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds + +/** + * Orchestrates the registry dump and database build pipeline. + */ +object RegistryRefreshService { + private val logger = logger() + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val refreshJobs = ConcurrentHashMap() + private val projectWatchers = ConcurrentHashMap() + + private val _dbUpdated = MutableSharedFlow(extraBufferCapacity = 16) + val dbUpdated = _dbUpdated.asSharedFlow() + + fun isRefreshing(project: ProjectBase): Boolean = refreshJobs.containsKey(project.projectDir.toString()) + + /** + * Starts watching for changes in the project's registryObjs directory and game_registry.db. + */ + fun startWatching(project: ProjectBase) { + val key = project.projectDir.toString() + if (projectWatchers.containsKey(key)) return + + val root = project.projectDir.resolve("registryObjs").toAbsolute() + if (!root.exists()) { + runCatching { root.mkdirs() } + } + + if (root.exists()) { + val watcher = root.watch({ event -> + handleWatchEvent(project, event) + }) + projectWatchers[key] = watcher + logger.info("Started watching registry for project '{}'", project.name) + } + } + + fun stopWatching(project: ProjectBase) { + val key = project.projectDir.toString() + projectWatchers.remove(key)?.close() + } + + private fun handleWatchEvent(project: ProjectBase, event: VWatchEvent) { + val fileName = event.path.fileName() + if (fileName == "latest.json" && (event.kind == VWatchEvent.Kind.Create || event.kind == VWatchEvent.Kind.Modify)) { + logger.info("Detected change in latest.json for '{}', triggering build", project.name) + triggerBuild(project) + } + } + + fun triggerRefresh(project: ProjectBase) { + val key = project.projectDir.toString() + if (refreshJobs.containsKey(key)) { + logger.info("Refresh already in progress for '{}'", project.name) + return + } + + val job = scope.launch { + try { + performRefresh(project) + } finally { + refreshJobs.remove(key) + } + } + refreshJobs[key] = job + } + + private fun triggerBuild(project: ProjectBase) { + val key = project.projectDir.toString() + ":build" + if (refreshJobs.containsKey(key) || refreshJobs.containsKey(project.projectDir.toString())) return + + val job = scope.launch { + try { + val locations = resolveRegistryLocations(project) + if (runRegistryBuilder(project, locations)) { + _dbUpdated.emit(project) + } + } finally { + refreshJobs.remove(key) + } + } + refreshJobs[key] = job + } + + private suspend fun performRefresh(project: ProjectBase) { + val taskId = ProjectTaskMngr.start( + projectPath = project.projectDir, + title = "Refreshing Registry", + detail = "Triggering dump from game...", + progressPercent = 0.0 + ) + + try { + // 1. Trigger dump + val bridgeResponse = CompanionBridge.sendCommand("dumpRegistry") + if (!bridgeResponse.ok) { + ProjectTaskMngr.update(taskId, detail = "Failed to trigger dump: ${bridgeResponse.message}") + delay(3000.milliseconds) + return + } + + ProjectTaskMngr.updateProgress(taskId, 20.0) + ProjectTaskMngr.update(taskId, detail = "Waiting for dump to complete...") + + // 2. Wait for a complete dump snapshot instead of waiting for a DB that does not exist yet. + val dumpReady = awaitCompleteDump(project, timeoutMs = 60_000) + if (!dumpReady) { + ProjectTaskMngr.update(taskId, detail = "Timed out waiting for registry dump.") + delay(3000.milliseconds) + return + } + + ProjectTaskMngr.updateProgress(taskId, 50.0) + ProjectTaskMngr.update(taskId, detail = "Building registry database (Rust)...") + + // 3. Run registry-builder (manual run to ensure progress tracking) + val locations = resolveRegistryLocations(project) + val buildOk = runRegistryBuilder(project, locations) + if (!buildOk) { + ProjectTaskMngr.update(taskId, detail = "Failed to build registry database.") + delay(3000.milliseconds) + return + } + + ProjectTaskMngr.updateProgress(taskId, 100.0) + ProjectTaskMngr.update(taskId, detail = "Registry refresh complete.") + + // 4. Notify UI (though the watcher might have already done it) + _dbUpdated.emit(project) + + delay(1000.milliseconds) + } catch (e: Exception) { + logger.error("Registry refresh failed for '{}'", project.name, e) + ProjectTaskMngr.update(taskId, detail = "Refresh failed: ${e.message}") + delay(3000.milliseconds) + } finally { + ProjectTaskMngr.finish(taskId) + } + } + + private fun runRegistryBuilder(project: ProjectBase, locations: RegistryLocations): Boolean { + val rootDir = VPath.get("").toAbsolute() // Project root + val builderPath = rootDir.resolve("tools/registry-builder") + + val cmd = listOf( + "cargo", "run", "--release", "--", + "--input", locations.registryObjs.toAbsolute().toString(), + "--output", locations.database.toAbsolute().toString() + ) + + logger.info("Running registry-builder: {}", cmd.joinToString(" ")) + + return try { + val pb = ProcessBuilder(cmd) + pb.directory(builderPath.toJFile()) + pb.redirectErrorStream(true) + val process = pb.start() + + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + + if (exitCode != 0) { + logger.error("registry-builder failed with exit code {}:\n{}", exitCode, output) + false + } else { + logger.info("registry-builder finished successfully.") + true + } + } catch (e: Exception) { + logger.error("Failed to execute registry-builder", e) + false + } + } + + private suspend fun awaitCompleteDump(project: ProjectBase, timeoutMs: Long): Boolean { + val locations = resolveRegistryLocations(project) + val root = locations.registryObjs + + return withTimeoutOrNull(timeoutMs.milliseconds) { + callbackFlow { + fun check() { + val latestPointer = readLatestPointer(root.resolve("latest.json")) + if (latestPointer != null) { + val manifestPath = root.resolve(latestPointer.path).resolve("manifest.json").toAbsolute() + val manifest = readManifest(manifestPath) + if (manifest?.complete == true) { + trySend(true) + close() + } + } + } + + // Initial check + check() + + val watcher = root.watch( + callback = { event: VWatchEvent -> + // Watch for latest.json changes in the root + if (event.path.fileName() == "latest.json") { + check() + } + } + ) + + awaitClose { watcher.close() } + }.first() + } ?: false + } + + private fun readLatestPointer(path: VPath): RegistryLatestPointer? = runCatching { + json.decodeFromString(path.readTextOrNull() ?: return null) + }.onFailure { t -> + logger.warn("Failed reading registry latest pointer '{}'", path.toAbsolute(), t) + }.getOrNull() + + private fun readManifest(path: VPath): RegistryDumpManifest? = runCatching { + json.decodeFromString(path.readTextOrNull() ?: return null) + }.onFailure { t -> + logger.warn("Failed reading registry manifest '{}'", path.toAbsolute(), t) + }.getOrNull() + + private fun resolveRegistryLocations(project: ProjectBase): RegistryLocations { + val root = project.projectDir.resolve("registryObjs").toAbsolute() + return RegistryLocations( + registryObjs = root, + database = root.resolve("game_registry.db") + ) + } + + private data class RegistryLocations( + val registryObjs: VPath, + val database: VPath + ) + + @Serializable + private data class RegistryLatestPointer( + val path: String, + @SerialName("snapshotId") + val snapshotId: String + ) + + @Serializable + private data class RegistryDumpManifest( + val complete: Boolean + ) +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/NamespacedId.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/NamespacedId.kt index cc599ea..ebe008c 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/NamespacedId.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/NamespacedId.kt @@ -7,6 +7,7 @@ private val FULL_ID = Regex("^[a-z0-9_.-]+:[a-z0-9_.-]+$") * Represents an id scoped by the owning namespace (usually an extension id). */ data class NamespacedId(val namespace: String, val id: String) { + init { require(LOCAL_ID.matches(namespace)) { "Invalid namespace '$namespace', expected ${LOCAL_ID.pattern}" } require(LOCAL_ID.matches(id)) { "Invalid id '$id', expected ${LOCAL_ID.pattern}" } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingDescriptor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingDescriptor.kt index c01866b..d38c8c3 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingDescriptor.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingDescriptor.kt @@ -150,7 +150,7 @@ data class SettingWidgetContext( /** * Optional contract for custom setting widgets that can refresh themselves from model state. * - * [SettingsView] calls this during staged/apply/cancel refresh so custom editors stay in sync + * [io.github.tritium_launcher.launcher.ui.settings.SettingsView] calls this during staged/apply/cancel refresh so custom editors stay in sync * with persisted and pending values. * * @see SettingWidgetContext.currentValue @@ -177,6 +177,8 @@ class WidgetSettingDescriptor( defaultValue: T, serializer: KSerializer?, val widgetFactory: SettingWidgetFactory, + val fullWidth: Boolean = false, + val fullHeight: Boolean = false, comments: List = emptyList(), order: Int = -1 ) : SettingDescriptor( diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsDelegate.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsDelegate.kt new file mode 100644 index 0000000..064f60c --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsDelegate.kt @@ -0,0 +1,38 @@ +package io.github.tritium_launcher.launcher.settings + +import kotlin.properties.ReadOnlyProperty + +/** + * Creates a read-only property delegate for a setting. + * + * @param key The namespaced id of the setting. + * @param defaultValue The value to return if the setting is not found or has an incompatible type. + * @param mapper Optional function to map the raw setting value to the target type [T]. + */ +fun setting( + key: NamespacedId, + defaultValue: T, + mapper: (Any?) -> T? = { @Suppress("UNCHECKED_CAST") (it as? T) } +): ReadOnlyProperty = ReadOnlyProperty { _, _ -> + mapper(SettingsMngr.currentValueOrNull(key)) ?: defaultValue +} + +/** + * Creates a read-only property delegate for an optional text setting. + */ +fun optionalTextSetting(key: NamespacedId): ReadOnlyProperty = ReadOnlyProperty { _, _ -> + val raw = (SettingsMngr.currentValueOrNull(key) as? String)?.trim().orEmpty() + raw.takeIf { it.isNotBlank() } +} + +/** + * Creates a read-only property delegate for an enum setting. + */ +fun > enumSetting( + key: NamespacedId, + fallback: T, + mapping: Map +): ReadOnlyProperty = ReadOnlyProperty { _, _ -> + val raw = (SettingsMngr.currentValueOrNull(key) as? String)?.trim()?.lowercase() ?: return@ReadOnlyProperty fallback + mapping[raw] ?: fallback +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsEvents.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsEvents.kt index e349ff6..2ec88c7 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsEvents.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsEvents.kt @@ -4,8 +4,6 @@ package io.github.tritium_launcher.launcher.settings * Base type for events emitted by [SettingsMngr]. * * @property namespace Namespace associated with this event. - * @see SettingsMngr.addListener - * @see SettingsMngr.removeListener */ sealed interface SettingsEvent { val namespace: String @@ -13,8 +11,6 @@ sealed interface SettingsEvent { /** * Listener callback invoked for every [SettingsEvent]. - * - * @see SettingsMngr.addListener */ typealias SettingsListener = (SettingsEvent) -> Unit diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsMngr.kt index 32533e2..0e039a5 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/settings/SettingsMngr.kt @@ -2,15 +2,18 @@ package io.github.tritium_launcher.launcher.settings import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigRenderOptions +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus import io.github.tritium_launcher.launcher.extension.Extension +import io.github.tritium_launcher.launcher.extension.core.CoreExtension.namespace import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.settings.SettingsMngr.addListener import io.github.tritium_launcher.launcher.settings.SettingsMngr.applyPending import io.github.tritium_launcher.launcher.settings.SettingsMngr.category import io.github.tritium_launcher.launcher.settings.SettingsMngr.childrenOf import io.github.tritium_launcher.launcher.settings.SettingsMngr.comment import io.github.tritium_launcher.launcher.settings.SettingsMngr.currentValue import io.github.tritium_launcher.launcher.settings.SettingsMngr.currentValueOrNull +import io.github.tritium_launcher.launcher.settings.SettingsMngr.events import io.github.tritium_launcher.launcher.settings.SettingsMngr.findSetting import io.github.tritium_launcher.launcher.settings.SettingsMngr.forNamespace import io.github.tritium_launcher.launcher.settings.SettingsMngr.persistAll @@ -18,12 +21,14 @@ import io.github.tritium_launcher.launcher.settings.SettingsMngr.persistNamespac import io.github.tritium_launcher.launcher.settings.SettingsMngr.register import io.github.tritium_launcher.launcher.settings.SettingsMngr.registerCategory import io.github.tritium_launcher.launcher.settings.SettingsMngr.registerSetting -import io.github.tritium_launcher.launcher.settings.SettingsMngr.removeListener import io.github.tritium_launcher.launcher.settings.SettingsMngr.suggestValue import io.github.tritium_launcher.launcher.settings.SettingsMngr.text import io.github.tritium_launcher.launcher.settings.SettingsMngr.toggle import io.github.tritium_launcher.launcher.settings.SettingsMngr.updateValue import io.github.tritium_launcher.launcher.settings.SettingsMngr.widget +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -31,7 +36,6 @@ import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.hocon.Hocon import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList /** * Central entry point for registering settings, reading persisted values, and writing updates. @@ -60,7 +64,8 @@ object SettingsMngr { private val values = ConcurrentHashMap() private val pendingRaw = ConcurrentHashMap>() private val loadLocks = ConcurrentHashMap() - private val listeners = CopyOnWriteArrayList() + private val _events = MutableSharedFlow(replay = 0) + val events: SharedFlow = _events.asSharedFlow() /* Registration */ @@ -83,9 +88,9 @@ object SettingsMngr { * @return Registered category node. * @see registerCategory */ - context(ext: Extension) + context(_: Extension) fun registerCategory(descriptor: SettingCategoryDescriptor): SettingsRegistry.CategoryNode = - registerCategory(ext.namespace, descriptor) + registerCategory(namespace, descriptor) /** * Registers a setting in [category] for [namespace] and applies any pending value. @@ -112,9 +117,9 @@ object SettingsMngr { * @return Registered setting node. * @see registerSetting */ - context(ext: Extension) + context(_: Extension) fun registerSetting(category: CategoryPath, descriptor: SettingDescriptor): SettingNode = - registerSetting(ext.namespace, category, descriptor) + registerSetting(namespace, category, descriptor) /* Builder helpers */ @@ -139,9 +144,9 @@ object SettingsMngr { * @return Registered category node. * @see category */ - context(ext: Extension) + context(_: Extension) fun category(id: String, block: CategoryBuilder.() -> Unit = {}): SettingsRegistry.CategoryNode = - category(ext.namespace, id, block) + category(namespace, id, block) /** * Builds and registers a toggle setting for [namespace]. @@ -166,9 +171,9 @@ object SettingsMngr { * @return Registered setting node. * @see toggle */ - context(ext: Extension) + context(_: Extension) fun toggle(category: CategoryPath, id: String, block: ToggleBuilder.() -> Unit = {}): SettingNode = - toggle(ext.namespace, category, id, block) + toggle(namespace, category, id, block) /** * Builds and registers a text setting for [namespace]. @@ -193,9 +198,9 @@ object SettingsMngr { * @return Registered setting node. * @see text */ - context(ext: Extension) + context(_: Extension) fun text(category: CategoryPath, id: String, block: TextBuilder.() -> Unit = {}): SettingNode = - text(ext.namespace, category, id, block) + text(namespace, category, id, block) /** * Builds and registers a comment-only setting entry for [namespace]. @@ -220,9 +225,9 @@ object SettingsMngr { * @return Registered comment setting node. * @see comment */ - context(ext: Extension) + context(_: Extension) fun comment(category: CategoryPath, id: String, block: CommentBuilder.() -> Unit = {}): SettingNode = - comment(ext.namespace, category, id, block) + comment(namespace, category, id, block) /** * Builds and registers a custom widget-backed setting for [namespace]. @@ -247,9 +252,9 @@ object SettingsMngr { * @return Registered setting node. * @see widget */ - context(ext: Extension) + context(_: Extension) fun widget(category: CategoryPath, id: String, block: WidgetBuilder.() -> Unit): SettingNode = - widget(ext.namespace, category, id, block) + widget(namespace, category, id, block) /** * Creates a namespace-scoped settings registrar that can be passed to external files. @@ -278,8 +283,8 @@ object SettingsMngr { * @return Namespace-scoped registrar bound to [Extension.namespace]. * @see forNamespace */ - context(ext: Extension) - fun scope(): SettingsNamespaceScope = forNamespace(ext.namespace) + context(_: Extension) + fun scope(): SettingsNamespaceScope = forNamespace(namespace) /** * Registers a reusable [block] for the current extension namespace. @@ -287,36 +292,13 @@ object SettingsMngr { * @param block Registration block to execute. * @see register */ - context(ext: Extension) + context(_: Extension) fun register(block: SettingsRegistration) { - register(ext.namespace, block) + register(namespace, block) } /* Events */ - /** - * Registers [listener] for settings events. - * - * Events are delivered synchronously on the calling thread. - * - * @param listener Event listener callback. - * @see SettingsEvent - * @see removeListener - */ - fun addListener(listener: SettingsListener) { - listeners += listener - } - - /** - * Removes a previously registered [listener]. - * - * @param listener Listener to remove. - * @see addListener - */ - fun removeListener(listener: SettingsListener) { - listeners -= listener - } - /** * Publishes a suggestion for [node] without mutating the setting. * @@ -532,6 +514,14 @@ object SettingsMngr { newValue = value ) ) + TritiumEventBus.publish( + TritiumEvent.SettingChanged( + nodeKey = node.key.id, + namespace = node.ownerNamespace, + oldValue = oldValue, + newValue = value + ) + ) } return SettingValidation.Valid } @@ -590,7 +580,7 @@ object SettingsMngr { loadEntry(node, rendered) } } - if (pendingNs != null && pendingNs!!.isEmpty()) { + if (pendingNs != null && pendingNs.isEmpty()) { pendingRaw.remove(namespace) } } @@ -822,16 +812,10 @@ object SettingsMngr { * Dispatches [event] to all registered listeners. * * @param event Event payload. - * @see addListener + * @see events */ private fun emitEvent(event: SettingsEvent) { - listeners.forEach { listener -> - try { - listener(event) - } catch (t: Throwable) { - logger.warn("Settings listener failed for {}", event.javaClass.simpleName, t) - } - } + _events.tryEmit(event) } } @@ -976,6 +960,8 @@ class WidgetBuilder(private val id: String) { var serializer: KSerializer? = null var comments: List = emptyList() var order: Int = -1 + var fullWidth: Boolean = false + var fullHeight: Boolean = false lateinit var widgetFactory: SettingWidgetFactory /** @@ -994,6 +980,8 @@ class WidgetBuilder(private val id: String) { defaultValue = default, serializer = serializer, widgetFactory = factory, + fullWidth = fullWidth, + fullHeight = fullHeight, comments = comments, order = order ) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/AccountsPanel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/AccountsPanel.kt index bf7bcbf..658cb3f 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/AccountsPanel.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/AccountsPanel.kt @@ -3,22 +3,21 @@ package io.github.tritium_launcher.launcher.ui.dashboard import io.github.tritium_launcher.launcher.* import io.github.tritium_launcher.launcher.accounts.AccountDescriptor import io.github.tritium_launcher.launcher.accounts.AccountProvider +import io.github.tritium_launcher.launcher.accounts.AuthMethod import io.github.tritium_launcher.launcher.accounts.ProfileMngr import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.platform.Platform import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.setStyle import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle -import io.github.tritium_launcher.launcher.ui.widgets.LineLabelWidget import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.* import io.qt.Nullable import io.qt.core.QTimer import io.qt.core.Qt -import io.qt.gui.QCloseEvent -import io.qt.gui.QPixmap -import io.qt.widgets.QFrame -import io.qt.widgets.QLayout -import io.qt.widgets.QPushButton -import io.qt.widgets.QWidget +import io.qt.gui.* +import io.qt.widgets.* import kotlinx.coroutines.* import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -27,7 +26,7 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi * [Dashboard] panel showing connected accounts and methods to connect them. * * Account services can be provided by extensions by registering an [AccountProvider]. - * @see [io.github.tritium_launcher.launcher.accounts.ui.MicrosoftAccountProvider] + * @see [io.github.tritium_launcher.launcher.accounts.MicrosoftAccountProvider] * @see [io.github.tritium_launcher.launcher.extension.core.CoreExtension] */ @OptIn(ExperimentalAtomicApi::class) @@ -40,15 +39,26 @@ class AccountsPanel internal constructor(): QWidget() { private val isRefreshing = AtomicBoolean(false) - private val mainLayout = vBoxLayout { - contentsMargins = 0.m - widgetSpacing = 10 + private val contentWidget = QWidget() + private val mainLayout = vBoxLayout(contentWidget) { + contentsMargins = 12.m + widgetSpacing = 16 } private val logger = logger() init { - setLayout(mainLayout) + val outerLayout = vBoxLayout(this) { + contentsMargins = 0.m + widgetSpacing = 0 + } + + val scrollArea = QScrollArea() + scrollArea.setWidget(contentWidget) + scrollArea.widgetResizable = true + scrollArea.frameShape = QFrame.Shape.NoFrame + scrollArea.horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + outerLayout.addWidget(scrollArea) setThemedStyle { selector("QWidget#accountCard") { @@ -70,15 +80,56 @@ class AccountsPanel internal constructor(): QWidget() { borderRadius(8) background("transparent") } + + selector("QLabel#keyEntryLabel") { + fontSize(11) + color(TColors.Subtext) + } + selector("QLineEdit#keyEntryField") { + borderRadius(6) + padding(6, 10) + } + selector("QPushButton#keyEntryOpen") { + border() + background("transparent") + borderRadius(6) + padding(6) + } + selector("QPushButton#keyEntryOpen:hover") { + backgroundColor(TColors.Surface2) + } + selector("QLabel#keyEntryStatus") { + fontSize(11) + padding(4, 8) + borderRadius(4) + } + selector("QLabel#keyEntryStatus[status=\"valid\"]") { + color(TColors.Green) + } + selector("QLabel#keyEntryStatus[status=\"invalid\"]") { + color(TColors.Error) + } + selector("QLabel#keyEntryStatus[status=\"unknown\"]") { + color(TColors.Subtext) + } + selector("QLabel#sectionTitle") { + fontSize(15) + fontWeight(700) + } + selector("QFrame#providerSection") { + borderRadius(10) + } } this.destroyed.connect { scope.cancel() } - ProfileMngr.addListener { _ -> - isLoading = false - QTimer.singleShot(0) { refreshUI() } + scope.launch { + ProfileMngr.profile.collect { _ -> + isLoading = false + QTimer.singleShot(0) { refreshUI() } + } } QTimer.singleShot(0) { refreshUI() } @@ -115,18 +166,16 @@ class AccountsPanel internal constructor(): QWidget() { } internal fun createProfileCard( - avatar: QPixmap?, + userAvatar: QPixmap?, displayName: String, subtitle: String?, actionText: String, - actionHandler: suspend () -> Unit, - secondaryText: String? = null, - secondaryHandler: (suspend () -> Unit)? = null + actionHandler: suspend () -> Unit ): QWidget { val card = frame { objectName = "accountCard" - setFrameStyle(QFrame.Shape.NoFrame.value()) + frameShape = QFrame.Shape.NoFrame } val cardLayout = hBoxLayout(card) { @@ -141,13 +190,12 @@ class AccountsPanel internal constructor(): QWidget() { contentsMargins = 0.m } + val avatarPix = userAvatar?.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) ?: QPixmap() val avatarLabel = label { objectName = "AccountPanelAvatarLabel" - val scaled = avatar?.scaled(72,72) ?: QPixmap() - - pixmap = scaled - minimumSize = qs(72, 72) - maximumSize = qs(72, 72) + pixmap = avatarPix + minimumSize = qs(64, 64) + maximumSize = qs(64, 64) setStyle { borderRadius(36) } @@ -187,18 +235,7 @@ class AccountsPanel internal constructor(): QWidget() { } actionsLayout.addWidget(primaryBtn) - var secBtn: QPushButton? = null - if (!secondaryText.isNullOrBlank() && secondaryHandler != null) { - secBtn = pushButton { - text = secondaryText - objectName = "secondary" - isEnabled = !isLoading - setFixedHeight(32) - } - actionsLayout.addWidget(secBtn) - } - - secBtn?.onClicked { + primaryBtn.onClicked { runOnGuiThread { isLoading = true primaryBtn.isEnabled = false @@ -206,9 +243,9 @@ class AccountsPanel internal constructor(): QWidget() { scope.launch { try { - secondaryHandler?.invoke() + actionHandler() } catch (t: Throwable) { - logger.warn("Secondary handler error", t) + logger.warn("Action handler error", t) } finally { runOnGuiThread { isLoading = false @@ -218,28 +255,176 @@ class AccountsPanel internal constructor(): QWidget() { } } - primaryBtn.onClicked { - runOnGuiThread { - isLoading = true - primaryBtn.isEnabled = false + cardLayout.addWidget(actions) + return card + } + + private val addEntryVisible = mutableMapOf() + + private fun createKeyEntryRow(provider: AccountProvider): QWidget { + val row = qWidget { objectName = "keyEntryRow" } + val layout = hBoxLayout(row) { + contentsMargins = 0.m + widgetSpacing = 8 + setAlignment(Qt.AlignmentFlag.AlignLeft) + } + + val lbl = label(provider.tokenLabel ?: "Token:") { objectName = "keyEntryLabel" } + layout.addWidget(lbl) + + val field = QLineEdit().apply { + setFixedWidth(280) + } + layout.addWidget(field) + + val tokenPageUrl = provider.tokenPageUrl + if (!tokenPageUrl.isNullOrBlank()) { + val openBtn = pushButton { + objectName = "keyEntryOpen" + icon = QIcon(TIcons.ExternalArrow) + setFixedSize(32, 32) + onClicked { + val setupWidget = provider.createTokenSetupWidget(this@AccountsPanel) + if (setupWidget != null) { + val dialog = QDialog(this@AccountsPanel).apply { + windowTitle = "${provider.displayName} Token Setup" + setMinimumSize(400, 420) + setMaximumSize(500, 600) + setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, true) + objectName = "tokenSetupDialog" + } + val dialogLayout = vBoxLayout(dialog) { + contentsMargins = 0.m + setSpacing(0) + } + dialogLayout.addWidget(setupWidget, 1) + + val btnBar = QFrame().apply { + frameShape = QFrame.Shape.NoFrame + val btnLayout = hBoxLayout(this) { + setContentsMargins(16, 12, 16, 12) + setSpacing(8) + } + btnLayout.addStretch() + val cancelBtn = pushButton("Cancel") { + onClicked { dialog.reject() } + } + btnLayout.addWidget(cancelBtn) + val openBtn2 = pushButton("Open Token Page") { + onClicked { + Platform.openBrowser(tokenPageUrl) + dialog.accept() + } + } + btnLayout.addWidget(openBtn2) + } + dialogLayout.addWidget(btnBar) + + dialog.setThemedStyle { + selector("#tokenSetupDialog") { + backgroundColor(TColors.Surface0) + } + } + + dialog.exec() + } else { + Platform.openBrowser(tokenPageUrl) + } + } } + layout.addWidget(openBtn) + } + val debounceTimer = QTimer() + debounceTimer.setSingleShot(true) + debounceTimer.timeout.connect { + val token = field.text().trim() + if (token.isEmpty()) return@connect scope.launch { try { - actionHandler() + provider.signInWithToken(token, this@AccountsPanel) } catch (t: Throwable) { - logger.warn("Action handler error", t) + logger.warn("Token validation failed for ${provider.id}", t) } finally { - runOnGuiThread { - isLoading = false - QTimer.singleShot(0) { refreshUI() } + runOnGuiThread { refreshUI() } + } + } + } + field.textChanged.connect { debounceTimer.start(400) } + + return row + } + + private fun createAddEntryWidget(provider: AccountProvider): QWidget? = when (provider.authMethod) { + AuthMethod.KEY -> createKeyEntryRow(provider) + AuthMethod.OAUTH_AND_KEY -> { + val container = qWidget() + val layout = vBoxLayout(container) { + contentsMargins = 0.m + widgetSpacing = 8 + } + val oauthBtn = pushButton { + text = "Sign In Online" + objectName = "primary" + setFixedHeight(38) + clicked.connect { + isEnabled = false + scope.launch { + try { + provider.signIn(this@AccountsPanel) + } catch (t: Throwable) { + logger.warn("Provider signIn failed: ${provider.id}", t) + } finally { + runOnGuiThread { + isEnabled = true + QTimer.singleShot(0) { refreshUI() } + } + } } } } + layout.addWidget(oauthBtn) + layout.addWidget(createKeyEntryRow(provider)) + container } + AuthMethod.OAUTH -> null + } - cardLayout.addWidget(actions) - return card + private fun composeProviderAvatar(serviceIcon: QPixmap?, userAvatar: QPixmap?, size: Int): QPixmap { + if (serviceIcon == null || serviceIcon.isNull()) { + return userAvatar?.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) ?: QPixmap() + } + val result = QPixmap(size, size) + result.fill(Qt.GlobalColor.transparent) + val painter = QPainter(result) + try { + val iconSize = size - 16 + val scaled = serviceIcon.scaled(iconSize, iconSize, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + painter.drawPixmap((size - scaled.width()) / 2, (size - scaled.height()) / 2, scaled) + + if (userAvatar != null && !userAvatar.isNull()) { + val emblemSize = size / 3 + val margin = 2 + val emX = size - emblemSize - margin + val emY = size - emblemSize - margin + + val clipPath = QPainterPath() + clipPath.addEllipse(emX.toDouble(), emY.toDouble(), emblemSize.toDouble(), emblemSize.toDouble()) + + painter.setBrush(QBrush(QColor(255, 255, 255))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(emX - 1, emY - 1, emblemSize + 2, emblemSize + 2) + + painter.save() + painter.setClipPath(clipPath) + val scaledEm = userAvatar.scaled(emblemSize, emblemSize, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + painter.drawPixmap(emX, emY, scaledEm) + painter.restore() + } + } finally { + painter.end() + } + return result } private fun refreshUI() { @@ -312,68 +497,163 @@ class AccountsPanel internal constructor(): QWidget() { for(result in providerResults) { val provider = result.first val accountList = result.second - - val section = QWidget() + val hasAccounts = accountList.isNotEmpty() + + val section = frame { + objectName = "providerSection" + frameShape = QFrame.Shape.NoFrame + provider.sectionColor?.let { color -> + setAttribute(Qt.WidgetAttribute.WA_StyledBackground, true) + styleSheet = "QFrame#providerSection { background-color: #$color; }" + } + } val sectionLayout = vBoxLayout(section) { - widgetSpacing = 8 + widgetSpacing = 10 + contentsMargins = 16.m + } + + val headerWidget = qWidget() + val headerLayout = hBoxLayout(headerWidget) { contentsMargins = 0.m + widgetSpacing = 8 + } + provider.serviceIcon?.let { icon -> + val iconLabel = label { + pixmap = icon.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + setFixedSize(32, 32) + } + headerLayout.addWidget(iconLabel) + } + val nameLabel = label(provider.displayName) { + objectName = "sectionTitle" } + headerLayout.addWidget(nameLabel) - val title = LineLabelWidget(provider.displayName, 0.10f) - sectionLayout.addWidget(title) - - if(accountList.isEmpty()) { - val signInBtn = pushButton { - text = "Sign in (${provider.displayName})" - objectName = "primary" - setFixedHeight(38) - clicked.connect { - isEnabled = false - scope.launch { - try { - provider.signIn(this@AccountsPanel) - } catch (t: Throwable) { - logger.warn("Provider signIn failed: ${provider.id}", t) - } finally { - runOnGuiThread { - isEnabled = true; QTimer.singleShot(0) { refreshUI() } + headerLayout.addStretch(1) + + val infoDesc = provider.infoDescription + if (infoDesc != null) { + val infoIcon = label { + pixmap = TIcons.QuestionMark + setFixedSize(16, 16) + toolTip = infoDesc + } + headerLayout.addWidget(infoIcon) + } + sectionLayout.addWidget(headerWidget) + + when { + accountList.isEmpty() && provider.authMethod == AuthMethod.KEY -> { + sectionLayout.addWidget(createKeyEntryRow(provider)) + } + accountList.isEmpty() && provider.authMethod == AuthMethod.OAUTH_AND_KEY -> { + val oauthBtn = pushButton { + text = "Sign In Online" + objectName = "primary" + setFixedHeight(38) + clicked.connect { + isEnabled = false + scope.launch { + try { + provider.signIn(this@AccountsPanel) + } catch (t: Throwable) { + logger.warn("Provider signIn failed: ${provider.id}", t) + } finally { + runOnGuiThread { + isEnabled = true + QTimer.singleShot(0) { refreshUI() } + } } } } } + sectionLayout.addWidget(oauthBtn) + + sectionLayout.addWidget(label("Or use a personal access token:") { objectName = "keyEntryLabel" }) + + sectionLayout.addWidget(createKeyEntryRow(provider)) } - sectionLayout.addWidget(signInBtn) - } else { - for(triple in accountList) { - val (acc, avatar, prov) = triple - - val displayName = acc.label ?: acc.username ?: provider.displayName - val subtitle = acc.subtitle ?: acc.username - val accountId = acc.id - - val card = createProfileCard( - avatar = avatar, - displayName = displayName, - subtitle = subtitle, - actionText = "Sign out", - actionHandler = suspend { - try { - prov.signOutAccount(accountId) - } catch (t: Throwable) { - logger.warn("signOutAccount failed for ${prov.id}/$accountId", t) + accountList.isEmpty() -> { + val signInBtn = pushButton { + text = "Sign in (${provider.displayName})" + objectName = "primary" + setFixedHeight(38) + clicked.connect { + isEnabled = false + scope.launch { + try { + provider.signIn(this@AccountsPanel) + } catch (t: Throwable) { + logger.warn("Provider signIn failed: ${provider.id}", t) + } finally { + runOnGuiThread { + isEnabled = true; QTimer.singleShot(0) { refreshUI() } + } + } } - }, - secondaryText = "Switch", - secondaryHandler = suspend { - val ok = try { prov.switchToAccount(accountId) } catch (t: Throwable) { - logger.warn("switchToAccount failed for ${prov.id}/$accountId", t); false + } + } + sectionLayout.addWidget(signInBtn) + } + else -> { + for(triple in accountList) { + val (acc, avatar, prov) = triple + + val displayName = acc.label ?: acc.username ?: provider.displayName + val subtitle = acc.subtitle ?: acc.username + val accountId = acc.id + + val card = createProfileCard( + userAvatar = avatar, + displayName = displayName, + subtitle = subtitle, + actionText = "Sign out", + actionHandler = suspend { + try { + prov.signOutAccount(accountId) + } catch (t: Throwable) { + logger.warn("signOutAccount failed for ${prov.id}/$accountId", t) + } } - if (!ok) { - try { prov.signIn(this@AccountsPanel) } catch (t: Throwable) { logger.warn("Interactive fallback failed", t) } + ) + sectionLayout.addWidget(card) + } + + if(provider.supportsMultipleAccounts) { + if(addEntryVisible[provider.id] == true) { + createAddEntryWidget(provider)?.let { sectionLayout.addWidget(it) } + } + + val addBtn = pushButton { + text = "Add ${provider.displayName} account" + objectName = "primary" + setFixedHeight(38) + } + addBtn.onClicked { + when(provider.authMethod) { + AuthMethod.OAUTH -> { + addBtn.isEnabled = false + scope.launch { + try { + provider.signIn(this@AccountsPanel) + } catch (t: Throwable) { + logger.warn("Provider signIn failed: ${provider.id}", t) + } finally { + runOnGuiThread { + addBtn.isEnabled = true + QTimer.singleShot(0) { refreshUI() } + } + } + } + } + else -> { + addEntryVisible[provider.id] = true + QTimer.singleShot(0) { refreshUI() } + } } } - ) - sectionLayout.addWidget(card) + sectionLayout.addWidget(addBtn) + } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/BuiltinProjectListStyles.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/BuiltinProjectListStyles.kt index 6216050..bc393cb 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/BuiltinProjectListStyles.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/BuiltinProjectListStyles.kt @@ -7,6 +7,7 @@ import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.gridLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget @@ -180,6 +181,8 @@ private class GridStyle(private val ctx: ProjectStyleContext) : ProjectListStyle init { root.setContentsMargins(0, 0, 0, 0) + AnimatedScrollController.attach(scroll) + AnimatedScrollController.attach(freeformPage) scrollLayout.contentsMargins = QMargins(0, 10, 0, 10) scrollLayout.widgetSpacing = 10 @@ -614,6 +617,7 @@ private class ListStyle(private val ctx: ProjectStyleContext) : ProjectListStyle } init { + AnimatedScrollController.attach(list) list.itemDoubleClicked.connect { item -> val project = item?.data(Qt.ItemDataRole.UserRole) as? ProjectBase ?: return@connect if (project.isInvalidCatalogProject()) return@connect @@ -1133,11 +1137,6 @@ private fun wrapProjectName(project: ProjectBase): String { return "
$escaped$invalidSuffix
" } -private fun wrapProjectName(name: String): String { - val escaped = escapeHtml(name) - return "
$escaped
" -} - private fun configureTileTitleLabel(label: QLabel, richText: String) { label.minimumWidth = GRID_TILE_TITLE_WIDTH label.text = richText diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/Dashboard.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/Dashboard.kt index d58b146..9009076 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/Dashboard.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/Dashboard.kt @@ -5,11 +5,13 @@ import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.ui.settings.SettingsLink import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.github.tritium_launcher.launcher.util.SeasonalEvents.isPrideMonth import io.qt.core.Qt import io.qt.gui.QIcon import io.qt.widgets.* @@ -40,16 +42,20 @@ class Dashboard internal constructor() : QMainWindow() { lineWidth = 0 } private var settingsBtn: QPushButton - private val settingsDialog by lazy { SettingsDialog(this) } + private val settingsDialog = SettingsDialog(this) private var selectedButton: QPushButton? = null private val dashboardWindowSize: Pair = CoreSettingValues.dashboardWindowSize() init { - windowTitle = "Tritium - Dashboard" + windowTitle = "Tritium Launcher - Dashboard" minimumSize = qs(dashboardWindowSize.first, dashboardWindowSize.second) maximumSize = qs(dashboardWindowSize.first, dashboardWindowSize.second) isWindowModified = false - windowIcon = QIcon(TIcons.Tritium) + windowIcon = if (isPrideMonth()) { + TIcons.TritiumGrayscale.applyRainbowOverlay(opacity = 0.5f).icon + } else { + QIcon(TIcons.Tritium.scaled(qs(256, 256), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)) + } val central = qWidget { objectName = "dashboard" @@ -81,12 +87,12 @@ class Dashboard internal constructor() : QMainWindow() { } val tritiumIconLabel = label { objectName = "dashboardTritiumIcon" - val icon = TIcons.Tritium + val icon = if (isPrideMonth()) TIcons.TritiumGrayscale.applyRainbowOverlay(opacity = 0.5f) else TIcons.Tritium if (!icon.isNull) { - pixmap = icon.scaled(qs(22, 22), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation) + pixmap = icon.scaled(qs(32, 32), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation) } - minimumSize = qs(22, 22) - maximumSize = qs(22, 22) + minimumSize = qs(32, 32) + maximumSize = qs(32, 32) } val tritiumTextWidget = qWidget { objectName = "dashboardTritiumText" @@ -109,6 +115,7 @@ class Dashboard internal constructor() : QMainWindow() { val projectsBtn = createNavBtn("Projects") val accountBtn = createNavBtn("Accounts") val themesBtn = createNavBtn("Themes") + val extensionsBtn = createNavBtn("Extensions") settingsBtn = createNavBtn("Settings").apply { isCheckable = false } @@ -131,13 +138,18 @@ class Dashboard internal constructor() : QMainWindow() { stackedWidget.currentIndex = 2 } + extensionsBtn.onClicked { + updateSelectedBtn(extensionsBtn) + stackedWidget.currentIndex = 3 + } + settingsBtn.onClicked { openSettings() } leftLayout.addWidget(tritiumWidget) leftLayout.addSpacing(8) - leftLayout.add(projectsBtn, accountBtn, themesBtn, settingsBtn) + leftLayout.add(projectsBtn, accountBtn, themesBtn, extensionsBtn, settingsBtn) leftLayout.addStretch(1) val bottomWidget = qWidget() @@ -181,7 +193,7 @@ class Dashboard internal constructor() : QMainWindow() { selector("#dashboardTritium") { backgroundColor(TColors.Surface0) - border(1, TColors.Surface2) + border(1, TColors.Surface1) borderRadius(6) } selector("#dashboardTritiumTitle") { @@ -240,6 +252,10 @@ class Dashboard internal constructor() : QMainWindow() { // Themes val themesPanel = ThemesPanel() stackedWidget.addWidget(themesPanel) + + // Extensions + val extensionsPanel = ExtensionsPanel() + stackedWidget.addWidget(extensionsPanel) } /** diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ExtensionsPanel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ExtensionsPanel.kt new file mode 100644 index 0000000..f8d0d5f --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ExtensionsPanel.kt @@ -0,0 +1,189 @@ +package io.github.tritium_launcher.launcher.ui.dashboard + +import io.github.tritium_launcher.launcher.extension.Extension +import io.github.tritium_launcher.launcher.extension.ExtensionLoader +import io.github.tritium_launcher.launcher.extension.ExtensionStateManager +import io.github.tritium_launcher.launcher.loadScaledPixmap +import io.github.tritium_launcher.launcher.m +import io.github.tritium_launcher.launcher.qs +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.TToggleSwitch +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.gui.QGuiApplication +import io.qt.gui.QPixmap +import io.qt.widgets.QFrame +import io.qt.widgets.QScrollArea +import io.qt.widgets.QSizePolicy +import io.qt.widgets.QWidget + +private fun Extension.scaledIconPixmap(size: Int, dprWidget: QWidget? = null): QPixmap { + val s = qs(size, size) + val src = icon?.pixmap(qs(256, 256))?.takeIf { !it.isNull } + if (src != null) return loadScaledPixmap(src.toImage(), s, dprWidget) + val dpr = QGuiApplication.primaryScreen()?.devicePixelRatio() ?: 1.0 + return ThemeMngr.getPixmap("ui/question", size, size, dpr) ?: QPixmap() +} + +class ExtensionsPanel internal constructor() : QWidget() { + private val mainLayout = vBoxLayout { + contentsMargins = 12.m + widgetSpacing = 12 + } + + init { + objectName = "extensionsPanel" + + val title = label("Extensions") { + objectName = "extensionsPanelTitle" + } + val desc = label("Manage installed extensions. Disabling an extension requires a restart to take effect.") { + objectName = "extensionsPanelDesc" + wordWrap = true + } + + val list = ExtensionsManageList() + + mainLayout.addWidget(title) + mainLayout.addWidget(desc) + mainLayout.addSpacing(8) + mainLayout.addWidget(list, 1) + + setLayout(mainLayout) + + setThemedStyle { + selector("#extensionsPanel") { backgroundColor(TColors.Surface0) } + selector("#extensionsPanelTitle") { fontSize(18); fontWeight(700) } + selector("#extensionsPanelDesc") { fontSize(12); color(TColors.Subtext) } + } + } +} + +/** + * Reusable extension list widget used in both Dashboard and Settings. + */ +class ExtensionsManageList internal constructor() : QScrollArea() { + private val state = ExtensionStateManager.load().toMutableMap() + + init { + objectName = "extensionsScroll" + val scrollContent = QWidget() + val scrollLayout = vBoxLayout(scrollContent) { + contentsMargins = 0.m + widgetSpacing = 4 + } + + setWidget(scrollContent) + widgetResizable = true + frameShape = Shape.NoFrame + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + val allExtensions = ExtensionLoader.allExtensions + val sorted = allExtensions.sortedBy { it.displayName.lowercase() } + + for (ext in sorted) { + scrollLayout.addWidget(ExtensionRow(ext)) + } + + if (sorted.isEmpty()) { + val empty = QWidget() + val emptyLayout = vBoxLayout(empty) { + contentsMargins = 0.m + widgetSpacing = 6 + } + emptyLayout.addStretch(1) + emptyLayout.addWidget(label("No extensions found.") { objectName = "extensionsEmpty" }) + emptyLayout.addStretch(1) + scrollLayout.addWidget(empty, 1) + } else { + scrollLayout.addStretch(1) + } + + setThemedStyle { + selector("#extensionsRow") { + backgroundColor(TColors.Surface0) + border(1, TColors.Surface1) + borderRadius(6) + } + selector("#extensionsRow:hover") { backgroundColor(TColors.Surface1) } + selector("#extensionsRowName") { fontSize(13); fontWeight(600) } + selector("#extensionsRowDesc") { fontSize(11); color(TColors.Subtext) } + selector("#extensionsRowNamespace") { fontSize(10); color(TColors.Subtext) } + selector("#extensionsRowBuiltin") { + fontSize(10) + color(TColors.Subtext) + border(1, TColors.Surface1) + borderRadius(4) + padding(2, 8, 2, 8) + } + selector("#extensionsEmpty") { fontSize(12); color(TColors.Subtext); textAlign("center") } + } + } + + private inner class ExtensionRow(ext: Extension) : QFrame() { + private val toggle = TToggleSwitch() + + init { + objectName = "extensionsRow" + setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + val layout = hBoxLayout(this) { + contentsMargins = 12.m + widgetSpacing = 12 + } + + val iconLabel = label { + pixmap = ext.scaledIconPixmap(48, this@ExtensionRow) + minimumSize = qs(48, 48) + maximumSize = qs(48, 48) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + } + layout.addWidget(iconLabel, 0) + + val infoWidget = QWidget() + val infoLayout = vBoxLayout(infoWidget) { + contentsMargins = 0.m + widgetSpacing = 2 + } + + val nameLabel = label(ext.displayName) { + objectName = "extensionsRowName" + } + + val descText = ext.description ?: "No description available." + val descLabel = label(descText) { + objectName = "extensionsRowDesc" + wordWrap = true + } + + val nsLabel = label("${ext.namespace} · ${if (ext.isBuiltin) "builtin" else if (ext.requiresRestart) "requires restart" else "restart not required"}") { + objectName = "extensionsRowNamespace" + } + + infoLayout.addWidget(nameLabel) + infoLayout.addWidget(descLabel) + infoLayout.addWidget(nsLabel) + + layout.addWidget(infoWidget, 1) + + if (!ext.isBuiltin) { + val currentState = state.getOrDefault(ext.namespace, true) + toggle.setChecked(currentState) + toggle.toggled.connect({ checked: Boolean -> + state[ext.namespace] = checked + ExtensionStateManager.setEnabled(ext.namespace, checked) + }) + layout.addWidget(toggle, 0) + } else { + val builtinLabel = label("Builtin") { + objectName = "extensionsRowBuiltin" + sizePolicy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + } + layout.addWidget(builtinLabel, 0) + } + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ProjectsPanel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ProjectsPanel.kt index 454cca8..1674014 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ProjectsPanel.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ProjectsPanel.kt @@ -3,8 +3,9 @@ package io.github.tritium_launcher.launcher.ui.dashboard import io.github.tritium_launcher.launcher.* import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.core.project.ProjectMngr -import io.github.tritium_launcher.launcher.core.project.ProjectMngrListener +import io.github.tritium_launcher.launcher.core.project.ProjectMngrEvent import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.import.ui.ImportProjectDialog import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.registry.DeferredRegistryBuilder import io.github.tritium_launcher.launcher.ui.dashboard.Dashboard.Companion.bgDashboardLogger @@ -23,10 +24,7 @@ import io.qt.gui.QIcon import io.qt.gui.QKeyEvent import io.qt.gui.QShowEvent import io.qt.widgets.* -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -75,7 +73,8 @@ class ProjectsPanelPrefs(private val file: VPath) { } /** Dashboard project list panel with pluggable styles. */ -class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { +class ProjectsPanel internal constructor(): QWidget() { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var currentProjects: List = emptyList() private var searchFilter: String = "" @@ -123,19 +122,28 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { if (refreshInFlight) refreshPending = true else refresh() } - ProjectMngr.addListener(this) + setupStyleRegistry() scheduleRefresh() setThemedStyle { selector("#projectsPanel") { backgroundColor(TColors.Surface0) } } - setupStyleRegistry() + scope.launch { + ProjectMngr.projectEvents.collect { event -> + when (event) { + is ProjectMngrEvent.Created -> scheduleRefresh() + is ProjectMngrEvent.FailedToGenerate -> {} + is ProjectMngrEvent.Opened -> {} + is ProjectMngrEvent.FinishedLoading -> {} + } + } + } } /** Stops watching and releases style resources. */ fun exit() { - ProjectMngr.removeListener(this) + scope.cancel() styleInstances.values.forEach { it.dispose() } } @@ -180,23 +188,7 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { if (dvdHotkeyBuffer == "dvd") activateDvdStyle() } - /** Handles project creation events. */ - override fun onProjectCreated(project: ProjectBase) { scheduleRefresh() } - - /** Handles project deletion events. */ - override fun onProjectDeleted(project: ProjectBase) { scheduleRefresh() } - - /** Handles project updates. */ - override fun onProjectUpdated(project: ProjectBase) { scheduleRefresh() } - - /** Handles project load completion events. */ - override fun onProjectsFinishedLoading(projects: List) {} - - /** Handles project generation failures. */ - override fun onProjectFailedToGenerate(project: ProjectBase, errorMsg: String, exception: Exception?) {} - - /** Handles project opened events. */ - override fun onProjectOpened(project: ProjectBase) {} + // ProjectMngrListener methods removed - now using projectEvents flow /** * Prompts for a `trproj.json` file and imports the owning project. @@ -231,7 +223,6 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { val projectFile = if (file.fileName() == "trproj.json") file else projectDir.resolve("trproj.json") if (!projectFile.exists()) { - // TODO: Add an import method for instances from other launchers Dashboard.logger.warn( "Import skipped for '{}' because trproj.json was not found in '{}'", selectedFile, @@ -267,7 +258,7 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { private fun refresh() { lastRefreshMs = System.currentTimeMillis() refreshInFlight = true - GlobalScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { bgDashboardLogger.info("Refreshing projects...") val projects = ProjectMngr.refreshProjects(ProjectMngr.RefreshSource.DASHBOARD) QTimer.singleShot(0) { @@ -290,11 +281,10 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { if (!immediate) searchDebounceTimer.stop() } - /** Opens a project and closes the dashboard. */ + /** Opens a project; ProjectWindows handles dashboard visibility after success. */ private fun openProject(project: ProjectBase) { if (project.typeId == ProjectMngr.INVALID_CATALOG_PROJECT_TYPE) return try { ProjectMngr.openProject(project) } catch (t: Throwable) { Dashboard.logger.error("Failed to open project: ${t.message}", t) } - Dashboard.I?.let { try { it.close() } catch (_: Throwable) {} } } /** Builds the top toolbar; keeps existing buttons intact. */ @@ -349,7 +339,7 @@ class ProjectsPanel internal constructor(): QWidget(), ProjectMngrListener { sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) icon = QIcon(TIcons.Import) iconSize = qs(32, 32) - onClicked { showImportProjectDialog() } + onClicked { ImportProjectDialog(this).exec() } } val cloneFromGit = TPushButton { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/SettingsDialog.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/SettingsDialog.kt index 533d00d..4e11c08 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/SettingsDialog.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/SettingsDialog.kt @@ -18,6 +18,7 @@ class SettingsDialog(parent: QWidget? = null) : QDialog(parent) { init { objectName = "settingsDialog" + setProperty("keymapFocusGroup", "settings") windowTitle = "Settings" modal = false resize(qs(1080, 760)) @@ -32,13 +33,14 @@ class SettingsDialog(parent: QWidget? = null) : QDialog(parent) { setThemedStyle { selector("#settingsDialog") { backgroundColor(TColors.Surface0) } } + + view.reload() } /** * Opens the dialog and optionally focuses a settings [link]. */ fun open(link: SettingsLink? = null) { - view.reload() if (link != null) { view.openLink(link) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ThemesPanel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ThemesPanel.kt index 40436e4..971449d 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ThemesPanel.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/dashboard/ThemesPanel.kt @@ -1,9 +1,14 @@ package io.github.tritium_launcher.launcher.ui.dashboard import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.font.FontMngr import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.m import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.settings.SettingNode +import io.github.tritium_launcher.launcher.settings.SettingsMngr import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr import io.github.tritium_launcher.launcher.ui.theme.ThemeType @@ -18,7 +23,9 @@ import io.qt.core.QSignalBlocker import io.qt.core.Qt import io.qt.gui.* import io.qt.widgets.* -import java.util.prefs.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlin.math.absoluteValue /** @@ -26,7 +33,6 @@ import kotlin.math.absoluteValue */ class ThemesPanel internal constructor(): QWidget() { private val logger = logger() - private val prefs: Preferences = Preferences.userRoot().node("/tritium") private val mainLayout = QVBoxLayout() @@ -88,7 +94,11 @@ class ThemesPanel internal constructor(): QWidget() { setupConnections() - ThemeMngr.addListener(themeListener) + CoroutineScope(Dispatchers.Main).launch { + ThemeMngr.currentThemeId.collect { themeId -> + themeListener() + } + } } private fun createThemeSection(): QGroupBox { @@ -159,20 +169,10 @@ class ThemesPanel internal constructor(): QWidget() { } private fun loadAvailableFonts() { - val fonts = mutableSetOf() - - try { - val sysFonts = QFontDatabase.families() - fonts.addAll(sysFonts) - } catch (e: Exception) { - logger.warn("Failed to load system fonts", e) - } - - val sortedFonts = fonts.sorted() + val fonts = FontMngr.availableFontFamilies() globalFontComboBox.clear() editorFontComboBox.clear() - - sortedFonts.forEach { f -> + fonts.forEach { f -> globalFontComboBox.addItem(f) editorFontComboBox.addItem(f) } @@ -204,7 +204,7 @@ class ThemesPanel internal constructor(): QWidget() { } themeComboBox.setModel(model) val target = entries.firstOrNull { it.id == prev }?.id - ?: entries.firstOrNull { it.id == current }?.id + ?: entries.firstOrNull { it.id == ThemeMngr.currentThemeIdValue }?.id ?: entries.firstOrNull()?.id val idx = target?.let { themeComboBox.findData(it) } ?: -1 if(idx >= 0) themeComboBox.currentIndex = idx @@ -230,15 +230,12 @@ class ThemesPanel internal constructor(): QWidget() { private fun loadCurrentFontSettings() { isUpdating = true - val appFont = QApplication.font() - val globalFamily = prefs.get("globalFontFamily", appFont.family()) - val globalSize = prefs.getInt("globalFontSize", appFont.pointSize()) + val (globalFamily, globalSize) = CoreSettingValues.globalFont() ensureFontInCombo(globalFontComboBox, globalFamily) globalFontComboBox.currentText = globalFamily globalFontSizeSpinner.value = globalSize.coerceIn(globalFontSizeSpinner.minimum, globalFontSizeSpinner.maximum) - val editorFamily = prefs.get("editorFontFamily", globalFamily) - val editorSize = prefs.getInt("editorFontSize", globalSize) + val (editorFamily, editorSize) = CoreSettingValues.editorFont() ensureFontInCombo(editorFontComboBox, editorFamily) editorFontComboBox.currentText = editorFamily editorFontSizeSpinner.value = editorSize.coerceIn(editorFontSizeSpinner.minimum, editorFontSizeSpinner.maximum) @@ -392,8 +389,8 @@ class ThemesPanel internal constructor(): QWidget() { val font = QFont(family, size) QApplication.setFont(font) applyFontToWidgets(font) - prefs.put("globalFontFamily", family) - prefs.putInt("globalFontSize", size) + val node = SettingsMngr.findSetting(CoreSettingKeys.GlobalFont) as? SettingNode + node?.let { SettingsMngr.updateValue(it, "$family|$size") } } catch (e: Exception) { logger.warn("Failed to apply global font '{}': {}", family, e.message) } @@ -420,7 +417,7 @@ class ThemesPanel internal constructor(): QWidget() { if(isUpdating) return val family = editorFontComboBox.currentText.takeIf { it.isNotBlank() } ?: return val size = editorFontSizeSpinner.value - prefs.put("editorFontFamily", family) - prefs.putInt("editorFontSize", size) + val node = SettingsMngr.findSetting(CoreSettingKeys.EditorFont) as? SettingNode + node?.let { SettingsMngr.updateValue(it, "$family|$size") } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/global/TooltipInterceptor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/global/TooltipInterceptor.kt new file mode 100644 index 0000000..08fd346 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/global/TooltipInterceptor.kt @@ -0,0 +1,77 @@ +package io.github.tritium_launcher.launcher.ui.global + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.ui.widgets.TTooltip +import io.github.tritium_launcher.launcher.ui.widgets.TTooltipStyle +import io.qt.core.QEvent +import io.qt.core.QObject +import io.qt.core.QTimer +import io.qt.gui.QHelpEvent +import io.qt.widgets.QWidget + +class TooltipInterceptor : QObject() { + private val hideTimer = QTimer().apply { + isSingleShot = true + interval = 80 + timeout.connect { TTooltip.hide() } + } + + override fun eventFilter(watched: QObject?, event: QEvent?): Boolean { + if (event == null) return false + + when (event.type()) { + QEvent.Type.ToolTip -> { + val targetWidget = watched as? QWidget ?: return false + + val contextWidget = findTooltipContext(targetWidget) ?: return false + val originalText = contextWidget.toolTip() ?: "" + + if (originalText.isNotBlank()) { + hideTimer.stop() + val he = event as QHelpEvent + + val useDefaultStyle = contextWidget.property("use_default_tooltip") as? Boolean ?: false + + if (useDefaultStyle) { + return false + } + + val customStyle = contextWidget.property("tt_style") as? TTooltipStyle + ?: TTooltipStyle() + + TTooltip.show(he.globalPos(), originalText, customStyle) + + contextWidget.toolTip = "" + + QTimer.singleShot(0) { + if (contextWidget.toolTip().isNullOrBlank()) { + contextWidget.toolTip = originalText + } + } + + return true + } + } + QEvent.Type.Leave, + QEvent.Type.Hide -> { + val targetWidget = watched as? QWidget + if (targetWidget != null) { + hideTimer.start() + } + } + else -> {} + } + return super.eventFilter(watched, event) + } + + private fun findTooltipContext(widget: QWidget?): QWidget? { + var current: QWidget? = widget + while (current != null) { + if (!current.toolTip().isNullOrBlank()) { + return current + } + current = current.parentWidget() + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/CacheManager.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/CacheManager.kt new file mode 100644 index 0000000..b39dfd2 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/CacheManager.kt @@ -0,0 +1,73 @@ +package io.github.tritium_launcher.launcher.ui.helpers + +import io.github.tritium_launcher.launcher.io.VPath +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime +import java.util.concurrent.ConcurrentHashMap + +object CacheManager { + data class CacheConfig( + val maxBytes: Long, + val maxAgeDays: Long = -1, + val checkEvery: Int = 5 + ) + + private val configs = mapOf( + "categories" to CacheConfig(maxBytes = 5L * 1024 * 1024), + "items" to CacheConfig(maxBytes = 100L * 1024 * 1024), + "descriptions" to CacheConfig(maxBytes = 50L * 1024 * 1024, maxAgeDays = 7), + "mod-import" to CacheConfig(maxBytes = 20L * 1024 * 1024), + ) + + private val writeCounters = ConcurrentHashMap() + + fun touch(file: VPath) { + runCatching { Files.setLastModifiedTime(file.toJPath(), FileTime.fromMillis(System.currentTimeMillis())) } + } + + fun evict(cacheDir: VPath, subdir: String) { + val config = configs[subdir] ?: return + val dir = cacheDir.resolve(subdir) + if (!dir.exists()) return + + val entries = mutableListOf>() + var totalSize = 0L + val cutoff = if (config.maxAgeDays > 0) + System.currentTimeMillis() - config.maxAgeDays * 24L * 60 * 60 * 1000L + else -1L + + try { + Files.walk(dir.toJPath()).use { stream -> + stream.filter { !Files.isDirectory(it) }.forEach { path -> + val mtime = Files.getLastModifiedTime(path).toMillis() + if (cutoff > 0 && mtime < cutoff) { + runCatching { Files.deleteIfExists(path) } + return@forEach + } + totalSize += Files.size(path) + entries.add(path to mtime) + } + } + } catch (_: Exception) { return } + + if (totalSize <= config.maxBytes) return + + entries.sortBy { it.second } + val target = (config.maxBytes * 0.8).toLong() + for ((path, _) in entries) { + if (totalSize <= target) break + val size = runCatching { Files.size(path) }.getOrNull() ?: continue + if (runCatching { Files.deleteIfExists(path) }.getOrDefault(false)) { + totalSize -= size + } + } + } + + fun evictIfNeeded(cacheDir: VPath, subdir: String) { + val config = configs[subdir] ?: return + val count = writeCounters.merge(subdir, 1) { old, _ -> old + 1 } ?: return + if (count % config.checkEvery != 0) return + evict(cacheDir, subdir) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/QApplication.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/QApplication.kt new file mode 100644 index 0000000..267afd4 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/helpers/QApplication.kt @@ -0,0 +1,8 @@ +package io.github.tritium_launcher.launcher.ui.helpers + +import io.qt.core.QObject +import io.qt.widgets.QApplication + +fun QApplication.installEventFilter(filterObj: QObject, condition: Boolean) { + if(condition) this.installEventFilter(filterObj) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/Hotkeys.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/Hotkeys.kt deleted file mode 100644 index 51daa2e..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/Hotkeys.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.github.tritium_launcher.launcher.ui.logging - -import io.github.tritium_launcher.launcher.connect -import io.qt.Nullable -import io.qt.core.QEvent -import io.qt.core.QObject -import io.qt.core.Qt -import io.qt.gui.QKeyEvent -import io.qt.widgets.QApplication - -/** - * Global hotkey handler. - * - * TODO: Keymap - */ -object Hotkeys { - private var installed = false - private var eventFilter: QObject? = null - private var dialog: LogDialog? = null - - /** - * Installs a global key handler - */ - fun install() { - if (installed) return - val app = QApplication.instance() ?: return - - val filter = object : QObject(app) { - override fun eventFilter( - watched: @Nullable QObject?, - event: @Nullable QEvent? - ): Boolean { - if (event?.type() != QEvent.Type.KeyPress) { - return super.eventFilter(watched, event) - } - val keyEvent = event as? QKeyEvent ?: return super.eventFilter(watched, event) - if (keyEvent.isAutoRepeat) return super.eventFilter(watched, event) - - if (matchesOpenLogShortcut(keyEvent)) { - openDialog() - return true - } - return super.eventFilter(watched, event) - } - } - - app.installEventFilter(filter) - eventFilter = filter - installed = true - } - - /** - * Opens the log viewer dialog. - */ - fun openDialog() { - val existing = dialog - if (existing != null) { - existing.openAndFocus() - return - } - - val created = LogDialog() - created.destroyed.connect { - if (dialog === created) dialog = null - } - dialog = created - created.openAndFocus() - } - - /** - * Matches Ctrl+Shift+I - */ - private fun matchesOpenLogShortcut(event: QKeyEvent): Boolean { - if (event.key() != Qt.Key.Key_I.value()) return false - val mods = event.modifiers() - val ctrl = mods.testFlag(Qt.KeyboardModifier.ControlModifier) - val shift = mods.testFlag(Qt.KeyboardModifier.ShiftModifier) - val alt = mods.testFlag(Qt.KeyboardModifier.AltModifier) - val meta = mods.testFlag(Qt.KeyboardModifier.MetaModifier) - return ctrl && shift && !alt && !meta - } -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialog.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialog.kt index 1056659..f5b81cb 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialog.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialog.kt @@ -9,12 +9,17 @@ import io.github.tritium_launcher.launcher.redactUserPath import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.TPushButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout import io.qt.core.QMimeData import io.qt.gui.QTextCursor import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch /** * Live log viewer dialog for `~/tritium/logs/tritium.log`. @@ -23,7 +28,7 @@ class LogDialog(parent: QWidget? = null) : QDialog(parent) { private val logPathLabel = QLabel() private val copyFileButton = TPushButton() private val logView = QPlainTextEdit() - private val unsubscribe: () -> Unit + private var unsubscribe: Job? = null init { objectName = "tritiumLogDialog" @@ -44,6 +49,7 @@ class LogDialog(parent: QWidget? = null) : QDialog(parent) { logView.objectName = "tritiumLogView" logView.isReadOnly = true logView.lineWrapMode = QPlainTextEdit.LineWrapMode.NoWrap + AnimatedScrollController.attach(logView) val header = QWidget(this) hBoxLayout(header) { @@ -72,7 +78,7 @@ class LogDialog(parent: QWidget? = null) : QDialog(parent) { selector("#tritiumLogView") { backgroundColor(TColors.Surface1) color(TColors.Text) - border(1, TColors.Surface2) + border(1, TColors.Surface1) borderRadius(4) } } @@ -81,14 +87,16 @@ class LogDialog(parent: QWidget? = null) : QDialog(parent) { copyFileButton.clicked.connect { copyCurrentLogFileToClipboard() } - unsubscribe = Logs.addEntryListener { entry -> - runOnGuiThread { - appendEntry(entry) + unsubscribe = CoroutineScope(Dispatchers.Main).launch { + Logs.entryFlow.collect { entry -> + runOnGuiThread { + appendEntry(entry) + } } } destroyed.connect { - unsubscribe() + unsubscribe?.cancel() } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialogMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialogMngr.kt new file mode 100644 index 0000000..d9c8dc6 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/logging/LogDialogMngr.kt @@ -0,0 +1,22 @@ +package io.github.tritium_launcher.launcher.ui.logging + +import io.github.tritium_launcher.launcher.connect + +object LogDialogMngr { + private var dialog: LogDialog? = null + + fun openDialog() { + val existing = dialog + if (existing != null) { + existing.openAndFocus() + return + } + + val created = LogDialog() + created.destroyed.connect { + if (dialog === created) dialog = null + } + dialog = created + created.openAndFocus() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/NotificationMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/NotificationMngr.kt index 3903ae8..783995b 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/NotificationMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/NotificationMngr.kt @@ -5,6 +5,9 @@ import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries import io.github.tritium_launcher.launcher.fromTR import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.awt.GraphicsEnvironment @@ -13,7 +16,6 @@ import java.awt.SystemTray import java.awt.TrayIcon import java.awt.image.BufferedImage import java.util.* -import java.util.concurrent.CopyOnWriteArrayList import javax.imageio.ImageIO /** @@ -29,7 +31,8 @@ object NotificationMngr { private val logger = logger() private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } - private val listeners = CopyOnWriteArrayList<(NotificationEvent) -> Unit>() + private val _events = MutableSharedFlow(replay = 0) + val events: SharedFlow = _events.asSharedFlow() private val lock = Any() private val entriesByScope = LinkedHashMap>() @@ -316,14 +319,6 @@ object NotificationMngr { return snapshot.sortedByDescending { it.createdAtEpochMs } } - /** - * Subscribes to notification events. - */ - fun addListener(listener: (NotificationEvent) -> Unit): () -> Unit { - listeners += listener - return { listeners -= listener } - } - /** * Clears all in-memory notification history. * @@ -358,13 +353,7 @@ object NotificationMngr { } private fun emit(event: NotificationEvent) { - listeners.forEach { listener -> - try { - listener(event) - } catch (t: Throwable) { - logger.warn("Notification listener failed for {}", event.javaClass.simpleName, t) - } - } + _events.tryEmit(event) } private fun isDisabledLocked(definitionId: String, project: ProjectBase?): Boolean { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/ProjectNotificationListPanel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/ProjectNotificationListPanel.kt index 86a60d4..503d666 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/ProjectNotificationListPanel.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/ProjectNotificationListPanel.kt @@ -7,6 +7,7 @@ import io.github.tritium_launcher.launcher.platform.Platform import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.TPushButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label @@ -17,6 +18,10 @@ import io.qt.core.QObject import io.qt.core.QTimer import io.qt.core.Qt import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -59,10 +64,11 @@ class ProjectNotificationListPanel( } } - private val unsubscribe: () -> Unit + private var unsubscribe: Job? = null init { objectName = "notificationListPanel" + AnimatedScrollController.attach(list) list.viewport()?.installEventFilter(viewportResizeFilter) hBoxLayout(controlsRow) { @@ -88,13 +94,13 @@ class ProjectNotificationListPanel( NotificationMngr.clearForProject(project, includeGlobal = true) } - unsubscribe = NotificationMngr.addListener { - runOnGuiThread { refresh() } + unsubscribe = CoroutineScope(Dispatchers.Main).launch { + NotificationMngr.events.collect { runOnGuiThread { refresh() } } } destroyed.connect { list.viewport()?.removeEventFilter(viewportResizeFilter) - unsubscribe() + unsubscribe?.cancel() } setThemedStyle { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/Toaster.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/Toaster.kt index 2fbbb7e..0540e92 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/Toaster.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/notifications/Toaster.kt @@ -18,6 +18,10 @@ import io.qt.core.QObject import io.qt.core.QTimer import io.qt.core.Qt import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch /** * Notification Toast stack. @@ -46,20 +50,20 @@ class Toaster( private val cardsById = LinkedHashMap() private var refreshQueued = false - private val unsubscribe: () -> Unit + private var unsubscribe: Job? = null init { overlayParent.installEventFilter(parentResizeFilter) container.hide() container.raise() - unsubscribe = NotificationMngr.addListener { - scheduleRefresh() + unsubscribe = CoroutineScope(Dispatchers.Main).launch { + NotificationMngr.events.collect { scheduleRefresh() } } window.destroyed.connect { overlayParent.removeEventFilter(parentResizeFilter) - unsubscribe() + unsubscribe?.cancel() container.disposeLater() } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectTaskMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectTaskMngr.kt index 5480849..103f927 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectTaskMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectTaskMngr.kt @@ -3,9 +3,9 @@ package io.github.tritium_launcher.launcher.ui.project import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.logger +import kotlinx.coroutines.flow.* import java.nio.file.Files import java.util.* -import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.roundToInt /** @@ -15,7 +15,18 @@ object ProjectTaskMngr { private val logger = logger() private val lock = Any() private val tasksById = LinkedHashMap() - private val listeners = CopyOnWriteArrayList<() -> Unit>() + + private val _tasks = MutableStateFlow>(emptyList()) + + /** + * Observable flow of all currently active background tasks. + */ + val tasks: StateFlow> = _tasks.asStateFlow() + + /** + * Compatibility flow that emits [Unit] whenever tasks change. + */ + val taskChanges: Flow = tasks.map { } /** * Runtime snapshot of a task currently tracked by [ProjectTaskMngr]. @@ -129,23 +140,18 @@ object ProjectTaskMngr { } /** - * Subscribes to task changes. + * Updates the StateFlow with the current list of active tasks. */ - fun addListener(listener: () -> Unit): () -> Unit { - listeners += listener - return { listeners -= listener } - } - private fun emitChanged() { - listeners.forEach { listener -> - try { - listener() - } catch (t: Throwable) { - logger.warn("Task listener failed", t) - } + val currentTasks = synchronized(lock) { + tasksById.values.toList() } + _tasks.value = currentTasks } + /** + * Returns filesystem scope for provided [VPath] + */ private fun scopeOf(path: VPath): String { val abs = path.toAbsolute().normalize() return try { @@ -161,10 +167,19 @@ object ProjectTaskMngr { } } + /** + * Trim task name + */ private fun normalizeTitle(raw: String): String = raw.trim().ifBlank { "Background task" } + /** + * Trim task detail + */ private fun normalizeDetail(raw: String): String = raw.trim() + /** + * Round progress to [Int] + */ private fun normalizeProgress(progressPercent: Double?): Int? { if (progressPercent == null || !progressPercent.isFinite()) return null return progressPercent.coerceIn(0.0, 100.0).roundToInt() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectUIState.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectUIState.kt index 3f03dec..fa36058 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectUIState.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectUIState.kt @@ -1,16 +1,48 @@ package io.github.tritium_launcher.launcher.ui.project +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +/** + * Custom serializer for ByteArray that stores data as an array of integers (0-255). + * This is more robust than the default serializer which might throw on unsigned values + * or use a format that varies between platforms/versions. + */ +object SafeByteArraySerializer : KSerializer { + override val descriptor: SerialDescriptor = ListSerializer(Int.serializer()).descriptor + + override fun serialize(encoder: Encoder, value: ByteArray) { + val ints = value.map { it.toInt() and 0xFF } + encoder.encodeSerializableValue(ListSerializer(Int.serializer()), ints) + } + + override fun deserialize(decoder: Decoder): ByteArray { + val ints = decoder.decodeSerializableValue(ListSerializer(Int.serializer())) + return ByteArray(ints.size) { ints[it].toByte() } + } +} + +/** + * Stored Values for a Project used for restoration + */ @Serializable -@ConsistentCopyVisibility -data class ProjectUIState internal constructor( +data class ProjectUIState( val tabMode: String = "SINGLE_ROW", val openFiles: List = emptyList(), val sidePanels: List = emptyList(), + val projectFilesActiveViewId: String = "project_files", + val projectFilesViewStates: List = emptyList(), val projectFilesExpandedPaths: List = emptyList(), val projectFilesSelectedPath: String? = null, + @Serializable(with = SafeByteArraySerializer::class) val mainWindowState: ByteArray? = null, + @Serializable(with = SafeByteArraySerializer::class) val mainWindowGeometry: ByteArray? = null, ) { @Serializable @@ -20,11 +52,25 @@ data class ProjectUIState internal constructor( val visible: Boolean ) + @Serializable + data class ProjectFilesViewState( + val viewId: String, + val expandedPaths: List = emptyList(), + val selectedPath: String? = null + ) + companion object { + private val parser = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + fun fromParts( tabMode: String, openFiles: List, sidePanels: List, + projectFilesActiveViewId: String, + projectFilesViewStates: List, projectFilesExpandedPaths: List, projectFilesSelectedPath: String?, state: ByteArray?, @@ -34,12 +80,26 @@ data class ProjectUIState internal constructor( tabMode = tabMode, openFiles = openFiles, sidePanels = sidePanels, + projectFilesActiveViewId = projectFilesActiveViewId, + projectFilesViewStates = projectFilesViewStates, projectFilesExpandedPaths = projectFilesExpandedPaths, projectFilesSelectedPath = projectFilesSelectedPath, mainWindowState = state, mainWindowGeometry = geom ) } + + /** + * Parses persisted UI state robustly using SafeByteArraySerializer. + */ + fun parseOrNull(text: String): ProjectUIState? { + return try { + parser.decodeFromString(text) + } catch (t: Throwable) { + // Fallback for extremely old or malformed payloads + null + } + } } override fun equals(other: Any?): Boolean { @@ -49,10 +109,12 @@ data class ProjectUIState internal constructor( if (tabMode != other.tabMode) return false if (openFiles != other.openFiles) return false if (sidePanels != other.sidePanels) return false + if (projectFilesActiveViewId != other.projectFilesActiveViewId) return false + if (projectFilesViewStates != other.projectFilesViewStates) return false if (projectFilesExpandedPaths != other.projectFilesExpandedPaths) return false if (projectFilesSelectedPath != other.projectFilesSelectedPath) return false - if (!mainWindowState.contentEquals(other.mainWindowState)) return false - if (!mainWindowGeometry.contentEquals(other.mainWindowGeometry)) return false + if (!(mainWindowState contentEquals other.mainWindowState)) return false + if (!(mainWindowGeometry contentEquals other.mainWindowGeometry)) return false return true } @@ -61,6 +123,8 @@ data class ProjectUIState internal constructor( var result = tabMode.hashCode() result = 31 * result + openFiles.hashCode() result = 31 * result + sidePanels.hashCode() + result = 31 * result + projectFilesActiveViewId.hashCode() + result = 31 * result + projectFilesViewStates.hashCode() result = 31 * result + projectFilesExpandedPaths.hashCode() result = 31 * result + (projectFilesSelectedPath?.hashCode() ?: 0) result = 31 * result + (mainWindowState?.contentHashCode() ?: 0) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectViewWindow.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectViewWindow.kt index e51f741..cafa8fe 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectViewWindow.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectViewWindow.kt @@ -1,13 +1,17 @@ package io.github.tritium_launcher.launcher.ui.project +import io.github.tritium_launcher.launcher.applyRainbowOverlay import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.onEvent import io.github.tritium_launcher.launcher.core.project.ProjectBase -import io.github.tritium_launcher.launcher.core.project.ProjectMngr import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.keymap.KeymapMngr import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.platform.GameLauncher +import io.github.tritium_launcher.launcher.qs import io.github.tritium_launcher.launcher.registry.DeferredRegistryBuilder import io.github.tritium_launcher.launcher.ui.dashboard.SettingsDialog import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread @@ -23,19 +27,25 @@ import io.github.tritium_launcher.launcher.ui.settings.SettingsLink import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon -import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout -import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.widget import io.github.tritium_launcher.launcher.util.ByteUtils +import io.github.tritium_launcher.launcher.util.SeasonalEvents +import io.github.tritium_launcher.launcher.util.SeasonalEvents.isPrideMonth import io.qt.Nullable import io.qt.core.QByteArray +import io.qt.core.QEvent import io.qt.core.QTimer +import io.qt.core.Qt import io.qt.core.Qt.DockWidgetArea -import io.qt.core.Qt.ItemDataRole.UserRole -import io.qt.core.Qt.WidgetAttribute.WA_TransparentForMouseEvents import io.qt.gui.* -import io.qt.widgets.* +import io.qt.widgets.QMainWindow +import io.qt.widgets.QMessageBox +import io.qt.widgets.QProgressBar +import io.qt.widgets.QWidget +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.serialization.json.Json import kotlin.random.Random @@ -43,7 +53,9 @@ import kotlin.random.Random * The main window for active Projects. */ class ProjectViewWindow internal constructor( - private val project: ProjectBase + private val project: ProjectBase, + initialUIState: ProjectUIState? = null, + initialOpenFiles: List? = null ): QMainWindow() { private val logger = logger() @@ -53,19 +65,9 @@ class ProjectViewWindow internal constructor( private val defaultWindowSize: Pair = CoreSettingValues.projectWindowDefaultSize() private val menuBarBuilder = ProjectMenuBar() - private val menuBottomDivider = widget(this) { - objectName = "projectMenuBottomDivider" - setAttribute(WA_TransparentForMouseEvents, true) - setThemedStyle { - selector("#projectMenuBottomDivider") { - backgroundColor(TColors.Surface2) - border() - } - } - hide() - } + private var backgroundLayer: ProjectBackgroundWidget private val editorArea = EditorArea(project) - private lateinit var sidePanelMngr: SidePanelMngr + private var sidePanelMngr: SidePanelMngr private lateinit var notificationOverlay: Toaster private val settingsDialog = SettingsDialog(this) private val statePersistTimer = QTimer(this).apply { @@ -73,113 +75,148 @@ class ProjectViewWindow internal constructor( interval = 3_000 timeout.connect { persistState() } } + private val rebuildMenusTimer = QTimer(this).apply { + isSingleShot = true + interval = 50 + timeout.connect { rebuildMenus() } + } private var uiState: ProjectUIState = ProjectUIState() private var lastPersistedState: ProjectUIState? = null private var suppressStatePersistence: Boolean = false - private var unsubscribeGameProcessListener: (() -> Unit)? = null - private var unsubscribeRuntimePreparationListener: (() -> Unit)? = null - private var unsubscribeTaskListener: (() -> Unit)? = null + private val savedDockWidths = mutableMapOf() + private var gameEventScope: CoroutineScope? = null + private var unsubscribeTaskListener: Job? = null + private var unsubscribeKeymapListener: Job? = null private val menuItemsRegistry = BuiltinRegistries.MenuItem + private var pendingOpenFiles: List? = initialOpenFiles + private var uiStateRestored = false init { - uiState = loadState() + uiState = initialUIState ?: run { + loadState() + } lastPersistedState = uiState - windowTitle = "Tritium | " + project.name + windowTitle = "Tritium Launcher | " + project.name + windowIcon = if (isPrideMonth()) { + TIcons.TritiumGrayscale.applyRainbowOverlay(opacity = 0.5f).icon + } else { + QIcon(TIcons.Tritium.scaled(qs(256, 256), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)) + } + menuBarBuilder.attach(this) + setAttribute(Qt.WidgetAttribute.WA_StyledBackground, true) + + backgroundLayer = ProjectBackgroundWidget(this) + backgroundLayer.lower() + + val projectFilesTreeState = ProjectFilesSidePanelProvider.TreeState( + expandedPaths = if (uiState.projectFilesViewStates.isNotEmpty()) + uiState.projectFilesViewStates.first().expandedPaths.toSet() + else uiState.projectFilesExpandedPaths.toSet(), + selectedPath = if (uiState.projectFilesViewStates.isNotEmpty()) + uiState.projectFilesViewStates.first().selectedPath + else uiState.projectFilesSelectedPath + ) + ProjectFilesSidePanelProvider.setPendingInitialDockState( + ProjectFilesSidePanelProvider.DockState( + activeViewId = uiState.projectFilesActiveViewId, + viewStates = listOf( + ProjectFilesSidePanelProvider.ViewState("project_files", projectFilesTreeState) + ) + ) + ) sidePanelMngr = SidePanelMngr( project = project, parent = this, - onStateChanged = { scheduleStatePersist() } - ) { id, dock -> - if(id == "project_files") { - val tree = (dock.widget() as? QTreeWidget) ?: dock.widget()?.findChild(QTreeWidget::class.java) - - tree?.itemDoubleClicked?.connect { item, _ -> - val path = item?.data(0, UserRole) as? VPath - if(path != null && !path.isDir()) { - editorArea.openFile(path) - } - } - tree?.itemExpanded?.connect { scheduleStatePersist() } - tree?.itemCollapsed?.connect { scheduleStatePersist() } - tree?.currentItemChanged?.connect { _, _ -> scheduleStatePersist() } - - ProjectFilesSidePanelProvider.restoreDockTreeState( - dock, - ProjectFilesSidePanelProvider.TreeState( - expandedPaths = uiState.projectFilesExpandedPaths.toSet(), - selectedPath = uiState.projectFilesSelectedPath - ) - ) - } - } + editorArea = editorArea, + onStateChanged = { scheduleStatePersist() }, + onAllProvidersBuilt = {} + ) - setCentralWidget(editorArea.widget()) + setCentralWidget(editorArea.widget().apply { + setProperty("keymapFocusGroup", "editor") + }) editorArea.onOpenFilesChanged = { scheduleStatePersist() } + notificationOverlay = Toaster(project, this) - QTimer.singleShot(0) { updateMenuBottomDivider() } - applyState(uiState) - installNotificationTestShortcut() DeferredRegistryBuilder(menuItemsRegistry) { runOnGuiThread { - menuBarBuilder.rebuildFor(this, project, null) + rebuildMenusTimer.start() } } - unsubscribeGameProcessListener = GameLauncher.addGameProcessListener { - runOnGuiThread { - if (!isVisible) return@runOnGuiThread - rebuildMenus() - } + gameEventScope = CoroutineScope(Dispatchers.Main + CoroutineName("GameProcessMngr")).apply { + onEvent { handleGameEvent() } + onEvent { handleGameEvent() } + onEvent { handleGameEvent() } } - unsubscribeRuntimePreparationListener = GameLauncher.addRuntimePreparationListener { + unsubscribeTaskListener = ProjectTaskMngr.taskChanges.onEach { runOnGuiThread { if (!isVisible) return@runOnGuiThread - rebuildMenus() + rebuildMenusTimer.start() } - } - unsubscribeTaskListener = ProjectTaskMngr.addListener { + }.launchIn(CoroutineScope(Dispatchers.Main + CoroutineName("ProjectTaskMngr"))) + + unsubscribeKeymapListener = KeymapMngr.activeKeymapFlow.onEach { runOnGuiThread { if (!isVisible) return@runOnGuiThread - rebuildMenus() + rebuildMenusTimer.start() } - } + }.launchIn(CoroutineScope(Dispatchers.Main + CoroutineName("KeymapMngr"))) destroyed.connect { - unsubscribeGameProcessListener?.invoke() - unsubscribeGameProcessListener = null - unsubscribeRuntimePreparationListener?.invoke() - unsubscribeRuntimePreparationListener = null - unsubscribeTaskListener?.invoke() + gameEventScope?.cancel() + gameEventScope = null + unsubscribeTaskListener?.cancel() unsubscribeTaskListener = null + unsubscribeKeymapListener?.cancel() + unsubscribeKeymapListener = null } } + private fun handleGameEvent() { + runOnGuiThread { + if (!isVisible) return@runOnGuiThread + rebuildMenusTimer.start() + } + } + + /** + * Ensures the Tritium files directory exists; creates otherwise + */ private fun ensureTDir() { if(!tDir.exists()) tDir.mkdirs() } - private fun applyState(state: ProjectUIState) { + /** + * Restores previous window state after the window is shown. + */ + private fun restoreUIState() { + if (uiStateRestored) return + uiStateRestored = true try { suppressStatePersistence = true - var restored = false - state.mainWindowGeometry?.let { - if(restoreGeometry(QByteArray(state.mainWindowGeometry))) { - restored = true + + uiState.mainWindowGeometry?.let { + if (!restoreGeometry(QByteArray(it))) { + resize(defaultWindowSize.first, defaultWindowSize.second) } - } - state.mainWindowState?.let { restoreState(QByteArray(state.mainWindowState)) } + } ?: resize(defaultWindowSize.first, defaultWindowSize.second) - if(!restored) { - resize(defaultWindowSize.first, defaultWindowSize.second) + uiState.mainWindowState?.let { + try { + restoreState(QByteArray(it)) + } catch (t: Throwable) { + logger.warn("Failed to restore window state for '{}'", project.name, t) + } } sidePanelMngr.restoreState( - state.sidePanels.mapNotNull { panel -> + uiState.sidePanels.mapNotNull { panel -> val area = parseDockArea(panel.area) ?: return@mapNotNull null SidePanelMngr.PersistedDockState( id = panel.id, @@ -189,7 +226,9 @@ class ProjectViewWindow internal constructor( } ) - editorArea.restoreOpenFiles(state.openFiles) + editorArea.restoreOpenFiles(pendingOpenFiles ?: uiState.openFiles) + pendingOpenFiles = null + captureDockWidths() } catch (t: Throwable) { logger.warn("Failed to apply UI state for '{}'", project.name, t) resize(defaultWindowSize.first, defaultWindowSize.second) @@ -198,25 +237,34 @@ class ProjectViewWindow internal constructor( } } + /** + * Loads previous window state + */ private fun loadState(): ProjectUIState { return try { ensureTDir() if (!stateFile.exists()) return ProjectUIState() val txt = stateFile.readTextOrNull() ?: return ProjectUIState() - return json.decodeFromString(txt) + return ProjectUIState.parseOrNull(txt) ?: ProjectUIState() } catch (t: Throwable) { logger.warn("Failed to load UI state for {}", project.name, t) ProjectUIState() } } + /** + * Saves window state + */ private fun persistState() { if (suppressStatePersistence) return try { ensureTDir() + captureDockWidths() val openFiles = editorArea.openFiles() - val geom = ByteUtils.toByteArray(saveGeometry().data()) - val state = ByteUtils.toByteArray(saveState().data()) + val geomQBA = saveGeometry() + val stateQBA = saveState() + val geom = ByteUtils.toByteArray(geomQBA.data()) + val state = ByteUtils.toByteArray(stateQBA.data()) val sidePanels = sidePanelMngr.captureState().map { dock -> ProjectUIState.SidePanelState( id = dock.id, @@ -229,8 +277,21 @@ class ProjectViewWindow internal constructor( val s = ProjectUIState( openFiles = openFiles, sidePanels = sidePanels, - projectFilesExpandedPaths = projectFilesTree.expandedPaths.toList(), - projectFilesSelectedPath = projectFilesTree.selectedPath, + projectFilesActiveViewId = projectFilesTree.activeViewId, + projectFilesViewStates = projectFilesTree.viewStates.map { viewState -> + ProjectUIState.ProjectFilesViewState( + viewId = viewState.viewId, + expandedPaths = viewState.treeState.expandedPaths.toList(), + selectedPath = viewState.treeState.selectedPath + ) + }, + projectFilesExpandedPaths = projectFilesTree.viewStates + .firstOrNull { it.viewId == projectFilesTree.activeViewId } + ?.treeState?.expandedPaths?.toList() + ?: emptyList(), + projectFilesSelectedPath = projectFilesTree.viewStates + .firstOrNull { it.viewId == projectFilesTree.activeViewId } + ?.treeState?.selectedPath, mainWindowState = state, mainWindowGeometry = geom ) @@ -245,22 +306,17 @@ class ProjectViewWindow internal constructor( } } + /** + * Schedule timer for persisting window state + */ private fun scheduleStatePersist() { if (suppressStatePersistence) return statePersistTimer.start() } - private fun installNotificationTestShortcut() { - val action = QAction(this).apply { - setShortcut("Ctrl+Alt+Shift+N") //TODO: Keymap - toolTip = "Emit a random notification test payload" - } - action.triggered.connect { - emitRandomTestNotification() - } - addAction(action) - } - + /** + * Emits a test notification + */ private fun emitRandomTestNotification() { val seed = Random.nextInt(1000, 9999) val header = listOf( @@ -274,7 +330,7 @@ class ProjectViewWindow internal constructor( TIcons.QuestionMark.icon, TIcons.Build.icon, TIcons.Run.icon, - TIcons.Tritium.icon + if (SeasonalEvents.isPrideMonth()) TIcons.TritiumGrayscale.applyRainbowOverlay().icon else TIcons.Tritium.icon ).random() val links: List? = if (Random.nextInt(100) < 70) { @@ -324,15 +380,48 @@ class ProjectViewWindow internal constructor( ) } + private fun captureDockWidths() { + savedDockWidths.clear() + for ((id, dock) in sidePanelMngr.dockWidgets()) { + savedDockWidths[id] = dock.width() + } + } + + private fun lockDockWidths() { + for ((id, w) in savedDockWidths) { + sidePanelMngr.getDock(id)?.minimumWidth = w + } + } + + private fun unlockDockWidths() { + for ((id, _) in savedDockWidths) { + sidePanelMngr.getDock(id)?.minimumWidth = 0 + } + } + + override fun changeEvent(event: @Nullable QEvent?) { + super.changeEvent(event) + if (event?.type() == QEvent.Type.WindowStateChange) { + lockDockWidths() + QTimer.singleShot(0) { unlockDockWidths() } + } + } + override fun showEvent(event: @Nullable QShowEvent?) { super.showEvent(event) - updateMenuBottomDivider() - if(::notificationOverlay.isInitialized) notificationOverlay.reposition() + if(::notificationOverlay.isInitialized) { + notificationOverlay.reposition() + } + if (!uiStateRestored) { + QTimer.singleShot(0) { restoreUIState() } + } } override fun resizeEvent(event: @Nullable QResizeEvent?) { + lockDockWidths() super.resizeEvent(event) - updateMenuBottomDivider() + QTimer.singleShot(50) { unlockDockWidths() } + backgroundLayer.setGeometry(0, 0, width(), height()) if(::notificationOverlay.isInitialized) notificationOverlay.reposition() scheduleStatePersist() } @@ -349,26 +438,35 @@ class ProjectViewWindow internal constructor( } statePersistTimer.stop() persistState() + TritiumEventBus.publish(TritiumEvent.ProjectClosing(project)) super.closeEvent(event) } + /** + * Returns the Dock Area name from [DockWidgetArea] value + */ private fun dockAreaName(area: DockWidgetArea): String = when (area) { - DockWidgetArea.LeftDockWidgetArea -> "left" - DockWidgetArea.RightDockWidgetArea -> "right" + DockWidgetArea.LeftDockWidgetArea -> "left" + DockWidgetArea.RightDockWidgetArea -> "right" DockWidgetArea.BottomDockWidgetArea -> "bottom" else -> "left" } + /** + * Returns the [DockWidgetArea] value from name + */ private fun parseDockArea(area: String): DockWidgetArea? = when (area.trim().lowercase()) { - "left" -> DockWidgetArea.LeftDockWidgetArea - "right" -> DockWidgetArea.RightDockWidgetArea + "left" -> DockWidgetArea.LeftDockWidgetArea + "right" -> DockWidgetArea.RightDockWidgetArea "bottom" -> DockWidgetArea.BottomDockWidgetArea else -> null } + /** + * Rebuilds the Menu Bar + */ fun rebuildMenus() { menuBarBuilder.rebuildFor(this, project, null) - QTimer.singleShot(0) { updateMenuBottomDivider() } } /** @@ -378,6 +476,16 @@ class ProjectViewWindow internal constructor( */ fun adjustEditorFontSize(delta: Int): Boolean = editorArea.adjustActiveEditorFont(delta) + /** + * Saves the currently active editor if it has unsaved changes. + */ + fun saveActiveEditor() = editorArea.saveActive() + + /** + * Saves all editors that have unsaved changes. + */ + fun saveAllEditors() = editorArea.saveAll() + /** * Canonical project identifier used by project-window routing logic. */ @@ -390,24 +498,11 @@ class ProjectViewWindow internal constructor( settingsDialog.open(link) } - private fun updateMenuBottomDivider() { - val menu = menuWidget() ?: run { - menuBottomDivider.hide() - return - } - val menuRect = menu.geometry - if(menuRect.height() <= 0 || width() <= 0) { - menuBottomDivider.hide() - return - } - val y = menuRect.y() + menuRect.height() - 1 - menuBottomDivider.setGeometry(0, y, width(), 1) - menuBottomDivider.show() - menuBottomDivider.raise() - } - + /** + * Asks whether the user wants to close the project when exiting Tritium, depending on [CoreSettingValues.closeProjectConfirmationPolicy] + */ private fun confirmCloseProjectIfNeeded(): Boolean { - val policy = CoreSettingValues.closeProjectConfirmationPolicy() + val policy = CoreSettingValues.closeProjectConfirmationPolicy if (policy != CoreSettingValues.CloseProjectConfirmationPolicy.Ask) return true val box = QMessageBox(this) @@ -420,23 +515,65 @@ class ProjectViewWindow internal constructor( box.exec() return box.clickedButton() == closeButton } +} + +private class ProjectBackgroundWidget(parent: QWidget) : QWidget(parent) { + private var backgroundPixmap: QPixmap? = null + private var scaledPixmap: QPixmap? = null + private var lastBgImagePath: String? = null + private var lastSize: io.qt.core.QSize? = null - companion object { - private val logger = logger(ProjectViewWindow::class) - - fun dashboardList( - list: QListWidget, - openWindow: (ProjectBase) -> Unit = { p -> ProjectViewWindow(p).apply { show() } } - ) { - list.itemDoubleClicked.connect { item -> - val name = item?.text() ?: return@connect - val proj = ProjectMngr.getProject(name) - if(proj != null) { - openWindow(proj) - } else { - logger.warn("Dashboard requested open for unknown project '{}'", name) + init { + setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, true) + } + + override fun paintEvent(event: @Nullable QPaintEvent?) { + val bgPath = CoreSettingValues.uiBackgroundImage + val currentSize = size() + + if (!bgPath.isNullOrBlank()) { + val pathChanged = bgPath != lastBgImagePath + val sizeChanged = currentSize != lastSize + + if (pathChanged) { + backgroundPixmap = QPixmap(bgPath) + lastBgImagePath = bgPath + } + + if (pathChanged || sizeChanged) { + backgroundPixmap?.let { pix -> + if (!pix.isNull) { + scaledPixmap = pix.scaled( + currentSize, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation + ) + } + } + lastSize = currentSize + } + + scaledPixmap?.let { scaled -> + if (!scaled.isNull) { + val painter = QPainter(this) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + val x = (width() - scaled.width()) / 2 + val y = (height() - scaled.height()) / 2 + painter.drawPixmap(x, y, scaled) + painter.end() + return } } + } else { + lastBgImagePath = null + backgroundPixmap = null + scaledPixmap = null + lastSize = null } + + // Default fallback if no image + val painter = QPainter(this) + painter.fillRect(rect(), QColor(TColors.Surface0)) + painter.end() } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectWindows.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectWindows.kt index 7783d2d..d759eab 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectWindows.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/ProjectWindows.kt @@ -7,8 +7,7 @@ import io.github.tritium_launcher.launcher.ui.dashboard.Dashboard import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.qt.core.QThread import io.qt.widgets.QApplication -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap /** @@ -16,9 +15,13 @@ import java.util.concurrent.ConcurrentHashMap */ object ProjectWindows { private val logger = logger() + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val openWindows = ConcurrentHashMap>() + /** + * Controls whether project opening always creates a new window or prefers reuse. + */ enum class OpenMode { NEW_WINDOW, CURRENT_WINDOW @@ -42,6 +45,9 @@ object ProjectWindows { openProjectInternal(project, closeDashboard) } + /** + * Opens a project while preferring to replace the active or otherwise reusable project window. + */ private fun openProjectInCurrentWindow(project: ProjectBase, closeDashboard: Boolean) { val replacement = preferredWindowForReuse() if (replacement == null) { @@ -71,6 +77,9 @@ object ProjectWindows { } } + /** + * Opens, creates, or focuses the canonical window entry for [project]. + */ @OptIn(ExperimentalCoroutinesApi::class) private fun openProjectInternal(project: ProjectBase, closeDashboard: Boolean) { val canonical = project.path.toString().trim() @@ -78,29 +87,63 @@ object ProjectWindows { val newDeferred = CompletableDeferred() val prevDeferred = openWindows.putIfAbsent(canonical, newDeferred) - if(prevDeferred == null) { - createWindowAsync(project, newDeferred, closeDashboard) + if (prevDeferred == null) { + scope.launch { + try { + val (state, files) = withContext(Dispatchers.IO) { + loadProjectInitialData(project) + } + + createWindowAsync(project, newDeferred, closeDashboard, state, files) + } catch (t: Throwable) { + logger.error("Failed to load project asynchronously for '{}'", project.name, t) + newDeferred.completeExceptionally(t) + openWindows.remove(canonical) + } + } return } // If an earlier creation is still pending, cancel it and start fresh to avoid deadlocks. - if(!prevDeferred.isCompleted) { + if (!prevDeferred.isCompleted) { logger.warn("Previous project window creation still pending for '{}', restarting.", project.name) prevDeferred.cancel() openWindows[canonical] = newDeferred - createWindowAsync(project, newDeferred, closeDashboard) + scope.launch { + try { + val (state, files) = withContext(Dispatchers.IO) { + loadProjectInitialData(project) + } + createWindowAsync(project, newDeferred, closeDashboard, state, files) + } catch (t: Throwable) { + logger.error("Failed to load project asynchronously for '{}'", project.name, t) + newDeferred.completeExceptionally(t) + openWindows.remove(canonical) + } + } return } - if(prevDeferred.isCancelled) { + if (prevDeferred.isCancelled) { openWindows[canonical] = newDeferred - createWindowAsync(project, newDeferred, closeDashboard) + scope.launch { + try { + val (state, files) = withContext(Dispatchers.IO) { + loadProjectInitialData(project) + } + createWindowAsync(project, newDeferred, closeDashboard, state, files) + } catch (t: Throwable) { + logger.error("Failed to load project asynchronously for '{}'", project.name, t) + newDeferred.completeExceptionally(t) + openWindows.remove(canonical) + } + } return } // Otherwise re-show existing window. prevDeferred.invokeOnCompletion { - if(prevDeferred.isCompleted) { + if (prevDeferred.isCompleted) { try { val w = prevDeferred.getCompleted() runOnGuiThread { @@ -108,11 +151,16 @@ object ProjectWindows { w.show() w.raise() w.activateWindow() - if(closeDashboard) { + if (closeDashboard) { Dashboard.I?.close() } } catch (t: Throwable) { - logger.debug("Failed to focus existing {} for '{}'", ProjectViewWindow::class.qualifiedName, project.name, t) + logger.debug( + "Failed to focus existing {} for '{}'", + ProjectViewWindow::class.qualifiedName, + project.name, + t + ) } } } catch (t: Throwable) { @@ -123,6 +171,9 @@ object ProjectWindows { return } + /** + * Returns the best candidate window to reuse for current-window project opens. + */ private fun preferredWindowForReuse(): ProjectViewWindow? { val active = QApplication.activeWindow() as? ProjectViewWindow if (active != null && active.isVisible) return active @@ -175,26 +226,36 @@ object ProjectWindows { } } + /** + * Ensures project window creation happens on the Qt GUI thread. + */ private fun createWindowAsync( project: ProjectBase, deferred: CompletableDeferred, - closeDashboard: Boolean + closeDashboard: Boolean, + initialState: ProjectUIState? = null, + initialFiles: List? = null ) { if (isGuiThread()) { - createWindow(project, deferred, closeDashboard) + createWindow(project, deferred, closeDashboard, initialState, initialFiles) return } - runOnGuiThread { createWindow(project, deferred, closeDashboard) } + runOnGuiThread { createWindow(project, deferred, closeDashboard, initialState, initialFiles) } } + /** + * Instantiates, shows, and registers a new project window. + */ private fun createWindow( project: ProjectBase, deferred: CompletableDeferred, - closeDashboard: Boolean + closeDashboard: Boolean, + initialState: ProjectUIState? = null, + initialFiles: List? = null ) { try { - val window = ProjectViewWindow(project) + val window = ProjectViewWindow(project, initialState, initialFiles) window.show() try { @@ -206,9 +267,10 @@ object ProjectWindows { deferred.complete(window) - window.destroyed.connect { openWindows.remove(project.path.toString().trim()) } - if(closeDashboard) { - Dashboard.I?.close() + val canonical = project.path.toString().trim() + window.destroyed.connect { openWindows.remove(canonical) } + if (closeDashboard) { + Dashboard.I?.hide() } } catch (t: Throwable) { deferred.completeExceptionally(t) @@ -217,6 +279,25 @@ object ProjectWindows { } } + private fun loadProjectInitialData(project: ProjectBase): Pair?> { + return try { + val dotTr = project.projectDir.resolve(".tr") + val stateFile = dotTr.resolve("tritium-ui.json") + if (!stateFile.exists()) { + return null to null + } + val txt = stateFile.readTextOrNull() ?: return null to null + val state = ProjectUIState.parseOrNull(txt) ?: return null to null + state to state.openFiles + } catch (t: Throwable) { + logger.warn("Failed to load initial project UI state for '{}'", project.name, t) + null to null + } + } + + /** + * Returns whether the current thread is Qt's GUI thread. + */ private fun isGuiThread(): Boolean { val app = QApplication.instance() ?: return false return QThread.currentThread() == app.thread() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorArea.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorArea.kt index fcbfe59..61dfdf1 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorArea.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorArea.kt @@ -1,15 +1,27 @@ package io.github.tritium_launcher.launcher.ui.project.editor +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.onEvent import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.io.VPath -import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.registry.DeferredRegistryBuilder +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor +import io.github.tritium_launcher.launcher.ui.project.editor.panes.TextEditorPane import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.core.QTimer import io.qt.gui.QIcon import io.qt.widgets.* +import kotlinx.coroutines.* /** * This is the main Editor area of [io.github.tritium_launcher.launcher.ui.project.ProjectViewWindow], @@ -24,12 +36,23 @@ class EditorArea( private val mainLayout = vBoxLayout(container) private val tabBar = EditorTabBar() private val stack = QStackedWidget() - private val paneIdx = mutableMapOf() + private val tabDescriptors = mutableMapOf() + + data class TabDescriptor( + val file: VPath?, + val icon: QIcon?, + val title: String, + val placeholder: QWidget, + val providerId: String? = null, + var pane: EditorPane? = null + ) private val providerRegistry = BuiltinRegistries.EditorPane private val syntaxRegistry = BuiltinRegistries.SyntaxLanguage private var providersSnapshot: List = emptyList() - private val logger = logger() + private val emptyStateWidget = createEmptyStateWidget() + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val autoSaveTimer = QTimer(container) init { container.objectName = "editorArea" @@ -40,103 +63,321 @@ class EditorArea( } container.setThemedStyle { val editorSurface = TColors.Surface1 + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() + selector("#editorArea") { - backgroundColor(editorSurface) + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(editorSurface) + } border() } selector("#editorStack") { - backgroundColor(editorSurface) + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(editorSurface) + } border() } + + selector("#editorStack QTextEdit, #editorStack QPlainTextEdit") { + if (isBgImageSet) { + backgroundColor("transparent") + } + } + selector("#editorStack QTextEdit > QWidget, #editorStack QPlainTextEdit > QWidget") { + if (isBgImageSet) { + backgroundColor("transparent") + } + } } mainLayout.setContentsMargins(0, 0, 0, 0) mainLayout.setSpacing(0) stack.frameShape = QFrame.Shape.NoFrame mainLayout.addWidget(tabBar) - mainLayout.addWidget(stack) + mainLayout.addWidget(stack, 1) + mainLayout.addWidget(emptyStateWidget, 1) + updateEmptyStateVisibility() DeferredRegistryBuilder(providerRegistry) { list -> providersSnapshot = list.sortedBy { it.order } } + + autoSaveTimer.timeout.connect { + if (CoreSettingValues.editorAutoSave) { + autoSaveAll() + } + } + updateAutoSaveTimer() + val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope.onEvent { event -> + val key = "${event.namespace}:${event.nodeKey}" + if (key == CoreSettingKeys.EditorAutoSave.toString() || key == CoreSettingKeys.EditorAutoSaveInterval.toString()) { + updateAutoSaveTimer() + } + } + container.destroyed.connect { scope.cancel() } + } + + private fun updateAutoSaveTimer() { + if (CoreSettingValues.editorAutoSave) { + autoSaveTimer.start(CoreSettingValues.editorAutoSaveInterval() * 1000) + } else { + autoSaveTimer.stop() + } + } + + fun saveActive() { + val current = stack.currentIndex + val pane = tabDescriptors[current]?.pane ?: return + if (pane.modified) { + scope.launch { + if (pane.save()) { + TritiumEventBus.publish(TritiumEvent.FileSaved(null, pane.file?.toAbsolute()?.toString())) + } + } + } + } + + fun saveAll() { + tabDescriptors.values.mapNotNull { it.pane }.filter { it.modified }.forEach { pane -> + scope.launch { + if (pane.save()) { + TritiumEventBus.publish(TritiumEvent.FileSaved(null, pane.file?.toAbsolute()?.toString())) + } + } + } + } + + private fun autoSaveAll() { + tabDescriptors.values.mapNotNull { it.pane }.filter { it.modified && it.allowAutoSave }.forEach { pane -> + scope.launch { + if (pane.save()) { + TritiumEventBus.publish(TritiumEvent.FileSaved(null, pane.file?.toAbsolute()?.toString())) + } + } + } } fun widget(): QWidget = container - fun openFile(file: VPath): EditorPane { + fun openFile(file: VPath): EditorPane? { val absolute = file.toAbsolute() - val existing = paneIdx.entries.firstOrNull { it.value.file.toAbsolute() == absolute } + val existingEntry = tabDescriptors.entries.firstOrNull { it.value.file?.toAbsolute() == absolute } + + if(existingEntry != null) { + val idx = existingEntry.key + tabBar.setCurrentIndex(idx) + stack.currentIndex = idx + return existingEntry.value.pane + } val fileIcon = resolveFileIcon(file, project) + val chosen = providersSnapshot.firstOrNull { it.canOpen(file, project) } + val tabTitle = chosen?.tabTitle(file, project) ?: file.fileName() + val resolvedTabIcon = chosen?.tabIcon(file, project) ?: fileIcon - if(existing != null) { - val idx = existing.key - tabBar.setCurrentIndex(idx) - stack.currentIndex = idx - return existing.value + val singletonGroup = chosen?.singletonGroup + if (singletonGroup != null) { + val existingSingleton = tabDescriptors.entries.firstOrNull { (_, desc) -> + val provider = resolveProvider(desc) + provider?.singletonGroup == singletonGroup + } + if (existingSingleton != null) { + closeTabInternal(existingSingleton.key) + } + } + + // Create a placeholder widget for the stacked widget + val placeholder = QWidget() + val idx = stack.addWidget(placeholder) + + val descriptor = TabDescriptor(file, resolvedTabIcon, tabTitle, placeholder) + tabDescriptors[idx] = descriptor + + tabBar.insertTab(idx, resolvedTabIcon, tabTitle) + tabBar.setCurrentIndex(idx) + stack.currentIndex = idx + + // If it's the current tab, instantiate it immediately + if (stack.currentIndex == idx) { + ensurePaneInstantiated(idx) + } + + updateEmptyStateVisibility() + onOpenFilesChanged?.invoke() + TritiumEventBus.publish(TritiumEvent.EditorOpened(null, file.toAbsolute().toString())) + return descriptor.pane + } + + fun openEditorPane( + provider: EditorPaneProvider, + title: String, + icon: QIcon? = null, + paneFactory: (ProjectBase) -> EditorPane + ): EditorPane { + val singletonGroup = provider.singletonGroup + if (singletonGroup != null) { + val existingSingleton = tabDescriptors.entries.firstOrNull { (_, desc) -> + val p = resolveProvider(desc) + p?.singletonGroup == singletonGroup + } + if (existingSingleton != null) { + closeTabInternal(existingSingleton.key) + } + } + + val pane = paneFactory(project) + val placeholder = QWidget() + val idx = stack.addWidget(placeholder) + + val descriptor = TabDescriptor( + file = null, + icon = icon, + title = title, + placeholder = placeholder, + providerId = provider.id, + pane = pane + ) + tabDescriptors[idx] = descriptor + + stack.insertWidget(idx, pane.widget()) + stack.removeWidget(placeholder) + placeholder.disposeLater() + + tabBar.insertTab(idx, icon, title) + tabBar.setCurrentIndex(idx) + stack.currentIndex = idx + + pane.onModifiedChanged = { modified -> + tabBar.setTabModifiedAt(idx, modified) + } + pane.onTitleChanged = { newTitle -> + tabBar.setTabText(idx, newTitle) + } + pane.onIconChanged = { newIcon -> + tabBar.setTabIconAt(idx, newIcon) + } + + pane.onOpen() + + updateEmptyStateVisibility() + onOpenFilesChanged?.invoke() + TritiumEventBus.publish(TritiumEvent.EditorOpened(provider.id, null)) + return pane + } + + private fun resolveProvider(desc: TabDescriptor): EditorPaneProvider? { + if (desc.providerId != null) { + return providersSnapshot.firstOrNull { it.id == desc.providerId } } + val file = desc.file ?: return null + return providersSnapshot.firstOrNull { it.canOpen(file, project) } + } + + private fun ensurePaneInstantiated(idx: Int): EditorPane? { + val desc = tabDescriptors[idx] ?: return null + if (desc.pane != null) return desc.pane + val file = desc.file ?: return null val chosen = providersSnapshot.firstOrNull { it.canOpen(file, project) } val pane = chosen?.create(project, file) ?: run { val lang = syntaxRegistry.all().find { it.matches(file) } TextEditorPane(project, file, lang) } + + desc.pane = pane val w = pane.widget() - val idx = stack.addWidget(w) - paneIdx[idx] = pane - tabBar.insertTab(idx, fileIcon, file.fileName()) - tabBar.setCurrentIndex(idx) - stack.currentIndex = idx + + // Replace placeholder with actual widget at the same index + val placeholderIndex = stack.indexOf(desc.placeholder) + if (placeholderIndex >= 0) { + stack.insertWidget(placeholderIndex, w) + stack.removeWidget(desc.placeholder) + desc.placeholder.disposeLater() + } else { + stack.addWidget(w) + } + + pane.onModifiedChanged = { modified -> + tabBar.setTabModifiedAt(idx, modified) + } + pane.onTitleChanged = { title -> + tabBar.setTabText(idx, title) + } + pane.onIconChanged = { icon -> + tabBar.setTabIconAt(idx, icon) + } + pane.onOpen() - onOpenFilesChanged?.invoke() return pane } - private fun defaultTextPane(project: ProjectBase, file: VPath): EditorPane = object : EditorPane(project, file) { - private val text = QTextEdit() + fun closeTab(idx: Int) { + val desc = tabDescriptors[idx] ?: return + val pane = desc.pane - init { - text.lineWrapMode = QTextEdit.LineWrapMode.NoWrap - text.frameShape = QFrame.Shape.NoFrame - text.viewport()?.setContentsMargins(0, 0, 0, 0) - try { if(file.exists()) text.plainText = file.readTextOr("") } catch (_: Throwable) {} - } + if (pane != null && pane.modified) { + val box = QMessageBox(container) + box.icon = QMessageBox.Icon.Question + box.windowTitle = "Unsaved Changes" + val fileName = pane.file?.fileName() ?: pane::class.simpleName ?: "Untitled" + box.text = "'$fileName' has unsaved changes. Do you want to save them?" + val saveBtn = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole) + val discardBtn = box.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole) + box.addButton(QMessageBox.StandardButton.Cancel) - override fun widget(): QWidget = text - override suspend fun save(): Boolean = try { - file.writeBytes(text.toPlainText().toByteArray()) - true - } catch (t: Throwable) { - logger.warn("DefaultTextPane save failed for {}", file.toAbsolute(), t) - false + box.exec() + val clicked = box.clickedButton() + if (clicked == saveBtn) { + scope.launch { + if (pane.save()) { + closeTabInternal(idx) + } + } + return + } else if (clicked != discardBtn) { + return + } } + + closeTabInternal(idx) } - fun closeTab(idx: Int) { - val pane = paneIdx[idx] ?: return - pane.onClose() + private fun closeTabInternal(idx: Int) { + val desc = tabDescriptors[idx] ?: return + desc.pane?.onClose() val w = stack.widget(idx) stack.removeWidget(w) tabBar.removeTab(idx) - paneIdx.remove(idx) + tabDescriptors.remove(idx) rebuild() + updateEmptyStateVisibility() onOpenFilesChanged?.invoke() + TritiumEventBus.publish(TritiumEvent.EditorClosed(desc.providerId, desc.file?.toAbsolute()?.toString())) } private fun rebuild() { - val new = HashMap() + val new = HashMap() for(i in 0 until stack.count) { val w = stack.widget(i) - val pane = paneIdx.values.find { it.widget() == w } - if(pane != null) new[i] = pane + val desc = tabDescriptors.values.find { it.pane?.widget() == w || it.placeholder == w } + if(desc != null) new[i] = desc } - paneIdx.clear() - paneIdx.putAll(new) + tabDescriptors.clear() + tabDescriptors.putAll(new) } private fun onTabSelected(idx: Int) { - if(idx >= 0 && idx < stack.count) stack.currentIndex = idx + if(idx >= 0 && idx < stack.count) { + ensurePaneInstantiated(idx) + stack.currentIndex = idx + } } - fun openFiles(): List = paneIdx.values.map { it.file.toAbsolute().toString() } + fun openFiles(): List = tabDescriptors.values.mapNotNull { it.file?.toAbsolute()?.toString() } fun restoreOpenFiles(paths: List) { var changed = false @@ -205,10 +446,60 @@ class EditorArea( return widget == container || container.isAncestorOf(widget) } + private fun updateEmptyStateVisibility() { + val hasTabs = tabBar.count > 0 + stack.isVisible = hasTabs + emptyStateWidget.isVisible = !hasTabs + } + + private fun createEmptyStateWidget(): QWidget { + return qWidget { + objectName = "editorEmptyState" + + val root = vBoxLayout(this) { + setContentsMargins(32, 24, 32, 24) + setSpacing(0) + } + + val leftHint = label("← Select a File to edit") { + objectName = "emptyStateLeftHint" + } + + val rightHint = label("Installed Mods →") { + objectName = "emptyStateRightHint" + } + + val bottomHint = label("Item Browser ↓") { + objectName = "emptyStateBottomHint" + } + + val topRow = hBoxLayout() { + addWidget(leftHint) + addStretch() + addWidget(rightHint) + } + root.addLayout(topRow) + root.addStretch(1) + + val bottomRow = hBoxLayout() { + addWidget(bottomHint) + addStretch() + } + root.addLayout(bottomRow) + + setThemedStyle { + selector("#editorEmptyState") { + backgroundColor(TColors.Surface0) + } + selector("#emptyStateLeftHint, #emptyStateRightHint, #emptyStateBottomHint") { + color(TColors.Subtext) + fontSize(13) + } + } + } + } + private fun resolveFileIcon(file: VPath, project: ProjectBase): QIcon? { - val ftr = BuiltinRegistries.FileType - val matches = ftr.all().filter { desc -> desc.matches(file, project) }.sortedBy { it.order } - val primary = matches.firstOrNull() - return primary?.icon + return FileTypeDescriptor.primary(file, project)?.icon } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPane.kt index 73f8673..3a2d64e 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPane.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPane.kt @@ -2,19 +2,33 @@ package io.github.tritium_launcher.launcher.ui.project.editor import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.io.VPath +import io.qt.gui.QIcon import io.qt.widgets.QWidget /** * Extendable impl for Editor Panes, which could be for editing text, menus, or other kinds of widgets. * A secondary class implementing [EditorPaneProvider] is necessary for registration. - * @see TextEditorPane - * @see io.github.tritium_launcher.launcher.ui.project.editor.pane.ImageViewerPane + * @see io.github.tritium_launcher.launcher.ui.project.editor.panes.TextEditorPane + * @see io.github.tritium_launcher.launcher.ui.project.editor.panes.ImageViewerPane * @see EditorArea */ abstract class EditorPane( val project: ProjectBase, - val file: VPath + val file: VPath? = null ) { + open val allowAutoSave: Boolean = true + + var modified: Boolean = false + set(value) { + if (field != value) { + field = value + onModifiedChanged?.invoke(value) + } + } + + var onModifiedChanged: ((Boolean) -> Unit)? = null + var onTitleChanged: ((String) -> Unit)? = null + var onIconChanged: ((QIcon?) -> Unit)? = null abstract fun widget(): QWidget diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPaneProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPaneProvider.kt index 5bc5e34..d9f18e5 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPaneProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorPaneProvider.kt @@ -3,13 +3,20 @@ package io.github.tritium_launcher.launcher.ui.project.editor import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.registry.Registrable +import io.qt.gui.QIcon interface EditorPaneProvider: Registrable { val displayName: String val order: Int + val singletonGroup: String? get() = null + fun canOpen(file: VPath, project: ProjectBase): Boolean + fun tabTitle(file: VPath, project: ProjectBase): String = file.fileName() + + fun tabIcon(file: VPath, project: ProjectBase): QIcon? = null + fun create(project: ProjectBase, file: VPath): EditorPane -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTab.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTab.kt index 42ef4ce..e1286cd 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTab.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTab.kt @@ -1,7 +1,9 @@ package io.github.tritium_launcher.launcher.ui.project.editor +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.onClicked import io.github.tritium_launcher.launcher.qs +import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle @@ -41,6 +43,13 @@ class EditorTab(icon: QIcon?, text: String, private val parentBar: EditorTabBar) update() } + var isModified = false + set(value) { + field = value + updateCloseBtn() + update() + } + var onClicked: (() -> Unit)? = null var onCloseClicked: (() -> Unit)? = null var onHoverChanged: (() -> Unit)? = null @@ -157,9 +166,30 @@ class EditorTab(icon: QIcon?, text: String, private val parentBar: EditorTabBar) private fun updateCloseBtn() { closeBtn.isVisible = true - closeBtn.isEnabled = showClose - closeBtn.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, !showClose) - closeBtn.icon = if(showClose) TIcons.SmallCross.icon else QIcon() + val intensity = CoreSettingValues.editorUnsavedIndicatorIntensity + val showUnsavedCircle = isModified && intensity == CoreSettingValues.UnsavedIndicatorIntensity.High && !isHovered + + if (showUnsavedCircle) { + closeBtn.isEnabled = true + closeBtn.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, false) + closeBtn.icon = unsavedCircleIcon() + } else { + closeBtn.isEnabled = showClose + closeBtn.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, !showClose) + closeBtn.icon = if(showClose) TIcons.SmallCross.icon else QIcon() + } + } + + private fun unsavedCircleIcon(): QIcon { + val pixmap = QPixmap(16, 16) + pixmap.fill(Qt.GlobalColor.transparent) + val painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QBrush(QColor(TColors.Unsaved))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(4, 4, 8, 8) + painter.end() + return QIcon(pixmap) } override fun paintEvent(event: @Nullable QPaintEvent?) { @@ -177,7 +207,13 @@ class EditorTab(icon: QIcon?, text: String, private val parentBar: EditorTabBar) if(isSelected) { val indicatorHeight = 2 - painter.fillRect(0, height() - indicatorHeight, width().coerceAtLeast(1), indicatorHeight, QColor(255, 255, 255, 230)) + val intensity = CoreSettingValues.editorUnsavedIndicatorIntensity + val color = if (isModified && intensity == CoreSettingValues.UnsavedIndicatorIntensity.High) { + QColor(TColors.Unsaved) + } else { + QColor(255, 255, 255, 230) + } + painter.fillRect(0, height() - indicatorHeight, width().coerceAtLeast(1), indicatorHeight, color) } painter.end() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTabBar.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTabBar.kt index dc66489..ca2df63 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTabBar.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/EditorTabBar.kt @@ -1,6 +1,7 @@ package io.github.tritium_launcher.launcher.ui.project.editor import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.m import io.github.tritium_launcher.launcher.onClicked import io.github.tritium_launcher.launcher.qs @@ -10,6 +11,8 @@ import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr import io.github.tritium_launcher.launcher.ui.theme.qt.StyleBuilder import io.github.tritium_launcher.launcher.ui.theme.qt.icon import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollAxis +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.toolButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.widget @@ -17,6 +20,10 @@ import io.qt.Nullable import io.qt.core.* import io.qt.gui.* import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch /** * An individual Tab Bar for the Editor. @@ -37,6 +44,14 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { tabWidgets.forEach { it.updateColors() } } + private val scope = CoroutineScope(Dispatchers.Main) + + private val smoothScroll = AnimatedScrollController.attach( + scrollArea, + axis = AnimatedScrollAxis.Horizontal, + interceptWheel = false + ) + var selectedHoveredTabColor: String = "rgba(255,255,255,0.18)" set(value) { field = value @@ -79,7 +94,7 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) setFixedHeight(tabBarHeight) - val mainLayout = hBoxLayout(this) { + hBoxLayout(this) { contentsMargins = 0.m widgetSpacing = 0 addWidget(scrollArea) @@ -114,12 +129,8 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { onClicked { showTabListMenu() } } - ThemeMngr.addListener(themeListener) - destroyed.connect { ThemeMngr.removeListener(themeListener) } - content.setFixedHeight(tabBarHeight) content.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - content.setLayout(contentLayout) scrollArea.setWidget(content) scrollArea.horizontalScrollBar()?.apply { @@ -127,6 +138,14 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { rangeChanged.connect { _, _ -> updateOverflowState() } } + scope.launch { + ThemeMngr.currentThemeId.collect { + updateBorderStyle() + tabWidgets.forEach { tab -> tab.updateColors() } + } + } + destroyed.connect { scope.cancel() } + val wheelFilter = object : QObject(scrollArea) { override fun eventFilter( watched: @Nullable QObject?, @@ -211,6 +230,11 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { w.update() } + fun setTabModifiedAt(idx: Int, modified: Boolean) { + val w = tabWidgets.getOrNull(idx) ?: return + w.isModified = modified + } + fun setCurrentIndex(idx: Int) { if(idx < 0 || idx >= tabWidgets.size) return val prev = currentIndex @@ -256,7 +280,7 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { else -> return@run }.coerceIn(sb.minimum, sb.maximum) - sb.value = newVal + scrollTo(newVal, animate = !instant) } if(instant) { @@ -293,9 +317,12 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { } override fun paintEvent(event: @Nullable QPaintEvent?) { - val painter = QPainter(this) - painter.fillRect(rect, QColor(TColors.Surface0)) - painter.end() + val bgImage = CoreSettingValues.uiBackgroundImage + if (bgImage.isNullOrBlank()) { + val painter = QPainter(this) + painter.fillRect(rect, QColor(TColors.Surface0)) + painter.end() + } super.paintEvent(event) } @@ -326,18 +353,27 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { deltaPx = scrollByWholeTab(deltaPx) } - if(deltaPx == 0) { + if(deltaPx == 0) { e.accept(); return } + + if(scrollBehavior == ScrollBehavior.WholeTab) { + val wholeDelta = scrollByWholeTab(deltaPx) + if(wholeDelta != 0) { + val sb = scrollArea.horizontalScrollBar() ?: run { e.accept(); return } + val newVal = (sb.value + wholeDelta).coerceIn(sb.minimum, sb.maximum) + scrollTo(newVal, false) + } e.accept() return } - val sb = scrollArea.horizontalScrollBar() ?: run { + val sb = scrollArea.horizontalScrollBar() ?: run { e.accept(); return } + if (!CoreSettingValues.uiAnimateScrolling) { + sb.value = (sb.value + deltaPx).coerceIn(sb.minimum, sb.maximum) e.accept() return } - val newVal = (sb.value + deltaPx).coerceIn(sb.minimum, sb.maximum) - sb.value = newVal + smoothScroll.nudgeBy(deltaPx) e.accept() } @@ -371,11 +407,12 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { } private fun updateBorderStyle() { - val tabBarSurface = TColors.Surface0 + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() + val tabBarSurface = if (isBgImageSet) "transparent" else TColors.Surface0 val surfaceWithBottomBorder: StyleBuilder.() -> Unit = { backgroundColor(tabBarSurface) border() - border(borderWidth, borderColor, "bottom") margin(0) padding(0) } @@ -480,6 +517,14 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { QTimer.singleShot(0) { updateOverflowState() } } + private fun scrollTo(target: Int, animate: Boolean) { + smoothScroll.scrollTo(target, animate) + } + + private fun stopScrollAnim() { + smoothScroll.stop() + } + val count: Int get() = tabWidgets.size fun setTabText(idx: Int, text: String) { @@ -505,4 +550,5 @@ class EditorTabBar(parent: QWidget? = null) : QWidget(parent) { content.minimumWidth = widthHint content.maximumWidth = 16777215 } + } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketColorGenerator.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketColorGenerator.kt new file mode 100644 index 0000000..3900114 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketColorGenerator.kt @@ -0,0 +1,165 @@ +package io.github.tritium_launcher.launcher.ui.project.editor + +import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr +import kotlinx.serialization.json.* +import kotlin.math.abs +import kotlin.math.pow + +object RainbowBracketColorGenerator { + private val bracketDir: VPath by lazy { + TConstants.TR_DIR.resolve(TConstants.Dirs.SETTINGS).resolve("rainbow_brackets").also { it.mkdirs() } + } + private val json = Json { prettyPrint = true } + + private const val CACHE_VERSION = 1 + + private const val COLOR_COUNT = 8 + private const val MIN_CONTRAST = 3.5 + + private const val GOLDEN_ANGLE = 137.508f + + private val hexColorRegex = Regex("^#[0-9A-Fa-f]{6}$") + + fun loadOrGenerate(themeId: String): List { + val bgHex = resolveBgHex(themeId) + val file = fileFor(themeId) + if (file.exists()) { + loadFromFile(file, bgHex)?.let { return it } + } + return generateAndSave(themeId, bgHex, file) + } + + private fun fileFor(themeId: String): VPath = + bracketDir.resolve("$themeId.json") + + private fun loadFromFile(file: VPath, currentBgHex: String): List? = try { + val text = file.readTextOrNull() ?: return null + val obj = json.parseToJsonElement(text).jsonObject + + if (obj["version"]?.jsonPrimitive?.int != CACHE_VERSION) return null + if (obj["bg_hex"]?.jsonPrimitive?.content != currentBgHex) return null + + val storedCount = obj["count"]?.jsonPrimitive?.int ?: return null + val colors = obj["colors"]?.jsonArray?.map { it.jsonPrimitive.content } ?: return null + + if (colors.size != storedCount) return null + if (colors.any { !hexColorRegex.matches(it) }) return null + + colors + } catch (_: Exception) { null } + + private fun generateAndSave(themeId: String, bgHex: String, file: VPath): List { + val colors = generateColors(bgHex, COLOR_COUNT) + saveToFile(file, themeId, bgHex, colors) + return colors + } + + private fun resolveBgHex(themeId: String): String = + listOf("LineEdit.Bg", "Surface1", "Surface0") + .firstNotNullOfOrNull { ThemeMngr.getThemeColorHex(themeId, it) } + ?: "#242424" + + internal fun generateColors(bgHex: String, count: Int = COLOR_COUNT): List { + val bgLum = relativeLuminance(bgHex) + val isDark = bgLum < 0.4 + + val baseSat: Float + val targetLit: Float + + if (isDark) { + val t = ((0.4 - bgLum) / 0.4).coerceIn(0.0, 1.0).toFloat() + baseSat = 0.75f + t * 0.10f + targetLit = 0.55f + t * 0.20f + } else if (bgLum > 0.6) { + val t = ((bgLum - 0.6) / 0.4).coerceIn(0.0, 1.0).toFloat() + baseSat = 0.70f - t * 0.05f + targetLit = 0.45f - t * 0.22f + } else { + baseSat = 0.75f + targetLit = 0.50f + } + + return (0 until count).map { i -> + val hue = (i * GOLDEN_ANGLE) % 360f + + var lit = targetLit + var bestLit = lit + var bestContrast = 0.0 + + var attempts = 0 + while (attempts < 20) { + val hex = hslToHex(hue, baseSat, lit) + val fgLum = relativeLuminance(hex) + val contrast = contrastRatio(bgLum, fgLum) + + if (contrast > bestContrast) { + bestContrast = contrast + bestLit = lit + } + + if (contrast >= MIN_CONTRAST) break + + lit = if (isDark) (lit + 0.03f).coerceAtMost(0.9f) + else (lit - 0.03f).coerceAtLeast(0.1f) + attempts++ + } + hslToHex(hue, baseSat, bestLit) + } + } + + private fun saveToFile(file: VPath, themeId: String, bgHex: String, colors: List) { + val content = buildJsonObject { + put("version", CACHE_VERSION) + put("theme_id", themeId) + put("bg_hex", bgHex) + put("count", colors.size) + put("colors", JsonArray(colors.map { JsonPrimitive(it) })) + } + file.writeBytes(json.encodeToString(JsonObject.serializer(), content).toByteArray()) + } + + private fun hslToHex(hue: Float, sat: Float, lit: Float): String { + val c = (1f - abs(2f * lit - 1f)) * sat + val x = c * (1f - abs(((hue / 60f) % 2f) - 1f)) + val m = lit - c / 2f + val (r, g, b) = when { + hue < 60f -> Triple(c, x, 0f) + hue < 120f -> Triple(x, c, 0f) + hue < 180f -> Triple(0f, c, x) + hue < 240f -> Triple(0f, x, c) + hue < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + val ri = ((r + m) * 255).toInt().coerceIn(0, 255) + val gi = ((g + m) * 255).toInt().coerceIn(0, 255) + val bi = ((b + m) * 255).toInt().coerceIn(0, 255) + return "#%02X%02X%02X".format(ri, gi, bi) + } + + private fun parseHex(hex: String): Triple { + val h = hex.removePrefix("#") + if(h.length != 6 && h.length != 8) return Triple(0.5,0.5,0.5) + return try { + val r = h.substring(0, 2).toInt(16) / 255.0 + val g = h.substring(2, 4).toInt(16) / 255.0 + val b = h.substring(4, 6).toInt(16) / 255.0 + Triple(r, g, b) + } catch (_: NumberFormatException) { + Triple(0.5,0.5,0.5) + } + } + + private fun relativeLuminance(hex: String): Double { + val (r, g, b) = parseHex(hex) + fun linearize(c: Double) = if (c <= 0.03928) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4) + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) + } + + private fun contrastRatio(l1: Double, l2: Double): Double { + val lighter = maxOf(l1, l2) + val darker = minOf(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketHighlighter.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketHighlighter.kt new file mode 100644 index 0000000..b8844c8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/RainbowBracketHighlighter.kt @@ -0,0 +1,148 @@ +package io.github.tritium_launcher.launcher.ui.project.editor + +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.hexToQColor +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr +import io.qt.gui.QColor +import io.qt.gui.QTextCharFormat +import io.qt.gui.QTextCursor +import io.qt.gui.QTextDocument +import io.qt.widgets.QTextEdit + +object RainbowBracketHighlighter { + data class StackEntry(val char: Char, val depth: Int, val pos: Int, val resultIdx: Int) + private data class Palette( + val formats: List, + val errorFormat: QTextCharFormat + ) + + private var palette: Palette? = null + private var cachedThemeId: String? = null + + private val currentPalette: Palette + get() { + val currentTheme = ThemeMngr.currentThemeIdValue + if (cachedThemeId != currentTheme || palette == null) { + val hexColors = RainbowBracketColorGenerator.loadOrGenerate( + currentTheme.ifBlank { "default" } + ) + val formats = hexColors.map { hex -> + QTextCharFormat().apply { setForeground(QColor(hex)) } + } + val errorFormat = QTextCharFormat().apply { + setForeground(TColors.Error.hexToQColor()) + } + palette = Palette(formats, errorFormat) + cachedThemeId = currentTheme + } + return palette!! + } + + private val closerToOpener = mapOf(')' to '(', ']' to '[', '}' to '{') + + fun highlight(textEdit: QTextEdit): List { + if (!CoreSettingValues.editorRainbowBrackets) return emptyList() + val doc = textEdit.document ?: return emptyList() + val text = doc.toPlainText() + if (text.isEmpty()) return emptyList() + + val p = currentPalette + val result = mutableListOf() + val stack = ArrayDeque() + + var i = 0 + while(i < text.length) { + when { + // Skip line comments + text.startsWith("//", i) -> { + i = (text.indexOf('\n', i).takeIf { it != -1 }?.plus(1)) ?: text.length + } + + // Skip block comments + text.startsWith("/*", i) -> { + i = (text.indexOf("*/", i + 2).takeIf { it != -1 }?.plus(2)) ?: text.length + } + + // Skip strings + text[i] == '"' -> { + i++ + while(i < text.length && text[i] != '"') { + if(text[i] == '\\') i++ + i++ + } + i++ + } + + // Skip multiline strings + text.startsWith("\"\"\"", i) -> { + i += 3 + while (i < text.length && !text.startsWith("\"\"\"", i)) i++ + i += 3 + } + + // Skip chars + text[i] == '\'' -> { + i++ + while(i < text.length && text[i] != '\'') { + if(text[i] == '\\') i++ + i++ + } + i++ + } + + text[i] in "([{" -> { + val depth = stack.size + val resultIdx = result.size + result += selection(doc, i, i + 1, p.formats[depth % p.formats.size]) + stack.addLast(StackEntry(text[i], depth, i, resultIdx)) + i++ + } + + text[i] in ")]}" -> { + val expectedOpener = closerToOpener[text[i]] + + when { + stack.isNotEmpty() && stack.last().char == expectedOpener -> { + val entry = stack.removeLast() + result.add(selection(doc, i, i + 1, p.formats[entry.depth % p.formats.size])) + } + + else -> { + val matchIdx = stack.indexOfLast { it.char == expectedOpener } + if(matchIdx >= 0) { + while(stack.size > matchIdx + 1) { + val orphan = stack.removeLast() + result[orphan.resultIdx] = + selection(doc, orphan.pos, orphan.pos + 1, p.errorFormat) + } + val entry = stack.removeLast() + result.add(selection(doc, i, i + 1, p.formats[entry.depth % p.formats.size])) + } else { + result.add(selection(doc, i, i + 1, p.errorFormat)) + } + } + } + i++ + } + + else -> i++ + } + } + + for(entry in stack) result[entry.resultIdx] = selection(doc, entry.pos, entry.pos + 1, p.errorFormat) + + return result + } + + private fun selection(doc: QTextDocument, start: Int, end: Int, format: QTextCharFormat): QTextEdit.ExtraSelection { + val limit = (doc.characterCount() - 1).coerceAtLeast(0) + return QTextEdit.ExtraSelection().apply { + cursor = QTextCursor(doc).apply { + setPosition(start.coerceIn(0, limit)) + setPosition(end.coerceIn(0, limit), QTextCursor.MoveMode.KeepAnchor) + } + this.format = format + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/TextEditorPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/TextEditorPane.kt deleted file mode 100644 index d8a3132..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/TextEditorPane.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.github.tritium_launcher.launcher.ui.project.editor - -import io.github.tritium_launcher.launcher.core.project.ProjectBase -import io.github.tritium_launcher.launcher.io.VPath -import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.lsp.LSPMngr -import io.github.tritium_launcher.launcher.ui.project.editor.lsp.LSPEditorAdapter -import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage -import io.github.tritium_launcher.launcher.ui.project.editor.syntax.UniversalHighlighter -import io.qt.gui.QFont -import io.qt.gui.QSyntaxHighlighter -import io.qt.widgets.QFrame -import io.qt.widgets.QTextEdit -import io.qt.widgets.QWidget - -/** - * The default code editor pane for all files in the Editor. - */ -class TextEditorPane( - project: ProjectBase, - file: VPath, - language: SyntaxLanguage?, -): EditorPane(project, file) { - private val textEdit = QTextEdit() - private val font = QFont("JetBrains Mono", 11) - private val highlighter: QSyntaxHighlighter? - private val lspAdapter: LSPEditorAdapter? - - private val logger = logger() - - init { - textEdit.font = font - textEdit.lineWrapMode = QTextEdit.LineWrapMode.NoWrap - textEdit.frameShape = QFrame.Shape.NoFrame - textEdit.viewport()?.setContentsMargins(0, 0, 0, 0) - highlighter = language?.let { - UniversalHighlighter(textEdit.document!!, it)// TODO: Cannot leave assert - } - - lspAdapter = LSPMngr.getOrStart(project, file)?.let { connection -> - LSPEditorAdapter(file, textEdit, connection) - } - - loadFile() - } - - private fun loadFile() { - try { - if(file.exists()) { - textEdit.plainText = file.readTextOr("") - } else { - textEdit.plainText = "" - } - } catch (t: Throwable) { - logger.warn("Failed to load file {}", file.toAbsolute(), t) - textEdit.plainText = "" - } - } - - override fun widget(): QWidget = textEdit - - override fun onClose() { - lspAdapter?.close() - } - - override suspend fun save(): Boolean = try { - val text = textEdit.toPlainText() - file.writeBytes(text.toByteArray()) - true - } catch (t: Throwable) { - logger.error("Failed saving {}", file.toAbsolute(), t) - false - } -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/FileTypeDescriptor.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/FileTypeDescriptor.kt index dd69a36..7926d67 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/FileTypeDescriptor.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/FileTypeDescriptor.kt @@ -1,6 +1,7 @@ package io.github.tritium_launcher.launcher.ui.project.editor.file import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.registry.Registrable import io.qt.gui.QIcon @@ -15,7 +16,46 @@ interface FileTypeDescriptor : Registrable { fun matches(file: VPath, project: ProjectBase): Boolean fun languageId(file: VPath, project: ProjectBase): String? = null + /** + * Whether this file type supports creating new files via the Project Files context menu. + * Override and return true when [createDefaultFile] is implemented. + */ + val supportsCreation: Boolean get() = false + + /** + * Whether this file type can be created in [directory]. + * Used to filter the "New > FileType" menu entries. + * Defaults to [supportsCreation]. + */ + fun canCreateIn(directory: VPath, project: ProjectBase): Boolean = supportsCreation + + /** + * Default file name used as the starting value in the "New File" dialog. + */ + fun defaultFileName(): String = "untitled" + + /** + * Creates a new file of this type in [directory] with the given base [name]. + * Called from the "New > FileType" context menu entry in the Project Files tree. + * Only invoked when [supportsCreation] is true. + * @param directory The directory to create the file in + * @param name The file name entered by the user + * @param project The current project + * @return The path to the created file, or null if creation failed + */ + fun createDefaultFile(directory: VPath, name: String, project: ProjectBase): VPath? = null + companion object { + private var sortedTypes: List? = null + + fun matching(file: VPath, project: ProjectBase): List { + val sorted = sortedTypes ?: BuiltinRegistries.FileType.all().sortedBy { it.order }.also { sortedTypes = it } + return sorted.filter { it.matches(file, project) } + } + + fun primary(file: VPath, project: ProjectBase): FileTypeDescriptor? = + matching(file, project).firstOrNull() + fun create( id: String, displayName: String, @@ -23,21 +63,41 @@ interface FileTypeDescriptor : Registrable { matches: (VPath, ProjectBase) -> Boolean, languageId: ((VPath, ProjectBase) -> String?)? = null, order: Int = 0, - ): FileTypeDescriptor = object : FileTypeDescriptor { - override val id = id - override val displayName: String = displayName - override val order = order - override val icon = icon - - override fun matches( - file: VPath, - project: ProjectBase - ): Boolean = matches(file, project) - - override fun languageId( - file: VPath, - project: ProjectBase - ): String? = languageId?.invoke(file, project) + canCreateIn: ((VPath, ProjectBase) -> Boolean)? = null, + defaultFileName: (() -> String)? = null, + createDefaultFile: ((VPath, String, ProjectBase) -> VPath?)? = null, + ): FileTypeDescriptor { + val supports = createDefaultFile != null + return object : FileTypeDescriptor { + override val id = id + override val displayName: String = displayName + override val order = order + override val icon = icon + override val supportsCreation: Boolean get() = supports + + override fun matches( + file: VPath, + project: ProjectBase + ): Boolean = matches(file, project) + + override fun languageId( + file: VPath, + project: ProjectBase + ): String? = languageId?.invoke(file, project) + + override fun canCreateIn( + directory: VPath, + project: ProjectBase + ): Boolean = canCreateIn?.invoke(directory, project) ?: supports + + override fun defaultFileName(): String = defaultFileName?.invoke() ?: "untitled" + + override fun createDefaultFile( + directory: VPath, + name: String, + project: ProjectBase + ): VPath? = createDefaultFile?.invoke(directory, name, project) + } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/builtin/BuiltinFileTypes.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/builtin/BuiltinFileTypes.kt index 6c11260..6b57e83 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/builtin/BuiltinFileTypes.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/file/builtin/BuiltinFileTypes.kt @@ -20,7 +20,11 @@ object BuiltinFileTypes { displayName = "File", icon = TIcons.File.icon, matches = { file, _ -> file.isFile() }, - order = Int.MAX_VALUE + order = Int.MAX_VALUE, + createDefaultFile = { directory, name, _ -> + val file = directory.resolve(name) + runCatching { file.writeBytesAtomic(ByteArray(0)) }.getOrDefault(file) + } ) val Folder = FileTypeDescriptor.create( @@ -54,16 +58,6 @@ object BuiltinFileTypes { matches = { file, _ -> file.extension().matches("js", "jsx", "mdx", "mjs") } ) - val KubeScript = FileTypeDescriptor.create( - id = "kubescript", - displayName = "KubeJS Script", - icon = TIcons.KubeScript.icon, - matches = { file, _ -> - file.parent().toString().matches("startup_scripts", "server_scripts", "client_scripts") && - file.extension().matches("js") - } - ) - val TS = FileTypeDescriptor.create( id = "ts", displayName = "TypeScript", @@ -241,11 +235,12 @@ object BuiltinFileTypes { icon = TIcons.ModConfig.icon, matches = { file, _ -> file.extension().matches( - "json", "json5", "toml", "properties", "cfg", "conf", "hocon", "yaml", "yml" + "json", "json5", "toml", "properties", "cfg", "yaml", "yml", "jsonc", "ini" ) && - file.parent().toString().matches("config", "defaultconfigs") && + file.parent().fileName().matches("config", "defaultconfigs") && file.parent().parent().exists() - } + }, + order = -100 ) val ZenScript = FileTypeDescriptor.create( @@ -293,7 +288,7 @@ object BuiltinFileTypes { ) fun all() = listOf( - File, Folder, CSV, HTML, JS, KubeScript, TS, Image, Json, TOML, Archive, Jar, Markdown, CSS, Python, Shell, + File, Folder, CSV, HTML, JS, TS, Image, Json, TOML, Archive, Jar, Markdown, CSS, Python, Shell, Powershell, Yaml, ModConfig, ZenScript, AnvilRegion, SessionLock, NBT, Schematic, McFunction ) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItem.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItem.kt new file mode 100644 index 0000000..533a885 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItem.kt @@ -0,0 +1,9 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.intelligence + +data class CompletionItem( + val label: String, + val kind: CompletionItemKind = CompletionItemKind.Text, + val detail: String? = null, + val documentation: String? = null, + val insertText: String? = null +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItemKind.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItemKind.kt new file mode 100644 index 0000000..aa4e73d --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/CompletionItemKind.kt @@ -0,0 +1,13 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.intelligence + +enum class CompletionItemKind { + Text, + Method, + Field, + Variable, + Class, + Function, + Property, + Keyword, + Module +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/HoverContent.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/HoverContent.kt new file mode 100644 index 0000000..1bd2d23 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/intelligence/HoverContent.kt @@ -0,0 +1,5 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.intelligence + +data class HoverContent( + val markdown: String +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/CompletionPopup.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/CompletionPopup.kt new file mode 100644 index 0000000..69fcbb1 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/CompletionPopup.kt @@ -0,0 +1,121 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.lsp + +import io.github.tritium_launcher.launcher.ui.project.editor.intelligence.CompletionItem +import io.qt.core.QMetaObject +import io.qt.core.Qt +import io.qt.gui.QFont +import io.qt.gui.QFontMetrics +import io.qt.gui.QKeyEvent +import io.qt.widgets.* + +/** + * Popup widget that displays code completion suggestions in a frameless list. + */ +class CompletionPopup(parent: QWidget?) : QFrame(parent) { + private val listWidget = QListWidget() + var onSelected: ((CompletionItem) -> Unit)? = null + private var completions: List = emptyList() + + init { + setWindowFlags(Qt.WindowType.ToolTip, Qt.WindowType.FramelessWindowHint) + setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) + lineWidth = 1 + focusPolicy = Qt.FocusPolicy.NoFocus + + val layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + setLayout(layout) + + listWidget.font = QFont("JetBrains Mono", 11) //TODO: Use set font + listWidget.horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + listWidget.focusPolicy = Qt.FocusPolicy.NoFocus + val slot = QMetaObject.Slot1 { item -> + val idx = listWidget.row(item) + if (idx in completions.indices) { + onSelected?.invoke(completions[idx]) + hide() + } + } + listWidget.itemClicked.connect(slot) + layout.addWidget(listWidget) + maximumHeight = 250 + } + + /** + * Populates the popup with completion items and resizes to fit their content. + * + * @param items Completion suggestions to display. + */ + fun setCompletions(items: List) { + completions = items + listWidget.clear() + val fm = QFontMetrics(listWidget.font()) + var maxWidth = 200 + for (item in items) { + val displayText = if (item.detail != null) "${item.label} · ${item.detail}" else item.label + val listItem = QListWidgetItem(displayText) + if (item.documentation != null) { + listItem.setToolTip(item.documentation) + } + listWidget.addItem(listItem) + maxWidth = maxOf(maxWidth, fm.horizontalAdvance(displayText)) + } + setFixedWidth((maxWidth + 30).coerceIn(200, 600)) + if (items.isNotEmpty()) { + listWidget.currentRow = 0 + } + } + + /** + * Handles keyboard input for navigation and selection. + * + * @param event Incoming key event from the editor. + * @return `true` if the event was handled, `false` to let it propagate. + */ + fun handleKeyEvent(event: QKeyEvent): Boolean { + when (event.key()) { + Qt.Key.Key_Up.value() -> { + val row = listWidget.currentRow() + if (row > 0) listWidget.currentRow = row - 1 + return true + } + Qt.Key.Key_Down.value() -> { + val row = listWidget.currentRow() + if (row < listWidget.count() - 1) listWidget.currentRow = row + 1 + return true + } + Qt.Key.Key_Tab.value() -> { + val item = listWidget.currentItem() ?: return true + val idx = listWidget.row(item) + if (idx in completions.indices) { + onSelected?.invoke(completions[idx]) + } + hide() + return true + } + Qt.Key.Key_Return.value(), Qt.Key.Key_Enter.value() -> { + val item = listWidget.currentItem() ?: return true + val idx = listWidget.row(item) + if (idx in completions.indices) { + onSelected?.invoke(completions[idx]) + } + hide() + return true + } + Qt.Key.Key_Escape.value() -> { + hide() + return true + } + } + return false + } + + /** + * Clears completion state and closes the popup. + */ + fun cleanup() { + listWidget.clear() + completions = emptyList() + close() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/HoverOverlay.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/HoverOverlay.kt new file mode 100644 index 0000000..60b1ccc --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/HoverOverlay.kt @@ -0,0 +1,56 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.lsp + +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.qt.core.QPoint +import io.qt.core.Qt +import io.qt.gui.QFont +import io.qt.widgets.QLabel +import io.qt.widgets.QVBoxLayout +import io.qt.widgets.QWidget +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer + +class HoverOverlay(parent: QWidget?) : QWidget(parent) { + private val label = QLabel() + + private val markdownParser = Parser.builder() + .extensions(listOf(TablesExtension.create())) + .build() + private val markdownRenderer = HtmlRenderer.builder() + .extensions(listOf(TablesExtension.create())) + .escapeHtml(false) + .build() + + init { + setWindowFlags(Qt.WindowType.Popup) + setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) + + val layout = QVBoxLayout() + layout.setContentsMargins(4, 4, 4, 4) + setLayout(layout) + + label.wordWrap = true + layout.addWidget(label) + + maximumWidth = 400 + } + + fun showHover(text: String, globalPos: QPoint) { + val (family, size) = CoreSettingValues.editorFont() + label.font = QFont(family, size) + val doc = markdownParser.parse(text) + val body = markdownRenderer.render(doc) + label.text = "
$body
" + adjustSize() + move(globalPos.x() + 10, globalPos.y() + 10) + raise() + show() + repaint() + } + + fun cleanup() { + label.clear() + close() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/LSPEditorAdapter.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/LSPEditorAdapter.kt index a6daed8..e9c2349 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/LSPEditorAdapter.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/lsp/LSPEditorAdapter.kt @@ -6,18 +6,16 @@ import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.lsp.LSPConnection import io.github.tritium_launcher.launcher.lsp.LSPEventBus import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.project.editor.RainbowBracketHighlighter import io.github.tritium_launcher.launcher.ui.theme.TColors import io.qt.gui.QColor import io.qt.gui.QTextCharFormat import io.qt.gui.QTextCursor import io.qt.gui.QTextDocument import io.qt.widgets.QTextEdit +import kotlinx.coroutines.* import org.eclipse.lsp4j.* -/** - * Bridges a QTextEdit instance to the LSP textDocument notifications and - * renders diagnostics as underlines in the editor. - */ class LSPEditorAdapter( val file: VPath, val textEdit: QTextEdit, @@ -25,8 +23,12 @@ class LSPEditorAdapter( ) { private val uri = file.toFileUriEncoded().toString() private var version = 0 - private val listenerId: Int private var isReady = false + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var diagnosticsJob: Job? = null + + var semanticSelections: List = emptyList() + private var diagnosticSelections: List = emptyList() init { connection.ready.thenRun { @@ -38,28 +40,40 @@ class LSPEditorAdapter( } textEdit.textChanged.connect { - if(!isReady) { - return@connect - } + if (!isReady) return@connect sendDidChange(textEdit.toPlainText()) + flushSelections() } - listenerId = LSPEventBus.subscribe { params -> - if(params.uri == uri) applyDiagnostics(params.diagnostics) + diagnosticsJob = scope.launch { + LSPEventBus.diagnostics.collect { params -> + if (params.uri == uri) { + applyDiagnostics(params.diagnostics) + } + } } } - /** - * Unregisters diagnostics listener and notifies the server that the document closed. - */ + fun openDocument(text: String) { + if (isReady) { + sendDidOpen(text) + } + } + + fun updateSemanticSelections(selections: List) { + semanticSelections = selections + flushSelections() + } + fun close() { - if(isReady) { + diagnosticsJob?.cancel() + scope.cancel() + if (isReady) { connection.server.textDocumentService.didClose( DidCloseTextDocumentParams(TextDocumentIdentifier(uri)) ) } - textEdit.setExtraSelections(emptyList()) - LSPEventBus.unsubscribe(listenerId) + flushSelections() io.github.tritium_launcher.launcher.lsp.LSPMngr.release(connection.project, connection.langId) } @@ -76,16 +90,13 @@ class LSPEditorAdapter( }) } - /** - * Applies diagnostics as underline selections in the editor. - */ private fun applyDiagnostics(diagnostics: List) { runOnGuiThread { val doc = textEdit.document ?: return@runOnGuiThread val selections = diagnostics.map { diag -> val start = getOffset(doc, diag.range.start) val end = getOffset(doc, diag.range.end) - val color = when(diag.severity) { + val color = when (diag.severity) { DiagnosticSeverity.Error -> TColors.Syntax.Error.hexToQColor() DiagnosticSeverity.Warning -> TColors.Syntax.Warning.hexToQColor() DiagnosticSeverity.Information -> TColors.Syntax.Information.hexToQColor() @@ -103,16 +114,18 @@ class LSPEditorAdapter( } } } - textEdit.setExtraSelections(selections) + diagnosticSelections = selections + flushSelections() } } - /** - * Converts LSP line/character positions into Qt document offsets. - */ + private fun flushSelections() { + textEdit.setExtraSelections(semanticSelections + diagnosticSelections + RainbowBracketHighlighter.highlight(textEdit)) + } + private fun getOffset(doc: QTextDocument, pos: Position): Int { val block = doc.findBlockByLineNumber(pos.line) - if(!block.isValid) return 0 + if (!block.isValid) return 0 return (block.position() + pos.character).coerceIn(0, doc.characterCount() - 1) } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/ImageViewerPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ImageViewerPane.kt similarity index 93% rename from src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/ImageViewerPane.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ImageViewerPane.kt index aa61c94..0dbdc9a 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/ImageViewerPane.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ImageViewerPane.kt @@ -1,4 +1,4 @@ -package io.github.tritium_launcher.launcher.ui.project.editor.pane +package io.github.tritium_launcher.launcher.ui.project.editor.panes import io.github.tritium_launcher.launcher.TConstants import io.github.tritium_launcher.launcher.connect @@ -12,6 +12,7 @@ import io.github.tritium_launcher.launcher.ui.project.editor.EditorPaneProvider import io.github.tritium_launcher.launcher.ui.project.editor.syntax.UniversalHighlighter import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget @@ -30,6 +31,7 @@ import io.qt.widgets.* * @see EditorPane */ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, file) { + private val paneFile: VPath = file private var originalPixmap: QPixmap? = null private var scaledPixmap: QPixmap? = null private var movie: QMovie? = null @@ -106,6 +108,7 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi font = QFont("JetBrains Mono", 10) lineWrapMode = QTextEdit.LineWrapMode.NoWrap isVisible = false + isReadOnly = true } private val content = qWidget { @@ -145,6 +148,11 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi } } + init { + AnimatedScrollController.attach(scroll) + AnimatedScrollController.attach(svgTextEdit) + } + private fun toggleMoviePlayback() { val m = movie ?: return when (m.state()) { @@ -168,7 +176,7 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi } override fun onOpen() { - val path = file.toAbsoluteString() + val path = paneFile.toAbsoluteString() val m = QMovie(path) if(m.isValid) { @@ -185,13 +193,13 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi updateMovieControls() imageLabel.updatePixmap() - val isSvg = file.extension().matches("svg", "svgz") + val isSvg = paneFile.extension().matches("svg", "svgz") if(isSvg) { - svgTextEdit.plainText = file.readTextOr("") + svgTextEdit.plainText = paneFile.readTextOr("") svgTextEdit.isVisible = true val syntaxRegistry = BuiltinRegistries.SyntaxLanguage - val lang = syntaxRegistry.all().find { it.matches(file) } + val lang = syntaxRegistry.all().find { it.matches(paneFile) } if(lang != null) { UniversalHighlighter(svgTextEdit.document!!, lang) } @@ -202,7 +210,7 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi val infoFromMovie = movie != null val pix = if(infoFromMovie) movie!!.currentPixmap() else originalPixmap if(pix != null && !pix.isNull) { - val bytes = file.sizeOrNull() ?: 0L + val bytes = paneFile.sizeOrNull() ?: 0L val sizeStr = formatFileSize(bytes) val frames = movie?.frameCount() ?: 0 val typeInfo = if(movie != null && frames > 1) ", Animation ($frames frames)" else "" @@ -226,16 +234,6 @@ class ImageViewerPane(project: ProjectBase, file: VPath): EditorPane(project, fi imageLabel.clear() } - override suspend fun save(): Boolean { - if(svgTextEdit.isVisible) { - return try { - file.writeBytes(svgTextEdit.toPlainText().toByteArray()) - true - } catch (_: Throwable) { false } - } - return true - } - private fun formatFileSize(bytes: Long): String { val units = arrayOf("B", "KB", "MB", "GB") var size = bytes.toDouble() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModBrowserState.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModBrowserState.kt new file mode 100644 index 0000000..b0f92bc --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModBrowserState.kt @@ -0,0 +1,68 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.panes + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.source.ModDependencyRef +import io.github.tritium_launcher.launcher.core.source.ModDetails +import io.github.tritium_launcher.launcher.core.source.ModSearchResult +import io.github.tritium_launcher.launcher.core.source.ModVersionOption +import io.qt.gui.QIcon +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +data class QueuedDownload( + val projectId: String, + val title: String, + val versionId: String, + val versionLabel: String, + val iconUrl: String?, + val dependencies: List, + val status: QueueStatus, + val requiresManualDownload: Boolean = false, + val projectUrl: String? = null, + val fileHash: String? = null, +) + +data class QueueStatus( + val missingDependencies: List = emptyList(), + val incompatibleWith: List = emptyList() +) + +object ModBrowserState { + private val projectStates = ConcurrentHashMap() + + fun forProject(project: ProjectBase): BrowserState { + val key = project.projectDir.toAbsolute().toString() + return projectStates.getOrPut(key) { BrowserState() } + } + + fun clearProject(project: ProjectBase) { + projectStates.remove(project.projectDir.toAbsolute().toString()) + } + + class BrowserState { + val detailsCache = ConcurrentHashMap() + val versionsCache = ConcurrentHashMap>() + val iconCache = ConcurrentHashMap() + val dominantColorCache = ConcurrentHashMap>() + val queuedDownloads = Collections.synchronizedMap(linkedMapOf()) + val manuallyQueuedIds = Collections.synchronizedSet(linkedSetOf()) + val queuedDetailIds = ConcurrentHashMap.newKeySet() + val resultsCache = Collections.synchronizedMap(linkedMapOf()) + + fun clearSearchState() { + resultsCache.clear() + queuedDetailIds.clear() + } + + fun clearAll() { + detailsCache.clear() + versionsCache.clear() + iconCache.clear() + dominantColorCache.clear() + queuedDownloads.clear() + manuallyQueuedIds.clear() + queuedDetailIds.clear() + resultsCache.clear() + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModConfigPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModConfigPane.kt new file mode 100644 index 0000000..f31799d --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModConfigPane.kt @@ -0,0 +1,746 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.panes + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.mod_config.* +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.m +import io.github.tritium_launcher.launcher.ui.project.editor.EditorPane +import io.github.tritium_launcher.launcher.ui.project.editor.EditorPaneProvider +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor +import io.github.tritium_launcher.launcher.ui.project.editor.file.builtin.BuiltinFileTypes +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.* +import io.qt.core.QEvent +import io.qt.core.QObject +import io.qt.core.Qt +import io.qt.gui.QFont +import io.qt.gui.QIcon +import io.qt.widgets.* +import java.math.BigDecimal + +internal class ModConfigPane( + project: ProjectBase, + file: VPath, + private var root: ConfigNode, + private val format: ConfigFormat +) : EditorPane(project, file) { + private val paneFile: VPath get() = file!! + + private fun applyItemMargins(layout: QBoxLayout, compact: Boolean) { + if (compact) { + layout.setContentsMargins(0, 8, 0, 8) + } else { + layout.setContentsMargins(0, 12, 0, 12) + } + } + + private val container = qWidget { + objectName = "modConfigPaneContainer" + autoFillBackground = true + } + private val layout = vBoxLayout(container) { + setAlignment(Qt.AlignmentFlag.AlignTop) + contentsMargins = 0.m + widgetSpacing = 0 + } + private val scrollArea = QScrollArea().apply { + objectName = "modConfigScrollArea" + widgetResizable = true + frameShape = QFrame.Shape.NoFrame + horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + setWidget(container) + } + + init { + AnimatedScrollController.attach(scrollArea) + scrollArea.viewport()?.objectName = "modConfigScrollViewport" + scrollArea.setThemedStyle { + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() + + selector("#modConfigScrollArea") { + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(TColors.Surface1) + } + border() + } + selector("#modConfigScrollViewport") { + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(TColors.Surface1) + } + border() + } + selector("#modConfigPaneContainer") { + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(TColors.Surface1) + } + border() + } + } + } + + override fun widget(): QWidget = scrollArea + override fun onOpen() { + val bgImage = CoreSettingValues.uiBackgroundImage + container.autoFillBackground = bgImage.isNullOrBlank() + rebuild() + } + + override suspend fun save(): Boolean = try { + paneFile.writeBytes(serialize().toByteArray()) + rebuild() + modified = false + true + } catch (_: Throwable) { + false + } + + fun rebuild() { + while(layout.count() > 0) { + layout.takeAt(0)?.widget()?.disposeLater() + } + + val compactTopLevel = hasTopLevelOnlySettings() + layout.setSpacing(if (compactTopLevel) 2 else 0) + + when(root) { + is ConfigObj -> { + val pendingComments = mutableListOf() + for((key, child) in (root as ConfigObj).entries) { + when { + key.startsWith("__comment_") -> pendingComments.add(child as ConfigComment) + child is ConfigObj -> { + pendingComments.clear() + layout.addWidget(buildWidget(child, key, listOf(key), FieldMeta(), compactTopLevel)) + } + else -> { + val meta = parseMetaFromComments(pendingComments) + pendingComments.clear() + layout.addWidget(buildWidget(child, key, listOf(key), meta, compactTopLevel)) + } + } + } + } + else -> layout.addWidget(buildWidget(root, null, emptyList(), FieldMeta(), compactTopLevel)) + } + layout.addStretch(1) + } + + fun serialize(): String = format.serialize(root) + + fun onNodeChanged(path: List, newNode: ConfigNode) { + mutateTree(root, path, newNode) + modified = true + } + + private fun mutateTree(node: ConfigNode, path: List, newNode: ConfigNode) { + if(path.isEmpty()) return + val parent = node as? ConfigObj ?: return + if(path.size == 1) { + parent.entries[path.first()] = newNode + } else { + mutateTree(parent.entries[path.first()]!!, path.drop(1), newNode) + } + } + + fun buildWidget(node: ConfigNode, key: String?, path: List, meta: FieldMeta, compact: Boolean = false): QWidget = when (node) { + is ConfigObj -> buildSection(node, key, path) + is ConfigArray -> buildArray(node, key, path, compact) + is ConfigString -> buildTextField(node, key, path, meta, compact) + is ConfigInt -> buildIntSpinner(node, key, path, meta, compact) + is ConfigDouble -> buildDoubleSpinner(node, key, path, meta, compact) + is ConfigBool -> buildCheckbox(node, key, path, meta, compact) + is ConfigComment -> qWidget() + is ConfigNull -> buildNullBadge(key, path, compact) + } + + fun parseMetaFromComments(comments: List): FieldMeta { + val descLines = mutableListOf() + var default: String? = null + var min: Double? = null + var max: Double? = null + + val defaultRegex = Regex("""default\s*:\s*([^;]+)""", RegexOption.IGNORE_CASE) + val rangeRegex = Regex("""range\s*:\s*\[([\d.\-]+)\s*~\s*([\d.\-]+)]""", RegexOption.IGNORE_CASE) + + for (comment in comments) { + val text = comment.text.trim() + when { + defaultRegex.containsMatchIn(text) -> { + default = defaultRegex.find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.trim('`') + ?.trim() + + rangeRegex.find(text)?.let { + min = it.groupValues[1].toDoubleOrNull() + max = it.groupValues[2].toDoubleOrNull() + } + } + rangeRegex.containsMatchIn(text) -> { + rangeRegex.find(text)?.let { + min = it.groupValues[1].toDoubleOrNull() + max = it.groupValues[2].toDoubleOrNull() + } + } + text.isNotBlank() -> descLines.add(text) + } + } + + return FieldMeta(descLines.joinToString(" "), default, min, max) + } + + private fun parseDefaultNode(template: ConfigNode, meta: FieldMeta): ConfigNode? { + val raw = meta.default?.trim()?.trim('`')?.trim() ?: return null + return when (template) { + is ConfigString -> ConfigString(raw.trim('"')) + is ConfigInt -> raw.toIntOrNull()?.let(::ConfigInt) + is ConfigDouble -> raw.toDoubleOrNull()?.let(::ConfigDouble) + is ConfigBool -> when (raw.lowercase()) { + "true" -> ConfigBool(true) + "false" -> ConfigBool(false) + else -> null + } + is ConfigNull -> when { + raw.equals("null", ignoreCase = true) -> ConfigNull() + raw.equals("true", ignoreCase = true) -> ConfigBool(true) + raw.equals("false", ignoreCase = true) -> ConfigBool(false) + raw.toIntOrNull() != null -> ConfigInt(raw.toInt()) + raw.toDoubleOrNull() != null -> ConfigDouble(raw.toDouble()) + else -> ConfigString(raw.trim('"')) + } + else -> null + } + } + + fun buildSection( + node: ConfigObj, + label: String?, + path: List + ): QWidget { + val section = qWidget() + val outerLayout = vBoxLayout(section) { + contentsMargins = 0.m + widgetSpacing = 0 + } + val innerLayoutHost = qWidget() + val innerLayout = vBoxLayout(innerLayoutHost) { + setContentsMargins(18, 0, 0, 8) + widgetSpacing = 0 + } + + if (label != null) { + var expanded = true + + val header = qWidget { + objectName = "modConfigSectionHeader" + minimumHeight = 32 + setCursor(Qt.CursorShape.PointingHandCursor) + } + val hl = hBoxLayout(header) { + setContentsMargins(12, 8, 12, 6) + widgetSpacing = 10 + } + + val titleLbl = QLabel(displayLabel(label).uppercase()).apply { + objectName = "modConfigSectionTitle" + } + val ruleLine = QFrame().apply { + objectName = "modConfigSectionRule" + frameShape = QFrame.Shape.HLine + frameShadow = QFrame.Shadow.Plain + lineWidth = 1 + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + maximumHeight = 1 + } + val arrowLbl = QLabel("▾").apply { + objectName = "modConfigSectionArrow" + } + + hl.addWidget(titleLbl, 0) + hl.addWidget(ruleLine, 1) + hl.addWidget(arrowLbl, 0) + + header.setThemedStyle { + val isBgImageSet = !CoreSettingValues.uiBackgroundImage.isNullOrBlank() + selector("QWidget#modConfigSectionHeader") { + backgroundColor(if (isBgImageSet) "transparent" else TColors.Surface1) + } + selector("QLabel#modConfigSectionTitle") { + color(TColors.Subtext) + fontSize(11) + padding(0, 0, 0, 2) + } + selector("QFrame#modConfigSectionRule") { + backgroundColor(TColors.Surface0) + border() + maxHeight(1) + } + selector("QLabel#modConfigSectionArrow") { + color(TColors.Subtext) + fontSize(11) + } + } + + val clickFilter = object : QObject(header) { + override fun eventFilter(watched: QObject?, event: QEvent?): Boolean { + if (watched === header && event?.type() == QEvent.Type.MouseButtonRelease) { + expanded = !expanded + innerLayoutHost.isVisible = expanded + arrowLbl.text = if (expanded) "▾" else "▸" + } + return super.eventFilter(watched, event) + } + } + header.installEventFilter(clickFilter) + + outerLayout.addWidget(header) + } + + val pendingComments = mutableListOf() + + for((key, value) in node.entries) { + when { + key.startsWith("__comment_") -> { + pendingComments.add(value as ConfigComment) + } + value is ConfigObj -> { + pendingComments.clear() + innerLayout.addWidget(buildWidget(value, key, path + key, FieldMeta())) + } + else -> { + val meta = parseMetaFromComments(pendingComments) + pendingComments.clear() + innerLayout.addWidget(buildWidget(value, key, path + key, meta)) + } + } + } + + outerLayout.addWidget(innerLayoutHost) + return section + } + + fun buildArray( + node: ConfigArray, + label: String?, + path: List, + compact: Boolean + ): QWidget { + val container = qWidget() + val outerLayout = vBoxLayout(container) { + widgetSpacing = if (compact) 6 else 8 + } + applyItemMargins(outerLayout, compact) + + val headerLayout = hBoxLayout { + contentsMargins = 0.m + widgetSpacing = 8 + } + + val title = label(displayLabel(label ?: "List")) { + wordWrap = true + font = QFont(font).apply { + setBold(true) + } + } + headerLayout.addWidget(title, 0) + + val addBtn = QToolButton().apply { + text = "+" + objectName = "modConfigInlineButton" + setFixedSize(28, 28) + } + + headerLayout.addWidget(addBtn) + headerLayout.addStretch() + outerLayout.addLayout(headerLayout) + + val itemsContainer = qWidget() + val itemsLayout = vBoxLayout(itemsContainer) { + setContentsMargins(16, 2, 0, 0) + widgetSpacing = 8 + } + outerLayout.addWidget(itemsContainer) + + fun rebuildItems() { + while(itemsLayout.count() > 0) { + itemsLayout.takeAt(0)?.widget()?.disposeLater() + } + node.items.forEachIndexed { i, child -> + val row = qWidget() + val rowLayout = hBoxLayout(row) { + contentsMargins = 0.m + widgetSpacing = 8 + } + + val childWidget = buildWidget(child, i.toString(), path + i.toString(), FieldMeta()) + rowLayout.addWidget(childWidget, 0) + + val removeBtn = QToolButton().apply { + text = "-" + objectName = "modConfigInlineButton" + setFixedSize(28, 28) + clicked.connect { + node.items.removeAt(i) + rebuildItems() + onNodeChanged(path, node) + } + } + rowLayout.addWidget(removeBtn, 0, Qt.AlignmentFlag.AlignTop) + rowLayout.addStretch() + itemsLayout.addWidget(row) + } + } + + rebuildItems() + + addBtn.clicked.connect { + val template = node.items.firstOrNull() ?: ConfigString("") + node.items.add(cloneEmpty(template)) + rebuildItems() + onNodeChanged(path, node) + } + + return container + } + + fun cloneEmpty(template: ConfigNode): ConfigNode = when(template) { + is ConfigString -> ConfigString("") + is ConfigInt -> ConfigInt(0) + is ConfigDouble -> ConfigDouble(0.0) + is ConfigBool -> ConfigBool(false) + is ConfigObj -> ConfigObj() + is ConfigArray -> ConfigArray() + else -> ConfigString("") + } + + fun buildLabeledRow( + key: String?, + meta: FieldMeta, + currentNode: ConfigNode, + widget: QWidget, + trailing: QWidget? = null, + compact: Boolean = false + ): Pair Unit> { + val container = qWidget() + val outerLayout = vBoxLayout(container) { + widgetSpacing = if (compact) 6 else 8 + } + applyItemMargins(outerLayout, compact) + + if (key != null) { + val lbl = label(displayLabel(key)) { + wordWrap = true + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + font = QFont(font).apply { + setBold(true) + } + } + outerLayout.addWidget(lbl) + } + + if (meta.description.isNotBlank()) { + val desc = label(meta.description) { + wordWrap = true + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + } + outerLayout.addWidget(desc) + } + + val row = qWidget() + val rowLayout = hBoxLayout(row) { + contentsMargins = 0.m + widgetSpacing = 8 + } + rowLayout.addWidget(widget, 0) + trailing?.let { rowLayout.addWidget(it, 0) } + rowLayout.addStretch() + outerLayout.addWidget(row) + + return wrapWithModifiedIndicator(container, currentNode, meta) + } + + private fun buildResetButton(control: QWidget, node: ConfigNode, meta: FieldMeta, onReset: (ConfigNode) -> Unit): QToolButton? { + val defaultNode = parseDefaultNode(node, meta) ?: return null + val size = control.sizeHint().height().coerceAtLeast(control.minimumSizeHint().height()).coerceAtLeast(22) + return QToolButton().apply { + icon = style()?.standardIcon(QStyle.StandardPixmap.SP_BrowserReload) ?: QIcon(TIcons.QuestionMark) + toolTip = "Reset to default (${meta.default})" + setFixedSize(size, size) + clicked.connect { onReset(defaultNode) } + } + } + + + fun buildTextField(node: ConfigString, key: String?, path: List, meta: FieldMeta, compact: Boolean): QWidget { + val field = QLineEdit(node.value).apply { + minimumHeight = 30 + minimumWidth = 280 + maximumWidth = 420 + } + if (meta.default != null) field.toolTip = "Default: ${meta.default}" + field.textChanged.connect { new -> + onNodeChanged(path, ConfigString(new)) + } + val reset = buildResetButton(field, node, meta) { defaultNode -> + val value = (defaultNode as? ConfigString)?.value ?: return@buildResetButton + if (field.text != value) field.text = value + onNodeChanged(path, ConfigString(value)) + } + return buildLabeledRow(key, meta, node, field, reset, compact).first + } + + fun buildIntSpinner(node: ConfigInt, key: String?, path: List, meta: FieldMeta, compact: Boolean): QWidget { + val spinner = QSpinBox().apply { + minimumHeight = 30 + minimumWidth = 160 + maximumWidth = 220 + } + spinner.setRange( + meta.min?.toInt() ?: Int.MIN_VALUE, + meta.max?.toInt() ?: Int.MAX_VALUE + ) + spinner.value = node.value + if (meta.default != null) spinner.toolTip = "Default: ${meta.default}" + spinner.valueChanged.connect { newVal -> + onNodeChanged(path, ConfigInt(newVal.toInt())) + } + val reset = buildResetButton(spinner, node, meta) { defaultNode -> + val value = (defaultNode as? ConfigInt)?.value ?: return@buildResetButton + if (spinner.value != value) spinner.value = value + onNodeChanged(path, ConfigInt(value)) + } + return buildLabeledRow(key, meta, node, spinner, reset, compact).first + } + + fun buildDoubleSpinner(node: ConfigDouble, key: String?, path: List, meta: FieldMeta, compact: Boolean): QWidget { + val spinner = object : QDoubleSpinBox() { + override fun textFromValue(value: Double): String { + val normalized = BigDecimal.valueOf(value).stripTrailingZeros().toPlainString() + return if (normalized.contains('.')) normalized else "$normalized.0" + } + }.apply { + minimumHeight = 30 + minimumWidth = 180 + maximumWidth = 240 + } + spinner.decimals = 12 + spinner.stepType = QAbstractSpinBox.StepType.AdaptiveDecimalStepType + spinner.setRange( + meta.min ?: -Double.MAX_VALUE, + meta.max ?: Double.MAX_VALUE + ) + spinner.value = node.value + if (meta.default != null) spinner.toolTip = "Default: ${meta.default}" + spinner.valueChanged.connect { newVal -> + onNodeChanged(path, ConfigDouble(newVal)) + } + val reset = buildResetButton(spinner, node, meta) { defaultNode -> + val value = (defaultNode as? ConfigDouble)?.value ?: return@buildResetButton + if (spinner.value != value) spinner.value = value + onNodeChanged(path, ConfigDouble(value)) + } + return buildLabeledRow(key, meta, node, spinner, reset, compact).first + } + + fun buildCheckbox(node: ConfigBool, key: String?, path: List, meta: FieldMeta, compact: Boolean): QWidget { + val checkbox = QCheckBox() + checkbox.isChecked = node.value + if (meta.default != null) checkbox.toolTip = "Default: ${meta.default}" + checkbox.stateChanged.connect { state -> + onNodeChanged(path, ConfigBool(state == Qt.CheckState.Checked.value())) + } + val checkboxRow = qWidget() + val checkboxLayout = hBoxLayout(checkboxRow) { + contentsMargins = 0.m + widgetSpacing = 10 + } + checkboxLayout.addWidget(checkbox, 0) + if (meta.description.isNotBlank()) { + checkboxLayout.addWidget(label(meta.description) { + wordWrap = true + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + }, 0) + } + val reset = buildResetButton(checkbox, node, meta) { defaultNode -> + val value = (defaultNode as? ConfigBool)?.value ?: return@buildResetButton + if (checkbox.isChecked != value) checkbox.isChecked = value + onNodeChanged(path, ConfigBool(value)) + } + reset?.let { checkboxLayout.addWidget(it, 0) } + checkboxLayout.addStretch() + return buildSettingCard(key, meta, node, checkboxRow, inlineDescription = true, compact = compact).first + } + + fun buildNullBadge(key: String?, path: List, compact: Boolean): QWidget { + val badge = label("null") + + val setButton = QPushButton("Set value").apply { + minimumHeight = 28 + } + setButton.clicked.connect { + onNodeChanged(path, ConfigString("")) + } + val reset = buildResetButton(setButton, ConfigNull(), FieldMeta(default = null)) { _ -> } + return buildLabeledRow(key, FieldMeta(), ConfigNull(), badge, reset ?: setButton, compact).first + } + + private fun buildSettingCard( + key: String?, + meta: FieldMeta, + currentNode: ConfigNode, + controlRow: QWidget, + inlineDescription: Boolean = false, + compact: Boolean = false + ): Pair Unit> { + val container = qWidget() + val outerLayout = vBoxLayout(container) { + widgetSpacing = if (compact) 6 else 8 + } + applyItemMargins(outerLayout, compact) + + if (key != null) { + outerLayout.addWidget(label(displayLabel(key)) { + wordWrap = true + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + font = QFont(font).apply { + setBold(true) + } + }) + } + + if (!inlineDescription && meta.description.isNotBlank()) { + outerLayout.addWidget(label(meta.description) { + wordWrap = true + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + }) + } + + outerLayout.addWidget(controlRow) + return wrapWithModifiedIndicator(container, currentNode, meta) + } + + private fun displayLabel(raw: String): String { + if (raw.isBlank()) return raw + return raw + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace('_', ' ') + .replace('-', ' ') + .trim() + .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + + private fun hasTopLevelOnlySettings(): Boolean { + val obj = root as? ConfigObj ?: return true + return obj.entries.none { (key, value) -> !key.startsWith("__comment_") && value is ConfigObj } + } + + private fun wrapWithModifiedIndicator(content: QWidget, currentNode: ConfigNode, meta: FieldMeta): Pair Unit> { + val wrapper = qWidget() + val wrapperLayout = hBoxLayout(wrapper) { + contentsMargins = 0.m + widgetSpacing = 0 + } + + val indicator = frame { + frameShape = QFrame.Shape.VLine + frameShadow = QFrame.Shadow.Plain + lineWidth = 2 + midLineWidth = 0 + setFixedWidth(2) + isVisible = isNonDefault(currentNode, meta) + } + + val gutter = qWidget() + vBoxLayout(gutter) { + contentsMargins = 16.m + widgetSpacing = 0 + addWidget(indicator, 1) + } + + wrapperLayout.addWidget(gutter, 0) + wrapperLayout.addWidget(content, 1) + return wrapper to { updatedNode -> + indicator.isVisible = isNonDefault(updatedNode, meta) + } + } + + private fun isNonDefault(node: ConfigNode, meta: FieldMeta): Boolean { + val defaultNode = parseDefaultNode(node, meta) ?: return false + return !configNodesEqual(node, defaultNode) + } + + private fun configNodesEqual(left: ConfigNode, right: ConfigNode): Boolean = when (left) { + is ConfigString if right is ConfigString -> left.value == right.value + is ConfigInt if right is ConfigInt -> left.value == right.value + is ConfigDouble if right is ConfigDouble -> left.value == right.value + is ConfigBool if right is ConfigBool -> left.value == right.value + is ConfigNull if right is ConfigNull -> true + is ConfigArray if right is ConfigArray -> + left.items.size == right.items.size && left.items.zip(right.items).all { (a, b) -> configNodesEqual(a, b) } + + is ConfigObj if right is ConfigObj -> { + val leftEntries = left.entries.filterKeys { !it.startsWith("__comment_") } + val rightEntries = right.entries.filterKeys { !it.startsWith("__comment_") } + leftEntries.size == rightEntries.size && + leftEntries.all { (key, value) -> rightEntries[key]?.let { configNodesEqual(value, it) } == true } + } + + else -> false + } + + object Provider : EditorPaneProvider { + override val id: String = "mod_config" + override val displayName: String = "Mod Config" + override val order: Int = 1 + + override fun canOpen( + file: VPath, + project: ProjectBase + ): Boolean { + val primary = FileTypeDescriptor.primary(file, project) + if (primary?.id != BuiltinFileTypes.ModConfig.id) return false + return resolveFormat(file) != null + } + + override fun create( + project: ProjectBase, + file: VPath + ): EditorPane { + val format = resolveFormat(file) + ?: error("Unsupported config format: ${file.extension().lowercase()}") + + val text = file.readTextOr("") + return ModConfigPane(project, file, format.parse(text), format) + } + + private fun resolveFormat(file: VPath): ConfigFormat? { + val text = file.readTextOr("") + val ext = file.extension().lowercase() + return when { + ext == "cfg" && looksLikeForgeCfg(text) -> ConfigFormat.of("forge_cfg") + else -> ConfigFormat.of(ext) + } + } + + private fun looksLikeForgeCfg(text: String): Boolean { + return text.lines().any { line -> + val t = line.trim() + t.length > 2 && t[1] == ':' && t[2] == '"' && t[0] in "SIBDCM" + } + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModDetailPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModDetailPane.kt new file mode 100644 index 0000000..5ab7f58 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/ModDetailPane.kt @@ -0,0 +1,620 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.panes + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.source.* +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.onClicked +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.helpers.CacheManager +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.project.editor.EditorPane +import io.github.tritium_launcher.launcher.ui.project.editor.EditorPaneProvider +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.widgets.RemoteImageTextBrowser +import io.github.tritium_launcher.launcher.ui.widgets.TComboBox +import io.github.tritium_launcher.launcher.ui.widgets.TPushButton +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.qt.core.QSize +import io.qt.core.Qt +import io.qt.gui.QIcon +import io.qt.gui.QPixmap +import io.qt.widgets.* +import kotlinx.coroutines.* +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.ext.task.list.items.TaskListItemsExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.nio.file.Files +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +class ModDetailPane( + project: ProjectBase, + private val modId: String +) : EditorPane(project) { + override val allowAutoSave: Boolean = false + + companion object { + private val EMPTY_ICON = QIcon(TIcons.Search) + } + + private val logger = logger() + private val shared = ModBrowserState.forProject(project) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 60_000 + } + defaultRequest { + header("User-Agent", ClientIdentity.userAgent) + header("X-Client-Info", ClientIdentity.clientInfoHeader) + } + } + + private val markdownExtensions = listOf( + TablesExtension.create(), + StrikethroughExtension.create(), + TaskListItemsExtension.create(), + ) + private val markdownParser = Parser.builder() + .extensions(markdownExtensions) + .build() + private val markdownRenderer = HtmlRenderer.builder() + .extensions(markdownExtensions) + .escapeHtml(false) + .build() + + private var detailsJob: Job? = null + private var activeContext: ModBrowserContext? = null + private var activeSource: ModSource? = null + private var detailsData: ModDetails? = null + private val versionsById = linkedMapOf() + + private val iconLabel = label { + setFixedSize(64, 64) + scaledContents = true + pixmap = TIcons.Search.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio) + } + private val titleLabel = label { wordWrap = true } + private val summaryLabel = label { wordWrap = true } + private val metaLabel = label { wordWrap = true } + private val sourceLabel = label() + private val contextLabel = label() + private val versionLabel = label("Version:") + private val versionCombo = TComboBox {} + private val queueButton = TPushButton { text = "Add to Queue"; minimumHeight = 30 } + private val openPageButton = TPushButton { text = "Open Page"; minimumHeight = 30 } + private val dependencyLabel = label("Dependencies") + private val dependencyScroll = QScrollArea() + private val dependencyContent = qWidget() + private val dependencyLayout = QHBoxLayout(dependencyContent) + private val imageCacheDir: VPath = fromTR("cache", "mod-browser", "descriptions") + + private suspend fun cachedImageFetch(url: String): ByteArray { + val sourceId = activeSource?.id ?: "unknown" + val cacheFile = imageCacheDir.resolve(sourceId).resolve(urlHash(url)) + cacheFile.bytesOrNull()?.let { + CacheManager.touch(cacheFile) + return it + } + val bytes = httpClient.get(url).bodyAsBytes() + if (bytes.isNotEmpty()) { + runCatching { + val path = cacheFile.toJPath() + Files.createDirectories(path.parent) + Files.write(path, bytes) + } + CacheManager.evictIfNeeded(imageCacheDir.parent(), "descriptions") + } + return bytes + } + + private fun urlHash(url: String): String = + MessageDigest.getInstance("MD5").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + + private val descriptionView = RemoteImageTextBrowser { url -> + cachedImageFetch(url) + } + private val statusLabel = label("Loading...") { wordWrap = true } + private val container = qWidget() + + init { + vBoxLayout(container) { + setContentsMargins(10, 10, 10, 10) + setSpacing(8) + + addWidget(qWidget().also { header -> + hBoxLayout(header) { + setContentsMargins(0, 0, 0, 0) + setSpacing(10) + addWidget(iconLabel) + addWidget(qWidget().also { textCol -> + vBoxLayout(textCol) { + setContentsMargins(0, 0, 0, 0) + setSpacing(4) + addWidget(titleLabel) + addWidget(summaryLabel) + addWidget(metaLabel) + } + }, 1) + } + }) + + addWidget(sourceLabel) + addWidget(contextLabel) + addWidget(qWidget().also { row -> + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(versionLabel) + addWidget(versionCombo) + addStretch(1) + } + }) + addWidget(qWidget().also { row -> + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(queueButton) + addWidget(openPageButton) + addStretch(1) + } + }) + addWidget(qWidget().also { dependencySection -> + vBoxLayout(dependencySection) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(qWidget().also { row -> + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + addWidget(dependencyLabel) + addStretch(1) + } + }) + addWidget(dependencyScroll) + } + }) + addWidget(descriptionView, 1) + addWidget(statusLabel) + } + + versionCombo.isEnabled = false + descriptionView.apply { + openExternalLinks = false + openLinks = false + lineWrapMode = QTextEdit.LineWrapMode.WidgetWidth + } + descriptionView.anchorClicked.connect { url -> + Platform.openBrowser(url.toString()) + } + dependencyLayout.apply { + setContentsMargins(0, 0, 0, 0) + setSpacing(10) + addStretch(1) + } + dependencyScroll.apply { + widgetResizable = true + frameShape = QFrame.Shape.NoFrame + setWidget(dependencyContent) + minimumHeight = 116 + maximumHeight = 116 + horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAsNeeded + verticalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + } + + versionCombo.currentIndexChanged.connect { + queueButton.isEnabled = versionCombo.currentData != null + renderDependencyStrip() + } + queueButton.onClicked { queueSelection() } + openPageButton.onClicked { + detailsData?.website?.takeIf { it.isNotBlank() }?.let { Platform.openBrowser(it) } + } + + queueButton.isEnabled = false + openPageButton.isEnabled = false + + ioScope.launch { CacheManager.evict(imageCacheDir.parent(), "descriptions") } + } + + override fun onOpen() { + if (detailsJob != null) return + loadDetails() + } + + override fun widget(): QWidget = container + + override suspend fun save(): Boolean { + modified = false + return true + } + + override fun onClose() { + detailsJob?.cancel() + ioScope.cancel() + httpClient.close() + } + + private fun loadDetails() { + detailsJob?.cancel() + val context = projectContext() ?: run { + statusLabel.text = "The Mod Browser requires a typed Modpack project." + return + } + activeContext = context + val source = resolveSource() ?: run { + statusLabel.text = "The project's configured mod source is not registered." + return + } + activeSource = source + + sourceLabel.text = "Source: ${source.displayName}" + contextLabel.text = buildString { + append("Minecraft: ") + append(context.minecraftVersion ?: "unknown") + append(" | Loader: ") + append(context.modLoaderId ?: "unknown") + } + + val cachedDetails = shared.detailsCache[modId] + val cachedVersions = shared.versionsCache[modId] + if (cachedDetails != null && cachedVersions != null) { + detailsData = cachedDetails + onTitleChanged?.invoke(cachedDetails.title) + bindVersions(cachedVersions) + renderDetails(cachedDetails) + statusLabel.text = "Loaded ${cachedDetails.title}" + return + } + + detailsJob = ioScope.launch { + logger.info("Loading mod detail: modId={} source={}", modId, source.id) + runOnGuiThread { statusLabel.text = "Loading details..." } + + val detailsDeferred = async { runCatching { fetchDetails(context, source, modId) } } + val versionsDeferred = async { runCatching { fetchVersions(context, source, modId) } } + val details = detailsDeferred.await() + val versions = versionsDeferred.await() + + val detailObj = details.getOrNull() + val detailError = details.exceptionOrNull() + if (detailError != null) { + logger.warn("Failed loading mod details for '{}': {}", modId, detailError.message, detailError) + } + val versionError = versions.exceptionOrNull() + if (versionError != null) { + logger.warn("Failed loading mod versions for '{}': {}", modId, versionError.message, versionError) + } + + val descriptionHtml = withContext(Dispatchers.Default) { + processDescriptionText(detailObj) + } + + runOnGuiThread { + detailsData = detailObj + detailObj?.title?.let { onTitleChanged?.invoke(it) } + bindVersions(versions.getOrDefault(emptyList())) + renderDetails(detailObj, detailError?.message, descriptionHtml) + statusLabel.text = when { + detailError != null -> detailError.message ?: "Failed loading details" + else -> "Loaded ${detailObj?.title ?: modId}" + } + } + } + } + + private fun processDescriptionText(details: ModDetails?): String? { + if (details == null) return null + val raw = details.description.ifBlank { details.summary }.takeIf { it.isNotBlank() } ?: return null + if (activeSource?.descriptionFormat == DescriptionFormat.HTML) return raw + val normalized = normalizeDescription(raw) + val doc = markdownParser.parse(normalized) + return markdownRenderer.render(doc) + } + + private suspend fun fetchDetails(context: ModBrowserContext, source: ModSource, id: String): ModDetails { + shared.detailsCache[id]?.let { return it } + val details = source.details(context, id) + shared.detailsCache[id] = details + return details + } + + private suspend fun fetchVersions(context: ModBrowserContext, source: ModSource, id: String): List { + shared.versionsCache[id]?.let { return it } + val versions = source.versions(context, id) + shared.versionsCache[id] = versions + return versions + } + + private fun bindVersions(versions: List) { + versionCombo.clear() + versionsById.clear() + versions.forEach { version -> + versionsById[version.id] = version + val suffix = version.releaseType?.let { " (${it.name.lowercase().replaceFirstChar(Char::uppercase)})" } ?: "" + versionCombo.addItem("${version.label}$suffix", version.id) + } + versionCombo.isEnabled = versions.isNotEmpty() + if (versions.isNotEmpty()) { + versionCombo.currentIndex = 0 + queueButton.isEnabled = true + } + val fm = versionCombo.fontMetrics() + val maxW = versions.maxOfOrNull { v -> + val s = v.releaseType?.let { " (${it.name.lowercase().replaceFirstChar(Char::uppercase)})" } ?: "" + fm.horizontalAdvance("${v.label}$s") + }?.plus(80) ?: 180 + versionCombo.minimumWidth = maxW.coerceAtLeast(180) + } + + private fun renderDetails(details: ModDetails?, error: String? = null, descriptionHtml: String? = null) { + if (details == null) { + titleLabel.text = modId + summaryLabel.text = "" + metaLabel.text = error ?: "" + applyIcon(null) + if (descriptionHtml != null) { + descriptionView.setHtmlContent(descriptionHtml) + } else { + renderDescription(error ?: "Failed to load mod details.") + } + openPageButton.isEnabled = false + return + } + + titleLabel.text = details.title + summaryLabel.text = details.summary + metaLabel.text = buildString { + details.author?.takeIf { it.isNotBlank() }?.let { append("Author: $it") } + details.downloads?.let { + if (isNotEmpty()) append(" | ") + append("Downloads: $it") + } + if (details.categories.isNotEmpty()) { + if (isNotEmpty()) append("\n") + append(details.categories.joinToString(", ")) + } + currentLatestCompatibleLabel(details)?.takeIf { it.isNotBlank() }?.let { + if (isNotEmpty()) append("\n") + append("Latest compatible: $it") + } + } + openPageButton.isEnabled = !details.website.isNullOrBlank() + queueIconLoad(details.id, details.iconUrl) + renderDependencyStrip() + if (descriptionHtml != null) { + descriptionView.setHtmlContent(descriptionHtml) + } else { + renderDescription(details.description.ifBlank { details.summary }) + } + } + + private fun renderDescription(raw: String) { + if (raw.isBlank()) { + descriptionView.plainText = "No description available." + return + } + if (activeSource?.descriptionFormat == DescriptionFormat.HTML) { + descriptionView.setHtmlContent(raw) + return + } + val normalized = normalizeDescription(raw) + val doc = markdownParser.parse(normalized) + descriptionView.setHtmlContent(markdownRenderer.render(doc)) + } + + private fun applyIcon(url: String?) { + if (url.isNullOrBlank()) { + iconLabel.pixmap = TIcons.Search.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio) + onIconChanged?.invoke(null) + } else { + shared.iconCache[url]?.let { icon -> + onIconChanged?.invoke(icon) + iconLabel.pixmap = icon.pixmap(64, 64) + } ?: run { + iconLabel.pixmap = TIcons.Search.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio) + queueIconLoad(shared.detailsCache[modId]?.id ?: modId, url) + } + } + } + + private fun queueIconLoad(key: String, url: String?) { + if (url.isNullOrBlank()) return + shared.iconCache[url]?.let { icon -> + iconLabel.pixmap = icon.pixmap(64, 64) + onIconChanged?.invoke(icon) + return + } + + ioScope.launch { + val iconBytes = runCatching { + httpClient.get(url).bodyAsBytes() + }.getOrNull() ?: return@launch + + runOnGuiThread { + val iconObj = runCatching { + val pixmap = QPixmap() + if (!pixmap.loadFromData(iconBytes)) error("Failed to decode icon") + QIcon(pixmap) + }.getOrElse { EMPTY_ICON } + + shared.iconCache[url] = iconObj + if (iconObj !== EMPTY_ICON) { + iconLabel.pixmap = iconObj.pixmap(64, 64) + onIconChanged?.invoke(iconObj) + } + } + } + } + + private fun renderDependencyStrip() { + dependencyContent.updatesEnabled = false + try { + while (dependencyLayout.count() > 0) { + val item = dependencyLayout.takeAt(0) + item?.widget()?.let { widget -> + widget.hide() + widget.setParent(null) + widget.dispose() + } + } + + val version = versionCombo.currentData?.let { it as? String }?.let(versionsById::get) + val dependencies = version?.dependencies + ?.filterNot { it.incompatible } + ?.distinctBy { it.projectId } + .orEmpty() + + dependencyLabel.isVisible = dependencies.isNotEmpty() + dependencyScroll.isVisible = dependencies.isNotEmpty() + dependencyLabel.text = if (dependencies.isEmpty()) "Dependencies" else "Dependencies (${dependencies.size})" + + if (dependencies.isEmpty()) { + dependencyLayout.addStretch(1) + return + } + + dependencies.forEach { dependency -> + val depDetails = shared.detailsCache[dependency.projectId] + dependencyLayout.addWidget(createDependencyButton(dependency, depDetails)) + if (depDetails == null) { + queueDependencyDetailLoad(dependency.projectId) + } + } + dependencyLayout.addStretch(1) + } finally { + dependencyContent.updatesEnabled = true + } + } + + private fun createDependencyButton(dependency: ModDependencyRef, details: ModDetails?): QToolButton = + QToolButton().apply { + text = details?.title ?: dependency.projectId + toolButtonStyle = Qt.ToolButtonStyle.ToolButtonTextUnderIcon + iconSize = QSize(48, 48) + setFixedWidth(96) + minimumHeight = 88 + autoRaise = true + icon = details?.iconUrl?.let(shared.iconCache::get) ?: EMPTY_ICON + } + + private fun queueDependencyDetailLoad(projectId: String) { + ioScope.launch { + val context = activeContext ?: return@launch + val source = activeSource ?: return@launch + runCatching { fetchDetails(context, source, projectId) } + runOnGuiThread { renderDependencyStrip() } + } + } + + private fun queueSelection() { + val versionId = versionCombo.currentData as? String ?: return + val details = detailsData ?: return + + val queued = QueuedDownload( + projectId = details.id, + title = details.title, + versionId = versionId, + versionLabel = versionsById[versionId]?.label ?: versionId, + iconUrl = details.iconUrl, + dependencies = versionsById[versionId]?.dependencies ?: emptyList(), + status = QueueStatus(), + projectUrl = details.website, + ) + shared.queuedDownloads[details.id] = queued + shared.manuallyQueuedIds += details.id + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + statusLabel.text = "Queued ${details.title}" + } + + private fun currentLatestCompatibleLabel(details: ModDetails): String? = + shared.versionsCache[details.id]?.firstOrNull()?.label ?: details.latestVersion + + private fun resolveSource(): ModSource? { + val sourceId = ((project as? Project<*>)?.typedMeta as? ModpackMeta)?.source + return sourceId?.let { id -> BuiltinRegistries.ModSource.all().find { s -> s.id == id } } + } + + private fun projectContext(): ModBrowserContext? { + val meta = ((project as? Project<*>)?.typedMeta as? ModpackMeta) ?: return null + return ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + } + + private fun normalizeDescription(text: String): String { + var normalized = text + .replace("\r\n", "\n") + .replace('\uFFFC', '\n') + .replace(Regex("""(?() + + fun register(modId: String, title: String) { + titles[modId] = title + } + + fun get(modId: String): String? = titles[modId] +} + +object ModDetailPaneProvider : EditorPaneProvider { + override val id: String = "mod_detail" + override val displayName: String = "Mod Details" + override val order: Int = 5 + override val singletonGroup: String = "mod_detail" + + override fun canOpen(file: VPath, project: ProjectBase): Boolean = false + + override fun tabTitle(file: VPath, project: ProjectBase): String = "Mod Details" + + override fun tabIcon(file: VPath, project: ProjectBase): QIcon? = null + + override fun create(project: ProjectBase, file: VPath): EditorPane { + return ModDetailPane(project, "unknown") + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/SettingsEditorPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/SettingsEditorPane.kt similarity index 76% rename from src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/SettingsEditorPane.kt rename to src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/SettingsEditorPane.kt index ab5ee65..073ac9b 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/pane/SettingsEditorPane.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/SettingsEditorPane.kt @@ -1,38 +1,54 @@ -package io.github.tritium_launcher.launcher.ui.project.editor.pane +package io.github.tritium_launcher.launcher.ui.project.editor.panes +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.matches import io.github.tritium_launcher.launcher.settings.SettingsMngr +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.github.tritium_launcher.launcher.ui.project.editor.EditorPane import io.github.tritium_launcher.launcher.ui.project.editor.EditorPaneProvider import io.github.tritium_launcher.launcher.ui.settings.SettingsLink import io.github.tritium_launcher.launcher.ui.settings.SettingsView import io.qt.widgets.QWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel /** - * Settings editor pane for project-scoped settings files. + * Settings editor panes for project-scoped settings files. * * @param project Active project context. - * @param file Backing file used only for provider matching. * @see SettingsView * @see SettingsMngr */ -class SettingsEditorPane(project: ProjectBase, file: VPath) : EditorPane(project, file) { +class SettingsEditorPane(project: ProjectBase) : EditorPane(project) { private val view = SettingsView() + private val scope = CoroutineScope(Dispatchers.Main) + + init { + scope.onEvent { + runOnGuiThread { modified = true } + } + } /** - * Returns the settings widget rendered by this editor pane. + * Returns the settings widget rendered by this editor panes. */ override fun widget(): QWidget = view /** - * Reloads category and setting rows when the pane is opened. + * Reloads category and setting rows when the panes are opened. */ override fun onOpen() { view.reload() } + override fun onClose() { + scope.cancel() + } + /** * Opens [link] in this settings pane. * @@ -49,6 +65,7 @@ class SettingsEditorPane(project: ProjectBase, file: VPath) : EditorPane(project */ override suspend fun save(): Boolean { SettingsMngr.persistAll() + modified = false return true } } @@ -75,9 +92,9 @@ class SettingsEditorPaneProvider : EditorPaneProvider { * * @param project Active project. * @param file File being opened. - * @return Settings editor pane instance. + * @return Settings editor panes instance. */ - override fun create(project: ProjectBase, file: VPath): EditorPane = SettingsEditorPane(project, file) + override fun create(project: ProjectBase, file: VPath): EditorPane = SettingsEditorPane(project) /** * Checks whether [file] looks like a settings file in `.tr`/`.tritium` directories. diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/TextEditorPane.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/TextEditorPane.kt new file mode 100644 index 0000000..1997c14 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/panes/TextEditorPane.kt @@ -0,0 +1,305 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.panes + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.kubejs.KubeJSIntelligenceService +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.lsp.LSPInstaller +import io.github.tritium_launcher.launcher.lsp.LSPMngr +import io.github.tritium_launcher.launcher.ui.project.editor.EditorPane +import io.github.tritium_launcher.launcher.ui.project.editor.RainbowBracketHighlighter +import io.github.tritium_launcher.launcher.ui.project.editor.lsp.LSPEditorAdapter +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.UniversalHighlighter +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.ItemSlotInfo +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.TreeSitterEditorAdapter +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.TreeSitterService +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController +import io.qt.Nullable +import io.qt.core.QTimer +import io.qt.gui.* +import io.qt.widgets.QFrame +import io.qt.widgets.QHBoxLayout +import io.qt.widgets.QTextEdit +import io.qt.widgets.QWidget +import kotlinx.coroutines.* + +class TextEditorPane( + project: ProjectBase, + file: VPath, + private val lang: SyntaxLanguage? +): EditorPane(project, file) { + private val paneFile: VPath get() = file!! + private val textEdit = DragDropTextEdit() + private val container = QFrame() + private val font = QFont("JetBrains Mono", 11) + private val highlighter: QSyntaxHighlighter? + + private val lspAdapter: LSPEditorAdapter? + private val treeSitterAdapter: TreeSitterEditorAdapter? + + private val logger = logger() + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var originalText: String = "" + private var loading: Boolean = false + private var rainbowTimer: QTimer? = null + private var gutter: LineNumberGutter? = null + + init { + textEdit.font = font + textEdit.lineWrapMode = QTextEdit.LineWrapMode.NoWrap + textEdit.frameShape = QFrame.Shape.NoFrame + AnimatedScrollController.attach(textEdit) + + gutter = LineNumberGutter(textEdit) + + container.frameShape = QFrame.Shape.NoFrame + container.objectName = "editorPaneContainer" + val layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(gutter) + layout.addWidget(textEdit) + + if (TreeSitterService.isAvailable() && isJsLanguage(lang)) { + logger.info("TextEditorPane: using TreeSitter for {}", paneFile.toAbsolute()) + treeSitterAdapter = TreeSitterEditorAdapter(paneFile, textEdit, project) + lspAdapter = null + highlighter = null + } else { + logger.info("TextEditorPane: TreeSitter not used (available={}, isJsLang={}) for {}", + TreeSitterService.isAvailable(), isJsLanguage(lang), paneFile.toAbsolute()) + treeSitterAdapter = null + val connection = LSPMngr.getOrStart(project, paneFile) + if (connection != null) { + lspAdapter = LSPEditorAdapter(paneFile, textEdit, connection) + highlighter = lang?.let { UniversalHighlighter(textEdit.document!!, it) } + } else { + lspAdapter = null + LSPInstaller.checkAndPromptInstallation(project, paneFile) + highlighter = lang?.let { UniversalHighlighter(textEdit.document!!, it) } + rainbowTimer = QTimer(textEdit).apply { + interval = 300 + isSingleShot = true + timeout.connect { + val rainbow = RainbowBracketHighlighter.highlight(textEdit) + if (rainbow.isNotEmpty()) { + textEdit.setExtraSelections(rainbow) + } + } + } + textEdit.textChanged.connect { + rainbowTimer?.start() + } + } + } + + textEdit.textChanged.connect { + if (!loading) { + modified = textEdit.toPlainText() != originalText + } + } + } + + private fun isJsLanguage(language: SyntaxLanguage?): Boolean { + return language?.id == "kubescript" + } + + private fun loadFile() { + if (loading) return + loading = true + textEdit.isReadOnly = true + textEdit.plainText = "Loading file..." + + scope.launch { + try { + val text = withContext(Dispatchers.IO) { + if (paneFile.exists()) { + paneFile.readTextOr("") + } else { + "" + } + } + originalText = text + textEdit.plainText = text + lspAdapter?.openDocument(text) + textEdit.document!!.isModified = false + modified = false + } catch (t: Throwable) { + logger.warn("Failed to load file {}", paneFile.toAbsolute(), t) + originalText = "" + textEdit.plainText = "" + lspAdapter?.openDocument("") + textEdit.document!!.isModified = false + modified = false + } finally { + loading = false + textEdit.isReadOnly = false + } + } + } + + override fun widget(): QWidget = container + + override fun onOpen() { + loadFile() + } + + override fun onClose() { + scope.cancel() + rainbowTimer?.stop() + rainbowTimer = null + lspAdapter?.close() + treeSitterAdapter?.close() + } + + override suspend fun save(): Boolean = try { + val text = textEdit.toPlainText() + paneFile.writeBytes(text.toByteArray()) + originalText = text + textEdit.document!!.isModified = false + modified = false + true + } catch (t: Throwable) { + logger.error("Failed saving {}", paneFile.toAbsolute(), t) + false + } + + private inner class DragDropTextEdit : QTextEdit() { + var currentDragSlot: ItemSlotInfo? = null + private var lastDragSlots: List = emptyList() + + override fun dragEnterEvent(event: @Nullable QDragEnterEvent?) { + val ev = event ?: return + if (ev.mimeData()?.hasText() == true && treeSitterAdapter != null) { + lastDragSlots = KubeJSIntelligenceService.findAllItemSlots(project, toPlainText()) + ev.acceptProposedAction() + return + } + super.dragEnterEvent(ev) + } + + override fun dragMoveEvent(event: @Nullable QDragMoveEvent?) { + val ev = event ?: return + if (ev.mimeData()?.hasText() == true && treeSitterAdapter != null) { + ev.acceptProposedAction() + val cursor = cursorForPosition(ev.position().toPoint()) + val charPos = cursor.position() + currentDragSlot = KubeJSIntelligenceService.findItemSlotAt(project, toPlainText(), charPos) + if (currentDragSlot == null) { + currentDragSlot = lastDragSlots.firstOrNull { + charPos in it.exprStartByte..it.exprEndByte + } + } + val slot = currentDragSlot + + val slotSelections = mutableListOf() + for (s in lastDragSlots) { + val alpha = if (s == slot) 60 else 30 + slotSelections.add(makeSlotHighlight(s, alpha)) + } + treeSitterAdapter.temporarySelections = slotSelections + treeSitterAdapter.flushSelections() + return + } + super.dragMoveEvent(ev) + } + + override fun dropEvent(event: @Nullable QDropEvent?) { + val ev = event ?: return + if (ev.mimeData()?.hasText() == true && treeSitterAdapter != null) { + val text = ev.mimeData()!!.text() + val slot = currentDragSlot + if (slot != null) { + val c = textCursor() + c.setPosition(slot.startByte) + c.setPosition(slot.endByte, QTextCursor.MoveMode.KeepAnchor) + c.insertText(text) + } else { + val c = cursorForPosition(ev.position().toPoint()) + c.insertText(text) + } + currentDragSlot = null + lastDragSlots = emptyList() + treeSitterAdapter.temporarySelections = emptyList() + treeSitterAdapter.flushSelections() + ev.acceptProposedAction() + return + } + super.dropEvent(ev) + } + + override fun dragLeaveEvent(event: @Nullable QDragLeaveEvent?) { + currentDragSlot = null + lastDragSlots = emptyList() + treeSitterAdapter?.temporarySelections = emptyList() + treeSitterAdapter?.flushSelections() + super.dragLeaveEvent(event) + } + + private fun makeSlotHighlight(slot: ItemSlotInfo, alpha: Int): ExtraSelection { + return ExtraSelection().apply { + cursor = QTextCursor(document()).apply { + setPosition(slot.startByte.coerceIn(0, document()!!.characterCount() - 1)) + setPosition(slot.endByte.coerceIn(0, document()!!.characterCount() - 1), QTextCursor.MoveMode.KeepAnchor) + } + format = QTextCharFormat().apply { + setBackground(QColor(TColors.Accent).apply { setAlpha(alpha) }) + } + } + } + } +} + +private class LineNumberGutter(private val editor: QTextEdit) : QWidget() { + private val gutterFont = QFont("JetBrains Mono", 11) //TODO: Use set font + private val fm = QFontMetrics(gutterFont) + private val gutterWidth = 48 + + init { + font = gutterFont + setFixedWidth(gutterWidth) + setAttribute(io.qt.core.Qt.WidgetAttribute.WA_TransparentForMouseEvents, true) + + editor.verticalScrollBar()?.valueChanged?.connect { repaint() } + editor.textChanged.connect { repaint() } + } + + override fun paintEvent(event: QPaintEvent?) { + val painter = QPainter(this) + painter.setFont(gutterFont) + + painter.fillRect(0, 0, width(), height(), QColor(TColors.Surface1)) + painter.setPen(QColor(TColors.Surface2)) + painter.drawLine(width() - 1, 0, width() - 1, height()) + + val doc = editor.document() ?: run { painter.end(); return } + val scrollPos = editor.verticalScrollBar()?.value() ?: 0 + val vpHeight = editor.viewport()?.height() ?: 0 + + painter.setPen(QColor(TColors.Subtext)) + + var block = doc.begin() + var lineNum = 1 + val layout = doc.documentLayout() + + while (block.isValid) { + val rect = layout.blockBoundingRect(block) + val blockTop = rect.y().toInt() + val blockHeight = rect.height().toInt() + + if (blockTop + blockHeight >= scrollPos && blockTop <= scrollPos + vpHeight) { + val y = blockTop - scrollPos + fm.ascent() + val text = lineNum.toString() + painter.drawText(6, y, text) + } + + lineNum++ + block = block.next() + } + + painter.end() + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/LSPDefinition.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/LSPDefinition.kt new file mode 100644 index 0000000..fc18515 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/LSPDefinition.kt @@ -0,0 +1,18 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.syntax + +import io.github.tritium_launcher.launcher.platform.Platform + +data class LSPDefinition( + val servers: List +) + +data class LSPServerDefinition( + val id: String, + val command: List, + val installSpec: LSPInstallSpec? = null +) + +data class LSPInstallSpec( + val downloadUrls: Map, + val binaryPath: String +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/SyntaxLanguage.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/SyntaxLanguage.kt index 2c596e2..cd6ab2b 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/SyntaxLanguage.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/SyntaxLanguage.kt @@ -4,18 +4,15 @@ import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.registry.Registrable /** - * Describes a syntax highlighting language and optional LSP integration. + * Describes a syntax highlighting language. * * Languages are gathered from the registry and used by the editor to * pick highlighting rules based on file matches. */ interface SyntaxLanguage: Registrable { val displayName: String - val rules: List - val parentLanguage: String? get() = null - - val lspCmd: List? get() = null - val lspCmds: List>? get() = null + val rules: List get() = emptyList() + val lsp: LSPDefinition? get() = null fun matches(file: VPath): Boolean @@ -24,15 +21,11 @@ interface SyntaxLanguage: Registrable { id: String, displayName: String, predicate: VPath.() -> Boolean, - rules: List = emptyList(), - lspCmd: List? = null, - lspCmds: List>? = null + rules: List = emptyList() ): SyntaxLanguage = object : SyntaxLanguage { override val id: String = id override val displayName: String = displayName override val rules: List = rules - override val lspCmd: List? = lspCmd - override val lspCmds: List>? = lspCmds override fun matches(file: VPath): Boolean = predicate(file) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/JsonLanguage.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/JsonLanguage.kt index 9a668c1..34384c0 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/JsonLanguage.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/JsonLanguage.kt @@ -2,6 +2,8 @@ package io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.matches +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.LSPDefinition +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.LSPServerDefinition import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxRule @@ -10,26 +12,32 @@ class JsonLanguage : SyntaxLanguage { override val displayName: String = "JSON" override val rules: List = listOf( - - // Keywords - SyntaxRule(Regex("\\b(true|false|null)\\b"), "Keyword"), - - // Numbers - SyntaxRule(Regex("-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"), "Number"), - - // Punctuation - SyntaxRule(Regex("[{}\\[\\],:]"), "Punctuation"), - - // String value + // Numbers: integer, decimal, scientific notation, all RFC 8259 forms SyntaxRule( - Regex("\"(?:[^\"\\\\\\u0000-\\u001F]|\\\\[\"\\\\/bfnrt]|\\\\u[0-9a-fA-F]{4})*\""), - "String" + Regex("-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"), + "Number" ), - - // Keys + // Literals SyntaxRule( - Regex("\"(?:[^\"\\\\\\u0000-\\u001F]|\\\\[\"\\\\/bfnrt]|\\\\u[0-9a-fA-F]{4})*\"(?=\\s*:)"), - "Key" + Regex("\\b(?:true|false|null)\\b"), + "Keyword" + ), + // Structural characters + SyntaxRule( + Regex("[{}\\[\\],:]"), + "Punctuation" + ), + // String values — must come before Key so Key selections override at key positions + SyntaxRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), "String"), + SyntaxRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\"(?=\\s*:)"), "Key"), + ) + + override val lsp: LSPDefinition = LSPDefinition( + servers = listOf( + LSPServerDefinition( + id = "vscode-json-languageserver", + command = listOf("vscode-json-languageserver", "--stdio") + ) ) ) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/PythonLanguage.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/PythonLanguage.kt index 1c6e7b6..7faad3a 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/PythonLanguage.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/PythonLanguage.kt @@ -2,8 +2,8 @@ package io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.matches -import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage -import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxRule +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.* /** * Basic Python syntax definition with multiple LSP command options. @@ -15,31 +15,47 @@ class PythonLanguage : SyntaxLanguage { override val displayName: String = "Python" override val rules: List = listOf( - // Keywords + SyntaxRule(Regex("#[^\n]*"), "Comment"), + SyntaxRule(Regex("\"\"\".*?\"\"\"|'''.*?'''", RegexOption.DOT_MATCHES_ALL), "Comment"), // docstrings + SyntaxRule(Regex("\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b"), "Number"), SyntaxRule( - Regex("\\b(False|None|True|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|raise|return|try|while|with|yield)\\b"), - "Keyword" + Regex( + "f?b?\"\"\".*?\"\"\"|f?b?'''.*?'''|f?b?\"(?:[^\"\\\\]|\\\\.)*\"|f?b?'(?:[^'\\\\]|\\\\.)*'", + RegexOption.DOT_MATCHES_ALL + ), "String" ), - - // Numbers - SyntaxRule( - Regex("(?!&|^~@]+"), "Operator"), ) - override val lspCmds: List> = listOf( - listOf("pyright-langserver", "--stdio"), - listOf("pylsp") + override val lsp: LSPDefinition = LSPDefinition( + servers = listOf( + LSPServerDefinition( + id = "basedpyright", + command = listOf("basedpyright-langserver", "--stdio"), + installSpec = LSPInstallSpec( + downloadUrls = mapOf( + Platform.Linux to "https://github.com/detachhead/basedpyright/releases/download/v1.1.350/basedpyright-linux-x64.tar.gz", + Platform.Windows to "https://github.com/detachhead/basedpyright/releases/download/v1.1.350/basedpyright-win-x64.zip" + ), + binaryPath = "bin/basedpyright-langserver" + ) + ), + LSPServerDefinition( + id = "pyright", + command = listOf("pyright-langserver", "--stdio"), + installSpec = LSPInstallSpec( + downloadUrls = mapOf( + Platform.Linux to "https://github.com/microsoft/pyright/releases/download/1.1.350/pyright-linux-x64.tar.gz", + Platform.Windows to "https://github.com/microsoft/pyright/releases/download/1.1.350/pyright-win-x64.zip" + ), + binaryPath = "bin/pyright-langserver" + ) + ), + LSPServerDefinition( + id = "pylsp", + command = listOf("pylsp") + ) + ) ) override fun matches(file: VPath): Boolean = file.extension().matches("py") diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/XmlLanguage.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/XmlLanguage.kt index 066b9d5..1237f52 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/XmlLanguage.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/syntax/builtin/XmlLanguage.kt @@ -2,6 +2,8 @@ package io.github.tritium_launcher.launcher.ui.project.editor.syntax.builtin import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.matches +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.LSPDefinition +import io.github.tritium_launcher.launcher.ui.project.editor.syntax.LSPServerDefinition import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxLanguage import io.github.tritium_launcher.launcher.ui.project.editor.syntax.SyntaxRule @@ -10,30 +12,35 @@ class XmlLanguage : SyntaxLanguage { override val displayName: String = "XML" override val rules: List = listOf( - - // Doctype - SyntaxRule(Regex("", RegexOption.DOT_MATCHES_ALL), "Keyword"), - - // Processing Instructions (e.g., ) - SyntaxRule(Regex("<\\?.*?\\?>", RegexOption.DOT_MATCHES_ALL), "Keyword"), - - // Tag names - SyntaxRule(Regex("", RegexOption.DOT_MATCHES_ALL), "String"), // Comments - SyntaxRule(Regex("", RegexOption.DOT_MATCHES_ALL), "Comment"), + SyntaxRule(Regex("", RegexOption.DOT_MATCHES_ALL), "Comment"), + // DOCTYPE declaration + SyntaxRule(Regex("]*>"), "Keyword"), + // Processing instructions + SyntaxRule(Regex("<\\?.*?\\?>", RegexOption.DOT_MATCHES_ALL), "Keyword"), + // Attribute values + SyntaxRule(Regex("\"[^\"]*\"|'[^']*'"), "String"), + // Attribute names + SyntaxRule(Regex("\\b([\\w:.-]+)(?=\\s*=)"), "Attribute"), + // Closing tags + SyntaxRule(Regex(""), "Tag"), + // Opening/void tags — just the tag name portion + SyntaxRule(Regex("<[\\w:.-]+"), "Tag"), + // Punctuation: < > / = ? + SyntaxRule(Regex("[<>/=?!]"), "Punctuation"), + // Entity references + SyntaxRule(Regex("&(?:#\\d+|#x[0-9a-fA-F]+|[\\w:.-]+);"), "Constant"), + ) - // CDATA - SyntaxRule(Regex("", RegexOption.DOT_MATCHES_ALL), "String") + override val lsp: LSPDefinition = LSPDefinition( + servers = listOf( + LSPServerDefinition( + id = "lemminx", + command = listOf("lemminx") + ) + ) ) override fun matches(file: VPath): Boolean = file.extension().matches("xml", "svg", "svgs", "xhtml", "xsd") diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/ItemSlotInfo.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/ItemSlotInfo.kt new file mode 100644 index 0000000..4476985 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/ItemSlotInfo.kt @@ -0,0 +1,8 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.treesitter + +data class ItemSlotInfo( + val startByte: Int, + val endByte: Int, + val exprStartByte: Int, + val exprEndByte: Int +) \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/JavaScriptNodeTypes.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/JavaScriptNodeTypes.kt new file mode 100644 index 0000000..cb75283 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/JavaScriptNodeTypes.kt @@ -0,0 +1,65 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.treesitter + +object JavaScriptNodeTypes { + + private val tokenMap = mapOf( + "comment" to "Comment", + "line_comment" to "Comment", + "block_comment" to "Comment", + "hash_bang_line" to "Comment", + + "string" to "String", + "template_string" to "String", + "template_literal" to "String", + "string_fragment" to "String", + "escape_sequence" to "String", + + "number" to "Number", + "decimal" to "Number", + "hex" to "Number", + + "function" to "Function", + "function_declaration" to "Function", + "arrow_function" to "Function", + "method_definition" to "Function", + "generator_function" to "Function", + "function_name" to "Function", + + "property_identifier" to "Property", + "property" to "Property", + "shorthand_property_identifier" to "Property", + "private_property_identifier" to "Property", + + "identifier" to "Variable", + "variable_declaration" to "Variable", + "variable_declarator" to "Variable", + "required_parameter" to "Variable", + "pattern" to "Variable", + + "module" to "Module", + "import_statement" to "Module", + "export_statement" to "Module", + "import_clause" to "Module", + "from_clause" to "Module", + "namespace_import" to "Module", + "named_imports" to "Module", + ) + + fun tokenName(treeSitterType: String): String? = tokenMap[treeSitterType] + + val keywordTypes = setOf( + "async", "await", "break", "case", "catch", "class", "const", "continue", + "debugger", "default", "delete", "do", "else", "export", "extends", "finally", + "for", "function", "if", "import", "in", "instanceof", "let", "new", "of", + "return", "static", "super", "switch", "this", "throw", "try", "typeof", + "var", "void", "while", "with", "yield", + "null", "undefined", "true", "false", "NaN", "Infinity" + ) + + val operatorTypes = setOf( + "+", "-", "*", "/", "%", "=", "==", "===", "!=", "!==", ">", "<", ">=", "<=", + "&&", "||", "!", "&", "|", "^", "~", "<<", ">>", ">>>", "??", "?.", "?", + ":", ",", ";", ".", "(", ")", "{", "}", "[", "]", "=>", "...", "++", "--", + "+=", "-=", "*=", "/=", "%=", "**", "**=" + ) +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterEditorAdapter.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterEditorAdapter.kt new file mode 100644 index 0000000..0cab62d --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterEditorAdapter.kt @@ -0,0 +1,511 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.treesitter + +import io.github.treesitter.ktreesitter.Node +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.kubejs.KubeJSIntelligenceService +import io.github.tritium_launcher.launcher.hexToQColor +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.ui.project.editor.RainbowBracketHighlighter +import io.github.tritium_launcher.launcher.ui.project.editor.intelligence.CompletionItem +import io.github.tritium_launcher.launcher.ui.project.editor.lsp.CompletionPopup +import io.github.tritium_launcher.launcher.ui.project.editor.lsp.HoverOverlay +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.qt.Nullable +import io.qt.core.* +import io.qt.gui.* +import io.qt.widgets.QTextEdit +import io.qt.widgets.QToolTip +import kotlinx.coroutines.* +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) +class TreeSitterEditorAdapter( + val file: VPath, + val textEdit: QTextEdit, + val project: ProjectBase +) { + private val log = logger() + private var parseJob: Job? = null + private var hoverJob: Job? = null + private var completionJob: Job? = null + private var signatureJob: Job? = null + private val bgDispatcher = newSingleThreadContext("TreeSitterBG") + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private var semanticSelections: List = emptyList() + private var diagnosticSelections: List = emptyList() + var temporarySelections: List = emptyList() + + private val completionPopup = CompletionPopup(textEdit) + private val hoverOverlay = HoverOverlay(textEdit.window()) + private var textChangedSinceCursorMoved = false + private var currentHoverSymbol: String? = null + + private val hoverHideTimer = QTimer() + + init { + hoverHideTimer.interval = 100 + hoverHideTimer.timeout.connect { checkHoverShouldHide() } + textEdit.tabChangesFocus = false + completionPopup.onSelected = { item -> + applyCompletion(item) + } + + textEdit.textChanged.connect { + textChangedSinceCursorMoved = true + scheduleParse() + flushSelections() + scheduleCompletionRefresh() + scheduleSignatureHelp() + } + + textEdit.cursorPositionChanged.connect { + if (completionPopup.isVisible && !textChangedSinceCursorMoved) { + completionJob?.cancel() + completionPopup.hide() + } + textChangedSinceCursorMoved = false + } + + val eventFilter = object : QObject() { + override fun eventFilter( + watched: @Nullable QObject?, + event: @Nullable QEvent? + ): Boolean { + if (event == null) return false + + when (event.type()) { + QEvent.Type.Show -> flushSelections() + QEvent.Type.KeyPress -> { + val keyEvent = event as QKeyEvent + hideOverlay() + QToolTip.hideText() + if (completionPopup.isVisible && completionPopup.handleKeyEvent(keyEvent)) { + return true + } + + val ctrl = keyEvent.modifiers().testFlag(Qt.KeyboardModifier.ControlModifier) + if (keyEvent.text() == "(") { + completionJob?.cancel() + signatureJob?.cancel() + completionPopup.hide() + scheduleSignatureHelp() + } else if (keyEvent.text() == "." || (ctrl && (keyEvent.key() == Qt.Key.Key_Space.value() || keyEvent.key() == Qt.Key.Key_Return.value() || keyEvent.key() == Qt.Key.Key_Enter.value()))) { + completionJob?.cancel() + if (keyEvent.text() == ".") { + requestCompletions() + } else { + requestCompletions(force = true) + return true + } + } else if (completionPopup.isVisible && keyEvent.key() == Qt.Key.Key_Backspace.value()) { + completionJob?.cancel() + requestCompletions() + } else if (keyEvent.key() == Qt.Key.Key_Tab.value() && !completionPopup.isVisible) { + textEdit.textCursor().insertText(" ") + return true + } + } + QEvent.Type.MouseMove -> { + val mouseEvent = event as QMouseEvent + val cursor = textEdit.cursorForPosition(mouseEvent.pos()) + if (!cursor.isNull) { + val symbol = extractSymbolAt(cursor) + if (symbol != null) { + scheduleHoverRequest(cursor, textEdit.viewport()!!.mapToGlobal(mouseEvent.pos())) + } else { + hideOverlay() + } + } else { + hideOverlay() + } + } + QEvent.Type.MouseButtonPress -> { + completionPopup.hide() + hideOverlay() + QToolTip.hideText() + val mouseEvent = event as QMouseEvent + if (mouseEvent.modifiers().testFlag(Qt.KeyboardModifier.ControlModifier) && mouseEvent.button() == Qt.MouseButton.LeftButton) { + val cursor = textEdit.cursorForPosition(mouseEvent.pos()) + val id = extractNamespacedIdAt(cursor) + if (id != null) { + io.github.tritium_launcher.launcher.core.TritiumEventBus.publish( + io.github.tritium_launcher.launcher.core.TritiumEvent.RegistryFocusRequest(id) + ) + return true + } + } + } + QEvent.Type.FocusOut -> { + hideOverlay() + QToolTip.hideText() + } + else -> {} + } + return super.eventFilter(watched, event) + } + } + + textEdit.installEventFilter(eventFilter) + textEdit.viewport()?.installEventFilter(eventFilter) + } + + fun close() { + scope.cancel() + bgDispatcher.close() + hoverHideTimer.stop() + completionPopup.cleanup() + hoverOverlay.cleanup() + } + + private fun scheduleParse() { + parseJob?.cancel() + // Read Qt widget state ON MAIN THREAD only + val text = textEdit.toPlainText() + val doc = textEdit.document ?: return + + parseJob = scope.launch(bgDispatcher) { + delay(300.milliseconds) + TreeSitterService.parse(text)?.let { parseResult -> + val selections = mutableListOf() + walkForHighlight(parseResult.rootNode, doc, selections) + val errorSelections = mutableListOf() + walkForErrors(parseResult.rootNode, doc, errorSelections) + withContext(Dispatchers.Main) { + semanticSelections = selections + diagnosticSelections = errorSelections + flushSelections() + } + } + } + } + + private fun walkForHighlight(node: Node, doc: QTextDocument, selections: MutableList) { + val tokenName = JavaScriptNodeTypes.tokenName(node.type) + if (tokenName == "String" || tokenName == "Comment" || tokenName == "Number") { + val start = node.startByte.toInt() + val end = node.endByte.toInt() + if (start >= end) return + val color = tokenColorFromName(tokenName) + selections += makeSelection(doc, start, end, color) + return + } + if (node.type == "identifier" && contextToken(node) != null) { + val color = tokenColorFromName(contextToken(node)!!) + val start = node.startByte.toInt() + val end = node.endByte.toInt() + if (start >= end) return + selections += makeSelection(doc, start, end, color) + return + } + val childCount = node.childCount.toInt() + if (childCount == 0) { + if (node.type.length == 1 && node.type[0] in "(){}[]") return + val color = tokenColor(node.type) ?: return + val start = node.startByte.toInt() + val end = node.endByte.toInt() + if (start >= end) return + selections += makeSelection(doc, start, end, color) + } else { + for (i in 0 until childCount) { + val child = node.child(i.toUInt()) ?: continue + walkForHighlight(child, doc, selections) + } + } + } + + private fun contextToken(node: Node): String? { + val parent = node.parent ?: return null + return when (parent.type) { + "member_expression" -> { + val firstChild = parent.child(0u) + if (firstChild != null && firstChild.startByte == node.startByte && firstChild.endByte == node.endByte) { + "Module" + } else null + } + else -> null + } + } + + private fun tokenColorFromName(name: String): QColor { + return when (name) { + "Comment" -> TColors.Syntax.Comment.hexToQColor() + "String" -> TColors.Syntax.String.hexToQColor() + "Number" -> TColors.Syntax.Number.hexToQColor() + "Function" -> TColors.Syntax.Function.hexToQColor() + "Property" -> TColors.Syntax.Property.hexToQColor() + "Keyword" -> TColors.Syntax.Keyword.hexToQColor() + "Operator" -> TColors.Syntax.Operator.hexToQColor() + "Variable" -> TColors.Syntax.Variable.hexToQColor() + "Module" -> TColors.Syntax.Namespace.hexToQColor() + else -> TColors.Syntax.Default.hexToQColor() + } + } + + private fun walkForErrors(node: Node, doc: QTextDocument, selections: MutableList) { + if (node.isError || node.isMissing) { + val start = node.startByte.toInt() + val end = node.endByte.toInt() + if (start in 0.. TColors.Syntax.Comment.hexToQColor() + "String" -> TColors.Syntax.String.hexToQColor() + "Number" -> TColors.Syntax.Number.hexToQColor() + "Function" -> TColors.Syntax.Function.hexToQColor() + "Property" -> TColors.Syntax.Property.hexToQColor() + "Keyword" -> TColors.Syntax.Keyword.hexToQColor() + "Operator" -> TColors.Syntax.Operator.hexToQColor() + "Variable" -> TColors.Syntax.Variable.hexToQColor() + "Module" -> TColors.Syntax.Namespace.hexToQColor() + else -> { + when (type) { + in JavaScriptNodeTypes.keywordTypes -> TColors.Syntax.Keyword.hexToQColor() + in JavaScriptNodeTypes.operatorTypes -> TColors.Syntax.Operator.hexToQColor() + else -> TColors.Syntax.Default.hexToQColor() + } + } + } + } + + private fun makeSelection(doc: QTextDocument, start: Int, end: Int, color: QColor): QTextEdit.ExtraSelection { + return QTextEdit.ExtraSelection().apply { + cursor = QTextCursor(doc).apply { + setPosition(start.coerceIn(0, doc.characterCount() - 1)) + setPosition(end.coerceIn(0, doc.characterCount() - 1), QTextCursor.MoveMode.KeepAnchor) + } + format = QTextCharFormat().apply { setForeground(color) } + } + } + + private fun makeErrorSelection(doc: QTextDocument, start: Int, end: Int): QTextEdit.ExtraSelection { + return QTextEdit.ExtraSelection().apply { + cursor = QTextCursor(doc).apply { + setPosition(start.coerceIn(0, doc.characterCount() - 1)) + setPosition(end.coerceIn(0, doc.characterCount() - 1), QTextCursor.MoveMode.KeepAnchor) + } + format = QTextCharFormat().apply { + setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline) + setUnderlineColor(TColors.Syntax.Error.hexToQColor()) + } + } + } + + private fun scheduleCompletionRefresh() { + completionJob?.cancel() + // Read Qt state on Main thread + val cursor = textEdit.textCursor() + val prefix = extractPrefix(cursor) + val hasDot = hasDotBeforeCursor(cursor) + val lineText = cursor.block().text() + val column = cursor.position() - cursor.block().position() + val cursorPosition = cursor.position() + val fullText = if (hasDot) textEdit.toPlainText() else "" + + completionJob = scope.launch(bgDispatcher) { + delay(200.milliseconds) + try { + requestCompletionsInternal(prefix, hasDot, lineText, column, cursorPosition, fullText) + } catch (t: Throwable) { + log.error("scheduleCompletionRefresh: exception in completion task", t) + } + } + } + + private fun requestCompletions(force: Boolean = false) { + val cursor = textEdit.textCursor() + val prefix = extractPrefix(cursor) + val hasDot = hasDotBeforeCursor(cursor) + if (!force && prefix.isEmpty() && !hasDot) return + val lineText = cursor.block().text() + val column = cursor.position() - cursor.block().position() + val cursorPosition = cursor.position() + val fullText = if (hasDot) textEdit.toPlainText() else "" + + scope.launch(bgDispatcher) { + requestCompletionsInternal(prefix, hasDot, lineText, column, cursorPosition, fullText) + } + } + + private fun requestCompletionsInternal(prefix: String, hasDot: Boolean, lineText: String, column: Int, cursorPosition: Int, fullText: String) { + if (prefix.isEmpty() && !hasDot) return + try { + val items = if (hasDot) { + log.info("requestCompletions: contextual path hasDot=true line='{}' col={} fullTextLen={}", lineText, column, fullText.length) + KubeJSIntelligenceService.getContextualCompletions(project, fullText, cursorPosition) + } else { + log.info("requestCompletions: line-only path line='{}' col={} prefix='{}'", lineText, column, prefix) + KubeJSIntelligenceService.getCompletions(project, lineText, column) + } + if (items.isEmpty()) log.info("requestCompletions: 0 items (line='{}', col={})", lineText, column) + else log.info("requestCompletions: {} items (line='{}', col={})", items.size, lineText, column) + + val filtered = if (prefix.isNotEmpty()) { + items.filter { it.label.lowercase().startsWith(prefix.lowercase()) } + } else if (hasDot) { + items + } else { + items.take(50) + } + + scope.launch(Dispatchers.Main) { + log.info("requestCompletions GUI: filtered={}, popupVisible={}", filtered.size, completionPopup.isVisible) + if (filtered.isEmpty()) { + log.info("requestCompletions GUI: hiding popup") + completionPopup.hide() + } else { + log.info("requestCompletions GUI: showing popup with {} items", filtered.size) + completionPopup.setCompletions(filtered) + val cursorRect = textEdit.cursorRect() + val globalPos = textEdit.viewport()?.mapToGlobal(cursorRect.bottomLeft()) + log.info("requestCompletions GUI: cursorRect={} globalPos={}", cursorRect, globalPos) + if (globalPos != null) { + completionPopup.move(globalPos) + } + completionPopup.show() + log.info("requestCompletions GUI: after show, popupVisible={}", completionPopup.isVisible) + } + } + } catch (t: Throwable) { + log.error("requestCompletions exception", t) + } + } + + private fun scheduleSignatureHelp() { + signatureJob?.cancel() + // Read Qt state on Main thread + val cursorPos = textEdit.textCursor().position() + val fullText = textEdit.toPlainText() + log.info("scheduleSignatureHelp: cursorPos={} fullTextLen={}", cursorPos, fullText.length) + + signatureJob = scope.launch(bgDispatcher) { + delay(200.milliseconds) + try { + val signature = KubeJSIntelligenceService.getSignatureHelp(project, fullText, cursorPos) + log.info("scheduleSignatureHelp: getSignatureHelp returned '{}'", signature) + if (signature == null) return@launch + launch(Dispatchers.Main) { + try { + val cursorRect = textEdit.cursorRect() + val globalPos = textEdit.viewport()?.mapToGlobal(cursorRect.bottomLeft()) ?: return@launch + QToolTip.showText(globalPos, signature, textEdit.viewport()) + } catch (t: Throwable) { + log.error("scheduleSignatureHelp: failed to show tooltip", t) + } + } + } catch (t: Throwable) { + log.error("signatureHelp exception", t) + } + } + } + + private fun hideOverlay() { + hoverJob?.cancel() + currentHoverSymbol = null + hoverHideTimer.stop() + hoverOverlay.hide() + } + + private fun checkHoverShouldHide() { + val globalPos = QCursor.pos() + val viewport = textEdit.viewport() ?: return + val viewportPos = viewport.mapFromGlobal(globalPos) + val cursor = textEdit.cursorForPosition(viewportPos) + if (cursor.isNull || extractSymbolAt(cursor) == null) { + hideOverlay() + } + } + + private fun scheduleHoverRequest(cursor: QTextCursor, globalPos: QPoint) { + hoverJob?.cancel() + val symbol = extractSymbolAt(cursor) ?: return + if (hoverOverlay.isVisible && symbol == currentHoverSymbol) return + currentHoverSymbol = symbol + hoverJob = scope.launch(bgDispatcher) { + delay(500.milliseconds) + val hover = KubeJSIntelligenceService.getHover(project, symbol) ?: return@launch + launch(Dispatchers.Main) { + hoverOverlay.showHover(hover.markdown, globalPos) + hoverHideTimer.start() + } + } + } + + private fun extractPrefix(cursor: QTextCursor): String { + val block = cursor.block() + val text = block.text() + val pos = cursor.position() - block.position() + if (pos <= 0 || pos > text.length) return "" + var start = pos + while (start > 0 && (text[start - 1].isLetterOrDigit() || text[start - 1] == '_' || text[start - 1] == '$')) start-- + return text.substring(start, pos) + } + + private fun hasDotBeforeCursor(cursor: QTextCursor): Boolean { + val block = cursor.block() + val text = block.text() + val pos = cursor.position() - block.position() + if (pos <= 0) return false + if (pos <= text.length && text[pos - 1] == '.') return true + var i = pos + while (i > 0 && (text[i - 1].isLetterOrDigit() || text[i - 1] == '_' || text[i - 1] == '$')) i-- + return i > 0 && text[i - 1] == '.' + } + + private fun extractSymbolAt(cursor: QTextCursor): String? { + val block = cursor.block() + val text = block.text() + val pos = cursor.position() - block.position() + if (pos < 0 || pos > text.length) return null + var start = pos + while (start > 0 && text[start - 1].isJavaIdentifierPart()) start-- + var end = pos + while (end < text.length && text[end].isJavaIdentifierPart()) end++ + return if (start < end) text.substring(start, end) else null + } + + private fun applyCompletion(item: CompletionItem) { + val cursor = textEdit.textCursor() + val text = cursor.block().text() + val pos = cursor.position() - cursor.block().position() + var start = pos + while (start > 0 && (text[start - 1].isLetterOrDigit() || text[start - 1] == '_' || text[start - 1] == '$')) start-- + cursor.setPosition(cursor.block().position() + start) + cursor.setPosition(cursor.block().position() + pos, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(item.insertText ?: item.label) + } + + private fun extractNamespacedIdAt(cursor: QTextCursor): String? { + val block = cursor.block() + val text = block.text() + val pos = cursor.position() - block.position() + if (pos < 0 || pos >= text.length) return null + + fun isValidIdChar(c: Char) = c in 'a'..'z' || c in '0'..'9' || c == '_' || c == '.' || c == '-' || c == '/' || c == ':' + + var start = pos + while (start > 0 && isValidIdChar(text[start - 1])) start-- + var end = pos + while (end < text.length && isValidIdChar(text[end])) end++ + val candidate = text.substring(start, end) + return if (candidate.contains(':')) candidate else null + } + + fun flushSelections() { + textEdit.setExtraSelections(temporarySelections + semanticSelections + diagnosticSelections + RainbowBracketHighlighter.highlight(textEdit)) + } + + fun getHighlightSelections(): List = + semanticSelections + diagnosticSelections +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterService.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterService.kt new file mode 100644 index 0000000..307e900 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/TreeSitterService.kt @@ -0,0 +1,89 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.treesitter + +import io.github.treesitter.ktreesitter.Node +import io.github.treesitter.ktreesitter.Parser +import io.github.treesitter.ktreesitter.Tree +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.ui.project.editor.treesitter.grammar.TreeSitterJavascript + +object TreeSitterService { + private val log = logger() + private var jsLanguage: io.github.treesitter.ktreesitter.Language? = null + + fun isAvailable(): Boolean = jsLanguage != null + + fun init() { + loadJsLanguage() + if (jsLanguage != null) { + log.info("Tree-sitter JavaScript grammar loaded") + } else { + log.warn("Tree-sitter JavaScript grammar not available") + } + } + + fun parse(source: String): TreeSitterParseResult? { + val lang = jsLanguage ?: return null + return try { + val parser = Parser(lang) + val tree = parser.parse(source) + TreeSitterParseResult(parser, tree) + } catch (e: Throwable) { + log.warn("Tree-sitter parse failed", e) + null + } + } + + private fun loadJsLanguage() { + jsLanguage = try { + TreeSitterJavascript.language() + } catch (e: Throwable) { + log.warn("Failed to load JS grammar", e) + null + } + } +} + +class TreeSitterParseResult( + private val parser: Parser, + val tree: Tree +) { + val rootNode: Node get() = tree.rootNode + + fun findNodeAt(bytePos: Int): Node? = findDeepestContaining(rootNode, bytePos) + + fun collectDiagnostics(): List { + val errors = mutableListOf() + collectErrors(rootNode, errors) + return errors + } + + private fun findDeepestContaining(node: Node, bytePos: Int): Node? { + val start = node.startByte.toInt() + val end = node.endByte.toInt() + if (bytePos !in start..) { + if (node.isError) { + errors.add(ParseError(ParseErrorType.ERROR, node.startByte.toInt(), node.endByte.toInt())) + } else if (node.isMissing) { + errors.add(ParseError(ParseErrorType.MISSING, node.startByte.toInt(), node.endByte.toInt())) + } + for (child in node.children) { + collectErrors(child, errors) + } + } +} + +data class ParseError( + val type: ParseErrorType, + val startByte: Int, + val endByte: Int +) + +enum class ParseErrorType { ERROR, MISSING } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/grammar/TreeSitterJavascript.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/grammar/TreeSitterJavascript.kt new file mode 100644 index 0000000..e0b63d8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/editor/treesitter/grammar/TreeSitterJavascript.kt @@ -0,0 +1,58 @@ +package io.github.tritium_launcher.launcher.ui.project.editor.treesitter.grammar + +import io.github.treesitter.ktreesitter.Language +import java.io.File.createTempFile + +object TreeSitterJavascript { + private const val LIB_NAME = "ktreesitter-javascript" + + private val language: Language by lazy { loadLanguage() } + + fun language(): Language = language + + private fun loadLanguage(): Language { + loadLibrary() + return Language(tree_sitter_javascript()) + } + + private fun loadLibrary() { + try { + System.loadLibrary(LIB_NAME) + } catch (_: UnsatisfiedLinkError) { + @Suppress("UnsafeDynamicallyLoadedCode") + System.load(libPath() ?: throw UnsatisfiedLinkError( + "Cannot find $LIB_NAME in java.library.path or classpath resources" + )) + } + } + + + + @JvmStatic + private external fun tree_sitter_javascript(): Long + + private fun libPath(): String? { + val osName = System.getProperty("os.name")!!.lowercase() + val archName = System.getProperty("os.arch")!!.lowercase() + val prefix: String + val ext: String + val os: String + when { + "windows" in osName -> { ext = "dll"; os = "windows"; prefix = "" } + "linux" in osName -> { ext = "so"; os = "linux"; prefix = "lib" } + "mac" in osName -> { ext = "dylib"; os = "macos"; prefix = "lib" } + else -> throw UnsupportedOperationException("Unsupported OS: $osName") + } + val arch = when { + "amd64" in archName || "x86_64" in archName -> "x64" + "aarch64" in archName || "arm64" in archName -> "aarch64" + else -> throw UnsupportedOperationException("Unsupported arch: $archName") + } + val libPath = "/lib/$os/$arch/$prefix$LIB_NAME.$ext" + val libUrl = javaClass.getResource(libPath) ?: return null + return createTempFile(prefix + LIB_NAME, ".$ext").apply { + writeBytes(libUrl.openStream().use { it.readAllBytes() }) + deleteOnExit() + }.path + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/MenuItem.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/MenuItem.kt index 569fd92..9bc70bf 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/MenuItem.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/MenuItem.kt @@ -22,6 +22,10 @@ data class MenuItem( val visible: Boolean = true, val enabled: Boolean = true, val shortcut: String? = null, + val shortcutActionId: String? = null, + val allowKeyboardShortcuts: Boolean = true, + val allowMouseShortcuts: Boolean = true, + val shortcutFocusGroups: Set = setOf("global"), val meta: Map = emptyMap(), val kind: MenuItemKind = MenuItemKind.MENU, val widgetFactory: ((MenuActionContext) -> QWidget)? = null, diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/ProjectMenuBar.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/ProjectMenuBar.kt index d574109..92db4fe 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/ProjectMenuBar.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/ProjectMenuBar.kt @@ -4,18 +4,30 @@ import io.github.tritium_launcher.launcher.connect import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.core.project.ProjectType import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.keymap.ActionRegistry +import io.github.tritium_launcher.launcher.keymap.KeymapMngr import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.m +import io.github.tritium_launcher.launcher.ui.project.ProjectViewWindow +import io.github.tritium_launcher.launcher.ui.project.menu.builtin.BuiltinMenuItems import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.LongPressButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.qt.core.QSize import io.qt.core.QTimer import io.qt.core.Qt import io.qt.gui.QAction import io.qt.gui.QGuiApplication +import io.qt.gui.QIcon +import io.qt.gui.QPixmap import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Command bar that replaces the native menu bar and supports: @@ -30,33 +42,65 @@ import io.qt.widgets.* */ class ProjectMenuBar : QWidget() { private val logger = logger() + private var attachedWindow: QMainWindow? = null + private var lastProject: ProjectBase? = null + private var lastSelection: Any? = null private val layout = hBoxLayout(this) { widgetSpacing = 0 contentsMargins = 0.m } + // Center section widgets + private var centerSection: QWidget? = null + private var projectIconLabel: QLabel? = null + private var projectNameLabel: QLabel? = null + private var playBtn: QPushButton? = null + private var stopBtn: QPushButton? = null + private var settingsBtn: QPushButton? = null + + /** Container for left-side menu items, overlaid to avoid shifting the center. */ + private var leftOverlay: QWidget? = null + init { objectName = "projectMenuBar" + setAttribute(Qt.WidgetAttribute.WA_StyledBackground, true) + val keymapJob = CoroutineScope(Dispatchers.Main).launch { + KeymapMngr.activeKeymapFlow.collect { + val window = attachedWindow ?: return@collect + if (!window.isVisible) return@collect + QTimer.singleShot(0) { + rebuildFor(window, lastProject, lastSelection) + } + } + } + destroyed.connect { + keymapJob.cancel() + } setThemedStyle { selector("#projectMenuBar") { backgroundColor(TColors.Surface0) - border() + border(1, TColors.Surface1, "bottom") + minHeight(32) } selector("#projectMenuBar QPushButton, #projectMenuBar QToolButton") { backgroundColor("transparent") color(TColors.Text) border() - minHeight(22) + minHeight(24) + borderRadius(4) + padding(4, 6, 4, 6) } selector("#projectMenuBar QPushButton:hover, #projectMenuBar QToolButton:hover") { backgroundColor(TColors.Surface1) + borderRadius(4) } selector("#projectMenuBar QPushButton:pressed, #projectMenuBar QToolButton:pressed") { backgroundColor(TColors.Surface2) + borderRadius(4) } selector("#projectMenuBar QPushButton:disabled, #projectMenuBar QToolButton:disabled") { @@ -65,8 +109,35 @@ class ProjectMenuBar : QWidget() { } selector("#projectMenuBar QPushButton[menuIconOnly=\"true\"]") { - minWidth(26) - maxWidth(26) + minWidth(28) + maxWidth(28) + } + + selector("#menuBarCenterSection") { + backgroundColor("transparent") + } + + selector("#menuBarProjectIcon") { + minWidth(18) + minHeight(18) + maxWidth(18) + maxHeight(18) + } + + selector("#menuBarProjectName") { + color(TColors.Text) + fontSize(13) + fontWeight(600) + } + + selector("#menuBarSettingsBtn") { + minWidth(28) + maxWidth(28) + borderRadius(4) + } + + selector("#menuBarSettingsBtn:hover") { + backgroundColor(TColors.Surface1) } // Hide the default drop-down indicator on top-level menu buttons. @@ -79,10 +150,14 @@ class ProjectMenuBar : QWidget() { } fun attach(window: QMainWindow) { + attachedWindow = window window.setMenuWidget(this) } fun rebuildFor(window: QMainWindow, project: ProjectBase?, selection: Any?) { + attachedWindow = window + lastProject = project + lastSelection = selection clearLayout() val allItems = BuiltinRegistries.MenuItem.all().toList() @@ -103,21 +178,202 @@ class ProjectMenuBar : QWidget() { val ctx = MenuActionContext(project, window, selection, it.meta) !isRightAligned(it) && it.isVisible(ctx) } - val rightItems = topSorted.filter { - val ctx = MenuActionContext(project, window, selection, it.meta) - isRightAligned(it) && it.isVisible(ctx) - } - leftItems.forEach { top -> - addTopItem(window, top, children, project, selection) + /* + Left-side overlay container – holds menu item widgets in its own + layout so they get proper parenting + show(). Positioned manually + outside the main layout to avoid shifting the center section. + */ + if (leftItems.isNotEmpty()) { + val container = QWidget(this) + container.objectName = "menuBarLeftOverlay" + val leftHBox = hBoxLayout(container) { + widgetSpacing = 0 + contentsMargins = 0.m + } + leftItems.forEach { top -> + addTopItemTo(leftHBox, window, top, children, project, selection) + } + container.show() + leftOverlay = container } - layout.addStretch(1) - rightItems.forEach { top -> - addTopItem(window, top, children, project, selection) + + if (project != null) { + layout.addStretch(1) + addCenterSection(window, project, selection) + layout.addStretch(1) + } else { + layout.addStretch(1) } + + createSettingsButton(window) + + positionAllChildren() update() } + private fun loadMenuIcon(key: String, targetSize: Int): QIcon { + val pix = TIcons.pixForKey(key, 24, 24) + if (!pix.isNull) { + val scaled = pix.scaled(targetSize, targetSize, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation) + return scaled.icon + } + return pix.icon + } + + private fun loadMenuIcon(icon: QIcon, targetSize: Int): QIcon { + val pix = icon.pixmap(targetSize, targetSize) + if (!pix.isNull) { + return pix.scaled(targetSize, targetSize, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation).icon + } + return icon + } + + private fun addCenterSection(window: QMainWindow, project: ProjectBase, selection: Any?) { + centerSection?.let { cs -> + layout.removeWidget(cs) + cs.disposeLater() + } + centerSection = QWidget().apply { + objectName = "menuBarCenterSection" + val hbox = hBoxLayout(this) { + widgetSpacing = 12 + contentsMargins = 0.m + } + + val iconPix = runCatching { + val iconPath = project.getIconPath() + QPixmap(iconPath) + }.getOrNull() + projectIconLabel = label { + objectName = "menuBarProjectIcon" + if (iconPix != null && !iconPix.isNull) { + pixmap = iconPix.scaled(18, 18, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } + } + + projectNameLabel = label(project.name) { + objectName = "menuBarProjectName" + } + + playBtn = LongPressButton().apply { + val playItem = BuiltinRegistries.MenuItem.all().find { it.id == "play" } + objectName = "menuBarPlayBtn" + setProperty("menuIconOnly", true) + isFlat = true + iconSize = QSize(24, 24) + + fun refreshPlayState() { + val ctx = playItem?.let { MenuActionContext(project, window, selection, it.meta) } + isEnabled = playItem?.isEnabled(ctx) ?: true + icon = playItem?.resolveIcon(ctx)?.let { loadMenuIcon(it, 24) } ?: loadMenuIcon("menu/run", 24) + toolTip = playItem?.tooltip ?: "Play" + } + refreshPlayState() + + if (playItem != null) { + onNormalClick = { + val actionCtx = MenuActionContext(project, window, selection, playItem.meta) + playItem.action?.invoke(actionCtx) + refreshPlayState() + } + onLongPress = { + CoroutineScope(Dispatchers.Main).launch { + BuiltinMenuItems.launchOrPrepare(project) + } + } + } + + val stateTimer = QTimer(this).apply { + interval = 200 + timeout.connect { refreshPlayState() } + start() + } + destroyed.connect { stateTimer.stop() } + } + + stopBtn = QPushButton().apply { + val stopItem = BuiltinRegistries.MenuItem.all().find { it.id == "stop_game" } + val useShiftHoverForceIcon = stopItem?.meta?.get("shiftHoverForceIcon") == "true" + objectName = "menuBarStopBtn" + setProperty("menuIconOnly", true) + isFlat = true + mouseTracking = true + iconSize = QSize(24, 24) + + fun refreshStopState() { + val ctx = stopItem?.let { MenuActionContext(project, window, selection, it.meta) } + isEnabled = stopItem?.isEnabled(ctx) ?: false + val showForceIcon = useShiftHoverForceIcon && + isEnabled && + underMouse() && + QGuiApplication.queryKeyboardModifiers().testFlag(Qt.KeyboardModifier.ShiftModifier) + val resolvedIcon = if (showForceIcon) TIcons.ForceStop.icon else stopItem?.resolveIcon(ctx) + icon = resolvedIcon?.let { loadMenuIcon(it, 24) } ?: loadMenuIcon("menu/stop", 24) + toolTip = if (showForceIcon) "Force-stop game process" else (stopItem?.tooltip ?: "Stop") + } + refreshStopState() + + if (stopItem != null) { + clicked.connect { + val actionCtx = MenuActionContext(project, window, selection, stopItem.meta) + stopItem.action?.invoke(actionCtx) + refreshStopState() + } + } + + val stateTimer = QTimer(this).apply { + interval = 50 + timeout.connect { refreshStopState() } + start() + } + destroyed.connect { stateTimer.stop() } + } + + hbox.addWidget(projectIconLabel!!) + hbox.addWidget(projectNameLabel!!) + hbox.addWidget(playBtn!!) + hbox.addWidget(stopBtn!!) + } + layout.addWidget(centerSection) + } + + private fun createSettingsButton(window: QMainWindow) { + settingsBtn = QPushButton(this).apply { + icon = loadMenuIcon("menu/settings", 22) + iconSize = QSize(22, 22) + objectName = "menuBarSettingsBtn" + toolTip = "Settings" + isFlat = true + setProperty("menuIconOnly", true) + clicked.connect { + (window as? ProjectViewWindow)?.openSettings() + } + show() + } + } + + private fun positionAllChildren() { + val h = height + leftOverlay?.let { container -> + container.adjustSize() + container.move(0, (h - container.height()) / 2) + container.raise() + } + + settingsBtn?.let { btn -> + btn.adjustSize() + val margin = 4 + btn.move(width - btn.width() - margin, (h - btn.height()) / 2) + btn.raise() + } + } + + override fun resizeEvent(event: io.qt.gui.QResizeEvent?) { + super.resizeEvent(event) + positionAllChildren() + } + private fun resolveProjectType(project: ProjectBase?): ProjectType? { val typeId = project?.typeId?.trim().orEmpty() if (typeId.isEmpty()) return null @@ -185,6 +441,17 @@ class ProjectMenuBar : QWidget() { children: Map>, project: ProjectBase?, selection: Any? + ) { + addTopItemTo(layout, window, top, children, project, selection) + } + + private fun addTopItemTo( + target: QLayout, + window: QMainWindow, + top: MenuItem, + children: Map>, + project: ProjectBase?, + selection: Any? ) { val ctx = MenuActionContext(project, window, selection, top.meta) if (!top.isVisible(ctx)) return @@ -192,24 +459,26 @@ class ProjectMenuBar : QWidget() { MenuItemKind.WIDGET -> { val widget = top.widgetFactory?.invoke(ctx) if (widget != null) { - layout.addWidget(widget) + target.addWidget(widget) } } MenuItemKind.ACTION -> { - layout.addWidget(makeActionButton(window, top, project, selection)) + target.addWidget(makeActionButton(window, top, project, selection)) } MenuItemKind.MENU -> { - layout.addWidget(makeMenuButton(window, top, children, project, selection)) + target.addWidget(makeMenuButton(window, top, children, project, selection)) } MenuItemKind.SEPARATOR -> { - layout.addWidget(makeSeparator()) + target.addWidget(makeSeparator()) } } } + + private fun makeSeparator(): QWidget { val sep = QFrame() sep.frameShape = QFrame.Shape.VLine @@ -222,6 +491,7 @@ class ProjectMenuBar : QWidget() { private fun makeActionButton(window: QMainWindow, item: MenuItem, project: ProjectBase?, selection: Any?): QPushButton { val baseCtx = MenuActionContext(project, window, selection, item.meta) + registerActionHandler(item, window, project, selection) val iconOnly = item.meta["iconOnly"]?.equals("true", ignoreCase = true) == true val useShiftHoverForceIcon = item.meta["shiftHoverForceIcon"]?.equals("true", ignoreCase = true) == true val btn = QPushButton(if (iconOnly) "" else item.resolveTitle(baseCtx)) @@ -287,7 +557,7 @@ class ProjectMenuBar : QWidget() { btn.toolButtonStyle = Qt.ToolButtonStyle.ToolButtonTextOnly btn.autoRaise = true - val menu = QMenu(window) + val menu = QMenu(btn) val kids = childItems(item, children, window, project, selection) if (kids.isNotEmpty()) { for (child in kids) { @@ -299,7 +569,7 @@ class ProjectMenuBar : QWidget() { } val submenuKids = childItems(child, children, window, project, selection) if (submenuKids.isNotEmpty() && child.kind != MenuItemKind.ACTION) { - val submenu = QMenu(child.resolveTitle(childCtx), window) + val submenu = QMenu(child.resolveTitle(childCtx), menu) submenuKids.forEach { grand -> addActionToMenu(submenu, grand, window, project, selection, children) } @@ -312,10 +582,19 @@ class ProjectMenuBar : QWidget() { // Allow top-level action for menu button if (item.action != null) { - val act = QAction(item.resolveTitle(baseCtx), window) + registerActionHandler(item, window, project, selection) + val act = QAction(item.resolveTitle(baseCtx), menu) item.resolveIcon(baseCtx)?.let { act.icon = it } act.isEnabled = item.isEnabled(baseCtx) - item.shortcut?.let { act.setShortcut(it) } + val actionId = shortcutActionIdFor(item) + val mappedShortcuts = KeymapMngr.sequencesFor(actionId) + val hasExplicitOverride = KeymapMngr.activeKeymap.localOverrides().containsKey(actionId) + val hasDeclaredShortcut = actionId in KeymapMngr.declaredActionIds() + if (mappedShortcuts.isNotEmpty() || hasExplicitOverride || hasDeclaredShortcut) { + act.setShortcuts(mappedShortcuts) + } else { + item.shortcut?.let { act.setShortcut(it) } + } act.triggered.connect { try { val ctx = MenuActionContext(project, window, selection, item.meta) @@ -325,6 +604,7 @@ class ProjectMenuBar : QWidget() { } } menu.insertAction(menu.actions().firstOrNull(), act) + this.addAction(act) } btn.setMenu(menu) @@ -348,7 +628,7 @@ class ProjectMenuBar : QWidget() { val subKids = childItems(item, children, window, project, selection) if (subKids.isNotEmpty() && item.kind != MenuItemKind.ACTION) { - val submenu = QMenu(item.resolveTitle(ctx), window) + val submenu = QMenu(item.resolveTitle(ctx), menu) subKids.forEach { sub -> addActionToMenu(submenu, sub, window, project, selection, children) } @@ -356,10 +636,19 @@ class ProjectMenuBar : QWidget() { return } - val act = QAction(item.resolveTitle(ctx), window) + val act = QAction(item.resolveTitle(ctx), menu) + registerActionHandler(item, window, project, selection) item.resolveIcon(ctx)?.let { act.icon = it } act.isEnabled = item.isEnabled(ctx) - item.shortcut?.let { act.setShortcut(it) } + val actionId = shortcutActionIdFor(item) + val mappedShortcuts = KeymapMngr.sequencesFor(actionId) + val hasExplicitOverride = KeymapMngr.activeKeymap.localOverrides().containsKey(actionId) + val hasDeclaredShortcut = actionId in KeymapMngr.declaredActionIds() + if (mappedShortcuts.isNotEmpty() || hasExplicitOverride || hasDeclaredShortcut) { + act.setShortcuts(mappedShortcuts) + } else { + item.shortcut?.let { act.setShortcut(it) } + } item.tooltip?.let { act.toolTip = it } act.triggered.connect { try { @@ -370,6 +659,23 @@ class ProjectMenuBar : QWidget() { } } menu.addAction(act) + this.addAction(act) + } + + private fun shortcutActionIdFor(item: MenuItem): String = + item.shortcutActionId ?: "menu.${item.id}" + + private fun registerActionHandler(item: MenuItem, window: QMainWindow, project: ProjectBase?, selection: Any?) { + if (item.action == null) return + ActionRegistry.registerHandler( + id = shortcutActionIdFor(item), + allowKeyboardShortcuts = item.allowKeyboardShortcuts, + allowMouseShortcuts = item.allowMouseShortcuts, + focusGroups = item.shortcutFocusGroups + ) { + val actionCtx = MenuActionContext(project, window, selection, item.meta) + item.action.invoke(actionCtx) + } } private fun childItems( @@ -389,13 +695,44 @@ class ProjectMenuBar : QWidget() { } private fun clearLayout() { + // 1. Manually remove and dispose of all actions associated with this widget + val currentActions = actions() + for (action in currentActions) { + removeAction(action) + action?.disposeLater() + } + + // 2. Clear the layout and dispose of all widgets (buttons) + // Disposing the buttons will also dispose of their parented QMenu objects. val count = layout.count() for (i in 0 until count) { val item = layout.takeAt(0) item?.widget()?.let { w -> w.hide() + // Explicitly unparent to stop any active shortcut participation immediately + w.setParent(null) w.disposeLater() } } + + leftOverlay?.let { w -> + w.hide() + w.setParent(null) + w.disposeLater() + } + + settingsBtn?.let { btn -> + btn.hide() + btn.setParent(null) + btn.disposeLater() + } + + leftOverlay = null + centerSection = null + projectIconLabel = null + projectNameLabel = null + playBtn = null + stopBtn = null + settingsBtn = null } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/builtin/BuiltinMenuItems.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/builtin/BuiltinMenuItems.kt index 22f3c5f..e62bebc 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/builtin/BuiltinMenuItems.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/menu/builtin/BuiltinMenuItems.kt @@ -1,8 +1,14 @@ package io.github.tritium_launcher.launcher.ui.project.menu.builtin import io.github.tritium_launcher.launcher.TConstants +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.mod.ModDatabase +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.core.project.ProjectMngr +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.fromTR import io.github.tritium_launcher.launcher.io.VPath import io.github.tritium_launcher.launcher.logger @@ -10,6 +16,7 @@ import io.github.tritium_launcher.launcher.platform.CompanionBridge import io.github.tritium_launcher.launcher.platform.CompanionBridgeResponse import io.github.tritium_launcher.launcher.platform.GameLauncher import io.github.tritium_launcher.launcher.platform.GameProcessMngr +import io.github.tritium_launcher.launcher.registrydb.RegistryRefreshService import io.github.tritium_launcher.launcher.ui.dashboard.Dashboard import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.github.tritium_launcher.launcher.ui.notifications.NotificationMngr @@ -29,7 +36,10 @@ import io.qt.gui.QGuiApplication import io.qt.gui.QIcon import io.qt.widgets.* import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.nio.file.Files +import kotlin.time.Duration.Companion.milliseconds /** * Built-in menu items contributed by the core extension. @@ -46,6 +56,8 @@ object BuiltinMenuItems { private const val KILL_WAIT_TIMEOUT_MS = 4_000L private const val DOWNLOAD_POLL_INTERVAL_MS = 200L private const val DOWNLOAD_WAIT_TIMEOUT_MS = 10 * 60_000L + private const val PLAY_ICON_COOLDOWN_MS = 2_000L + private var lastPlayClickMs = 0L val Play = MenuItem( id = "play", @@ -62,10 +74,11 @@ object BuiltinMenuItems { if (project == null) { TIcons.Run.icon } else { + val inCooldown = System.currentTimeMillis() - lastPlayClickMs < PLAY_ICON_COOLDOWN_MS when { GameLauncher.isGameRunning(project) -> TIcons.Rerun.icon - isAssetGenerationActive(project) -> TIcons.Download.icon - GameLauncher.needsRuntimeDownload(project) -> TIcons.Download.icon + !inCooldown && isAssetGenerationActive(project) -> TIcons.Download.icon + !inCooldown && GameLauncher.needsRuntimeDownload(project) -> TIcons.Download.icon else -> TIcons.Run.icon } } @@ -74,11 +87,21 @@ object BuiltinMenuItems { val project = ctx.project project != null && !isAssetGenerationActive(project) }, - tooltip = "Play game (reruns if running, downloads runtime when missing)", + tooltip = "Tap to play (tap = reload server when running; hold to restart)", action = { ctx -> val project = ctx.project ?: return@MenuItem scope.launch { - launchOrPrepare(project) + if (CoreSettingValues.smartRerun && GameLauncher.isGameRunning(project)) { + val response = CompanionBridge.reloadServer() + if (response.ok) { + postBridgeResponse(project, "Reload", response) + } else { + logger.warn("Smart rerun reload failed: {}. Falling back to restart.", response.message) + launchOrPrepare(project) + } + } else { + launchOrPrepare(project) + } } } ) @@ -200,6 +223,46 @@ object BuiltinMenuItems { } ) + val FileSepAfterRecent = MenuItem( + id = "file_sep_after_recent", + title = "", + parentId = File.id, + order = 11, + kind = MenuItemKind.SEPARATOR + ) + + val Save = MenuItem( + id = "save", + title = "Save", + parentId = File.id, + order = 12, + kind = MenuItemKind.ACTION, + shortcut = "Ctrl+S", + action = { ctx -> + (ctx.window as? ProjectViewWindow)?.saveActiveEditor() + } + ) + + val SaveAll = MenuItem( + id = "save_all", + title = "Save All", + parentId = File.id, + order = 13, + kind = MenuItemKind.ACTION, + shortcut = "Ctrl+Shift+S", + action = { ctx -> + (ctx.window as? ProjectViewWindow)?.saveAllEditors() + } + ) + + val FileSepAfterSave = MenuItem( + id = "file_sep_after_save", + title = "", + parentId = File.id, + order = 14, + kind = MenuItemKind.SEPARATOR + ) + val CloseProject = MenuItem( id = "close_project", title = "Close Project", @@ -508,10 +571,8 @@ object BuiltinMenuItems { order = 50, kind = MenuItemKind.ACTION, action = { ctx -> - val project = ctx.project - scope.launch { - val response = CompanionBridge.sendCommand("dumpRegistry") - postBridgeResponse(project, "Dump Registry", response) + ctx.project?.let { project -> + RegistryRefreshService.triggerRefresh(project) } } ) @@ -588,6 +649,127 @@ object BuiltinMenuItems { } ) + val CheckModUpdates = MenuItem( + id = "check_mod_updates", + title = "Check Mod Updates", + parentId = View.id, + order = 35, + kind = MenuItemKind.ACTION, + icon = TIcons.Download.icon, + enabledResolver = { ctx -> ctx.project?.typeId == "modpack" }, + action = { ctx -> + TritiumEventBus.publish(TritiumEvent.UpdateCheckRequested) + } + ) + + val FileSepBeforeExport = MenuItem( + id = "file_sep_before_export", + title = "", + parentId = File.id, + order = 15, + kind = MenuItemKind.SEPARATOR + ) + + val ExportReleaseManifest = MenuItem( + id = "export_release_manifest", + title = "Export Release Manifest", + parentId = File.id, + order = 16, + kind = MenuItemKind.ACTION, + icon = TIcons.JSON.icon, + enabledResolver = { ctx -> ctx.project?.typeId == "modpack" }, + action = { ctx -> + val project = ctx.project ?: return@MenuItem + doExportReleaseManifest(project, ctx.window) + } + ) + + private fun doExportReleaseManifest(project: ProjectBase, window: QWidget?) { + scope.launch { + try { + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta ?: return@launch + val mods = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getReleaseModsFull() } + } + val manifestMods = mods.map { mod -> + val deps = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getDependencies(mod.projectId) } + } + ExportManifestMod( + source = mod.source, + projectId = mod.projectId, + versionId = mod.versionId, + fileName = mod.fileName, + displayName = mod.displayName, + side = mod.side.name, + releaseType = mod.releaseType, + dependencies = deps + ) + } + val manifest = ExportManifest( + formatVersion = 1, + minecraftVersion = meta.minecraftVersion, + modLoader = meta.loader, + modLoaderVersion = meta.loaderVersion, + mods = manifestMods + ) + val jsonStr = exportJson.encodeToString(manifest) + val target = project.projectDir.resolve(".tr/release-manifest.json") + withContext(Dispatchers.IO) { + target.parent().mkdirs() + target.writeTextAtomic(jsonStr) + } + TritiumEventBus.publish(TritiumEvent.ReleaseManifestExported(project, target.toAbsolute().toString())) + runOnGuiThread { + QMessageBox.information( + window, + "Export Complete", + "Release manifest written to:\n${target.toAbsolute()}" + ) + } + } catch (t: Throwable) { + logger.warn("Failed to export release manifest", t) + runOnGuiThread { + QMessageBox.warning( + window, + "Export Failed", + t.message ?: "Unknown error" + ) + } + } + } + } + + private object ManifestJson { + val instance = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + } + + @Serializable + private data class ExportManifest( + val formatVersion: Int = 1, + val minecraftVersion: String, + val modLoader: String, + val modLoaderVersion: String, + val mods: List + ) + + @Serializable + private data class ExportManifestMod( + val source: String, + val projectId: String, + val versionId: String, + val fileName: String, + val displayName: String, + val side: String, + val releaseType: String, + val dependencies: List = emptyList() + ) + + private val exportJson get() = ManifestJson.instance + private enum class EditCommand { UNDO, REDO, @@ -597,7 +779,8 @@ object BuiltinMenuItems { DELETE } - private suspend fun launchOrPrepare(project: ProjectBase) { + suspend fun launchOrPrepare(project: ProjectBase) { + lastPlayClickMs = System.currentTimeMillis() if (isAssetGenerationActive(project)) return val wasRunning = GameLauncher.isGameRunning(project) if (wasRunning) { @@ -647,7 +830,7 @@ object BuiltinMenuItems { startDir, "Tritium Project (trproj.json);;JSON Files (*.json);;All Files (*)" ) - val selectedPath = chosen.result.trim() + val selectedPath = chosen?.result?.trim().orEmpty() if (selectedPath.isBlank()) return val selected = VPath.get(selectedPath).expandHome().toAbsolute().normalize() @@ -819,7 +1002,8 @@ object BuiltinMenuItems { val targets = listOf( TConstants.Dirs.CACHE, TConstants.Dirs.LOADERS, - TConstants.Dirs.ASSETS + TConstants.Dirs.ASSETS, + "mb-cache" ) val deleted = mutableListOf() @@ -891,7 +1075,7 @@ object BuiltinMenuItems { val end = System.currentTimeMillis() + timeoutMs.coerceAtLeast(0L) while (System.currentTimeMillis() < end) { if (!GameLauncher.isGameRunning(project)) return true - delay(STOP_POLL_INTERVAL_MS) + delay(STOP_POLL_INTERVAL_MS.milliseconds) } return !GameLauncher.isGameRunning(project) } @@ -910,7 +1094,7 @@ object BuiltinMenuItems { val end = System.currentTimeMillis() + timeoutMs.coerceAtLeast(0L) while (System.currentTimeMillis() < end) { if (!GameLauncher.isRuntimePreparationActive(project)) return true - delay(DOWNLOAD_POLL_INTERVAL_MS) + delay(DOWNLOAD_POLL_INTERVAL_MS.milliseconds) } return !GameLauncher.isRuntimePreparationActive(project) } @@ -949,6 +1133,12 @@ object BuiltinMenuItems { NewProjectFromExisting, NewProjectFromGit, RecentProjects, + FileSepAfterRecent, + Save, + SaveAll, + FileSepAfterSave, + FileSepBeforeExport, + ExportReleaseManifest, CloseProject, FileSepBeforeInvalidate, InvalidateCaches, @@ -966,6 +1156,7 @@ object BuiltinMenuItems { ViewToolWindows, ViewIncreaseFont, ViewDecreaseFont, + CheckModUpdates, Game, LaunchGame, StopGame, diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/DockWidget.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/DockWidget.kt index 4ecf881..42c95a1 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/DockWidget.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/DockWidget.kt @@ -4,7 +4,9 @@ import io.qt.gui.QIcon import io.qt.gui.QPixmap import io.qt.widgets.QDockWidget import io.qt.widgets.QMainWindow -import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Used in [io.github.tritium_launcher.launcher.ui.project.ProjectViewWindow] to display content in pop-out panes. @@ -18,13 +20,11 @@ open class DockWidget(title: String, parent: QMainWindow?): QDockWidget(title, p get() = property("dockIndex") as? Int ?: 0 set(value) { setProperty("dockIndex", value) - onIndexChangedListeners.forEach { it(value) } + _indexChanges.tryEmit(value) } - private val onIndexChangedListeners = CopyOnWriteArrayList<(Int) -> Unit>() - - fun addOnIndexChanged(listener: (Int) -> Unit) { onIndexChangedListeners.add(listener) } - fun removeOnIndexChanged(listener: (Int) -> Unit) { onIndexChangedListeners.remove(listener) } + private val _indexChanges = MutableSharedFlow(replay = 0) + val indexChanges: SharedFlow = _indexChanges.asSharedFlow() fun applyIcon(icon: QIcon?) { if(icon != null) windowIcon = icon } fun applyIcon(icon: QPixmap?) { if(icon != null) windowIcon = QIcon(icon) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ModBrowserSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ModBrowserSidePanelProvider.kt new file mode 100644 index 0000000..52d4eeb --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ModBrowserSidePanelProvider.kt @@ -0,0 +1,1265 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.mod.* +import io.github.tritium_launcher.launcher.core.onEvent +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.source.* +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.fromTR +import io.github.tritium_launcher.launcher.io.* +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.onClicked +import io.github.tritium_launcher.launcher.platform.ClientIdentity +import io.github.tritium_launcher.launcher.platform.Platform +import io.github.tritium_launcher.launcher.ui.helpers.CacheManager +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.project.ProjectTaskMngr +import io.github.tritium_launcher.launcher.ui.project.editor.EditorArea +import io.github.tritium_launcher.launcher.ui.project.editor.panes.* +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController +import io.github.tritium_launcher.launcher.ui.widgets.TMultiStateCategoryComboBox +import io.github.tritium_launcher.launcher.ui.widgets.TPushButton +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.* +import io.qt.core.QByteArray +import io.qt.core.QRectF +import io.qt.core.QSize +import io.qt.core.Qt +import io.qt.gui.* +import io.qt.svg.QSvgRenderer +import io.qt.widgets.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime + +class ModBrowserSidePanelProvider : SidePanelProvider { + override val id: String = "mod_browser" + override val displayName: String = "Mod Browser" + override var icon: QIcon? = QIcon(TIcons.Search) + override val order: Int = 7 + override val closeable: Boolean = true + override val floatable: Boolean = true + override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.LeftDockWidgetArea + override val allowSplit: Boolean = false + + override fun create(project: ProjectBase): DockWidget { + val dock = DockWidget(displayName, null) + dock.setWidget(ModBrowserSidePanel(project, this)) + return dock + } + + override fun onDockCreated(project: ProjectBase, editorArea: EditorArea, dock: DockWidget, onStateChanged: () -> Unit) { + val panel = dock.widget() as? ModBrowserSidePanel + panel?.onOpenDetailRequested = { modId, title, _ -> + ModDetailMeta.register(modId, title) + editorArea.openEditorPane( + provider = ModDetailPaneProvider, + title = title, + paneFactory = { ModDetailPane(it, modId = modId) } + ) + } + } +} + +class ModBrowserSidePanel( + private val project: ProjectBase, + private val provider: ModBrowserSidePanelProvider? = null +) : QWidget() { + var onOpenDetailRequested: ((modId: String, title: String, iconUrl: String?) -> Unit)? = null + + companion object { + private const val PAGE_SIZE = 25 + private val EMPTY_ICON = QIcon(TIcons.Search) + } + + private val logger = logger() + private val state = ModBrowserState.forProject(project) + private val sources = BuiltinRegistries.ModSource + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 60_000 + } + defaultRequest { + header("User-Agent", ClientIdentity.userAgent) + header("X-Client-Info", ClientIdentity.clientInfoHeader) + } + } + + private val availableItemsById = linkedMapOf() + private val searchResultsById = linkedMapOf() + private val availableRowWidgets = linkedMapOf() + private val queuedRowWidgets = linkedMapOf() + private val iconCache get() = state.iconCache + private val dominantColorCache get() = state.dominantColorCache + private val iconLoadSemaphore = Semaphore(8) + private val mbCacheDir: VPath = fromTR("cache", "mod-browser") + + private var activeContext: ModBrowserContext? = null + private var activeSource: ModSource? = null + private var selectedResultId: String? = null + private var totalHits: Int = 0 + private var nextOffset: Int = 0 + private var lastQueryText: String = "" + private var lastIncludedCategories: Set = emptySet() + private var lastExcludedCategories: Set = emptySet() + private var isLoadingPage: Boolean = false + private var hasMoreResults: Boolean = false + private var lastPageLoadMs: Long = 0L + private var searchGeneration: Int = 0 + private var searchJob: Job? = null + private var currentSupportMessage: String? = null + private val prefetchSemaphore = Semaphore(4) + private var downloadsWatcher: VPathWatcher? = null + + private val container = qWidget() + private var suppressSelectionEvents = false + private val searchField = QLineEdit() + private val searchButton = TPushButton { text = "Search"; minimumHeight = 30 } + private val categoryCombo = TMultiStateCategoryComboBox() + private val availableList = QListWidget().apply { + setAttribute(Qt.WidgetAttribute.WA_StyledBackground) + } + private val queuedList = QListWidget() + private val downloadQueuedButton = TPushButton { text = "Download Queue"; minimumHeight = 30 } + private val removeQueuedButton = TPushButton { text = "Remove Selected"; minimumHeight = 30 } + private val statusLabel = label("Ready") { wordWrap = true } + + init { + AnimatedScrollController.attach(availableList) + AnimatedScrollController.attach(queuedList) + + vBoxLayout(this) { + setContentsMargins(0, 0, 0, 0) + + addWidget(container.apply { + vBoxLayout(this) { + setContentsMargins(4, 4, 4, 4) + setSpacing(6) + + addWidget(qWidget().also { row -> + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(searchField, 1) + addWidget(searchButton) + } + }) + + addWidget(categoryCombo) + addWidget(qWidget().also { listPane -> + vBoxLayout(listPane) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(label("Mods to Download")) + addWidget(availableList, 1) + } + }, 1) + addWidget(qWidget().also { queuedPane -> + vBoxLayout(queuedPane) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(label("Selected for Download")) + addWidget(queuedList, 1) + addWidget(qWidget().also { row -> + hBoxLayout(row) { + setContentsMargins(0, 0, 0, 0) + setSpacing(6) + addWidget(downloadQueuedButton) + addWidget(removeQueuedButton) + } + }) + } + }) + } + }, 1) + + addWidget(statusLabel) + } + + searchField.placeholderText = "Search Mods" + listOf(availableList, queuedList).forEach { list -> + list.iconSize = QSize(40, 40) + list.uniformItemSizes = false + list.horizontalScrollBarPolicy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + } + availableList.apply { + objectName = "availableModList" + selectionMode = QAbstractItemView.SelectionMode.NoSelection + verticalScrollMode = QAbstractItemView.ScrollMode.ScrollPerPixel + } + queuedList.selectionMode = QAbstractItemView.SelectionMode.SingleSelection + + searchButton.onClicked { startFreshSearch() } + searchField.returnPressed.connect { startFreshSearch() } + categoryCombo.onSelectionChanged = { startFreshSearch() } + availableList.currentItemChanged.connect { current, _ -> + if (suppressSelectionEvents) return@connect + selectedResultId = current?.data(Qt.ItemDataRole.UserRole) as? String + updateSelectedRowGradient() + } + availableList.itemClicked.connect { item -> + val resultId = item?.data(Qt.ItemDataRole.UserRole) as? String ?: return@connect + val result = searchResultsById[resultId] ?: return@connect + selectedResultId = resultId + updateSelectedRowGradient() + onOpenDetailRequested?.invoke(resultId, result.title, result.iconUrl) + } + availableList.itemDoubleClicked.connect { item -> + val resultId = item?.data(Qt.ItemDataRole.UserRole) as? String ?: return@connect + if (state.queuedDownloads.containsKey(resultId)) { + removeQueuedDownload(resultId) + } else { + queueResult(resultId) + } + } + queuedList.currentItemChanged.connect { current, _ -> + val resultId = current?.data(Qt.ItemDataRole.UserRole) as? String + selectedResultId = resultId + updateSelectedRowGradient() + removeQueuedButton.isEnabled = queuedList.currentRow() >= 0 + } + queuedList.itemClicked.connect { item -> + val projectId = item?.data(Qt.ItemDataRole.UserRole) as? String ?: return@connect + val queued = state.queuedDownloads[projectId] ?: return@connect + if (queued.requiresManualDownload) { + val url = queued.projectUrl?.let { "$it/download/${queued.versionId}" } + if (url != null) Platform.openBrowser(url) + } + } + availableList.verticalScrollBar()?.valueChanged?.connect { value -> + val now = System.currentTimeMillis() + if (now - lastPageLoadMs < 350) return@connect + val bar = availableList.verticalScrollBar() + if (bar != null && bar.maximum() > 0 && value >= bar.maximum() - 2) { + lastPageLoadMs = now + loadNextPage() + } + } + downloadQueuedButton.onClicked { downloadQueue() } + removeQueuedButton.onClicked { removeQueuedSelection() } + + downloadQueuedButton.isEnabled = false + removeQueuedButton.isEnabled = false + + projectContext()?.let { ctx -> + val source = resolveSource() ?: return@let + activeContext = ctx + activeSource = source + provider?.icon = QIcon(source.icon) + val support = source.support(ctx) + currentSupportMessage = support.message + if (!support.available) { + statusLabel.text = support.message ?: "Source unavailable" + return@let + } + statusLabel.text = "Ready" + ioScope.launch { + val categories = runCatching { source.getCategories(ctx) }.getOrElse { + emptyList() + } + runOnGuiThread { + categoryCombo.setEntries(categories.map { Triple(it.id, it.displayName, it.iconUrl) }) + startFreshSearch() + } + categories.forEach { cat -> + val url = cat.iconUrl ?: return@forEach + val cached = iconCache[url] + if (cached != null) { + runOnGuiThread { categoryCombo.setEntryIcon(cat.id, cached.pixmap(48, 48)) } + } else { + ioScope.launch { + downloadAndCacheIcon(url, subdir = "categories", sourceId = source.id, onCached = { icon -> + categoryCombo.setEntryIcon(cat.id, icon.pixmap(48, 48)) + }) + } + } + } + } + } ?: run { + statusLabel.text = "The Mod Browser requires a typed Modpack project." + } + + state.queuedDownloads.values.forEach { addQueuedDownloadItem(it) } + updateQueueButtons() + } + + private fun projectContext(): ModBrowserContext? { + val meta = ((project as? Project<*>)?.typedMeta as? ModpackMeta) ?: return null + return ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + } + + private fun resolveSource(): ModSource? { + val sourceId = ((project as? Project<*>)?.typedMeta as? ModpackMeta)?.source + return sourceId?.let { id -> sources.all().find { it.id == id } } + } + + private fun startFreshSearch() { + val context = activeContext ?: return + val source = activeSource ?: return + if (!source.support(context).available) return + + lastQueryText = searchField.text.trim() + val includedCategories = categoryCombo.includedIds() + val excludedCategories = categoryCombo.excludedIds() + lastIncludedCategories = includedCategories + lastExcludedCategories = excludedCategories + nextOffset = 0 + totalHits = 0 + hasMoreResults = true + searchResultsById.clear() + availableItemsById.clear() + selectedResultId = null + searchGeneration++ + suppressSelectionEvents = true + availableList.clear() + suppressSelectionEvents = false + availableRowWidgets.clear() + loadPage(reset = true) + } + + private fun loadNextPage() { + if (!hasMoreResults || isLoadingPage) return + loadPage(reset = false) + } + + private fun loadPage(reset: Boolean) { + val context = activeContext ?: return + val source = activeSource ?: return + if (isLoadingPage) return + + isLoadingPage = true + val offset = nextOffset + searchJob?.cancel() + searchJob = ioScope.launch { + runOnGuiThread { + statusLabel.text = if (offset == 0) "Searching ${source.displayName}..." else "Loading more results..." + } + val page = runCatching { + source.search( + context = context, + query = ModSearchQuery( + text = lastQueryText, + includedCategories = lastIncludedCategories, + excludedCategories = lastExcludedCategories, + offset = offset, + limit = PAGE_SIZE + ) + ) + } + runOnGuiThread { + isLoadingPage = false + val resolved = page.getOrNull() + if (resolved == null) { + statusLabel.text = page.exceptionOrNull()?.message ?: "Search failed" + if (reset) availableList.clear() + return@runOnGuiThread + } + + totalHits = resolved.total + nextOffset = offset + resolved.results.size + hasMoreResults = nextOffset < totalHits && resolved.results.isNotEmpty() + statusLabel.text = "Showing ${searchResultsById.size + resolved.results.size} of $totalHits" + + suppressSelectionEvents = true + availableList.updatesEnabled = false + val newResults = mutableListOf() + try { + resolved.results.forEach { result -> + if (searchResultsById.containsKey(result.id)) return@forEach + searchResultsById[result.id] = result + state.resultsCache[result.id] = result + addAvailableResultItem(result) + queueIconLoad(result.id, result.iconUrl) + newResults.add(result) + } + } finally { + availableList.updatesEnabled = true + suppressSelectionEvents = false + } + + val totalCached = state.detailsCache.size + val newBudget = 500 - totalCached + if (newBudget > 0 && newResults.isNotEmpty()) { + ioScope.launch { + newResults.take(newBudget).forEach { result -> + launch { + prefetchSemaphore.withPermit { + runCatching { fetchDetails(context, source, result.id) } + runCatching { fetchVersions(context, source, result.id) } + preloadIcon(result.iconUrl) + } + } + } + } + } + } + } + } + + private fun addAvailableResultItem(result: ModSearchResult) { + val item = QListWidgetItem().apply { + setData(Qt.ItemDataRole.UserRole, result.id) + } + availableItemsById[result.id] = item + availableList.addItem(item) + refreshAvailableResultItem(result.id) + } + + private fun refreshAvailableResultItem(resultId: String) { + val result = searchResultsById[resultId] ?: return + val item = availableItemsById[resultId] ?: return + disposeItemWidget(availableList, item) + val row = createAvailableRow(result) + availableRowWidgets[result.id] = row + availableList.setItemWidget(item, row.root) + item.setSizeHint(row.root.sizeHint()) + } + + private fun createAvailableRow(result: ModSearchResult): AvailableRowWidgets { + val icon = label { + setFixedSize(40, 40) + scaledContents = true + pixmap = listIconPixmap(result.iconUrl) + } + val title = label(result.title) { + val titleFont = font() + titleFont.setBold(state.queuedDownloads.containsKey(result.id)) + font = titleFont + } + val meta = label(buildAvailableMetaText(result)) { + styleSheet = "color: ${TColors.Subtext};" + } + val root = object : QWidget() { + override fun sizeHint(): QSize { + val hint = super.sizeHint() + return QSize(hint.width(), maxOf(hint.height(), 48)) + } + }.also { widget -> + widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, true) + hBoxLayout(widget) { + setContentsMargins(6, 4, 6, 4) + setSpacing(8) + addWidget(icon, 0, Qt.AlignmentFlag.AlignVCenter) + addWidget(qWidget().also { textCol -> + vBoxLayout(textCol) { + setContentsMargins(0, 0, 0, 0) + setSpacing(2) + addWidget(title) + if (meta.text().isNotBlank()) addWidget(meta) + } + }, 1) + } + } + return AvailableRowWidgets(root = root, iconLabel = icon) + } + + private fun addQueuedDownloadItem(queued: QueuedDownload) { + val item = QListWidgetItem().apply { + setData(Qt.ItemDataRole.UserRole, queued.projectId) + } + queuedList.addItem(item) + val row = createQueuedRow(queued) + queuedRowWidgets[queued.projectId] = row + queuedList.setItemWidget(item, row.root) + item.setSizeHint(row.root.sizeHint()) + } + + private fun createQueuedRow(queued: QueuedDownload): QueuedRowWidgets { + val icon = label { + setFixedSize(40, 40) + scaledContents = true + pixmap = listIconPixmap(queued.iconUrl) + } + val title = label(queued.title) { + val titleFont = font() + titleFont.setBold(true) + font = titleFont + } + val version = label(queued.versionLabel) { + styleSheet = "color: ${TColors.Subtext};" + } + val infoText = buildQueueInfoText(queued) + val info = label(infoText) { + styleSheet = "color: ${TColors.Subtext};" + isVisible = infoText.isNotBlank() + } + val errorText = buildQueueErrorText(queued) + val error = label(errorText) { + styleSheet = "color: ${TColors.Error};" + wordWrap = true + isVisible = errorText.isNotBlank() + } + val downloadUrl = if (queued.requiresManualDownload) { + queued.projectUrl?.let { "$it/download/${queued.versionId}" } + } else null + val link = if (downloadUrl != null) { + label("$downloadUrl") { + textFormat = Qt.TextFormat.RichText + wordWrap = true + isVisible = true + } + } else label("") { isVisible = false } + val root = object : QWidget() { + override fun sizeHint(): QSize { + val hint = super.sizeHint() + return QSize(hint.width(), maxOf(hint.height(), 48)) + } + }.also { widget -> + widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, true) + hBoxLayout(widget) { + setContentsMargins(6, 4, 6, 4) + setSpacing(8) + addWidget(icon, 0, Qt.AlignmentFlag.AlignTop) + addWidget(qWidget().also { textCol -> + vBoxLayout(textCol) { + setContentsMargins(0, 0, 0, 0) + setSpacing(2) + addWidget(title) + addWidget(version) + if (info.isVisible) addWidget(info) + if (error.isVisible) addWidget(error) + if (link.isVisible) addWidget(link) + } + }, 1) + } + } + return QueuedRowWidgets(root = root, iconLabel = icon) + } + + private fun extractDominantColor(pixmap: QPixmap): Triple? { + val small = pixmap.scaled(4, 4, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) + if (small.isNull) return null + val image = small.toImage() ?: return null + if (image.isNull) return null + for (y in 0 until image.height()) { + for (x in 0 until image.width()) { + val argb = image.pixel(x, y) + val alpha = (argb ushr 24) and 0xFF + if (alpha >= 128) { + return Triple( + (argb ushr 16) and 0xFF, + (argb ushr 8) and 0xFF, + argb and 0xFF + ) + } + } + } + return null + } + + private fun updateSelectedRowGradient() { + for (i in 0 until availableList.count()) { + availableList.item(i)?.setBackground(QBrush()) + } + for (i in 0 until queuedList.count()) { + queuedList.item(i)?.setBackground(QBrush()) + } + val id = selectedResultId ?: return + val result = searchResultsById[id] ?: return + val iconUrl = result.iconUrl ?: return + val (r, g, b) = dominantColorCache[iconUrl] ?: return + val gradient = QLinearGradient(0.0, 0.0, 1.0, 0.0).apply { + setCoordinateMode(QGradient.CoordinateMode.ObjectBoundingMode) + setColorAt(0.0, QColor(r, g, b, 180)) + setColorAt(1.0, QColor(0, 0, 0, 0)) + } + availableItemsById[id]?.setBackground(QBrush(gradient)) + for (i in 0 until queuedList.count()) { + val item = queuedList.item(i) ?: continue + if (item.data(Qt.ItemDataRole.UserRole) as? String == id) { + item.setBackground(QBrush(gradient)) + break + } + } + } + + private fun disposeItemWidget(list: QListWidget, item: QListWidgetItem) { + list.itemWidget(item)?.let { widget -> + widget.hide() + list.removeItemWidget(item) + widget.dispose() + } + } + + private fun listIconPixmap(iconUrl: String?, size: Int = 40): QPixmap = + iconUrl?.let(iconCache::get)?.pixmap(size, size) + ?: TIcons.Search.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio) + + private fun buildAvailableMetaText(result: ModSearchResult): String = buildString { + result.author?.takeIf { it.isNotBlank() }?.let { append("by $it") } + if (result.categories.isNotEmpty()) { + if (isNotEmpty()) append(" | ") + append(result.categories.joinToString(", ")) + } + } + + private fun buildQueueInfoText(queued: QueuedDownload): String = + if (queued.dependencies.isNotEmpty()) { + "${queued.dependencies.size} dependenc${if (queued.dependencies.size == 1) "y" else "ies"}" + } else "" + + private fun buildQueueErrorText(queued: QueuedDownload): String = buildString { + if (queued.requiresManualDownload) { + append("Manual download required (blocked by mod author)") + return@buildString + } + queued.status.missingDependencies.takeIf { it.isNotEmpty() }?.let { + append("Missing: ") + append(it.joinToString(", ")) + } + queued.status.incompatibleWith.takeIf { it.isNotEmpty() }?.let { + if (isNotEmpty()) append("\n") + append("Incompatible: ") + append(it.joinToString(", ")) + } + } + + private fun queueIconLoad(key: String, url: String?) { + if (url.isNullOrBlank()) return + val cached = iconCache[url] + if (cached != null) { + availableRowWidgets[key]?.iconLabel?.pixmap = cached.pixmap(40, 40) + queuedRowWidgets[key]?.iconLabel?.pixmap = cached.pixmap(40, 40) + return + } + ioScope.launch { + var acquired = false + try { + iconLoadSemaphore.acquire() + acquired = true + downloadAndCacheIcon(url, key, subdir = "items", sourceId = activeSource?.id ?: "") + } finally { + if (acquired) iconLoadSemaphore.release() + } + } + } + + private fun urlHash(url: String): String = + MessageDigest.getInstance("MD5").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun cacheFile(subdir: String, sourceId: String, url: String): VPath = + mbCacheDir.resolve(subdir).resolve(sourceId).resolve(urlHash(url)) + + private fun bytesToIcon(bytes: ByteArray): QIcon = runCatching { + val pixmap = QPixmap() + if (pixmap.loadFromData(bytes)) return@runCatching QIcon(pixmap) + val svgText = bytes.toString(Charsets.UTF_8).replace("currentColor", "#000000") + val renderer = QSvgRenderer(QByteArray(svgText.toByteArray(Charsets.UTF_8))) + val svgPix = QPixmap(64, 64) + svgPix.fill(Qt.GlobalColor.transparent) + val painter = QPainter(svgPix) + try { + painter.setRenderHint(QPainter.RenderHint.Antialiasing, true) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, true) + renderer.render(painter, QRectF(0.0, 0.0, 64.0, 64.0)) + } finally { + painter.end() + } + QIcon(svgPix) + }.getOrElse { EMPTY_ICON } + + private suspend fun downloadAndCacheIcon( + iconUrl: String, + listKey: String? = null, + subdir: String? = null, + sourceId: String? = null, + onCached: ((QIcon) -> Unit)? = null + ) { + if (subdir != null && sourceId != null) { + val cached = cacheFile(subdir, sourceId, iconUrl).bytesOrNull() + if (cached != null) { + CacheManager.touch(cacheFile(subdir, sourceId, iconUrl)) + runOnGuiThread { + val iconObj = bytesToIcon(cached) + if (iconObj !== EMPTY_ICON) { + iconCache[iconUrl] = iconObj + if (!dominantColorCache.containsKey(iconUrl)) { + val color = extractDominantColor(iconObj.pixmap(40, 40)) + if (color != null) { + dominantColorCache[iconUrl] = color + if (listKey == selectedResultId) updateSelectedRowGradient() + } + } + if (listKey != null) { + availableRowWidgets[listKey]?.iconLabel?.pixmap = iconObj.pixmap(40, 40) + queuedRowWidgets[listKey]?.iconLabel?.pixmap = iconObj.pixmap(40, 40) + } + onCached?.invoke(iconObj) + } + } + return + } + } + val bytes = runCatching { + if (iconUrl.startsWith("data:image/svg+xml,")) { + iconUrl.removePrefix("data:image/svg+xml,").toByteArray(Charsets.UTF_8) + } else { + httpClient.get(iconUrl).bodyAsBytes() + } + }.getOrNull() ?: return + if (subdir != null && sourceId != null) { + runCatching { + val path = cacheFile(subdir, sourceId, iconUrl).toJPath() + Files.createDirectories(path.parent) + Files.write(path, bytes) + } + CacheManager.evictIfNeeded(mbCacheDir, subdir) + } + runOnGuiThread { + val iconObj = bytesToIcon(bytes) + iconCache[iconUrl] = iconObj + if (iconObj !== EMPTY_ICON && !dominantColorCache.containsKey(iconUrl)) { + val color = extractDominantColor(iconObj.pixmap(40, 40)) + if (color != null) { + dominantColorCache[iconUrl] = color + if (listKey == selectedResultId) updateSelectedRowGradient() + } + } + if (listKey != null) { + availableRowWidgets[listKey]?.iconLabel?.pixmap = iconObj.pixmap(40, 40) + queuedRowWidgets[listKey]?.iconLabel?.pixmap = iconObj.pixmap(40, 40) + } + onCached?.invoke(iconObj) + } + } + + private fun preloadIcon(iconUrl: String?) { + if (iconUrl.isNullOrBlank()) return + if (iconCache.containsKey(iconUrl)) return + ioScope.launch { + downloadAndCacheIcon(iconUrl) + } + } + + private fun queueResult(resultId: String) { + ioScope.launch { + val context = activeContext ?: return@launch + val source = activeSource ?: return@launch + val versionId = runCatching { + fetchVersions(context, source, resultId).firstOrNull()?.id + }.getOrNull() + if (versionId == null) { + runOnGuiThread { statusLabel.text = "No compatible version available." } + return@launch + } + val details = runCatching { + fetchDetails(context, source, resultId) + }.getOrNull() + if (details == null) { + runOnGuiThread { statusLabel.text = "Failed to load mod details." } + return@launch + } + val version = fetchVersions(context, source, resultId).firstOrNull { it.id == versionId } + val queued = QueuedDownload( + projectId = resultId, + title = details.title, + versionId = versionId, + versionLabel = version?.label ?: versionId, + iconUrl = details.iconUrl, + dependencies = version?.dependencies ?: emptyList(), + status = QueueStatus(), + projectUrl = details.website, + ) + runOnGuiThread { + state.queuedDownloads[resultId] = queued + state.manuallyQueuedIds += resultId + renderQueuedDownloads() + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + refreshAvailableResultItem(resultId) + statusLabel.text = "Queued ${details.title}" + } + } + } + + private suspend fun fetchDetails(context: ModBrowserContext, source: ModSource, id: String): ModDetails { + state.detailsCache[id]?.let { return it } + val details = source.details(context, id) + state.detailsCache[id] = details + return details + } + + private suspend fun fetchVersions(context: ModBrowserContext, source: ModSource, id: String): List { + state.versionsCache[id]?.let { return it } + val versions = source.versions(context, id) + state.versionsCache[id] = versions + return versions + } + + private fun renderQueuedDownloads() { + queuedList.updatesEnabled = false + try { + queuedList.clear() + queuedRowWidgets.clear() + state.queuedDownloads.values.forEach { queued -> + addQueuedDownloadItem(queued) + queueIconLoad(queued.projectId, queued.iconUrl) + } + } finally { + queuedList.updatesEnabled = true + } + updateQueueButtons() + if (state.queuedDownloads.any { it.value.requiresManualDownload }) { + startDownloadsWatcher() + } else { + stopDownloadsWatcher() + } + } + + private fun updateQueueButtons() { + downloadQueuedButton.isEnabled = state.queuedDownloads.isNotEmpty() + removeQueuedButton.isEnabled = queuedList.currentRow() >= 0 + } + + private fun removeQueuedSelection() { + val item = queuedList.currentItem() ?: return + val projectId = item.data(Qt.ItemDataRole.UserRole) as? String ?: return + removeQueuedDownload(projectId) + } + + private fun removeQueuedDownload(projectId: String) { + state.queuedDownloads.remove(projectId) + state.manuallyQueuedIds.remove(projectId) + removeOrphanedQueuedDependencies() + renderQueuedDownloads() + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + refreshAvailableResultItem(projectId) + } + + private fun removeOrphanedQueuedDependencies() { + val requiredByQueued = state.queuedDownloads.values + .flatMap { queued -> queued.dependencies.filter { it.required }.map { it.projectId } } + .toMutableSet() + var removedAny: Boolean + do { + removedAny = false + val orphanIds = state.queuedDownloads.keys.filter { queuedId -> + queuedId !in state.manuallyQueuedIds && queuedId !in requiredByQueued + } + if (orphanIds.isNotEmpty()) { + orphanIds.forEach { orphanId -> + state.queuedDownloads.remove(orphanId) + state.manuallyQueuedIds.remove(orphanId) + refreshAvailableResultItem(orphanId) + } + requiredByQueued.clear() + requiredByQueued += state.queuedDownloads.values + .flatMap { queued -> queued.dependencies.filter { it.required }.map { it.projectId } } + removedAny = true + } + } while (removedAny) + } + + @OptIn(ExperimentalTime::class) + private fun downloadQueue() { + val context = activeContext ?: return + val source = activeSource ?: return + val queued = state.queuedDownloads.values.toList() + if (queued.isEmpty()) return + + ioScope.launch { + val taskId = ProjectTaskMngr.start( + projectPath = project.projectDir, + title = "Downloading queued mods (Side Panel)", + detail = "Preparing downloads", + progressPercent = null + ) + val modsDir = project.projectDir.resolve("mods") + modsDir.mkdirs() + val cacheEnabled = CoreSettingValues.modCacheEnabled + val downloadSemaphore = Semaphore(4) + val fallbacks = sources.all() + .filterIsInstance() + .sortedBy { it.priority } + + data class PerModData( + val projectId: String, + val installedMod: InstalledMod, + val depIds: List + ) + + val failures = ConcurrentLinkedQueue>() + + val perModResults: List = coroutineScope { + queued.map { queuedMod -> + async { + downloadSemaphore.withPermit { + try { + val resolved = resolveInstallDownload(context, source, queuedMod.projectId, queuedMod.versionId, fallbacks) + if (resolved.downloadUrl == null) { + val existing = scanDownloadsForMod(queuedMod, resolved.plan.fileHash) + if (existing != null) { + val (jarPath, hash) = existing + logger.info("Found existing download for '{}': {}", queuedMod.title, jarPath.fileName()) + val installedMod = prepareJarInstall(jarPath, queuedMod, hash, source.id) + val depIds = queuedMod.dependencies.filter { it.required }.map { it.projectId } + return@withPermit PerModData(projectId = queuedMod.projectId, installedMod = installedMod, depIds = depIds) + } + runOnGuiThread { + state.queuedDownloads[queuedMod.projectId] = queuedMod.copy( + requiresManualDownload = true, + fileHash = resolved.plan.fileHash, + ) + renderQueuedDownloads() + startDownloadsWatcher() + } + val msg = "Manual download required (blocked by mod author)" + failures.add(queuedMod.projectId to msg) + return@withPermit null + } + val jarPath = modsDir.resolve(resolved.fileName) + val response = httpClient.prepareGet(resolved.downloadUrl).execute() + val channel = response.bodyAsChannel() + val totalBytes = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() ?: -1L + val digest = MessageDigest.getInstance("SHA-1") + jarPath.parent().mkdirs() + java.io.FileOutputStream(jarPath.toJFile()).use { fos -> + val buffer = ByteArray(8 * 1024) + var downloaded = 0L + while (!channel.isClosedForRead) { + val rc = channel.readAvailable(buffer, 0, buffer.size) + if (rc <= 0) break + fos.write(buffer, 0, rc) + digest.update(buffer, 0, rc) + downloaded += rc + totalBytes.takeIf { it > 0 }?.let { total -> + ProjectTaskMngr.updateProgress(taskId, (downloaded.toDouble() / total) * 100.0) + } + } + } + val fileHash = digest.digest().joinToString("") { "%02x".format(it) } + logger.info("Downloaded queued mod '{}' as '{}'", queuedMod.projectId, resolved.fileName) + + if (cacheEnabled) { + val cacheFile = ModDatabase.cachePathFor(fileHash) + cacheFile.parent().mkdirs() + cacheFile.writeBytesAtomic(jarPath.toJFile().readBytes()) + } + + val jarInfo = readModJarInfo(jarPath) + val modId = jarInfo?.modId ?: queuedMod.projectId + val displayName = jarInfo?.displayName ?: queuedMod.title + val side = jarInfo?.side ?: ModSide.BOTH + + val iconBytes = try { + queuedMod.iconUrl?.let { url -> httpClient.get(url).bodyAsBytes() } + } catch (_: Exception) { null } + val iconPath: String? = if (iconBytes != null) { + val iconFile = ModDatabase.iconPathFor(queuedMod.projectId) + iconFile.writeBytesAtomic(iconBytes) + iconFile.toAbsolute().toString() + } else { + val jarIcon = readModJarIcon(jarPath) + if (jarIcon != null) { + val iconFile = ModDatabase.iconPathFor(queuedMod.projectId) + iconFile.writeBytesAtomic(jarIcon) + iconFile.toAbsolute().toString() + } else null + } + + val depIds = queuedMod.dependencies.filter { it.required }.map { it.projectId } + + PerModData( + projectId = queuedMod.projectId, + installedMod = InstalledMod( + projectId = queuedMod.projectId, + modId = modId, + fileName = resolved.fileName, + displayName = displayName, + side = side, + releaseType = resolved.plan.releaseType?.name?.lowercase() ?: "release", + source = source.id, + versionId = resolved.plan.versionId, + versionLabel = resolved.plan.versionLabel, + iconPath = iconPath, + projectUrl = null, + fileHash = fileHash, + installedAt = Clock.System.now(), + requiresManualDownload = resolved.requiresManualDownload, + ), + depIds = depIds + ) + } catch (e: Exception) { + logger.warn("Failed to download mod '{}': {}", queuedMod.title, e.message, e) + failures.add(queuedMod.projectId to (e.message ?: "Unknown error")) + null + } + } + } + }.awaitAll() + } + + val successful = perModResults.filterNotNull() + + if (successful.isNotEmpty()) { + ModDatabase(project.projectDir).use { db -> + successful.forEach { data -> + db.install(data.installedMod) + db.setDependencies(data.projectId, data.depIds) + TritiumEventBus.publish( + TritiumEvent.ModInstalled( + project, data.projectId, + data.installedMod.modId, + data.installedMod.displayName, + data.installedMod.versionId, + data.installedMod.versionLabel + ) + ) + } + } + runOnGuiThread { + successful.forEach { state.queuedDownloads.remove(it.projectId) } + state.manuallyQueuedIds.removeAll { pid -> successful.none { it.projectId == pid } } + renderQueuedDownloads() + } + TritiumEventBus.publish(TritiumEvent.ModsInstalled) + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + } + + ProjectTaskMngr.finish(taskId) + runOnGuiThread { + val manualCount = state.queuedDownloads.count { it.value.requiresManualDownload } + val message = when { + failures.isEmpty() && manualCount == 0 -> "Downloaded ${successful.size} mod(s)" + failures.isEmpty() && manualCount > 0 -> "Downloaded ${successful.size} mod(s), $manualCount require manual download" + successful.isEmpty() && manualCount > 0 -> "$manualCount mod(s) require manual download" + successful.isEmpty() -> "All downloads failed" + else -> "Downloaded ${successful.size}/${queued.size} mod(s), ${failures.size} failed" + } + statusLabel.text = message + } + } + } + + private fun startDownloadsWatcher() { + if (downloadsWatcher != null) return + if (state.queuedDownloads.none { it.value.requiresManualDownload }) return + + val downloadsDir = VPath.parse(System.getProperty("user.home")).resolve("Downloads") + if (!downloadsDir.isDir()) { + logger.warn("Downloads directory not found: {}", downloadsDir.toAbsolute()) + return + } + + ioScope.launch { + scanDownloadsFolder(downloadsDir) + if (state.queuedDownloads.none { it.value.requiresManualDownload }) { + runOnGuiThread { stopDownloadsWatcher() } + } + } + + logger.info("Starting Downloads folder watcher for manual download detection") + downloadsWatcher = downloadsDir.watch( + callback = { event -> + if (event.kind == VWatchEvent.Kind.Create) { + val name = event.path.fileName() + if (name.endsWith(".jar", ignoreCase = true)) { + ioScope.launch { + handleDownloadsJar(event.path) + } + } + } + }, + options = VWatchOptions( + kinds = listOf(java.nio.file.StandardWatchEventKinds.ENTRY_CREATE), + ), + ctx = Dispatchers.IO + ) + } + + private fun stopDownloadsWatcher() { + downloadsWatcher?.close() + downloadsWatcher = null + } + + private suspend fun scanDownloadsFolder(downloadsDir: VPath) { + val existingJars = downloadsDir.list() + .filter { it.fileName().endsWith(".jar", ignoreCase = true) } + .sortedByDescending { it.lastModifiedOrNull() } + for (jarPath in existingJars) { + handleDownloadsJar(jarPath) + if (state.queuedDownloads.none { it.value.requiresManualDownload }) break + } + } + + private fun jarMatchesMod(jarPath: VPath, expectedHash: String?, projectId: String): String? { + val bytes = runCatching { jarPath.bytesOrNothing() }.getOrNull() ?: return null + val hash = ModDatabase.sha1(bytes) + if (expectedHash != null && hash == expectedHash) return hash + val info = runCatching { readModJarInfo(jarPath) }.getOrNull() + if (info != null && info.modId == projectId) return hash + return null + } + + private suspend fun prepareJarInstall(jarPath: VPath, queued: QueuedDownload, hash: String, sourceId: String): InstalledMod { + val modsDir = project.projectDir.resolve("mods") + modsDir.mkdirs() + val targetPath = modsDir.resolve(jarPath.fileName()) + withContext(Dispatchers.IO) { + Files.copy( + jarPath.toJPath(), + targetPath.toJPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + + val jarInfo = readModJarInfo(targetPath) + val projectId = queued.projectId + val modId = jarInfo?.modId ?: projectId + val displayName = jarInfo?.displayName ?: queued.title + val side = jarInfo?.side ?: ModSide.BOTH + + val iconBytes = try { + queued.iconUrl?.let { url -> httpClient.get(url).bodyAsBytes() } + } catch (_: Exception) { null } ?: readModJarIcon(targetPath) + val iconPath: String? = if (iconBytes != null) { + val iconFile = ModDatabase.iconPathFor(projectId) + iconFile.writeBytesAtomic(iconBytes) + iconFile.toAbsolute().toString() + } else null + + return InstalledMod( + projectId = projectId, + modId = modId, + fileName = targetPath.fileName(), + displayName = displayName, + side = side, + releaseType = "release", + source = sourceId, + versionId = queued.versionId, + versionLabel = queued.versionLabel, + iconPath = iconPath, + projectUrl = queued.projectUrl, + fileHash = hash, + installedAt = Clock.System.now(), + requiresManualDownload = true, + ) + } + + private suspend fun commitManualInstall(installedMod: InstalledMod, depIds: List = emptyList()) { + val projectId = installedMod.projectId + withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> + db.install(installedMod) + db.setDependencies(projectId, depIds) + } + } + + runOnGuiThread { + state.queuedDownloads.remove(projectId) + state.manuallyQueuedIds.remove(projectId) + renderQueuedDownloads() + if (state.queuedDownloads.none { it.value.requiresManualDownload }) { + stopDownloadsWatcher() + } + } + + TritiumEventBus.publish( + TritiumEvent.ModInstalled( + project, projectId, installedMod.modId, installedMod.displayName, + installedMod.versionId, installedMod.versionLabel + ) + ) + TritiumEventBus.publish(TritiumEvent.ModsInstalled) + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + + runOnGuiThread { statusLabel.text = "Detected and installed manual download: ${installedMod.displayName}" } + } + + private fun scanDownloadsForMod(queued: QueuedDownload, fileHash: String?): Pair? { + val downloadsDir = VPath.parse(System.getProperty("user.home")).resolve("Downloads") + if (!downloadsDir.isDir()) return null + val jars = downloadsDir.list() + .filter { it.fileName().endsWith(".jar", ignoreCase = true) } + .sortedByDescending { it.lastModifiedOrNull() } + for (jarPath in jars) { + val hash = jarMatchesMod(jarPath, fileHash, queued.projectId) ?: continue + return jarPath to hash + } + return null + } + + private suspend fun handleDownloadsJar(jarPath: VPath) { + val pendingManual = state.queuedDownloads.filter { it.value.requiresManualDownload } + if (pendingManual.isEmpty()) return + + delay(2000.milliseconds) + + for ((_, queued) in pendingManual) { + val hash = jarMatchesMod(jarPath, queued.fileHash, queued.projectId) ?: continue + logger.info("Detected manual download for mod '{}': {}", queued.title, jarPath.fileName()) + val installedMod = prepareJarInstall(jarPath, queued, hash, activeSource?.id ?: "unknown") + val depIds = queued.dependencies.filter { it.required }.map { it.projectId } + commitManualInstall(installedMod, depIds) + return + } + } + + init { + ioScope.onEvent { renderQueuedDownloads() } + ioScope.onEvent { + val manualIds = state.queuedDownloads.filter { it.value.requiresManualDownload }.keys + if (manualIds.isEmpty()) return@onEvent + ioScope.launch { + val installed = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> + manualIds.filter { db.exists(it) } + } + } + if (installed.isNotEmpty()) { + runOnGuiThread { + installed.forEach { state.queuedDownloads.remove(it) } + renderQueuedDownloads() + TritiumEventBus.publish(TritiumEvent.QueuedDownloadsChanged) + } + } + } + } + destroyed.connect { + stopDownloadsWatcher() + searchJob?.cancel() + ioScope.cancel() + httpClient.close() + } + } +} + +private data class AvailableRowWidgets( + val root: QWidget, + val iconLabel: QLabel +) + +private data class QueuedRowWidgets( + val root: QWidget, + val iconLabel: QLabel +) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesContextAction.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesContextAction.kt new file mode 100644 index 0000000..1afd7f0 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesContextAction.kt @@ -0,0 +1,67 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.registry.Registrable +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor +import io.qt.gui.QIcon +import io.qt.widgets.QTreeWidget + +/** + * A context menu action for the Project Files tree. + * + * Extensions implement this to add custom right-click actions on files and directories. + * Register instances via [io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries.ProjectFilesAction]. + */ +interface ProjectFilesContextAction : Registrable { + val displayName: String + val order: Int + val icon: QIcon? + + /** + * Which section of the context menu this action belongs to. + * Sections are rendered in order with separators between them. + */ + val section: Section + + /** + * Whether the tree should be refreshed after execution. + */ + val needsRefresh: Boolean + + /** + * Whether this action should appear in the context menu for the given path. + */ + fun matches(path: VPath, isDirectory: Boolean, fileType: FileTypeDescriptor?, project: ProjectBase): Boolean + + /** + * Execute this action. [tree] is provided for post-execution operations like refresh or selection changes. + */ + fun execute(path: VPath, project: ProjectBase, tree: QTreeWidget) + + enum class Section { NEW, CLIPBOARD, RENAME, DELETE, RELOAD, EXTENSIONS } + + companion object { + fun create( + id: String, + displayName: String, + order: Int = 0, + icon: QIcon? = null, + section: Section = Section.EXTENSIONS, + needsRefresh: Boolean = false, + matches: (VPath, Boolean, FileTypeDescriptor?, ProjectBase) -> Boolean = { _, _, _, _ -> true }, + execute: (VPath, ProjectBase, QTreeWidget) -> Unit + ): ProjectFilesContextAction = object : ProjectFilesContextAction { + override val id = id + override val displayName = displayName + override val order = order + override val icon = icon + override val section = section + override val needsRefresh = needsRefresh + override fun matches(path: VPath, isDirectory: Boolean, fileType: FileTypeDescriptor?, project: ProjectBase): Boolean = + matches(path, isDirectory, fileType, project) + override fun execute(path: VPath, project: ProjectBase, tree: QTreeWidget) = + execute(path, project, tree) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesSidePanelProvider.kt index 7fff16a..00ad0e3 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesSidePanelProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesSidePanelProvider.kt @@ -1,17 +1,36 @@ package io.github.tritium_launcher.launcher.ui.project.sidebar import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.core.project.ProjectDirWatcher import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.m import io.github.tritium_launcher.launcher.registry.DeferredRegistryBuilder +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.project.editor.EditorArea +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon -import io.qt.core.QSize +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.Nullable +import io.qt.core.* import io.qt.core.Qt.ItemDataRole.UserRole +import io.qt.gui.QCursor +import io.qt.gui.QDropEvent import io.qt.gui.QIcon import io.qt.widgets.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.* /** * The standard project files view while a Project is open. @@ -19,23 +38,160 @@ import io.qt.widgets.* * @see SidePanelProvider * @see DockWidget */ -class ProjectFilesSidePanelProvider: SidePanelProvider { + +private var _clipboardSource: List = emptyList() +private var _clipboardIsCut = false + +internal fun clipboardSource(): List = _clipboardSource + +internal fun setClipboard(paths: List, isCut: Boolean) { + _clipboardSource = paths + _clipboardIsCut = isCut +} + +internal fun clipboardIsCut(): Boolean = _clipboardIsCut + +internal fun clearClipboard() { + _clipboardSource = emptyList() + _clipboardIsCut = false +} + +internal fun showInlineInput(parent: QWidget, defaultText: String, globalPos: QPoint): String? { + val dialog = QDialog(parent, Qt.WindowFlags(Qt.WindowType.Popup)) + val layout = vBoxLayout(dialog) { + contentsMargins = 4.m + } + val edit = QLineEdit(dialog) + edit.minimumWidth = 200 + edit.text = defaultText + edit.selectAll() + layout.addWidget(edit) + dialog.adjustSize() + dialog.move(globalPos) + + var result: String? = null + edit.returnPressed.connect { + val text = edit.text().trim() + if (text.isNotEmpty()) result = text + dialog.accept() + } + + edit.setFocus() + if (dialog.exec() == 1) return result + return null +} + +internal fun selectedPaths(tree: QTreeWidget): List { + val items = tree.selectedItems() + if (items.isNotEmpty()) { + return items.mapNotNull { it?.data(0, UserRole) as? VPath } + } + val current = tree.currentItem() + val path = current?.data(0, UserRole) as? VPath + return if (path != null) listOf(path) else emptyList() +} + +internal fun pasteTo(targetDir: VPath, onRefresh: () -> Unit = {}) { + if (_clipboardSource.isEmpty()) return + var anySuccess = false + for (src in _clipboardSource) { + val srcPath = runCatching { VPath.parse(src) }.getOrNull() ?: continue + val dest = targetDir.resolve(srcPath.fileName()) + if (_clipboardIsCut) { + runCatching { + Files.move(srcPath.toJPath(), dest.toJPath(), StandardCopyOption.REPLACE_EXISTING) + anySuccess = true + } + } else { + runCatching { + Files.copy(srcPath.toJPath(), dest.toJPath(), StandardCopyOption.REPLACE_EXISTING) + anySuccess = true + } + } + } + if (_clipboardIsCut) { + _clipboardSource = emptyList() + _clipboardIsCut = false + } + if (anySuccess) onRefresh() +} + +internal fun promptNewFolder(targetDir: VPath, globalPos: QPoint, onRefresh: () -> Unit = {}) { + val name = showInlineInput(QApplication.activeWindow() ?: return, "", globalPos) + if (name != null) { + val newDir = targetDir.resolve(name) + if (runCatching { newDir.mkdirs() }.getOrDefault(false)) { + onRefresh() + } + } +} + +internal fun promptRename(path: VPath, tree: QTreeWidget, onRefresh: () -> Unit = {}) { + val oldName = path.fileName() + val globalPos = QCursor.pos() + val newName = showInlineInput(tree, oldName, globalPos) + if (newName == null || newName == oldName) return + val parent = runCatching { path.parent() }.getOrNull() ?: return + val dest = parent.resolve(newName) + runCatching { + Files.move(path.toJPath(), dest.toJPath(), StandardCopyOption.REPLACE_EXISTING) + onRefresh() + } +} + +internal fun promptDelete(path: VPath, tree: QWidget, onRefresh: () -> Unit = {}) { + val name = path.fileName() + val isDir = runCatching { path.isDir() }.getOrDefault(false) + val kind = if (isDir) "folder" else "file" + val result = QMessageBox.question( + tree, "Delete $kind", + "Are you sure you want to delete \"$name\"?", + QMessageBox.StandardButtons( + QMessageBox.StandardButton.Yes, + QMessageBox.StandardButton.No + ), + QMessageBox.StandardButton.Yes + ) + if (result != QMessageBox.StandardButton.Yes) return + runCatching { + val jPath = path.toJPath() + if (isDir) { + Files.walk(jPath).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } else { + Files.deleteIfExists(jPath) + } + onRefresh() + } +} + +class ProjectFilesSidePanelProvider: SidePanelProvider, SidePanelTitleBarAccessoryProvider { data class TreeState( val expandedPaths: Set, val selectedPath: String? ) + data class ViewState( + val viewId: String, + val treeState: TreeState + ) + + data class DockState( + val activeViewId: String, + val viewStates: List + ) + override val id: String = "project_files" override val displayName: String = "Project Files" - override val icon: QIcon = TIcons.Folder.icon + override var icon: QIcon? = TIcons.Folder.icon override val order: Int = 0 override val closeable: Boolean = false override val floatable: Boolean = false + override val defaultVisible: Boolean = true override fun create(project: ProjectBase): DockWidget { val dock = DockWidget(displayName, null) - val tree = QTreeWidget().apply { + val tree = FileTreeWidget({ project }, { refreshDock(dock) }).apply { headerHidden = true selectionMode = QAbstractItemView.SelectionMode.SingleSelection alternatingRowColors = false @@ -44,59 +200,160 @@ class ProjectFilesSidePanelProvider: SidePanelProvider { iconSize = QSize(16, 16) frameShape = QFrame.Shape.NoFrame styleSheet = "QTreeWidget { border: none; } QTreeView::item { margin: 0px; padding: 0px; }" + verticalScrollMode = QAbstractItemView.ScrollMode.ScrollPerPixel + dragEnabled = true + acceptDrops = true + setDropIndicatorShown(true) } + AnimatedScrollController.attach(tree) dock.setWidget(tree) + val controller = Controller(project, dock, tree) + controllers[dock] = controller + controller.start() + dock.destroyed.connect { controllers.remove(dock)?.dispose() } + + return dock + } + + override fun createTitleBarAccessory(project: ProjectBase, dock: DockWidget, onStateChanged: () -> Unit): QWidget? { + val controller = controllers[dock] ?: return null + return controller.createViewSelector(onStateChanged) + } - fun refresh() { - val stateBeforeRefresh = captureTreeState(tree) - populateTree(project, tree) - restoreTreeState(tree, stateBeforeRefresh) + override fun onDockCreated(project: ProjectBase, editorArea: EditorArea, dock: DockWidget, onStateChanged: () -> Unit) { + val tree = dock.widget() as? QTreeWidget ?: dock.findChild(QTreeWidget::class.java) + tree?.itemDoubleClicked?.connect { item, _ -> + val path = item?.data(0, UserRole) as? VPath + if (path != null && !path.isDir()) { + editorArea.openFile(path) + } } + tree?.itemExpanded?.connect { onStateChanged() } + tree?.itemCollapsed?.connect { onStateChanged() } + tree?.currentItemChanged?.connect { _, _ -> onStateChanged() } - DeferredRegistryBuilder(BuiltinRegistries.FileType) { _ -> - refresh() + val pendingState = pendingInitialDockState + if (pendingState != null) { + pendingInitialDockState = null + restoreDockTreeState(dock, pendingState) } + } - val watcher = ProjectDirWatcher(project.projectDir) - watcher.start({ - refresh() - }) + companion object { + private val controllers = WeakHashMap() + private var pendingInitialDockState: DockState? = null - dock.destroyed.connect { watcher.stop() } + fun setPendingInitialDockState(state: DockState) { + pendingInitialDockState = state + } - return dock - } + fun captureDockTreeState(dock: QDockWidget?): DockState { + val typedDock = dock as? DockWidget ?: return DockState("project_files", emptyList()) + return controllers[typedDock]?.captureState() ?: DockState("project_files", emptyList()) + } - private fun populateTree(project: ProjectBase, tree: QTreeWidget) { - tree.clear() - val root = tree.invisibleRootItem() ?: return - buildNode(project.projectDir, root, project) - } + fun restoreDockTreeState(dock: QDockWidget?, state: DockState) { + val typedDock = dock as? DockWidget ?: return + controllers[typedDock]?.restoreState(state) + } + + internal fun refreshDock(dock: DockWidget) { + controllers[dock]?.refresh() + } - private fun buildNode(path: VPath, parent: QTreeWidgetItem, project: ProjectBase) { - val files = path.list().takeIf { it.isNotEmpty() } ?: return - val sorted = files.sortedWith(compareBy({ !it.isDir() }, { it.fileName() })) - val ftr = BuiltinRegistries.FileType - for(c in sorted) { + private fun defaultSortChildren(children: List): List { + val (dirs, files) = children.partition { runCatching { it.isDir() }.getOrDefault(false) } + return dirs.sortedBy { it.fileName().lowercase() } + files.sortedBy { it.fileName().lowercase() } + } + + private fun buildNode( + project: ProjectBase, + parent: QTreeWidgetItem, + spec: ProjectFilesNodeSpec, + viewMode: ProjectFilesViewMode, + presentations: List + ) { + val path = spec.path val item = QTreeWidgetItem(parent) - item.setText(0, c.fileName()) - item.setData(0, UserRole, c) - val matches = ftr.all().filter { desc -> desc.matches(c, project) }.sortedBy { it.order } - val primary = matches.firstOrNull() - if(primary != null && primary.icon != null) item.setIcon(0, primary.icon ?: TIcons.File.icon) - if(c.isDir()) buildNode(c, item, project) + val primary = FileTypeDescriptor.primary(path, project) + val currentName = spec.label ?: path.fileName() + item.setText(0, applyDisplayName(project, path.parent(), path, primary, currentName, presentations)) + item.setData(0, UserRole, path) + if (primary?.icon != null) item.setIcon(0, primary.icon ?: TIcons.File.icon) + + if (!runCatching { path.isDir() }.getOrDefault(false)) return + + // Add dummy child if directory has content, to show the expander + if (runCatching { path.list().isNotEmpty() }.getOrDefault(false)) { + QTreeWidgetItem(item).apply { setText(0, "Loading...") } + } } - } - companion object { - fun captureDockTreeState(dock: QDockWidget?): TreeState { - val tree = dock?.widget() as? QTreeWidget ?: return TreeState(emptySet(), null) - return captureTreeState(tree) + private fun expandNode( + project: ProjectBase, + item: QTreeWidgetItem, + viewMode: ProjectFilesViewMode, + presentations: List + ) { + val path = item.data(0, UserRole) as? VPath ?: return + if (!runCatching { path.isDir() }.getOrDefault(false)) return + + // Clear dummy or existing children + while (item.childCount() > 0) { + item.removeChild(item.child(0)) + } + + val rawChildren = viewMode.childEntries(path, project) + if (rawChildren.isEmpty()) return + + val activePresentations = presentations + .filter { it.matches(path, project) } + .sortedBy { it.order } + + var sortedPaths = defaultSortChildren(rawChildren.map { it.path }) + activePresentations.forEach { presentation -> + sortedPaths = presentation.sortChildren(path, sortedPaths, project) + } + val childSpecsByPath = rawChildren.associateBy { it.path } + sortedPaths.forEach { child -> + val childSpec = childSpecsByPath[child] ?: ProjectFilesNodeSpec(child) + buildNode(project, item, childSpec, viewMode, presentations) + } } - fun restoreDockTreeState(dock: QDockWidget?, state: TreeState) { - val tree = dock?.widget() as? QTreeWidget ?: return - restoreTreeState(tree, state) + private fun populateTree( + project: ProjectBase, + tree: QTreeWidget, + viewMode: ProjectFilesViewMode?, + presentations: List + ) { + tree.clear() + val root = tree.invisibleRootItem() ?: return + val mode = viewMode ?: return + val rootEntries = mode.rootEntries(project) + val specsByPath = rootEntries.associateBy { it.path } + defaultSortChildren(rootEntries.map { it.path }).forEach { path -> + val spec = specsByPath[path] ?: ProjectFilesNodeSpec(path) + buildNode(project, root, spec, mode, presentations) + } + } + + private fun applyDisplayName( + project: ProjectBase, + directory: VPath, + child: VPath, + primary: FileTypeDescriptor?, + initialDisplayName: String, + presentations: List + ): String { + val activePresentations = presentations + .filter { it.matches(directory, project) } + .sortedBy { it.order } + var displayName = initialDisplayName + activePresentations.forEach { presentation -> + displayName = presentation.displayName(directory, child, project, primary, displayName) + } + return displayName } private fun captureTreeState(tree: QTreeWidget): TreeState { @@ -119,19 +376,26 @@ class ProjectFilesSidePanelProvider: SidePanelProvider { walk(child) } - val selectedPath = (tree.currentItem()?.data(0, UserRole) as? VPath)?.toAbsolute()?.toString() + val selectedPath = pathOf(visibleSelectionItem(tree.currentItem())) return TreeState(expandedPaths = expanded, selectedPath = selectedPath) } - private fun restoreTreeState(tree: QTreeWidget, state: TreeState) { + private fun restoreTreeState(tree: QTreeWidget, state: TreeState, project: ProjectBase, viewMode: ProjectFilesViewMode?, presentations: List) { val root = tree.invisibleRootItem() ?: return var selectedItem: QTreeWidgetItem? = null + val itemsByPath = linkedMapOf() fun walk(item: QTreeWidgetItem) { - val path = (item.data(0, UserRole) as? VPath)?.toAbsolute()?.toString() + val path = pathOf(item) if(!path.isNullOrBlank() && state.expandedPaths.contains(path)) { + if (viewMode != null) { + expandNode(project, item, viewMode, presentations) + } item.isExpanded = true } + if(!path.isNullOrBlank()) { + itemsByPath[path] = item + } if(selectedItem == null && !path.isNullOrBlank() && path == state.selectedPath) { selectedItem = item } @@ -146,10 +410,353 @@ class ProjectFilesSidePanelProvider: SidePanelProvider { walk(child) } + if(selectedItem == null && !state.selectedPath.isNullOrBlank()) { + var cursor = runCatching { VPath.parse(state.selectedPath) }.getOrNull() + while(cursor != null && selectedItem == null) { + selectedItem = itemsByPath[cursor.toAbsolute().toString()] + cursor = runCatching { cursor.parent() }.getOrNull() + } + } + selectedItem?.let { item -> tree.setCurrentItem(item) tree.scrollToItem(item) } } + + private fun visibleSelectionItem(item: QTreeWidgetItem?): QTreeWidgetItem? { + var current = item ?: return null + while(true) { + val parent = current.parent() ?: return current + if(!parent.isExpanded) { + return parent + } + current = parent + } + } + + private fun pathOf(item: QTreeWidgetItem?): String? = + (item?.data(0, UserRole) as? VPath)?.toAbsolute()?.toString() + + private fun isDescendantOf(item: QTreeWidgetItem, ancestor: QTreeWidgetItem): Boolean { + var current = item.parent() + while(current != null) { + if(current == ancestor) return true + current = current.parent() + } + return false + } + } + + private class FileTreeWidget( + private val projectProvider: () -> ProjectBase, + private val onDropCompleted: () -> Unit + ) : QTreeWidget() { + override fun mimeData(items: MutableCollection): QMimeData { + val mimeData = QMimeData() + val urls = mutableListOf() + for (item in items) { + val path = item?.data(0, UserRole) as? VPath ?: continue + val file = path.toJFile() + if (file.exists()) { + urls.add(QUrl.fromLocalFile(file.absolutePath)) + } + } + if (urls.isNotEmpty()) { + mimeData.setUrls(urls) + } + return mimeData + } + + override fun dropEvent(event: @Nullable QDropEvent?) { + val ev = event ?: return + val mimeData = ev.mimeData() ?: return + + val pos = ev.position().toPoint() + val targetItem = itemAt(pos) + val project = projectProvider() + val targetDir = resolveDropTarget(targetItem, project) + + if (ev.source() === this) { + val items = selectedItems() + var moved = false + for (item in items) { + val path = item?.data(0, UserRole) as? VPath ?: continue + if (path == targetDir) continue + if (runCatching { path.isDir() }.getOrDefault(false) && targetDir.startsWith(path)) continue + val dest = targetDir.resolve(path.fileName()) + runCatching { + Files.move(path.toJPath(), dest.toJPath(), StandardCopyOption.REPLACE_EXISTING) + moved = true + } + } + if (moved) { + ev.acceptProposedAction() + onDropCompleted() + } + } else if (mimeData.hasUrls()) { + var copied = false + for (url in mimeData.urls()) { + val filePath = url.toLocalFile() ?: continue + val source = VPath.parse(filePath) + val dest = targetDir.resolve(source.fileName()) + runCatching { + Files.copy(source.toJPath(), dest.toJPath(), StandardCopyOption.REPLACE_EXISTING) + copied = true + } + } + if (copied) { + ev.acceptProposedAction() + onDropCompleted() + } + } + } + + override fun dropMimeData(parent: QTreeWidgetItem?, index: Int, data: QMimeData?, action: Qt.DropAction): Boolean = false + + private fun resolveDropTarget(item: QTreeWidgetItem?, project: ProjectBase): VPath { + if (item != null) { + val path = item.data(0, UserRole) as? VPath + if (path != null && runCatching { path.isDir() }.getOrDefault(false)) { + return path + } + val parent = path?.let { runCatching { it.parent() }.getOrNull() } + if (parent != null) return parent + } + return project.projectDir + } + } + + private class Controller( + private val project: ProjectBase, + private val dock: DockWidget, + private val tree: QTreeWidget + ) { + private val logger = logger() + private var presentations = emptyList() + private var viewModes = emptyList() + private var contextActions = emptyList() + private var activeViewId = "project_files" + private val perViewState = linkedMapOf() + private var selectorButton: QToolButton? = null + private var titleBarStateChanged: (() -> Unit)? = null + private val scope = CoroutineScope(Dispatchers.Main) + + private val watcher = ProjectDirWatcher(project.projectDir) + + fun start() { + tree.contextMenuPolicy = Qt.ContextMenuPolicy.CustomContextMenu + tree.customContextMenuRequested.connect { pos -> + showContextMenu(pos) + } + + tree.itemExpanded.connect { item -> + if (item != null) { + expandNode(project, item, currentViewMode() ?: return@connect, presentations) + } + syncStateFromTree() + } + tree.itemCollapsed.connect { item -> + val collapsed = item ?: return@connect + val current = tree.currentItem() ?: return@connect + if (current != collapsed && isDescendantOf(current, collapsed)) { + tree.setCurrentItem(collapsed) + } + syncStateFromTree() + } + tree.currentItemChanged.connect { _, _ -> syncStateFromTree() } + + DeferredRegistryBuilder(BuiltinRegistries.FileType) { refresh() } + DeferredRegistryBuilder(BuiltinRegistries.ProjectTreeDirectoryPresentation) { snapshot -> + presentations = snapshot.sortedBy { it.order } + refresh() + } + DeferredRegistryBuilder(BuiltinRegistries.ProjectFilesViewMode) { snapshot -> + viewModes = snapshot.sortedBy { it.order } + if (viewModes.none { it.id == activeViewId }) { + activeViewId = viewModes.firstOrNull()?.id ?: "project_files" + } + updateSelectorText() + rebuildSelectorMenu() + refresh() + } + DeferredRegistryBuilder(BuiltinRegistries.ProjectFilesAction) { snapshot -> + contextActions = snapshot.sortedBy { it.order } + } + + scope.onEvent { event -> + val key = "${event.namespace}:${event.nodeKey}" + if (key == CoreSettingKeys.ProjectFilesConfigSort.toString()) { + runOnGuiThread { refresh() } + } + } + watcher.start(::refresh) + } + + private fun showContextMenu(pos: QPoint) { + val item = tree.itemAt(pos) ?: return + val path = item.data(0, UserRole) as? VPath ?: return + val isDir = runCatching { path.isDir() }.getOrDefault(false) + val fileType = FileTypeDescriptor.primary(path, project) + val targetDir: VPath = if (isDir) { + path + } else { + runCatching { path.parent() }.getOrNull() ?: path + } + + val globalPos = (tree.viewport() ?: tree).mapToGlobal(pos) + val menu = QMenu(tree) + + val newMenu = menu.addMenu("New") ?: return + + val creatableTypes = BuiltinRegistries.FileType.all() + .filter { it.canCreateIn(targetDir, project) } + .sortedBy { it.order } + + val kubejsType = creatableTypes.firstOrNull { it.id == "kubescript" } + val plainFileType = creatableTypes.firstOrNull { it.id == "file" } + val primaryType = kubejsType ?: plainFileType + + if (primaryType != null) { + val primaryAction = newMenu.addAction(primaryType.icon ?: QIcon(), primaryType.displayName) + primaryAction?.triggered?.connect { + val name = showInlineInput(tree, "", globalPos) + if (name != null) { + primaryType.createDefaultFile(targetDir, name, project)?.let { refresh() } + } + } + } + + val folderAction = newMenu.addAction(TIcons.Folder.icon, "Folder") + folderAction?.triggered?.connect { + promptNewFolder(targetDir, globalPos, ::refresh) + } + + newMenu.addSeparator() + + val remaining = if (primaryType != null) { + creatableTypes.filter { it != primaryType } + } else { + creatableTypes + } + val sortedRemaining = if (plainFileType != null && primaryType != plainFileType) { + listOf(plainFileType) + remaining.filter { it != plainFileType } + } else { + remaining + } + sortedRemaining.forEach { type -> addNewFileTypeAction(type, targetDir, newMenu, globalPos) } + + var lastSection: ProjectFilesContextAction.Section? = null + val matching = contextActions + .filter { it.section != ProjectFilesContextAction.Section.NEW && it.matches(path, isDir, fileType, project) } + .sortedBy { it.section } + for (action in matching) { + if (lastSection != null && action.section != lastSection) { + menu.addSeparator() + } + lastSection = action.section + val qAction = menu.addAction(action.icon ?: QIcon(), action.displayName) + qAction?.triggered?.connect { + try { + action.execute(path, project, tree) + if (action.needsRefresh) refresh() + } catch (t: Throwable) { + logger.warn("Failed to execute context action '{}'", action.id, t) + } + } + } + + if (menu.isEmpty) return + menu.exec((tree.viewport() ?: tree).mapToGlobal(pos)) + } + + private fun addNewFileTypeAction(type: FileTypeDescriptor, targetDir: VPath, parentMenu: QMenu, globalPos: QPoint) { + val action = parentMenu.addAction(type.icon ?: QIcon(), type.displayName) + action?.triggered?.connect { + try { + val name = showInlineInput(tree, "", globalPos) + if (name != null) { + type.createDefaultFile(targetDir, name, project)?.let { refresh() } + } + } catch (t: Throwable) { + logger.warn("Failed to create new '{}' file", type.id, t) + } + } + } + + fun dispose() { + scope.cancel() + watcher.stop() + } + + fun createViewSelector(onStateChanged: () -> Unit): QWidget { + titleBarStateChanged = onStateChanged + val button = QToolButton().apply { + autoRaise = true + popupMode = QToolButton.ToolButtonPopupMode.InstantPopup + } + selectorButton = button + updateSelectorText() + rebuildSelectorMenu() + return button + } + + fun captureState(): DockState { + syncStateFromTree() + return DockState( + activeViewId = activeViewId, + viewStates = perViewState.map { (viewId, treeState) -> ViewState(viewId, treeState) } + ) + } + + fun restoreState(state: DockState) { + perViewState.clear() + state.viewStates.forEach { perViewState[it.viewId] = it.treeState } + activeViewId = state.activeViewId.takeIf { it.isNotBlank() } ?: activeViewId + if (viewModes.isNotEmpty() && viewModes.none { it.id == activeViewId }) { + activeViewId = viewModes.first().id + } + updateSelectorText() + rebuildSelectorMenu() + refresh() + } + + private fun rebuildSelectorMenu() { + val button = selectorButton ?: return + val menu = QMenu(button) + viewModes.forEach { mode -> + val action = menu.addAction(mode.displayName) + action?.isCheckable = true + action?.isChecked = mode.id == activeViewId + action?.triggered?.connect { + if (activeViewId == mode.id) return@connect + syncStateFromTree() + activeViewId = mode.id + updateSelectorText() + refresh() + rebuildSelectorMenu() + titleBarStateChanged?.invoke() + } + } + button.setMenu(menu) + } + + private fun updateSelectorText() { + selectorButton?.text = currentViewMode()?.displayName ?: "View" + } + + private fun currentViewMode(): ProjectFilesViewMode? = + viewModes.firstOrNull { it.id == activeViewId } ?: viewModes.firstOrNull() + + private fun syncStateFromTree() { + perViewState[activeViewId] = captureTreeState(tree) + } + + internal fun refresh() { + val stateBeforeRefresh = perViewState[activeViewId] ?: TreeState(emptySet(), null) + populateTree(project, tree, currentViewMode(), presentations) + restoreTreeState(tree, stateBeforeRefresh, project, currentViewMode(), presentations) + syncStateFromTree() + } } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesViewMode.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesViewMode.kt new file mode 100644 index 0000000..1223c61 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectFilesViewMode.kt @@ -0,0 +1,54 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.registry.Registrable + +data class ProjectFilesNodeSpec( + val path: VPath, + val label: String? = null +) + +interface ProjectFilesViewMode : Registrable { + val displayName: String + val order: Int get() = 0 + + fun rootEntries(project: ProjectBase): List + + fun childEntries(parent: VPath, project: ProjectBase): List = + runCatching { parent.list() } + .getOrDefault(emptyList()) + .map { ProjectFilesNodeSpec(it) } +} + +object ProjectFilesViewModes { + fun all(): List = listOf(ProjectViewMode, ProjectFilesFlatViewMode) +} + +private object ProjectViewMode : ProjectFilesViewMode { + override val id: String = "project" + override val displayName: String = "Project" + override val order: Int = 0 + + override fun rootEntries(project: ProjectBase): List = + BuiltinRegistries.ProjectRootDirectory.all() + .flatMap { it.getRootDirectories(project) } + + override fun childEntries(parent: VPath, project: ProjectBase): List = + runCatching { parent.list() } + .getOrDefault(emptyList()) + .filterNot { it.fileName().startsWith('.') } + .map { ProjectFilesNodeSpec(it) } +} + +private object ProjectFilesFlatViewMode : ProjectFilesViewMode { + override val id: String = "project_files" + override val displayName: String = "Project Files" + override val order: Int = 100 + + override fun rootEntries(project: ProjectBase): List = + runCatching { project.projectDir.list() } + .getOrDefault(emptyList()) + .map { ProjectFilesNodeSpec(it) } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectInstalledModsSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectInstalledModsSidePanelProvider.kt new file mode 100644 index 0000000..2108c70 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectInstalledModsSidePanelProvider.kt @@ -0,0 +1,941 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.TritiumEventBus +import io.github.tritium_launcher.launcher.core.mod.* +import io.github.tritium_launcher.launcher.core.onEvent +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.core.project.ProjectDirWatcher +import io.github.tritium_launcher.launcher.core.source.HashFallbackProvider +import io.github.tritium_launcher.launcher.core.source.ModBrowserContext +import io.github.tritium_launcher.launcher.core.source.ModVersionOption +import io.github.tritium_launcher.launcher.core.source.resolveInstallDownload +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.ui.project.editor.EditorArea +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ModDetailMeta +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ModDetailPane +import io.github.tritium_launcher.launcher.ui.project.editor.panes.ModDetailPaneProvider +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.toolButton +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.qt.core.QSize +import io.qt.core.QTimer +import io.qt.core.Qt +import io.qt.gui.* +import io.qt.widgets.* +import kotlinx.coroutines.* +import java.io.File +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime + +class ProjectInstalledModsSidePanelProvider : SidePanelProvider { + override val id: String = "installed_mods" + override val displayName: String = "Installed Mods" + override var icon: QIcon? = TIcons.CSV.icon + override val order: Int = 6 + + override val closeable: Boolean = false + override val floatable: Boolean = false + override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.RightDockWidgetArea + + override fun create(project: ProjectBase): DockWidget { + val dock = DockWidget(displayName, null) + dock.setWidget(InstalledModsPanel(project)) + return dock + } + + override fun onDockCreated(project: ProjectBase, editorArea: EditorArea, dock: DockWidget, onStateChanged: () -> Unit) { + val panel = dock.widget() as? InstalledModsPanel + panel?.onOpenDetailRequested = { modId, title -> + ModDetailMeta.register(modId, title) + editorArea.openEditorPane( + provider = ModDetailPaneProvider, + title = title, + paneFactory = { ModDetailPane(it, modId = modId) } + ) + } + } +} + +class InstalledModsPanel( + private val project: ProjectBase +) : QWidget() { + var onOpenDetailRequested: ((modId: String, title: String) -> Unit)? = null + private val logger = logger() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val listWidget = QListWidget() + private val dominantColorMap = HashMap>() + private val watcher = ProjectDirWatcher(project.projectDir.resolve("mods")) + private val updateAvailability = HashMap() + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 60_000 + } + } + + init { + objectName = "installedModsPanel" + val layout = vBoxLayout(this) { + setContentsMargins(4, 4, 4, 4) + setSpacing(4) + } + + val checkUpdatesButton = toolButton { + icon = TIcons.Download.icon + autoRaise = true + toolTip = "Check for updates" + } + + val refreshButton = toolButton { + icon = TIcons.Rerun.icon + autoRaise = true + toolTip = "Refresh" + } + + val headerRow = QWidget() + hBoxLayout(headerRow) { + setContentsMargins(0, 0, 0, 0) + setSpacing(4) + addStretch(1) + addWidget(checkUpdatesButton) + addWidget(refreshButton) + } + + listWidget.apply { + objectName = "installedModsList" + wordWrap = true + spacing = 2 + selectionMode = QAbstractItemView.SelectionMode.NoSelection + focusPolicy = Qt.FocusPolicy.NoFocus + } + + val countLabel = label { + objectName = "installedModsCount" + } + + layout.addWidget(headerRow) + layout.addWidget(listWidget, 1) + layout.addWidget(countLabel) + + setThemedStyle { + selector("#installedModsPanel") { + backgroundColor(TColors.Surface0) + } + selector("#installedModsList") { + backgroundColor(TColors.Surface0) + color(TColors.Text) + border() + } + selector("#installedModsList::item") { + padding(6) + } + selector("#installedModsCount") { + color(TColors.Subtext) + fontSize(10) + } + } + + refreshButton.clicked.connect { refreshMods() } + checkUpdatesButton.clicked.connect { checkUpdates() } + + project.projectDir.resolve("mods").mkdirs() + watcher.start(::refreshMods) + scope.onEvent { refreshMods() } + scope.onEvent { checkUpdates() } + listWidget.currentItemChanged.connect { current, _ -> + updateSelectedRowGradient(current?.data(Qt.ItemDataRole.UserRole) as? String) + } + + listWidget.itemDoubleClicked.connect { item -> + val projectId = item?.data(Qt.ItemDataRole.UserRole) as? String ?: return@connect + scope.launch { + val mod = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getByProjectId(projectId) } + } + val title = mod?.displayName ?: projectId + onOpenDetailRequested?.invoke(projectId, title) + } + } + + val hourlyTimer = QTimer(this).apply { + interval = 1.hours.inWholeMilliseconds.toInt() + timeout.connect { checkUpdates() } + start() + } + + destroyed.connect { + watcher.stop() + hourlyTimer.stop() + scope.cancel() + httpClient.close() + } + refreshMods() + } + + private fun refreshMods() { + scope.launch { + val mods = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> + syncFromModsDir(db) + populateMissingIcons(db) + db.getAll() + } + } + val updates = ModUpdateChecker.checkAll(project, mods) + updateAvailability.clear() + updateAvailability.putAll(updates) + + listWidget.clear() + dominantColorMap.clear() + mods.forEach { mod -> + val updateOption = updateAvailability[mod.projectId] + addModItem(mod, updateOption) + } + val labels = findChildren(QLabel::class.java) + val updateCount = updateAvailability.size + val countText = "${mods.size} mod(s) installed" + labels.firstOrNull { it.objectName == "installedModsCount" }?.text = + if (updateCount > 0) "$countText | $updateCount update(s) available" + else countText + } + } + + private fun checkUpdates() { + scope.launch { + val mods = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getAll() } + } + val updates = ModUpdateChecker.checkAll(project, mods) + updateAvailability.clear() + updateAvailability.putAll(updates) + val updateCount = updateAvailability.size + val labels = findChildren(QLabel::class.java) + labels.firstOrNull { it.objectName == "installedModsCount" }?.let { label -> + val current = label.text + val base = current.substringBefore(" | ") + label.text = if (updateCount > 0) "$base | $updateCount update(s) available" else base + } + for (i in 0 until listWidget.count()) { + val item = listWidget.item(i) + val projectId = item?.data(Qt.ItemDataRole.UserRole) as? String ?: continue + val row = listWidget.itemWidget(item) as? ModListRow ?: continue + row.updateUpdateOption(updateAvailability[projectId]) + } + } + } + + @OptIn(ExperimentalTime::class) + private fun syncFromModsDir(db: ModDatabase) { + ModDatabase.restoreFromRegistryIfNeeded(db, project.projectDir) + val modsDirFile = project.projectDir.resolve("mods") + if (!modsDirFile.isDir()) return + + val registry = ModRegistryStore(project.projectDir) + val existingHashes = db.getAll().mapNotNull { it.fileHash }.toSet() + val existingIds = db.getAll().map { it.projectId }.toSet() + + val jarFiles = modsDirFile.listFiles { f -> f.fileName().endsWith(".jar", ignoreCase = true) } + for (jarFile in jarFiles) { + val bytes = try { jarFile.bytesOrNothing() } catch (_: Exception) { continue } + val hash = ModDatabase.sha1(bytes) + if (hash in existingHashes) continue + + val info = readModJarInfo(VPath.parse(jarFile.toAbsoluteString())) + if (info == null) { + logger.warn("Could not read metadata from '{}', skipping db import", jarFile.fileName()) + continue + } + + val registryEntry = registry.getEntryByModId(info.modId) + val projectId = registryEntry?.projectId ?: info.modId + if (projectId in existingIds) continue + + val iconBytes = readModJarIcon(VPath.parse(jarFile.toAbsoluteString())) + val iconPath: String? = if (iconBytes != null) { + val iconFile = ModDatabase.iconPathFor(projectId) + iconFile.writeBytesAtomic(iconBytes) + iconFile.toAbsolute().toString() + } else null + + val installedMod = if (registryEntry != null) { + registry.toInstalledMod(registryEntry).copy( + fileName = jarFile.fileName(), + iconPath = iconPath ?: registryEntry.iconPath, + fileHash = hash, + installedAt = jarFile.lastModifiedOrNull() + ) + } else { + InstalledMod( + projectId = projectId, + modId = info.modId, + fileName = jarFile.fileName(), + displayName = info.displayName, + side = info.side, + releaseType = "release", + source = "unknown", + versionId = projectId, + versionLabel = "", + iconPath = iconPath, + projectUrl = null, + fileHash = hash, + installedAt = jarFile.lastModifiedOrNull() + ) + } + db.install(installedMod) + if (registryEntry != null && registryEntry.dependencies.isNotEmpty()) { + db.setDependencies(registryEntry.projectId, registryEntry.dependencies) + } + logger.info("Imported existing mod '{}' from '{}' into mods database", info.displayName, jarFile.fileName()) + } + } + + private fun populateMissingIcons(db: ModDatabase) { + val modsDirFile = project.projectDir.resolve("mods") + if (!modsDirFile.isDir()) return + + val allMods = db.getAll() + for (mod in allMods) { + if (mod.iconPath?.isNotBlank() == true) { + val iconFile = File(mod.iconPath) + if (iconFile.exists()) continue + } + + if (mod.fileName.isBlank()) continue + val jarFile = modsDirFile.resolve(mod.fileName) + if (!jarFile.exists()) continue + + val iconBytes = readModJarIcon(jarFile) ?: continue + val iconFile = ModDatabase.iconPathFor(mod.projectId) + iconFile.writeBytesAtomic(iconBytes) + val absPath = iconFile.toAbsolute().toString() + db.updateIconPath(mod.projectId, absPath) + logger.info("Extracted icon for mod '{}' from '{}'", mod.displayName, mod.fileName) + } + } + + private suspend fun addModItem(mod: InstalledMod, updateOption: ModVersionOption? = null) { + val prevVersion = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { it.getPreviousVersion(mod.projectId) } + } + val skippedVersion = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { it.getSkippedVersion(mod.projectId, mod.versionId) } + } + val item = QListWidgetItem() + item.setData(Qt.ItemDataRole.UserRole, mod.projectId) + val row = ModListRow(mod, updateOption, prevVersion, skippedVersion) + item.setSizeHint(row.sizeHint()) + listWidget.addItem(item) + listWidget.setItemWidget(item, row) + row.removeRequested.connect { modId -> + confirmAndUninstall(modId) + } + row.enableToggled.connect { modId -> + toggleEnabled(modId) + } + row.releaseToggled.connect { modId -> + toggleRelease(modId) + } + row.updateRequested.connect { modId -> + performUpdate(modId) + } + row.downgradeRequested.connect { modId -> + performDowngrade(modId) + } + row.skipRequested.connect { modId -> + performSkip(modId) + } + row.installSkippedRequested.connect { modId -> + performInstallSkipped(modId) + } + dominantColorMap[mod.projectId]?.let {} // skip if already cached + val color = extractDominantColor(row.iconLabel.pixmap() ?: return@addModItem) + if (color != null) dominantColorMap[mod.projectId] = color + } + + private fun extractDominantColor(pixmap: QPixmap): Triple? { + val small = pixmap.scaled(4, 4, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) + if (small.isNull) return null + val image = small.toImage() ?: return null + if (image.isNull) return null + for (y in 0 until image.height()) { + for (x in 0 until image.width()) { + val argb = image.pixel(x, y) + val alpha = (argb ushr 24) and 0xFF + if (alpha >= 128) { + return Triple( + (argb ushr 16) and 0xFF, + (argb ushr 8) and 0xFF, + argb and 0xFF + ) + } + } + } + return null + } + + private fun updateSelectedRowGradient(projectId: String?) { + for (i in 0 until listWidget.count()) { + listWidget.item(i)?.setBackground(QBrush()) + } + if (projectId == null) return + val (r, g, b) = dominantColorMap[projectId] ?: return + for (i in 0 until listWidget.count()) { + val item = listWidget.item(i) ?: continue + if (item.data(Qt.ItemDataRole.UserRole) as? String == projectId) { + val gradient = QLinearGradient(0.0, 0.0, 1.0, 0.0).apply { + setCoordinateMode(QGradient.CoordinateMode.ObjectBoundingMode) + setColorAt(0.0, QColor(r, g, b, 110)) + setColorAt(1.0, QColor(0, 0, 0, 0)) + } + item.setBackground(QBrush(gradient)) + return + } + } + } + + private fun performUpdate(modId: String) { + val updateOption = updateAvailability[modId] ?: return + scope.launch { + val mod = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getByProjectId(modId) } + } ?: return@launch + + withContext(Dispatchers.IO) { + val source = BuiltinRegistries.ModSource.all().find { it.id == mod.source } ?: return@withContext + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta ?: return@withContext + val context = ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + + try { + val fallbacks = BuiltinRegistries.ModSource.all() + .filterIsInstance() + .sortedBy { it.priority } + val resolved = resolveInstallDownload(context, source, modId, updateOption.id, fallbacks) + if (resolved.downloadUrl == null) { + error("Update requires manual download (blocked by mod author)") + } + val modsDir = project.projectDir.resolve("mods") + modsDir.mkdirs() + val bytes = httpClient.get(resolved.downloadUrl).bodyAsBytes() + val oldJar = modsDir.resolve(mod.fileName) + if (oldJar.exists()) oldJar.moveToTrash() + + val jarPath = modsDir.resolve(resolved.fileName) + jarPath.writeBytesAtomic(bytes) + + val fileHash = ModDatabase.sha1(bytes) + ModDatabase(project.projectDir).use { db -> + db.recordVersionChange( + projectId = modId, + oldVersionId = mod.versionId, + oldVersionLabel = mod.versionLabel, + oldFileHash = mod.fileHash, + newVersionId = resolved.plan.versionId, + newVersionLabel = resolved.plan.versionLabel + ) + db.install(mod.copy( + fileName = resolved.fileName, + versionId = resolved.plan.versionId, + versionLabel = resolved.plan.versionLabel, + fileHash = fileHash, + installedAt = Clock.System.now(), + requiresManualDownload = resolved.requiresManualDownload, + )) + } + TritiumEventBus.publish(TritiumEvent.ModUpdated(project, modId, mod.displayName, mod.versionId, resolved.plan.versionId)) + TritiumEventBus.publish(TritiumEvent.ModInstalled(project, modId, mod.modId, mod.displayName, resolved.plan.versionId, resolved.plan.versionLabel)) + } catch (t: Throwable) { + logger.warn("Failed to update mod '{}'", mod.displayName, t) + } + } + refreshMods() + } + } + + private fun performDowngrade(modId: String) { + scope.launch { + val mod = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getByProjectId(modId) } + } ?: return@launch + + withContext(Dispatchers.IO) { + val source = BuiltinRegistries.ModSource.all().find { it.id == mod.source } ?: return@withContext + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta ?: return@withContext + val context = ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + + try { + val prev = ModDatabase(project.projectDir).use { it.getPreviousVersion(modId) } ?: return@withContext + val fallbacks = BuiltinRegistries.ModSource.all() + .filterIsInstance() + .sortedBy { it.priority } + val resolved = resolveInstallDownload(context, source, modId, prev.oldVersionId, fallbacks) + if (resolved.downloadUrl == null) { + error("Downgrade requires manual download (blocked by mod author)") + } + val modsDir = project.projectDir.resolve("mods") + modsDir.mkdirs() + val bytes = httpClient.get(resolved.downloadUrl).bodyAsBytes() + val oldJar = modsDir.resolve(mod.fileName) + if (oldJar.exists()) oldJar.moveToTrash() + + val jarPath = modsDir.resolve(resolved.fileName) + jarPath.writeBytesAtomic(bytes) + + val fileHash = ModDatabase.sha1(bytes) + ModDatabase(project.projectDir).use { db -> + db.recordVersionChange( + projectId = modId, + oldVersionId = mod.versionId, + oldVersionLabel = mod.versionLabel, + oldFileHash = mod.fileHash, + newVersionId = prev.oldVersionId, + newVersionLabel = prev.oldVersionLabel + ) + db.install(mod.copy( + fileName = resolved.fileName, + versionId = prev.oldVersionId, + versionLabel = prev.oldVersionLabel, + fileHash = fileHash, + installedAt = Clock.System.now(), + requiresManualDownload = resolved.requiresManualDownload, + )) + } + TritiumEventBus.publish(TritiumEvent.ModDowngraded(project, modId, mod.displayName, mod.versionId, prev.oldVersionId)) + TritiumEventBus.publish(TritiumEvent.ModInstalled(project, modId, mod.modId, mod.displayName, prev.oldVersionId, prev.oldVersionLabel)) + } catch (t: Throwable) { + logger.warn("Failed to downgrade mod '{}'", mod.displayName, t) + } + } + refreshMods() + } + } + + private fun performSkip(modId: String) { + val updateOption = updateAvailability[modId] ?: return + scope.launch { + withContext(Dispatchers.IO) { + val mod = ModDatabase(project.projectDir).use { db -> db.getByProjectId(modId) } + if (mod != null) { + ModDatabase(project.projectDir).use { db -> + db.recordVersionChange( + projectId = modId, + oldVersionId = mod.versionId, + oldVersionLabel = mod.versionLabel, + oldFileHash = mod.fileHash, + newVersionId = updateOption.id, + newVersionLabel = updateOption.label, + skipped = true + ) + } + TritiumEventBus.publish(TritiumEvent.ModSkipped(project, modId, mod.displayName, updateOption.id, updateOption.label)) + } + } + updateAvailability.remove(modId) + for (i in 0 until listWidget.count()) { + val item = listWidget.item(i) + val pid = item?.data(Qt.ItemDataRole.UserRole) as? String ?: continue + if (pid == modId) { + val row = listWidget.itemWidget(item) as? ModListRow ?: continue + row.updateUpdateOption(null) + break + } + } + } + } + + private fun performInstallSkipped(modId: String) { + scope.launch { + val mod = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getByProjectId(modId) } + } ?: return@launch + + val skippedRecord = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { it.getSkippedVersion(modId, mod.versionId) } + } ?: return@launch + + withContext(Dispatchers.IO) { + val source = BuiltinRegistries.ModSource.all().find { it.id == mod.source } ?: return@withContext + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta ?: return@withContext + val context = ModBrowserContext( + project = project, + minecraftVersion = meta.minecraftVersion, + modLoaderId = meta.loader + ) + + try { + val fallbacks = BuiltinRegistries.ModSource.all() + .filterIsInstance() + .sortedBy { it.priority } + val resolved = resolveInstallDownload(context, source, modId, skippedRecord.newVersionId, fallbacks) + if (resolved.downloadUrl == null) { + error("Install requires manual download (blocked by mod author)") + } + val modsDir = project.projectDir.resolve("mods") + modsDir.mkdirs() + val bytes = httpClient.get(resolved.downloadUrl).bodyAsBytes() + val oldJar = modsDir.resolve(mod.fileName) + if (oldJar.exists()) oldJar.moveToTrash() + + val jarPath = modsDir.resolve(resolved.fileName) + jarPath.writeBytesAtomic(bytes) + + val fileHash = ModDatabase.sha1(bytes) + ModDatabase(project.projectDir).use { db -> + db.recordVersionChange( + projectId = modId, + oldVersionId = mod.versionId, + oldVersionLabel = mod.versionLabel, + oldFileHash = mod.fileHash, + newVersionId = skippedRecord.newVersionId, + newVersionLabel = skippedRecord.newVersionLabel + ) + db.install(mod.copy( + fileName = resolved.fileName, + versionId = skippedRecord.newVersionId, + versionLabel = skippedRecord.newVersionLabel, + fileHash = fileHash, + installedAt = Clock.System.now(), + requiresManualDownload = resolved.requiresManualDownload, + )) + } + TritiumEventBus.publish(TritiumEvent.ModUpdated(project, modId, mod.displayName, mod.versionId, skippedRecord.newVersionId)) + TritiumEventBus.publish(TritiumEvent.ModInstalled(project, modId, mod.modId, mod.displayName, skippedRecord.newVersionId, skippedRecord.newVersionLabel)) + } catch (t: Throwable) { + logger.warn("Failed to install skipped version for mod '{}'", mod.displayName, t) + } + } + refreshMods() + } + } + + private fun confirmAndUninstall(projectId: String) { + scope.launch { + val mod = withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> db.getByProjectId(projectId) } + } + if (mod == null) { + refreshMods() + return@launch + } + withContext(Dispatchers.Main) { + val dialog = QMessageBox(window()) + dialog.icon = QMessageBox.Icon.Question + dialog.windowTitle = "Delete Mod" + dialog.text = "Delete \"${mod.displayName}\"?\nThe jar file will be moved to trash." + dialog.addButton(QMessageBox.StandardButton.Yes) + dialog.addButton(QMessageBox.StandardButton.No) + dialog.exec() + if (dialog.clickedButton() == dialog.buttons().firstOrNull { it?.text() == "&Yes" }) { + scope.launch { + withContext(Dispatchers.IO) { + project.projectDir.resolve("mods").resolve(mod.fileName).moveToTrash() + ModDatabase(project.projectDir).use { db -> db.uninstall(projectId) } + TritiumEventBus.publish(TritiumEvent.ModUninstalled(project, projectId, mod.modId, mod.displayName)) + } + refreshMods() + } + } + } + } + } + + private fun toggleEnabled(projectId: String) { + scope.launch { + var enabled = false + withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> + val mod = db.getByProjectId(projectId) ?: return@use + enabled = !mod.enabled + db.setEnabled(projectId, enabled) + } + } + TritiumEventBus.publish(TritiumEvent.ModEnabledToggled(project, projectId, enabled)) + refreshMods() + } + } + + private fun toggleRelease(projectId: String) { + scope.launch { + var excluded = false + withContext(Dispatchers.IO) { + ModDatabase(project.projectDir).use { db -> + val mod = db.getByProjectId(projectId) ?: return@use + excluded = !mod.excludedFromRelease + db.setExcludedFromRelease(projectId, excluded) + } + } + TritiumEventBus.publish(TritiumEvent.ModReleaseToggled(project, projectId, excluded)) + refreshMods() + } + } +} + +private class ModListRow( + private val mod: InstalledMod, + private var updateOption: ModVersionOption? = null, + private var previousVersion: VersionHistoryRecord? = null, + private var skippedVersion: VersionHistoryRecord? = null +) : QWidget() { + val removeRequested = Signal1() + val enableToggled = Signal1() + val releaseToggled = Signal1() + val updateRequested = Signal1() + val downgradeRequested = Signal1() + val skipRequested = Signal1() + val installSkippedRequested = Signal1() + val iconLabel: QLabel + private val nameLabel: QLabel + private val metaLabel: QLabel + private val menu: QMenu + private val updateAction: QAction? + private val downgradeAction: QAction? + private val skipAction: QAction? + private val installSkippedAction: QAction? + + override fun sizeHint(): QSize = QSize(200, 54) + + fun updateUpdateOption(option: ModVersionOption?) { + updateOption = option + refreshMenuActions() + } + + fun updatePreviousVersion(prev: VersionHistoryRecord?) { + previousVersion = prev + refreshMenuActions() + } + + fun updateSkippedVersion(version: VersionHistoryRecord?) { + skippedVersion = version + refreshMenuActions() + } + + private fun refreshMenuActions() { + updateAction?.let { + if (updateOption != null) { + it.text = "Update to v${updateOption!!.label}" + it.enabled = true + it.toolTip = "" + it.visible = true + } else { + it.text = "Up to date" + it.enabled = false + it.toolTip = "No updates available" + it.visible = true + } + } + downgradeAction?.let { + if (previousVersion != null) { + it.text = "Downgrade to v${previousVersion!!.oldVersionLabel}" + it.enabled = true + it.toolTip = "" + it.visible = true + } else { + it.visible = false + } + } + skipAction?.let { + if (updateOption != null) { + it.text = "Skip v${updateOption!!.label}" + it.enabled = true + it.visible = true + } else { + it.visible = false + } + } + installSkippedAction?.let { + if (skippedVersion != null) { + it.text = "Install Skipped v${skippedVersion!!.newVersionLabel}" + it.enabled = true + it.visible = true + } else { + it.visible = false + } + } + val hasUpdateIcon = updateOption != null + if (hasUpdateIcon) { + nameLabel.objectName = "modListNameUpdate" + } else if (!mod.enabled) { + nameLabel.objectName = "modListNameDisabled" + } else { + nameLabel.objectName = "modListName" + } + nameLabel.style()?.unpolish(nameLabel) + nameLabel.style()?.polish(nameLabel) + } + + init { + objectName = "modListRow" + val layout = QHBoxLayout(this).apply { + setContentsMargins(4, 2, 4, 2) + setSpacing(8) + } + + val iconFile = mod.iconPath?.takeIf { it.isNotBlank() }?.let { File(it) } + iconLabel = QLabel().apply { + setFixedSize(32, 32) + setAlignment(Qt.AlignmentFlag.AlignCenter) + val pix = if (iconFile != null && iconFile.exists()) { + QPixmap(iconFile.absolutePath) + } else { + TIcons.Search + } + pixmap = pix.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } + + val textColumn = QWidget() + val textLayout = QVBoxLayout(textColumn).apply { + setContentsMargins(0, 0, 0, 0) + setSpacing(2) + } + + nameLabel = QLabel(mod.displayName).apply { + val f = QFont(font()) + f.setBold(true) + font = f + objectName = "modListName" + } + if (updateOption != null) { + nameLabel.objectName = "modListNameUpdate" + } else if (!mod.enabled) { + nameLabel.objectName = "modListNameDisabled" + } + val metaText = buildString { + append(mod.modId) + if (mod.versionLabel.isNotBlank()) append(" · ${mod.versionLabel}") + if (updateOption != null) append(" · v${updateOption!!.label} available") + if (mod.side != ModSide.BOTH) append(" · ${mod.side.name}") + append(" · ${mod.releaseType}") + if (!mod.enabled) append(" · DISABLED") + if (mod.excludedFromRelease) append(" · DEV") + } + metaLabel = QLabel(metaText).apply { + val f = QFont(font()) + f.setPointSize(9) + font = f + objectName = "modListMeta" + } + + textLayout.addWidget(nameLabel) + textLayout.addWidget(metaLabel) + + val menuButton = QToolButton().apply { + icon = TIcons.SmallMenu.icon + iconSize = QSize(14, 14) + autoRaise = true + toolTip = "Mod actions" + } + + menu = QMenu(this) + + updateAction = menu.addAction("")?.apply { + if (updateOption != null) { + text = "Update to v${updateOption!!.label}" + enabled = true + } else { + text = "Up to date" + enabled = false + toolTip = "No updates available" + } + triggered.connect { updateRequested.emit(mod.projectId) } + } + + skipAction = menu.addAction("")?.apply { + if (updateOption != null) { + text = "Skip v${updateOption!!.label}" + visible = true + } else { + visible = false + } + triggered.connect { skipRequested.emit(mod.projectId) } + } + + downgradeAction = menu.addAction("")?.apply { + if (previousVersion != null) { + text = "Downgrade to v${previousVersion!!.oldVersionLabel}" + visible = true + } else { + visible = false + } + triggered.connect { downgradeRequested.emit(mod.projectId) } + } + + installSkippedAction = menu.addAction("")?.apply { + if (skippedVersion != null) { + text = "Install Skipped v${skippedVersion!!.newVersionLabel}" + visible = true + } else { + visible = false + } + triggered.connect { installSkippedRequested.emit(mod.projectId) } + }!! + + menu.addSeparator() + + val enabledLabel = if (mod.enabled) "Disable" else "Enable" + menu.addAction(enabledLabel)?.let { + it.triggered.connect { enableToggled.emit(mod.projectId) } + } + + val releaseLabel = if (mod.excludedFromRelease) "Include in Release" else "Exclude from Release" + menu.addAction(releaseLabel)?.let { + it.triggered.connect { releaseToggled.emit(mod.projectId) } + } + + menu.addSeparator() + + menu.addAction("Delete")?.let { + it.triggered.connect { removeRequested.emit(mod.projectId) } + } + + menuButton.setMenu(menu) + menuButton.popupMode = QToolButton.ToolButtonPopupMode.InstantPopup + + layout.addWidget(iconLabel, 0, Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(textColumn, 1) + layout.addWidget(menuButton) + + setThemedStyle { + selector("#modListName") { + color(TColors.Text) + } + selector("#modListNameDisabled") { + color(TColors.Surface2) + } + selector("#modListNameUpdate") { + color(TColors.Green) + } + selector("#modListMeta") { + color(TColors.Subtext) + } + } + + if (!mod.enabled) { + iconLabel.setGraphicsEffect(QGraphicsOpacityEffect().apply { opacity = 0.45 }) + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectLogsSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectLogsSidePanelProvider.kt index d331e97..8c767dc 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectLogsSidePanelProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectLogsSidePanelProvider.kt @@ -12,6 +12,7 @@ import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon import io.github.tritium_launcher.launcher.ui.theme.qt.qtStyle import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.TComboBox import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label @@ -33,7 +34,7 @@ import kotlin.time.ExperimentalTime class ProjectLogsSidePanelProvider : SidePanelProvider { override val id: String = "mc_logs" override val displayName: String = "MC Logs" - override val icon: QIcon = TIcons.Log.icon + override var icon: QIcon? = TIcons.Log.icon override val order: Int = 10 override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.BottomDockWidgetArea @@ -124,6 +125,7 @@ class ProjectLogsSidePanelProvider : SidePanelProvider { setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) document()?.maximumBlockCount = MAX_LOG_BLOCKS } + AnimatedScrollController.attach(logView) val logsDir = project.projectDir.resolve("logs") var activePath = logsDir.resolve("latest.log") diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectModpackSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectModpackSidePanelProvider.kt new file mode 100644 index 0000000..261b4ca --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectModpackSidePanelProvider.kt @@ -0,0 +1,156 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.core.project.ModpackMeta +import io.github.tritium_launcher.launcher.core.project.Project +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.m +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.gridLayout +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.qWidget +import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBoxLayout +import io.qt.core.Qt +import io.qt.gui.QFont +import io.qt.gui.QIcon +import io.qt.gui.QPixmap +import io.qt.widgets.QSizePolicy +import io.qt.widgets.QWidget + +/** + * Side panel showing high-level modpack metadata. + * + * This is read-only for now. Future iterations may expose actions for changing project source, + * Minecraft version, loader, and license. + */ +class ProjectModpackSidePanelProvider : SidePanelProvider { + override val id: String = "modpack" + override val displayName: String = "Modpack" + override var icon: QIcon? = TIcons.ModConfig.icon + override val order: Int = 5 + + override val closeable: Boolean = false + override val floatable: Boolean = false + override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.RightDockWidgetArea + + override fun create(project: ProjectBase): DockWidget { + val dock = DockWidget(displayName, null) + dock.setWidget(ModpackSummaryPanel(project)) + return dock + } +} + +private class ModpackSummaryPanel( + private val project: ProjectBase +) : QWidget() { + private val modSources = BuiltinRegistries.ModSource + private val modLoaders = BuiltinRegistries.ModLoader + private val licenses = BuiltinRegistries.License + + init { + objectName = "modpackSidePanel" + setThemedStyle { + selector("#modpackSidePanel") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + border() + } + selector("#modpackSummaryTitle") { + color(TColors.Text) + fontSize(13) + } + selector("#modpackSummaryValue") { + color(TColors.Text) + } + selector("#modpackSummaryKey") { + color(TColors.Subtext) + } + selector("#modpackSummaryCard") { + backgroundColor(TColors.Surface0) + border(1, TColors.Surface2) + padding(10) + } + } + + val outer = vBoxLayout(this) { + contentsMargins = 12.m + widgetSpacing = 12 + } + + val headerCard = qWidget { objectName = "modpackSummaryCard" } + val headerLayout = vBoxLayout(headerCard) { + contentsMargins = 12.m + widgetSpacing = 8 + } + + val iconLabel = label { + setAlignment(Qt.AlignmentFlag.AlignCenter) + pixmap = projectIconPixmap() + } + val titleLabel = label(project.name) { + objectName = "modpackSummaryTitle" + setAlignment(Qt.AlignmentFlag.AlignCenter) + wordWrap = true + font = QFont(font).apply { setBold(true) } + } + + headerLayout.addWidget(iconLabel) + headerLayout.addWidget(titleLabel) + outer.addWidget(headerCard) + + val detailsCard = qWidget { objectName = "modpackSummaryCard" } + val detailsLayout = gridLayout(detailsCard) { + contentsMargins = 12.m + setHorizontalSpacing(10) + setVerticalSpacing(8) + } + + val meta = (project as? Project<*>)?.typedMeta as? ModpackMeta + val rows = listOf( + "Chosen Mod Source" to resolveModSource(meta?.source), + "MC Version" to meta?.minecraftVersion.orUnknown(), + "Mod Loader" to resolveModLoader(meta?.loader), + "Mod Loader Version" to meta?.loaderVersion.orUnknown(), + "License" to resolveLicense(meta?.license) + ) + + rows.forEachIndexed { index, (labelText, valueText) -> + val key = label(labelText) { + objectName = "modpackSummaryKey" + setAlignment(Qt.AlignmentFlag.AlignTop) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + } + val value = label(valueText) { + objectName = "modpackSummaryValue" + wordWrap = true + setAlignment(Qt.AlignmentFlag.AlignTop) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + } + detailsLayout.addWidget(key, index, 0) + detailsLayout.addWidget(value, index, 1) + } + + outer.addWidget(detailsCard) + outer.addStretch(1) + } + + private fun projectIconPixmap(): QPixmap { + val pix = QPixmap(project.getIconPath()) + val base = if (pix.isNull) TIcons.Folder else pix + return base.scaled(72, 72, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } + + private fun resolveModSource(id: String?): String = + modSources.all().firstOrNull { it.id == id }?.displayName ?: id.orUnknown() + + private fun resolveModLoader(id: String?): String = + modLoaders.all().firstOrNull { it.id == id }?.displayName ?: id.orUnknown() + + private fun resolveLicense(id: String?): String = + licenses.all().firstOrNull { it.id == id }?.name ?: id.orUnknown() + + private fun String?.orUnknown(): String = this?.takeIf { it.isNotBlank() } ?: "Unknown" +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectNotificationsSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectNotificationsSidePanelProvider.kt index 2407ef5..cecfd98 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectNotificationsSidePanelProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectNotificationsSidePanelProvider.kt @@ -13,12 +13,13 @@ import io.qt.gui.QIcon class ProjectNotificationsSidePanelProvider : SidePanelProvider { override val id: String = "notifications" override val displayName: String = "Notifications" - override val icon: QIcon = TIcons.QuestionMark.icon + override var icon: QIcon? = TIcons.QuestionMark.icon override val order: Int = 20 override val closeable: Boolean = false override val floatable: Boolean = false override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.RightDockWidgetArea + override val allowSplit: Boolean = false override fun create(project: ProjectBase): DockWidget { val dock = DockWidget(displayName, null) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRegistryBrowserSidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRegistryBrowserSidePanelProvider.kt new file mode 100644 index 0000000..f5fe854 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRegistryBrowserSidePanelProvider.kt @@ -0,0 +1,1711 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.currentDpr +import io.github.tritium_launcher.launcher.extension.kubejs.KubeJSIntelligenceService +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.logger +import io.github.tritium_launcher.launcher.registrydb.* +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.github.tritium_launcher.launcher.ui.theme.TIcons +import io.github.tritium_launcher.launcher.ui.theme.qt.icon +import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.TTooltip +import io.github.tritium_launcher.launcher.ui.widgets.TTooltipStyle +import io.qt.core.* +import io.qt.gui.* +import io.qt.widgets.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.filter +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import java.util.* +import kotlin.math.ceil +import kotlin.math.max + + +/** + * JEI-like registry browser panel with fixed-page item browsing and bottom search. + */ +class ProjectRegistryBrowserSidePanelProvider : SidePanelProvider, SidePanelTitleBarAccessoryProvider { + override val id: String = "registry_browser" + override val displayName: String = "Item Browser" + override var icon: QIcon? = TIcons.SmallGrass.icon + override val order: Int = 15 + override val closeable: Boolean = false + override val floatable: Boolean = false + override val preferredArea: Qt.DockWidgetArea = Qt.DockWidgetArea.BottomDockWidgetArea + override val allowSplit: Boolean = false + override val allowedDockAreas: Set = setOf(Qt.DockWidgetArea.BottomDockWidgetArea) + + override fun create(project: ProjectBase): DockWidget { + val dock = DockWidget(displayName, null).apply { + minimumWidth = 250 + maximumWidth = 450 + } + val controller = Controller(project, dock, preferredArea) + controllers[dock] = controller + dock.setWidget(controller.root) + controller.start() + dock.destroyed.connect { + controller.cleanup() + controllers.remove(dock) + } + return dock + } + + override fun createTitleBarAccessory(project: ProjectBase, dock: DockWidget, onStateChanged: () -> Unit): QWidget? { + val controller = controllers[dock] ?: return null + return QToolButton().apply { + icon = TIcons.Rerun.icon + iconSize = QSize(16, 16) + autoRaise = true + toolTip = "Refresh Item Browser" + clicked.connect { + controller.refreshFromDatabase() + onStateChanged() + } + setThemedStyle { + selector("QToolButton") { + background("transparent") + border() + padding(0) + margin(0) + } + selector("QToolButton:hover") { + backgroundColor(TColors.Surface1) + } + } + } + } + + private class Controller( + val project: ProjectBase, + private val dock: DockWidget, + private val preferredArea: Qt.DockWidgetArea + ) { + val root = QWidget() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val logger = logger() + private val outerLayout = QVBoxLayout(root) + private val header = QWidget(root) + private val headerLayout = QHBoxLayout(header) + private val prevPageButton = QToolButton(header) + private val pageLabel = QLabel(header) + private val nextPageButton = QToolButton(header) + + private val mainContainer = QWidget(root) + private val mainLayout = QHBoxLayout(mainContainer).apply { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + } + private val leftContainer = QWidget(mainContainer) + private val leftLayout = QVBoxLayout(leftContainer).apply { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + } + private val viewport = GridViewport(this, leftContainer) + private val viewportLayout = QVBoxLayout(viewport) + private val statusLabel = QLabel(viewport) + private val gridWidget = QWidget(viewport) + private val gridLayout = QGridLayout(gridWidget).apply { + sizeConstraint = QLayout.SizeConstraint.SetNoConstraint + } + + internal val detailPanel = DetailPanel(mainContainer, this) + + private val footer = QWidget(leftContainer) + private val footerLayout = QHBoxLayout(footer) + private val searchField = QLineEdit(footer) + private val searchDebounce = QTimer(root) + private val resizeDebounce = QTimer(root) + + private var lastArea: Qt.DockWidgetArea? = null + + private var searchText: String = "" + private var currentPage: Int = 0 + private var totalItems: Int = 0 + private var itemsPerPage: Int = 1 + private var columns: Int = 1 + private var lastColumns: Int = 0 + private var rows: Int = 1 + private var selectedItemId: String? = null + private var visibleItems: List = emptyList() + private val slotButtons = mutableListOf() + private var snapshotDir: VPath? = null + private var restoreAfterLoad: String? = null + + init { + root.objectName = "registryBrowserPanel" + header.objectName = "registryBrowserHeader" + viewport.objectName = "registryBrowserViewport" + statusLabel.objectName = "registryBrowserStatus" + gridWidget.objectName = "registryBrowserGrid" + footer.objectName = "registryBrowserFooter" + searchField.objectName = "registryBrowserSearch" + + outerLayout.setContentsMargins(4, 4, 4, 4) + outerLayout.setSpacing(4) + + headerLayout.setContentsMargins(2, 2, 2, 2) + headerLayout.setSpacing(4) + footerLayout.setContentsMargins(2, 2, 2, 2) + footerLayout.setSpacing(4) + + prevPageButton.text = "<" + prevPageButton.autoRaise = false + prevPageButton.toolTip = "Previous Page" + prevPageButton.minimumSize = QSize(24, 22) + + nextPageButton.text = ">" + nextPageButton.autoRaise = false + nextPageButton.toolTip = "Next Page" + nextPageButton.minimumSize = QSize(24, 22) + + pageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + pageLabel.font = QFont(pageLabel.font).apply { setPointSize(10); setBold(true) } + + headerLayout.addWidget(prevPageButton) + headerLayout.addWidget(pageLabel, 1) + headerLayout.addWidget(nextPageButton) + + viewportLayout.setContentsMargins(0, 0, 0, 0) + viewportLayout.setSpacing(0) + + statusLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + statusLabel.wordWrap = true + statusLabel.margin = 12 + + gridLayout.setContentsMargins(2, 2, 2, 2) + gridLayout.setHorizontalSpacing(2) + gridLayout.setVerticalSpacing(2) + gridLayout.setAlignment(Qt.Alignment(Qt.AlignmentFlag.AlignTop, Qt.AlignmentFlag.AlignLeft)) + + viewportLayout.addWidget(statusLabel, 1) + viewportLayout.addWidget(gridWidget, 1) + + leftLayout.addWidget(header) + leftLayout.addWidget(viewport, 1) + leftLayout.addWidget(footer) + + mainLayout.addWidget(leftContainer, 1) + mainLayout.addWidget(detailPanel, 1) + + searchField.placeholderText = "Search items..." + searchField.clearButtonEnabled = true + searchField.minimumHeight = 24 + + footerLayout.addWidget(searchField, 1) + + outerLayout.addWidget(mainContainer, 1) + + dock.dockLocationChanged.connect { area -> + updateLayoutForArea(area) + } + + searchDebounce.isSingleShot = true + searchDebounce.interval = 120 + + resizeDebounce.isSingleShot = true + resizeDebounce.interval = 50 + resizeDebounce.timeout.connect { + if (updatePageGeometry()) { + currentPage = 0 + refreshFromDatabase() + } + } + + root.setThemedStyle { + selector("#registryBrowserPanel") { + backgroundColor(TColors.Surface0) + } + selector("#registryBrowserHeader") { + backgroundColor(TColors.Surface0) + any("border-bottom", "1px solid ${TColors.Surface1}") + } + selector("#registryBrowserFooter") { + backgroundColor(TColors.Surface0) + any("border-top", "1px solid ${TColors.Surface1}") + } + selector("#registryBrowserViewport") { + backgroundColor(TColors.Surface0) + } + selector("#registryBrowserStatus") { + color(TColors.Subtext) + fontSize(11) + } + selector("#registryBrowserSearch") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + border(1, TColors.Surface1) + borderRadius(4) + padding(2, 6, 2, 6) + } + selector("#registryBrowserHeader QToolButton") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + border(1, TColors.Surface1) + borderRadius(3) + } + selector("#registryBrowserHeader QToolButton:hover") { + backgroundColor(TColors.Surface2) + } + selector("#registryBrowserHeader QToolButton:disabled") { + color(TColors.Surface2) + } + selector("QToolButton#registryBrowserSlot") { + background("transparent") + border() + padding(0) + } + selector("QToolButton#registryBrowserSlot:hover") { + background("transparent") + } + selector("QToolButton#registryBrowserSlot:checked") { + background("transparent") + border(1, TColors.Accent) + } + } + } + + fun start() { + RegistryRefreshService.startWatching(project) + prevPageButton.clicked.connect { changePage(currentPage - 1) } + nextPageButton.clicked.connect { changePage(currentPage + 1) } + searchField.textChanged.connect { + searchText = searchField.text.trim() + currentPage = 0 + restoreAfterLoad = null + searchDebounce.start() + } + searchDebounce.timeout.connect { refreshFromDatabase() } + + scope.onEvent { event -> + searchField.text = event.id + searchText = event.id + currentPage = 0 + refreshFromDatabase() + dock.show() + dock.raise() + } + + scope.launch { + RegistryRefreshService.dbUpdated + .filter { it.projectDir.toString() == project.projectDir.toString() } + .collect { + RegistryDatabase.invalidateCachedConnection() + KubeJSIntelligenceService.invalidateConnection() + refreshFromDatabase() + } + } + + val state = loadState(project) + if (state.lastSearchText != null) { + searchText = state.lastSearchText + searchField.text = state.lastSearchText + } + if (state.lastSelectedId != null) { + restoreAfterLoad = state.lastSelectedId + } + if (state.lastPage > 0) { + currentPage = state.lastPage + } + + val initialArea = (dock.parent() as? QMainWindow)?.dockWidgetArea(dock) ?: preferredArea + updateLayoutForArea(initialArea) + + updatePageGeometry() + refreshFromDatabase() + } + + private fun updateLayoutForArea(area: Qt.DockWidgetArea) { + if (area == lastArea) return + lastArea = area + + if (area == Qt.DockWidgetArea.BottomDockWidgetArea) { + dock.maximumWidth = 10000 + dock.minimumHeight = 150 + dock.maximumHeight = 800 + } else { + dock.maximumWidth = 450 + dock.minimumHeight = 0 + dock.maximumHeight = 10000 + } + } + + fun cleanup() { + scope.cancel() + } + + private fun changePage(page: Int) { + if (totalPages() <= 1) return + val next = ((page % totalPages()) + totalPages()) % totalPages() + if (next == currentPage) return + currentPage = next + val offset = currentPage * itemsPerPage + scope.launch { + val items = withContext(Dispatchers.IO) { + RegistryDatabase.searchItems(project, searchText, offset, itemsPerPage) + } + visibleItems = items + updateGridSlots() + updateHeader() + } + } + + private fun updateHeader() { + pageLabel.text = if (totalItems <= 0) "0 / 0" else "${currentPage + 1} / ${totalPages()}" + } + + fun refreshFromDatabase() { + scope.launch { + val status = withContext(Dispatchers.IO) { RegistryDatabase.status(project) } + when (status) { + is RegistryDbStatus.Ready -> { + snapshotDir = status.manifestPath.parent() + val (count, items) = withContext(Dispatchers.IO) { + clampPage() + val offset = currentPage * itemsPerPage + val count = RegistryDatabase.countItems(project, searchText) + val items = RegistryDatabase.searchItems(project, searchText, offset, itemsPerPage) + count to items + } + totalItems = count + visibleItems = items + rebuildSlots() + updateHeader() + + val restoreId = restoreAfterLoad + if (restoreId != null) { + restoreAfterLoad = null + if (items.any { it.id == restoreId }) { + selectItem(restoreId, addToHistory = false) + } + } + } + + else -> { + snapshotDir = null + visibleItems = emptyList() + totalItems = 0 + selectedItemId = null + showStatus(messageFor(status)) + updateHeader() + } + } + } + } + + fun onViewportResized() { + resizeDebounce.start() + } + + private fun updatePageGeometry(): Boolean { + val slotSize = SLOT_SIZE + val spacing = gridLayout.horizontalSpacing().coerceAtLeast(0) + val availableWidth = max(viewport.width() - 4, slotSize) + val availableHeight = max(1, viewport.height() - 4) + + val nextColumns = max(1, (availableWidth + spacing) / (slotSize + spacing)) + val nextRows = max(1, (availableHeight + spacing) / (slotSize + spacing)) + val nextItemsPerPage = max(1, nextColumns * nextRows) + + val changed = nextColumns != columns || nextRows != rows || nextItemsPerPage != itemsPerPage + columns = nextColumns + rows = nextRows + itemsPerPage = nextItemsPerPage + return changed + } + + private fun clampPage() { + val maxPage = max(0, totalPages() - 1) + currentPage = currentPage.coerceIn(0, maxPage) + } + + private fun totalPages(): Int = + if (totalItems <= 0) 1 else ceil(totalItems / itemsPerPage.toDouble()).toInt() + + private fun rebuildSlots() { + if (visibleItems.isEmpty()) { + clearGrid() + showStatus( + if (searchText.isBlank()) { + "No items found.\nLaunch the game with the Companion mod to generate the item registry." + } else { + "No items match \"$searchText\"." + } + ) + return + } + + val columnsChanged = columns != lastColumns + if (columnsChanged) { + detachGridButtons() + lastColumns = columns + } + + statusLabel.isVisible = false + gridWidget.isVisible = true + + val newCount = visibleItems.size + val oldCount = slotButtons.size + + if (newCount > oldCount) { + repeat(newCount - oldCount) { i -> + SlotButton(gridWidget, scope, this::selectItem).also { btn -> + btn.objectName = "registryBrowserSlot" + btn.minimumSize = QSize(SLOT_SIZE, SLOT_SIZE) + btn.maximumSize = QSize(SLOT_SIZE, SLOT_SIZE) + btn.isCheckable = true + slotButtons.add(btn) + val slotIndex = oldCount + i + gridLayout.addWidget(btn, slotIndex / columns, slotIndex % columns) + } + } + } + + slotButtons.forEachIndexed { index, button -> + if (index < newCount) { + val item = visibleItems[index] + button.setItem(item, selectedItemId == item.id, snapshotDir) + button.isVisible = true + if (columnsChanged && index < oldCount) { + gridLayout.addWidget(button, index / columns, index % columns) + } + } else { + button.isVisible = false + } + } + + if (slotButtons.size > newCount + 64) { + val toRemove = slotButtons.subList(newCount, slotButtons.size) + toRemove.forEach { + gridLayout.removeWidget(it) + it.disposeLater() + } + toRemove.clear() + } + } + + private fun updateGridSlots() { + if (visibleItems.isEmpty()) { + clearGrid() + showStatus( + if (searchText.isBlank()) { + "No items found.\nLaunch the game with the Companion mod to generate the item registry." + } else { + "No items match \"$searchText\"." + } + ) + return + } + + val newCount = visibleItems.size + val oldCount = slotButtons.size + + if (newCount > oldCount) { + repeat(newCount - oldCount) { i -> + SlotButton(gridWidget, scope, this::selectItem).also { btn -> + btn.objectName = "registryBrowserSlot" + btn.minimumSize = QSize(SLOT_SIZE, SLOT_SIZE) + btn.maximumSize = QSize(SLOT_SIZE, SLOT_SIZE) + btn.isCheckable = true + slotButtons.add(btn) + val slotIndex = oldCount + i + gridLayout.addWidget(btn, slotIndex / columns, slotIndex % columns) + } + } + } + + slotButtons.forEachIndexed { index, button -> + if (index < newCount) { + val item = visibleItems[index] + button.setItem(item, selectedItemId == item.id, snapshotDir) + button.isVisible = true + } else { + button.isVisible = false + } + } + + statusLabel.isVisible = false + gridWidget.isVisible = true + } + + fun selectItem(id: String, addToHistory: Boolean = true) { + if (selectedItemId == id) return + selectedItemId = id + updateSelectionState() + scope.launch { + val result = withContext(Dispatchers.IO) { RegistryDatabase.itemDetailWithRecipes(project, id) } + if (result.detail != null) { + detailPanel.setItem(result.detail, result.recipeUsage, result.recipeDetails, snapshotDir, scope) + } + saveState( + project, RegistryBrowserState( + lastSelectedId = id, + lastSearchText = searchText.takeIf { it.isNotBlank() }, + lastPage = currentPage + ) + ) + } + } + + private fun updateSelectionState() { + slotButtons.forEach { slot -> + if (slot.isVisible) { + slot.isChecked = slot.itemId == selectedItemId + } + } + } + + private fun detachGridButtons() { + slotButtons.forEach { gridLayout.removeWidget(it) } + } + + private fun clearGrid() { + detachGridButtons() + slotButtons.forEach { it.disposeLater() } + slotButtons.clear() + } + + private fun showGrid() { + statusLabel.isVisible = false + gridWidget.isVisible = true + } + + private fun showStatus(message: String) { + statusLabel.text = message + statusLabel.isVisible = true + gridWidget.isVisible = false + } + + private fun messageFor(status: RegistryDbStatus): String = when (status) { + is RegistryDbStatus.MissingRoot -> + "This project hasn't been exported to the item registry yet.\nLaunch the game with the Companion mod to create it." + + is RegistryDbStatus.MissingLatestPointer -> + "No registry snapshot has been selected yet.\nLaunch the game and run the registry export, then refresh." + + is RegistryDbStatus.MissingDatabase -> + "The registry database hasn't been built yet.\nLaunch the game and run the registry export to generate it." + + is RegistryDbStatus.MissingManifest -> + "A registry snapshot was found but its manifest file is missing.\nRe-run the registry export to fix this." + + is RegistryDbStatus.InvalidLatestPointer -> + "The registry snapshot pointer file is corrupted.\nRe-run the registry export to fix this." + + is RegistryDbStatus.InvalidManifest -> + "The registry manifest file is corrupted.\nRe-run the registry export to fix this." + + is RegistryDbStatus.IncompleteDump -> + "The registry snapshot is incomplete.\nRe-run the registry export to generate a full snapshot." + + is RegistryDbStatus.SchemaMismatch -> + "The registry database is from a different version of Tritium.\nRun the registry export again to rebuild it with the latest format." + + is RegistryDbStatus.StaleDatabase -> + "The registry database is outdated.\nA new registry snapshot is available — refresh to update." + + is RegistryDbStatus.InvalidDatabase -> + "The registry database is corrupted or unreadable.\nRe-run the registry export to rebuild it." + + is RegistryDbStatus.Ready -> + "" + } + } + + private class GridViewport( + private val controller: Controller, + parent: QWidget? + ) : QWidget(parent) { + override fun minimumSizeHint(): QSize = QSize(1, 1) + + override fun event(event: QEvent?): Boolean { + if (event?.type() == QEvent.Type.Resize) { + controller.onViewportResized() + } + return super.event(event) + } + } + + private class DetailPanel( + parent: QWidget?, + private val controller: Controller + ) : QWidget(parent) { + private val outerLayout = QVBoxLayout(this) + private val tabBar = QWidget() + private val tabBarLayout = QHBoxLayout(tabBar) + private val detailTabButton = QToolButton() + private val recipesTabButton = QToolButton() + + // Detail view + private val detailScrollArea = QScrollArea() + private val detailContent = QWidget() + private val detailLayout = QVBoxLayout(detailContent) + private val itemIconLabel = QLabel() + private val itemNameLabel = QLabel() + private val itemNamespaceLabel = QLabel() + private val itemIdLabel = QLabel() + private val tagsContainer = QWidget() + private val tagsLayout = QHBoxLayout(tagsContainer) + private val dynamicPropsContainer = QWidget() + private val dynamicPropsLayout = QVBoxLayout(dynamicPropsContainer) + private val dynamicLabels = mutableListOf() + + // Recipes view + private val recipeContainer = QWidget() + private val recipeContainerLayout = QVBoxLayout(recipeContainer) + private val recipeScrollArea = QScrollArea() + private val recipeContent = QWidget() + private val recipeLayout = QVBoxLayout(recipeContent) + private val catalystRow = QWidget() + private val catalystRowLayout = QHBoxLayout(catalystRow) + private val catalystButtonGroup = QButtonGroup(catalystRow) + private val recipeWidgetCache = mutableMapOf() + private val catalystWidgetCache = mutableMapOf() + + private var currentSnapshotDir: VPath? = null + private var allRecipes: List = emptyList() + private var selectedRecipeTypeId: String? = null + private var currentScope: CoroutineScope? = null + + init { + outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.setSpacing(0) + + // Tab bar + tabBarLayout.setContentsMargins(4, 4, 4, 0) + tabBarLayout.setSpacing(2) + detailTabButton.text = "Detail" + detailTabButton.isCheckable = true + detailTabButton.isChecked = true + recipesTabButton.text = "Recipes" + recipesTabButton.isCheckable = true + detailTabButton.clicked.connect { showDetailTab() } + recipesTabButton.clicked.connect { showRecipeTab() } + tabBarLayout.addWidget(detailTabButton) + tabBarLayout.addWidget(recipesTabButton) + tabBarLayout.addStretch(1) + outerLayout.addWidget(tabBar) + + // Detail tab + detailScrollArea.widgetResizable = true + detailScrollArea.setWidget(detailContent) + detailScrollArea.frameShape = QFrame.Shape.NoFrame + detailLayout.setContentsMargins(8, 8, 8, 8) + detailLayout.setSpacing(12) + + val infoSection = QWidget() + val infoLayout = QVBoxLayout(infoSection) + infoLayout.setContentsMargins(0, 0, 0, 0) + infoLayout.setSpacing(6) + + val headerRow = QWidget() + val headerRowLayout = QHBoxLayout(headerRow) + headerRowLayout.setContentsMargins(0, 0, 0, 0) + headerRowLayout.setSpacing(8) + + itemIconLabel.setFixedSize(64, 64) + itemIconLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + itemIconLabel.scaledContents = false + + val textColumn = QWidget() + val textColumnLayout = QVBoxLayout(textColumn) + textColumnLayout.setContentsMargins(0, 0, 0, 0) + textColumnLayout.setSpacing(4) + + itemNameLabel.font = QFont(itemNameLabel.font).apply { setPointSize(12); setBold(true) } + itemNameLabel.wordWrap = true + + itemNamespaceLabel.styleSheet = "color: ${TColors.Accent}; font-weight: bold;" + + itemIdLabel.styleSheet = "color: ${TColors.Subtext};" + itemIdLabel.wordWrap = true + itemIdLabel.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + textColumnLayout.addWidget(itemNameLabel) + textColumnLayout.addWidget(itemNamespaceLabel) + textColumnLayout.addWidget(itemIdLabel) + textColumnLayout.addStretch(1) + + headerRowLayout.addWidget(itemIconLabel) + headerRowLayout.addWidget(textColumn, 1) + + infoLayout.addWidget(headerRow) + + // Dynamic properties section + dynamicPropsLayout.setContentsMargins(0, 0, 0, 0) + dynamicPropsLayout.setSpacing(4) + dynamicPropsLayout.addStretch(1) + infoLayout.addWidget(dynamicPropsContainer) + + // Tags section (at bottom) + tagsLayout.setContentsMargins(0, 8, 0, 0) + tagsLayout.setSpacing(4) + tagsLayout.addStretch(1) + infoLayout.addWidget(tagsContainer) + + detailLayout.addWidget(infoSection) + detailLayout.addStretch(1) + + outerLayout.addWidget(detailScrollArea, 1) + + // Recipe tab + recipeContainerLayout.setContentsMargins(0, 0, 0, 0) + recipeContainerLayout.setSpacing(0) + + recipeScrollArea.widgetResizable = true + recipeScrollArea.setWidget(recipeContent) + recipeScrollArea.frameShape = QFrame.Shape.NoFrame + recipeLayout.setContentsMargins(4, 4, 4, 4) + recipeLayout.setSpacing(2) + recipeLayout.addStretch(1) + + catalystRowLayout.setContentsMargins(4, 2, 4, 4) + catalystRowLayout.setSpacing(2) + catalystRow.sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + + recipeContainerLayout.addWidget(recipeScrollArea, 1) + recipeContainerLayout.addWidget(catalystRow) + outerLayout.addWidget(recipeContainer, 1) + + showDetailTab() + + setThemedStyle { + selector("QTextEdit") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + border(1, TColors.Surface2) + borderRadius(4) + } + selector("QToolButton") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + border(1, TColors.Surface2) + borderRadius(3) + } + selector("QToolButton:hover") { + backgroundColor(TColors.Surface2) + } + selector("QToolButton:checked") { + backgroundColor(TColors.Accent) + color(TColors.Text) + border(1, TColors.Accent) + } + } + } + + private fun showDetailTab() { + detailTabButton.isChecked = true + recipesTabButton.isChecked = false + detailScrollArea.isVisible = true + recipeContainer.isVisible = false + } + + private fun showRecipeTab() { + detailTabButton.isChecked = false + recipesTabButton.isChecked = true + detailScrollArea.isVisible = false + recipeScrollArea.isVisible = true + recipeContainer.isVisible = true + } + + fun selectRecipeType(recipeTypeId: String) { + if (selectedRecipeTypeId == recipeTypeId) return + selectedRecipeTypeId = recipeTypeId + catalystWidgetCache[recipeTypeId]?.isChecked = true + currentScope?.let { refreshRecipes(emptyList(), currentSnapshotDir, it) } + } + + private val skipKeys = setOf("id", "namespace", "path", "displayName", "tags", "texturePath", "rawJson") + + private fun addDynamicField(key: String, value: JsonElement, indent: Int) { + val indentPx = indent * 16 + val displayKey = key.replaceFirstChar { it.uppercase() } + + when (value) { + is JsonPrimitive -> { + val displayValue = if (value.isString) value.content else value.toString() + val label = QLabel("${" ".repeat(indent)}${displayKey}: $displayValue").apply { + styleSheet = "color: ${TColors.Text}; padding-left: ${indentPx}px;" + wordWrap = true + } + dynamicLabels.add(label) + dynamicPropsLayout.insertWidget(dynamicPropsLayout.count() - 1, label) + } + + is JsonArray -> { + val label = QLabel("${" ".repeat(indent)}${displayKey}:").apply { + styleSheet = "color: ${TColors.Text}; padding-left: ${indentPx}px;" + } + dynamicLabels.add(label) + dynamicPropsLayout.insertWidget(dynamicPropsLayout.count() - 1, label) + value.forEachIndexed { index, element -> + addDynamicField("[$index]", element, indent + 1) + } + } + + is JsonObject -> { + val label = QLabel("${" ".repeat(indent)}${displayKey}:").apply { + styleSheet = "color: ${TColors.Accent}; font-weight: bold; padding-left: ${indentPx}px;" + } + dynamicLabels.add(label) + dynamicPropsLayout.insertWidget(dynamicPropsLayout.count() - 1, label) + value.forEach { (subKey, subValue) -> + addDynamicField(subKey, subValue, indent + 1) + } + } + } + } + + fun setItem( + item: RegistryItemDetail, + recipeUsage: RegistryItemRecipeUsage, + recipeDetails: List, + snapshotDir: VPath?, + scope: CoroutineScope + ) { + currentSnapshotDir = snapshotDir + currentScope = scope + selectedRecipeTypeId = null + allRecipes = recipeUsage.producedBy + recipeUsage.usedIn + showDetailTab() + + itemNameLabel.text = item.displayName ?: item.path + itemIdLabel.text = item.id + itemNamespaceLabel.text = item.namespace + + // Create tag chips + while (tagsLayout.count() > 1) { + tagsLayout.takeAt(0)?.widget()?.disposeLater() + } + item.tags.forEach { tag -> + val chip = QLabel(tag).apply { + styleSheet = """ + QLabel { + background-color: ${TColors.Surface2}; + color: ${TColors.Text}; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + } + """.trimIndent() + } + tagsLayout.insertWidget(tagsLayout.count() - 1, chip) + } + + // Parse raw JSON and display dynamic fields + val jsonObj = runCatching { parseJsonObject(item.rawJson) }.getOrNull() + dynamicLabels.forEach { it.disposeLater() } + dynamicLabels.clear() + while (dynamicPropsLayout.count() > 1) { + dynamicPropsLayout.takeAt(0)?.widget()?.disposeLater() + } + + jsonObj?.forEach { key, value -> + if (key in skipKeys) return@forEach + addDynamicField(key, value, 0) + } + + val texPath = item.texturePath + val targetId = item.id + val displayText = item.displayName?.take(1) ?: "?" + itemIconLabel.pixmap = QPixmap() + itemIconLabel.text = displayText + scope.launch { + val pixmap = withContext(Dispatchers.IO) { loadIcon(targetId, texPath, snapshotDir, 64) } + if (isActive) { + if (pixmap != null) { + itemIconLabel.pixmap = pixmap + itemIconLabel.text = "" + } + } + } + + refreshCatalysts(item.id, scope) + refreshRecipes(recipeDetails, snapshotDir, scope) + } + + private fun refreshCatalysts(itemId: String, scope: CoroutineScope) { + catalystWidgetCache.forEach { (_, btn) -> btn.disposeLater() } + catalystWidgetCache.clear() + while (catalystRowLayout.count() > 0) { + catalystRowLayout.takeAt(0)?.widget()?.disposeLater() + } + + scope.launch { + val recipeTypes = withContext(Dispatchers.IO) { + RegistryDatabase.recipeTypesForItem(controller.project, itemId) + } + val allCatalystIds = recipeTypes.flatMap { it.catalystIds }.distinct() + val catalystItems = if (allCatalystIds.isNotEmpty()) { + withContext(Dispatchers.IO) { + RegistryDatabase.itemSummariesByIds(controller.project, allCatalystIds) + }.associateBy { it.id } + } else emptyMap() + + if (isActive) { + buildCatalystRow(recipeTypes, catalystItems, scope) + } + } + } + + private fun buildCatalystRow( + recipeTypes: List, + catalystItems: Map, + scope: CoroutineScope + ) { + catalystWidgetCache.forEach { (_, btn) -> btn.disposeLater() } + catalystWidgetCache.clear() + while (catalystRowLayout.count() > 0) { + catalystRowLayout.takeAt(0)?.widget()?.disposeLater() + } + catalystButtonGroup.buttons().filterNotNull().forEach { catalystButtonGroup.removeButton(it) } + + catalystRow.isVisible = true + + if (recipeTypes.isEmpty()) { + catalystRow.isVisible = false + return + } + + recipeTypes.forEach { rt -> + val catalystItem = rt.catalystIds.firstNotNullOfOrNull { catalystItems[it] } + val btn = CatalystButton(rt, catalystItem, currentSnapshotDir, scope, controller) + catalystWidgetCache[rt.recipeTypeId] = btn + catalystButtonGroup.addButton(btn) + catalystRowLayout.addWidget(btn) + } + catalystRowLayout.addStretch(1) + + catalystButtonGroup.exclusive = true + + if (selectedRecipeTypeId != null) { + catalystWidgetCache[selectedRecipeTypeId]?.isChecked = true + } else if (recipeTypes.isNotEmpty()) { + selectedRecipeTypeId = recipeTypes.first().recipeTypeId + catalystWidgetCache[selectedRecipeTypeId]?.isChecked = true + } + + catalystButtonGroup.buttonClicked.connect { btn -> + selectedRecipeTypeId = catalystWidgetCache.entries.find { it.value == btn }?.key + currentScope?.let { refreshRecipes(emptyList(), currentSnapshotDir, it) } + } + } + + private fun refreshRecipes( + recipeDetails: List, + snapshotDir: VPath?, + scope: CoroutineScope + ) { + val detailsById = recipeDetails.associateBy { it.id } + val filteredRecipes = if (selectedRecipeTypeId != null) { + allRecipes.filter { it.recipeType == selectedRecipeTypeId } + } else { + allRecipes + } + val neededIds = filteredRecipes.map { it.id }.toSet() + + // Cancel icon loads for widgets that will be removed + recipeWidgetCache.forEach { (id, widget) -> + if (id !in neededIds) { + widget.cancelIconLoad() + } + } + + // Build set of widgets currently in layout + val widgetsInLayout = mutableSetOf() + for (i in 0 until recipeLayout.count()) { + recipeLayout.itemAt(i)?.widget()?.let { widgetsInLayout.add(it) } + } + + // Remove widgets no longer needed + val toRemove = recipeWidgetCache.keys.filter { it !in neededIds }.toList() + toRemove.forEach { id -> + recipeWidgetCache.remove(id)?.let { widget -> + recipeLayout.removeWidget(widget) + widget.disposeLater() + } + } + + // Remove any stale widgets (not in cache but still in layout) + widgetsInLayout.filter { it !in recipeWidgetCache.values }.forEach { w -> + recipeLayout.removeWidget(w) + w.disposeLater() + } + + // Hide unused cached widgets + recipeWidgetCache.forEach { (id, widget) -> + widget.isVisible = id in neededIds + } + + if (filteredRecipes.isEmpty()) { + recipeLayout.addWidget(QLabel("No recipes found.").apply { + styleSheet = "color: ${TColors.Subtext};" + }) + } else { + filteredRecipes.forEach { recipe -> + val detail = detailsById[recipe.id] + val widget = recipeWidgetCache.getOrPut(recipe.id) { + RecipeRow(recipe, detail, snapshotDir, controller, scope) + } + widget.isVisible = true + if (widget.parentWidget() != recipeContent) { + recipeLayout.addWidget(widget) + } + } + } + } + } + + private class RecipeRow( + private val recipe: RegistryRecipeSummary, + private val detail: RegistryRecipeDetail?, + snapshotDir: VPath?, + private val controller: Controller, + scope: CoroutineScope + ) : QFrame() { + private val iconLoadJob: Job? + + init { + val layout = QHBoxLayout(this) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(8) + + val iconLabel = QLabel().apply { + setFixedSize(28, 28) + alignment = Qt.Alignment(Qt.AlignmentFlag.AlignCenter) + } + + val recipeTypeLabel = QLabel(recipe.recipeType ?: "Recipe").apply { + font = QFont(font).apply { setBold(true) } + } + + layout.addWidget(iconLabel) + layout.addWidget(recipeTypeLabel) + layout.addStretch(1) + + val targetId = if (detail != null) { + val recipeRoot = parseJsonObject(detail.rawJson) + val outputs = asJsonArrayOrEmpty(recipeRoot?.get("outputs")) + outputs.firstOrNull()?.let { asJsonObjectOrNull(it) }?.let { primitiveContentOrNull(it.get("id")) } + } else null + + // Load recipe type icon + val recipeTypeIcon = recipe.recipeType?.let { rt -> + scope.launch { + val pixmap = withContext(Dispatchers.IO) { loadIcon("recipe_type:$rt", null, snapshotDir, 24) } + if (isActive && pixmap != null) { + iconLabel.pixmap = pixmap + } + } + } + + // Load output item icon as fallback + iconLoadJob = if (targetId != null && recipeTypeIcon == null) { + val outputId = targetId + scope.launch { + val pixmap = withContext(Dispatchers.IO) { loadIcon(outputId, null, snapshotDir, 24) } + if (isActive) { + if (pixmap != null) { + iconLabel.pixmap = pixmap + } else { + iconLabel.text = abbreviation(recipe.recipeType ?: "Recipe") + } + } + } + } else { + iconLabel.text = abbreviation(recipe.recipeType ?: "Recipe") + null + } + + frameShape = Shape.StyledPanel + setThemedStyle { + selector("QFrame") { + backgroundColor(TColors.Surface1) + border(1, TColors.Surface2) + borderRadius(3) + } + selector("QFrame:hover") { + backgroundColor(TColors.Surface2) + } + } + + setCursor(Qt.CursorShape.PointingHandCursor) + } + + private fun abbreviation(name: String): String { + val words = name.split(Regex("\\s+|[_-]")).filter { it.isNotBlank() } + return when { + words.size >= 2 -> (words[0].first().toString() + words[1].first().toString()).uppercase() + name.length >= 2 -> name.take(2).uppercase() + else -> name.uppercase() + } + } + + override fun mousePressEvent(event: QMouseEvent?) { + super.mousePressEvent(event) + recipe.recipeType?.let { recipeType -> + controller.detailPanel.selectRecipeType(recipeType) + } + } + + fun cancelIconLoad() { + iconLoadJob?.cancel() + } + } + + private class CatalystButton( + recipeType: RecipeTypeCatalyst, + private val catalystItem: RegistryItemSummary?, + private val snapshotDir: VPath?, + private val scope: CoroutineScope, + private val controller: Controller + ) : QToolButton() { + private var iconLoadJob: Job? = null + + init { + setFixedSize(SLOT_SIZE, SLOT_SIZE) + isCheckable = true + autoRaise = true + objectName = "registryBrowserSlot" + toolTip = catalystItem?.let { "${it.displayName ?: it.id}\n${it.id}" } ?: (recipeType.displayName + ?: recipeType.recipeTypeId) + + loadIcon() + + clicked.connect { + catalystItem?.id?.let { controller.selectItem(it) } + } + + setThemedStyle { + selector("QToolButton#registryBrowserSlot") { + background("transparent") + border() + padding(0) + } + selector("QToolButton#registryBrowserSlot:hover") { + background("transparent") + } + selector("QToolButton#registryBrowserSlot:checked") { + background("transparent") + border(1, TColors.Accent) + } + } + } + + private fun loadIcon() { + val item = catalystItem ?: return + iconLoadJob?.cancel() + val id = item.id + val texPath = item.texturePath + val snapDir = snapshotDir + iconLoadJob = scope.launch { + val pixmap = withContext(Dispatchers.IO) { loadIcon(id, texPath, snapDir, 64) } + if (isActive) { + if (pixmap != null) { + icon = QIcon(pixmap) + iconSize = QSize(SLOT_SIZE - 4, SLOT_SIZE - 4) + text = "" + } else { + icon = QIcon() + text = abbreviation(item) + } + } + } + } + + private fun abbreviation(item: RegistryItemSummary): String { + val base = item.displayName?.trim().takeUnless { it.isNullOrBlank() } ?: item.path + val words = base.split(Regex("\\s+|[_-]")).filter { it.isNotBlank() } + return when { + words.size >= 2 -> (words[0].first().toString() + words[1].first().toString()).uppercase() + base.length >= 2 -> base.take(2).uppercase() + else -> base.uppercase() + } + } + } + + private class RecipeValueButton( + values: List, + component: RecipeComponentLayout?, + snapshotDir: VPath?, + project: ProjectBase, + private val controller: Controller + ) : QToolButton() { + private val entries = values.ifEmpty { + listOf( + RenderedValue( + "value", + "placeholder", + component?.label ?: "empty", + 0, + component?.label + ) + ) + } + private val previews: List = entries.flatMap { value -> + when (value.valueType) { + "item" if value.refType == "item" -> listOf( + RecipeValuePreview( + id = value.id, + label = value.displayName ?: value.id, + texturePath = runCatching { RegistryDatabase.itemTexturePath(project, value.id) }.getOrNull(), + clickableItemId = value.id + ) + ) + + "item" if value.refType == "tag" -> runCatching { + RegistryDatabase.itemPreviewsForTag(project, value.id).map { + RecipeValuePreview( + id = it.id, + label = "#${value.id}", + texturePath = it.texturePath, + clickableItemId = it.id + ) + } + }.getOrDefault( + listOf(RecipeValuePreview("#${value.id}", "#${value.id}", null, null)) + ) + + else -> { + val preview = runCatching { + RegistryDatabase.customValuePreview( + project, + value.valueType, + value.id + ) + }.getOrNull() + listOf( + RecipeValuePreview( + id = value.id, + label = value.displayName ?: preview?.displayName ?: value.id, + texturePath = preview?.texturePath, + clickableItemId = null + ) + ) + } + } + } + private var currentIndex = 0 + + init { + val targetWidth = component?.width ?: 28 + val targetHeight = component?.height ?: 28 + setFixedSize(targetWidth, targetHeight) + autoRaise = true + render(snapshotDir) + if (previews.size > 1) { + val timer = QTimer(this).apply { + interval = 1000 + timeout.connect { + currentIndex = (currentIndex + 1) % previews.size + render(snapshotDir) + } + start() + } + destroyed.connect { timer.stop() } + } + toolTip = previews.joinToString("\n") { it.label } + clicked.connect { + currentPreview()?.clickableItemId?.let(controller::selectItem) + } + setThemedStyle { + selector("QToolButton") { + backgroundColor(TColors.Surface1) + border(1, TColors.Surface2) + borderRadius(3) + padding(0) + } + selector("QToolButton:hover") { + backgroundColor(TColors.Surface2) + } + } + } + + private fun render(snapshotDir: VPath?) { + val preview = currentPreview() + val pixmap = preview?.let { loadIcon(it.id, it.texturePath, snapshotDir, minOf(width(), height()) - 4) } + if (pixmap != null) { + icon = QIcon(pixmap) + iconSize = QSize(minOf(width(), height()) - 4, minOf(width(), height()) - 4) + text = "" + } else { + icon = QIcon() + text = when { + preview != null -> abbreviation(preview.label) + else -> "?" + } + } + } + + private fun currentPreview(): RecipeValuePreview? = + previews.getOrNull(currentIndex) + + private fun abbreviation(text: String): String { + val words = text.split(Regex("\\s+|[_:-]")).filter { it.isNotBlank() } + return when { + words.size >= 2 -> (words[0].first().toString() + words[1].first().toString()).uppercase() + text.isNotBlank() -> text.take(2).uppercase() + else -> "?" + } + } + } + + internal class SlotButton( + parent: QWidget?, + private val scope: CoroutineScope, + private val onItemSelected: (String) -> Unit + ) : QToolButton(parent) { + var itemId: String? = null + private set + private var itemTexturePath: String? = null + private var itemSnapshotDir: VPath? = null + internal var itemTooltipText: String = "" + internal var itemTooltipStyle: TTooltipStyle = TTooltipStyle() + private var pressPos: QPoint? = null + private var iconLoadJob: Job? = null + + init { + autoRaise = true + mouseTracking = false + clicked.connect { + val id = itemId + if (id != null) onItemSelected(id) + } + } + + override fun mousePressEvent(event: QMouseEvent?) { + if (event?.button() == Qt.MouseButton.LeftButton && itemId != null) { + pressPos = event.pos() + } + super.mousePressEvent(event) + } + + override fun mouseMoveEvent(event: QMouseEvent?) { + if (pressPos != null && event != null && itemId != null) { + val dx = event.pos().x() - pressPos!!.x() + val dy = event.pos().y() - pressPos!!.y() + if (dx * dx + dy * dy >= 16) { + pressPos = null + startDrag() + return + } + } + super.mouseMoveEvent(event) + } + + override fun mouseReleaseEvent(event: QMouseEvent?) { + pressPos = null + super.mouseReleaseEvent(event) + } + + private fun startDrag() { + TTooltip.hide() + val drag = QDrag(this) + val mimeData = QMimeData() + mimeData.setText(itemId!!) + drag.setMimeData(mimeData) + + val dragPixmap = createDragPixmap(itemId!!, itemTexturePath, itemSnapshotDir, itemTooltipStyle) + + val shiftX = 24 + val shiftY = 24 + + val padded = QPixmap(dragPixmap.width() + shiftX, dragPixmap.height() + shiftY) + padded.fill("transparent") + + val painter = QPainter(padded) + painter.drawPixmap(shiftX, shiftY, dragPixmap) + painter.end() + + drag.setPixmap(padded) + drag.setHotSpot(QPoint(0, 0)) + + drag.exec(Qt.DropAction.CopyAction) + } + + private fun createDragPixmap( + id: String, + texturePath: String?, + snapshotDir: VPath?, + tooltipStyle: TTooltipStyle + ): QPixmap { + val iconSize = 18 + val iconPixmap = loadIcon(id, texturePath, snapshotDir, iconSize) + return TTooltip.renderPixmap(id, tooltipStyle, iconPixmap, iconSize) + } + + fun setItem(item: RegistryItemSummary, selected: Boolean, snapshotDir: VPath?) { + itemId = item.id + itemTexturePath = item.texturePath + itemSnapshotDir = snapshotDir + itemTooltipStyle = itemTooltipStyleProvider(item) + isChecked = selected + itemTooltipText = buildString { + append(item.displayName ?: item.id) + append("\n") + append(item.id) + if (item.tags.isNotEmpty()) { + append("\n") + append(item.tags.joinToString(", ")) + } + } + setProperty("tt_style", itemTooltipStyle) + toolTip = itemTooltipText + iconLoadJob?.cancel() + + val cacheKey = "${item.id}|${item.texturePath}|64|${currentDpr(null)}|${snapshotDir?.toAbsolute()}" + val cached = pixmapCache[cacheKey] + if(cached != null) { + icon = QIcon(cached) + iconSize = QSize(32, 32) + text = "" + iconLoadJob = null + return + } + + iconLoadJob = scope.launch { + val pixmap = withContext(Dispatchers.IO) { loadIcon(item.id, item.texturePath, snapshotDir, 64) } + if (isActive) { + if (pixmap != null) { + icon = QIcon(pixmap) + iconSize = QSize(32, 32) + text = "" + } + } + } + } + + private fun abbreviation(item: RegistryItemSummary): String { + val base = item.displayName?.trim().takeUnless { it.isNullOrBlank() } ?: item.path + val words = base.split(Regex("\\s+|[_-]")).filter { it.isNotBlank() } + return when { + words.size >= 2 -> (words[0].first().toString() + words[1].first().toString()).uppercase() + base.length >= 2 -> base.take(2).uppercase() + else -> base.uppercase() + } + } + } + + companion object { + private const val SLOT_SIZE = 36 + private const val STATE_FILE_NAME = "registry-browser.json" + private val controllers = WeakHashMap() + private val recipeJson = Json { ignoreUnknownKeys = true } + private val pixmapCache = object : LinkedHashMap(256, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry?): Boolean = size > 512 + } + private val stateJson = Json { ignoreUnknownKeys = true; prettyPrint = false } + var itemTooltipStyleProvider: (RegistryItemSummary) -> TTooltipStyle = { item -> + minecraftTooltipStyleForRarity(item.rarity) + } + + @Serializable + private data class RegistryBrowserState( + val lastSelectedId: String? = null, + val lastSearchText: String? = null, + val lastPage: Int = 0 + ) + + private fun stateFileFor(project: ProjectBase): VPath = + project.projectDir.resolve(".tr").resolve(STATE_FILE_NAME) + + private fun loadState(project: ProjectBase): RegistryBrowserState = + runCatching { + val file = stateFileFor(project) + if (file.exists()) { + val text = file.readTextOrNull() + if (!text.isNullOrBlank()) { + return stateJson.decodeFromString(text) + } + } + RegistryBrowserState() + }.getOrDefault(RegistryBrowserState()) + + private fun saveState(project: ProjectBase, state: RegistryBrowserState) = + runCatching { + val file = stateFileFor(project) + val dir = file.parent() + if (!dir.exists()) dir.mkdirs() + file.writeTextAtomic(stateJson.encodeToString(state)) + } + + private fun parseJsonObject(raw: String): JsonObject? = + runCatching { asJsonObjectOrNull(recipeJson.parseToJsonElement(raw)) }.getOrNull() + + private fun loadIcon(id: String, texturePath: String?, snapshotDir: VPath?, size: Int): QPixmap? { + if (snapshotDir == null) return null + val dpr = currentDpr(QWidget(null)) + val physicalSize = (size * dpr).toInt() + + val cacheKey = "$id|$texturePath|$size|$dpr|${snapshotDir.toAbsolute()}" + pixmapCache[cacheKey]?.let { return it } + + val candidates = buildList { + val parts = id.split(':') + if (parts.size == 2) { + val namespace = parts[0] + val path = parts[1] + // Preferred candidates FIRST (overrides DB path) + add("icons/${namespace}/${path}.png") + add("icons/${namespace}_${path.replace('/', '_')}.png") + } + + texturePath?.let { add(it) } + + if (parts.size == 2) { + val namespace = parts[0] + val path = parts[1] + addAll( + listOf( + "assets/textures/${namespace}/item/${path}.png", + "assets/textures/${namespace}/block/${path}.png", + "assets/${namespace}/textures/item/${path}.png", + "assets/${namespace}/textures/block/${path}.png" + ) + ) + } + } + + for (relPath in candidates) { + val iconPath = snapshotDir.resolve(relPath) + if (iconPath.exists()) { + val pixmap = QPixmap() + if (pixmap.load(iconPath.toAbsolute().toString())) { + val scaled = scaledHighQuality(pixmap, physicalSize, physicalSize) + scaled.setDevicePixelRatio(dpr) + pixmapCache[cacheKey] = scaled + return scaled + } + } + } + return null + } + + private fun scaledHighQuality(src: QPixmap, targetW: Int, targetH: Int): QPixmap { + if (src.width() <= 32 && src.height() <= 32) { + return src.scaled(targetW, targetH, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation) + } + + var img = src.toImage() + while (img.width() / 2 >= targetW && img.height() / 2 >= targetH) { + img = img.scaled( + img.width() / 2, + img.height() / 2, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + } + + val finalImg = img.scaled(targetW, targetH, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + + return QPixmap.fromImage(finalImg) + } + + private fun loadTexture(textureRef: String?, snapshotDir: VPath?, width: Int, height: Int): QPixmap? { + if (textureRef.isNullOrBlank() || snapshotDir == null) return null + val cacheKey = "texture|$textureRef|$width|$height|${snapshotDir.toAbsolute()}" + pixmapCache[cacheKey]?.let { return it } + + val parts = textureRef.split(':', limit = 2) + if (parts.size != 2) return null + val namespace = parts[0] + val path = parts[1].removePrefix("textures/") + val texturePath = snapshotDir.resolve("assets/textures/$namespace/$path") + if (!texturePath.exists()) return null + val pixmap = QPixmap() + return if (pixmap.load(texturePath.toAbsolute().toString())) { + val scaled = QPixmap( + pixmap.scaled( + width, + height, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation + ) + ) + pixmapCache[cacheKey] = scaled + scaled + } else { + null + } + } + + private fun isNamespacedId(value: String): Boolean = + value.contains(':') && value.none(Char::isWhitespace) + + private fun asJsonObjectOrNull(element: JsonElement?): JsonObject? = element as? JsonObject + + private fun asJsonArrayOrEmpty(element: JsonElement?): JsonArray = + element as? JsonArray ?: JsonArray(emptyList()) + + private fun primitiveContentOrNull(element: JsonElement?): String? = + runCatching { (element as? JsonPrimitive)?.content }.getOrNull() + + private fun primitiveIntOrNull(element: JsonElement?): Int? = + primitiveContentOrNull(element)?.toIntOrNull() + + private fun primitiveLongOrNull(element: JsonElement?): Long? = + primitiveContentOrNull(element)?.toLongOrNull() + + private fun minecraftTooltipStyleForRarity(rarity: String?): TTooltipStyle { + fun color(hex: String, alpha: Int = 190): QColor = + QColor(hex).apply { setAlpha(alpha) } + + return when (rarity?.lowercase(Locale.ROOT)) { + "uncommon" -> TTooltipStyle( + borderTop = color("#ffff55"), + borderBottom = color("#bfa53f") + ) + "rare" -> TTooltipStyle( + borderTop = color("#55ffff"), + borderBottom = color("#2aa8c8") + ) + "epic" -> TTooltipStyle( + borderTop = color("#ff55ff"), + borderBottom = color("#9f3fd0") + ) + else -> TTooltipStyle() + } + } + } + + private data class RecipeBinding( + val componentId: String, + val entries: List + ) + + private data class RecipeComponentLayout( + val id: String, + val category: String, + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val label: String? + ) + + private data class RenderedValue( + val refType: String, + val valueType: String, + val id: String, + val amount: Long, + val displayName: String? + ) + + private data class RecipeValuePreview( + val id: String, + val label: String, + val texturePath: String?, + val clickableItemId: String? + ) +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRootDirectoryProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRootDirectoryProvider.kt new file mode 100644 index 0000000..de1ca65 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectRootDirectoryProvider.kt @@ -0,0 +1,24 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.registry.Registrable + +/** + * Provides root directories for the "Project" view mode. + */ +interface ProjectRootDirectoryProvider : Registrable { + fun getRootDirectories(project: ProjectBase): List +} + +/** + * Helper to create a simple provider for a single directory. + */ +fun projectRootDirectory(id: String, relativePath: String, label: String): ProjectRootDirectoryProvider { + return object : ProjectRootDirectoryProvider { + override val id: String = id + override fun getRootDirectories(project: ProjectBase): List { + val path = project.projectDir.resolve(relativePath) + return if (path.isDir()) listOf(ProjectFilesNodeSpec(path, label)) else emptyList() + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectTreeDirectoryPresentation.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectTreeDirectoryPresentation.kt new file mode 100644 index 0000000..ac04600 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/ProjectTreeDirectoryPresentation.kt @@ -0,0 +1,75 @@ +package io.github.tritium_launcher.launcher.ui.project.sidebar + +import io.github.tritium_launcher.launcher.core.project.ProjectBase +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.io.VPath +import io.github.tritium_launcher.launcher.registry.Registrable +import io.github.tritium_launcher.launcher.ui.project.editor.file.FileTypeDescriptor + +/** + * Extension point for customizing how a specific directory is presented in the project files tree. + * + * Implementations can target one or more directories and modify ordering or display names for the + * immediate children shown within that directory. + */ +interface ProjectTreeDirectoryPresentation : Registrable { + val order: Int get() = 0 + + fun matches(directory: VPath, project: ProjectBase): Boolean + + fun sortChildren(directory: VPath, children: List, project: ProjectBase): List = children + + fun displayName( + directory: VPath, + child: VPath, + project: ProjectBase, + primaryType: FileTypeDescriptor?, + currentDisplayName: String + ): String = currentDisplayName +} + +object ProjectTreeDirectoryPresentations { + fun all(): List = listOf(ConfigDirectoryPresentation) +} + +private object ConfigDirectoryPresentation : ProjectTreeDirectoryPresentation { + override val id: String = "config_directory" + override val order: Int = 0 + + override fun matches(directory: VPath, project: ProjectBase): Boolean = + directory.toAbsolute() == project.projectDir.resolve("config").toAbsolute() + + override fun sortChildren(directory: VPath, children: List, project: ProjectBase): List { + val mode = CoreSettingValues.projectFilesConfigSortMode + val comparator = when (mode) { + CoreSettingValues.ProjectFilesConfigSortMode.Alphabetical -> + compareBy({ !it.isDir() }, { displayStem(it).lowercase() }, { it.fileName().lowercase() }) + CoreSettingValues.ProjectFilesConfigSortMode.FileType -> + compareBy({ !it.isDir() }, { fileTypeSortKey(it, project) }, { displayStem(it).lowercase() }, { it.fileName().lowercase() }) + } + return children.sortedWith(comparator) + } + + override fun displayName( + directory: VPath, + child: VPath, + project: ProjectBase, + primaryType: FileTypeDescriptor?, + currentDisplayName: String + ): String { + if (child.isDir()) return currentDisplayName + return displayStem(child) + } + + private fun fileTypeSortKey(path: VPath, project: ProjectBase): String { + val specific = FileTypeDescriptor.matching(path, project).firstOrNull { it.id != "modcfg" } + val resolved = specific ?: FileTypeDescriptor.primary(path, project) + return resolved?.displayName?.lowercase() ?: path.extension().lowercase() + } + + private fun displayStem(path: VPath): String { + val name = path.fileName() + val dot = name.lastIndexOf('.') + return if (dot <= 0) name else name.substring(0, dot) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelMngr.kt index c4715d8..60f952a 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelMngr.kt @@ -3,10 +3,12 @@ package io.github.tritium_launcher.launcher.ui.project.sidebar import io.github.tritium_launcher.launcher.connect import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.registry.DeferredRegistryBuilder import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread import io.github.tritium_launcher.launcher.ui.project.ProjectTaskMngr +import io.github.tritium_launcher.launcher.ui.project.editor.EditorArea import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.TIcons import io.github.tritium_launcher.launcher.ui.theme.qt.icon @@ -18,6 +20,12 @@ import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.widg import io.qt.core.* import io.qt.gui.* import io.qt.widgets.* +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlin.math.abs /** @@ -28,8 +36,9 @@ import kotlin.math.abs class SidePanelMngr( private val project: ProjectBase, private val parent: QMainWindow, + private val editorArea: EditorArea, private val onStateChanged: () -> Unit = {}, - private val onDockCreated: (String, DockWidget) -> Unit = { _, _ -> }, + private val onAllProvidersBuilt: () -> Unit = {}, ) { data class PersistedDockState( val id: String, @@ -44,6 +53,8 @@ class SidePanelMngr( private val providersById = LinkedHashMap() private val dockStyleDisposers = mutableMapOf Unit>() + fun getDock(id: String): DockWidget? = docks[id] + private val leftBar = createSidebar(Qt.ToolBarArea.LeftToolBarArea) private val rightBar = createSidebar(Qt.ToolBarArea.RightToolBarArea) private val bottomBar = createSidebar(Qt.ToolBarArea.BottomToolBarArea) @@ -53,7 +64,7 @@ class SidePanelMngr( private var leftSpacerAction: QAction? = null private var rightSpacerAction: QAction? = null private var bottomTaskSpacerAction: QAction? = null - private var bottomTaskUnsubscribe: (() -> Unit)? = null + private var bottomTaskUnsubscribe: Job? = null private var bottomTaskWidgetAction: QAction? = null private lateinit var bottomTaskWidget: QWidget private lateinit var bottomTaskLabel: QLabel @@ -65,36 +76,27 @@ class SidePanelMngr( parent.setProperty("sidebar.separatorColor", TColors.Surface0) parent.setThemedStyle { val dockSurface = TColors.Surface0 - val dockBorder = TColors.Surface2 + val dockBorder = TColors.Surface1 + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() - selector("QToolBar") { - backgroundColor(dockSurface) - border() - } - selector("#leftDockBar") { - backgroundColor(dockSurface) - border() - border(1, dockBorder, "right", "solid") - } - selector("#rightDockBar") { - backgroundColor(dockSurface) - border() - border(1, dockBorder, "left", "solid") - } - selector("#bottomDockBar") { - backgroundColor(dockSurface) - border() - border(1, dockBorder, "top", "solid") - } selector("QMainWindow::separator") { - backgroundColor(dockSurface) + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(dockSurface) + } any("image", "none") - border() + any("border", "1px solid $dockBorder") minWidth(1) minHeight(1) } selector("#dockTitleBar") { - backgroundColor(dockSurface) + if (isBgImageSet) { + backgroundColor("rgba(0, 0, 0, 40)") + } else { + backgroundColor(dockSurface) + } border() } selector("#dockTitleLabel") { @@ -110,7 +112,7 @@ class SidePanelMngr( } selector("#bottomTaskProgress") { backgroundColor(TColors.Surface2) - border(1, TColors.Surface2) + border(1, TColors.Surface1) borderRadius(3) } selector("#bottomTaskProgress::chunk") { @@ -119,28 +121,33 @@ class SidePanelMngr( } } installBottomTaskIndicator() - bottomTaskUnsubscribe = ProjectTaskMngr.addListener { + bottomTaskUnsubscribe = ProjectTaskMngr.taskChanges.onEach { runOnGuiThread { refreshBottomTaskIndicator() } - } + }.launchIn(CoroutineScope(Dispatchers.Main + CoroutineName("ProjectTaskMngr"))) parent.destroyed.connect { - bottomTaskUnsubscribe?.invoke() + bottomTaskUnsubscribe?.cancel() bottomTaskUnsubscribe = null } DeferredRegistryBuilder(BuiltinRegistries.SidePanel) { providers -> runOnGuiThread { buildProviders(providers.sortedBy { it.order }) + onAllProvidersBuilt() } } } + /** + * Creates a [QToolBar] from [Qt.ToolBarArea] + */ private fun createSidebar(area: Qt.ToolBarArea): QToolBar = QToolBar().apply { - objectName = when (area) { - Qt.ToolBarArea.LeftToolBarArea -> "leftDockBar" - Qt.ToolBarArea.RightToolBarArea -> "rightDockBar" + val areaId = when (area) { + Qt.ToolBarArea.LeftToolBarArea -> "leftDockBar" + Qt.ToolBarArea.RightToolBarArea -> "rightDockBar" Qt.ToolBarArea.BottomToolBarArea -> "bottomDockBar" else -> "leftDockBar" } + objectName = areaId isMovable = false isFloatable = false val vertical = area != Qt.ToolBarArea.BottomToolBarArea @@ -154,6 +161,39 @@ class SidePanelMngr( iconSize = QSize(16, 16) } setThemedStyle { + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() + val dockSurface = TColors.Surface0 + val dockBorder = TColors.Surface1 + + selector("QToolBar") { + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(dockSurface) + } + border() + when (areaId) { + "leftDockBar" -> any("border-right", "1px solid $dockBorder") + "rightDockBar" -> any("border-left", "1px solid $dockBorder") + "bottomDockBar" -> any("border-top", "1px solid $dockBorder") + } + } + selector("QToolButton") { + backgroundColor("transparent") + border() + borderRadius(3) + margin(1, 2, 1, 2) + } + selector("QToolButton:hover") { + backgroundColor(TColors.Surface1) + } + selector("QToolButton:checked") { + backgroundColor(TColors.Surface2) + } + selector("QToolButton:pressed") { + backgroundColor(TColors.Surface2) + } selector("QToolBar::handle") { any("image", "none") any("width", "0px") @@ -179,6 +219,9 @@ class SidePanelMngr( } } + /** + * Build [QDockWidget]s from registered [SidePanelProvider]'s + */ private fun buildProviders(providers: List) { for(p in providers) { try { @@ -188,14 +231,24 @@ class SidePanelMngr( dock.features = QDockWidget.DockWidgetFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) val persisted = pendingDockStates[p.id] - val initialArea = persisted?.area ?: normalizeDockArea(p.preferredArea) + val initialArea = persisted?.area ?: run { + val pref = p.preferredArea + if (p.allowedDockAreas.contains(pref)) pref else p.allowedDockAreas.firstOrNull() ?: normalizeDockArea(pref) + } - val action = QAction(p.icon, "").apply { + val action = QAction(p.icon ?: QIcon(), "").apply { toolTip = p.displayName isCheckable = true isChecked = persisted?.visible ?: dock.isVisible triggered.connect { checked -> if(checked) { + val area = parent.dockWidgetArea(dock) + docks.values.forEach { otherDock -> + if (otherDock != dock && parent.dockWidgetArea(otherDock) == area && otherDock.isVisible) { + val otherProvider = providersById[otherDock.objectName] + if (otherProvider?.allowSplit == false || !p.allowSplit) otherDock.hide() + } + } dock.show() dock.raise() } else { @@ -211,17 +264,17 @@ class SidePanelMngr( parent.addDockWidget(initialArea, dock) addDockActionToToolbar(initialArea, action, p.id) - setDockVisibility(dock, action, persisted?.visible ?: true) + setDockVisibility(dock, action, persisted?.visible ?: p.defaultVisible) dock.objectName = p.id applyDockAreaChrome(dock) - dock.applyIcon(p.icon) + p.icon?.let { dock.applyIcon(it) } setupTitleBar(dock, p, initialArea) dock.destroyed.connect { dockStyleDisposers.remove(dock)?.invoke() } - onDockCreated(p.id, dock) + p.onDockCreated(project, editorArea, dock, onStateChanged) onStateChanged() } catch (t: Throwable) { logger.warn("Failed to create side panel {}", p.id, t) @@ -229,6 +282,9 @@ class SidePanelMngr( } } + /** + * Create title bar for specified [SidePanelProvider] + */ private fun setupTitleBar(dock: DockWidget, provider: SidePanelProvider, currentArea: Qt.DockWidgetArea) { val titleBar = widget().apply { objectName = "dockTitleBar" } val layout = hBoxLayout(titleBar) { @@ -236,10 +292,16 @@ class SidePanelMngr( widgetSpacing = 5 } - layout.addWidget(label { pixmap = provider.icon.pixmap(16,16) ?: QPixmap() }) + layout.addWidget(label { objectName = "dockTitleBarIcon"; pixmap = provider.icon?.pixmap(16, 16) ?: QPixmap() }) layout.addWidget(label(provider.displayName) { objectName = "dockTitleLabel" }) layout.addStretch() + if (provider is SidePanelTitleBarAccessoryProvider) { + provider.createTitleBarAccessory(project, dock, onStateChanged)?.let { accessory -> + layout.addWidget(accessory) + } + } + val toolBtn = toolButton { icon = TIcons.SmallMenu.icon iconSize = QSize(16, 16) @@ -261,7 +323,7 @@ class SidePanelMngr( } val menu = QMenu(toolBtn) - val areas = mapOf( + val areas = listOf( "Move to Left" to Qt.DockWidgetArea.LeftDockWidgetArea, "Move to Right" to Qt.DockWidgetArea.RightDockWidgetArea, "Move to Bottom" to Qt.DockWidgetArea.BottomDockWidgetArea, @@ -269,6 +331,8 @@ class SidePanelMngr( for((label, area) in areas) { if(area == currentArea) continue + // Only show moves that the provider allows + if (!provider.allowedDockAreas.contains(area)) continue menu.addAction(label)?.triggered?.connect { moveDock(dock, provider, area) } } @@ -277,8 +341,13 @@ class SidePanelMngr( dock.setTitleBarWidget(titleBar) } + /** + * Moves a provided [DockWidget] to a different [Qt.DockWidgetArea] + */ private fun moveDock(dock: DockWidget, provider: SidePanelProvider, newArea: Qt.DockWidgetArea) { val area = normalizeDockArea(newArea) + // Respect provider's allowed dock areas + if (!provider.allowedDockAreas.contains(area)) return parent.addDockWidget(area, dock) val action = dockActions[provider.id] ?: return @@ -294,6 +363,9 @@ class SidePanelMngr( onStateChanged() } + /** + * Adds an action to [SidePanelProvider] toolbar + */ private fun addDockActionToToolbar(area: Qt.DockWidgetArea, action: QAction, providerId: String) { val toolbar = toolbarForDockArea(area) if (area == Qt.DockWidgetArea.BottomDockWidgetArea) { @@ -340,28 +412,55 @@ class SidePanelMngr( else -> Qt.DockWidgetArea.LeftDockWidgetArea } + /** + * Style a provided [DockWidget] + */ private fun applyDockAreaChrome(dock: DockWidget) { dockStyleDisposers.remove(dock)?.invoke() dockStyleDisposers[dock] = dock.setThemedStyle { val dockSurface = TColors.Surface0 + val dockBorder = TColors.Surface2 + val bgImage = CoreSettingValues.uiBackgroundImage + val isBgImageSet = !bgImage.isNullOrBlank() + selector("QDockWidget") { - backgroundColor(dockSurface) + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(dockSurface) + } border() } selector("#dockTitleBar") { - backgroundColor(dockSurface) + if (isBgImageSet) { + backgroundColor("rgba(0, 0, 0, 40)") + } else { + backgroundColor(dockSurface) + } border() } selector("#dockTitleLabel") { color(TColors.Text) } selector("QDockWidget > QWidget") { - backgroundColor(dockSurface) + if (isBgImageSet) { + backgroundColor("transparent") + } else { + backgroundColor(dockSurface) + } border() } + selector("QDockWidget QTreeView, QDockWidget QTreeWidget, QDockWidget QListView, QDockWidget QListWidget") { + if (isBgImageSet) { + backgroundColor("transparent") + } + } } } + /** + * Bind actions to a [SidePanelProvider] using its ID + */ private fun bindDockActionWidget(toolbar: QToolBar, action: QAction, providerId: String) { fun install() { val button = toolbar.widgetForAction(action) as? QToolButton ?: return @@ -374,6 +473,9 @@ class SidePanelMngr( QTimer.singleShot(0) { install() } } + /** + * Installs a Dragging event filter to enable moving [SidePanelProvider] to another area + */ private fun installDockButtonDrag(button: QToolButton, providerId: String) { button.installEventFilter(object : QObject(button) { private var pressPos: QPoint? = null @@ -409,6 +511,9 @@ class SidePanelMngr( }) } + /** + * Drag helper for [installDockButtonDrag] + */ private fun startDockButtonDrag(button: QToolButton, providerId: String) { val mime = QMimeData().apply { setData(dockDragMimeType, QByteArray(providerId.toByteArray())) } val drag = QDrag(button) @@ -418,6 +523,9 @@ class SidePanelMngr( drag.exec() } + /** + * Drag helper for [installDockButtonDrag] + */ private fun installSidebarDropTarget(toolbar: QToolBar, area: Qt.DockWidgetArea) { toolbar.acceptDrops = true toolbar.installEventFilter(object : QObject(toolbar) { @@ -452,6 +560,9 @@ class SidePanelMngr( }) } + /** + * Gets a dock ID from [QMimeData] + */ private fun extractDockId(mimeData: QMimeData?): String? { val md = mimeData ?: return null if(!md.hasFormat(dockDragMimeType)) return null @@ -463,12 +574,26 @@ class SidePanelMngr( return id.takeIf { it.isNotBlank() } } + /** + * Moves a dock using its ID + */ private fun moveDockById(dockId: String, area: Qt.DockWidgetArea) { val dock = docks[dockId] ?: return val provider = providersById[dockId] ?: return moveDock(dock, provider, area) } + /** + * Toggles a dock widget by its provider ID + */ + fun toggleDock(id: String) { + val action = dockActions[id] ?: return + action.trigger() + } + + /** + * Shows or hides specified [DockWidget] + */ private fun setDockVisibility(dock: DockWidget, action: QAction, visible: Boolean) { if (visible) { dock.show() @@ -479,6 +604,9 @@ class SidePanelMngr( action.isChecked = visible } + /** + * Installs an Active Task indicator to the bottom toolbar + */ private fun installBottomTaskIndicator() { val spacer = QWidget(bottomBar).apply { sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) @@ -515,6 +643,9 @@ class SidePanelMngr( refreshBottomTaskIndicator() } + /** + * Refresh the active task indicator + */ private fun refreshBottomTaskIndicator() { if (!::bottomTaskWidget.isInitialized) return @@ -567,8 +698,14 @@ class SidePanelMngr( parent.updateGeometry() } + /** + * Returns active dock widgets + */ fun dockWidgets(): Map = HashMap(docks) + /** + * Persist docks' states + */ fun captureState(): List { return docks.entries.map { (id, dock) -> PersistedDockState( @@ -579,6 +716,9 @@ class SidePanelMngr( } } + /** + * Restores dock states + */ fun restoreState(states: List) { pendingDockStates.clear() states.forEach { state -> diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelProvider.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelProvider.kt index c041ea5..2abf858 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelProvider.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/sidebar/SidePanelProvider.kt @@ -2,21 +2,52 @@ package io.github.tritium_launcher.launcher.ui.project.sidebar import io.github.tritium_launcher.launcher.core.project.ProjectBase import io.github.tritium_launcher.launcher.registry.Registrable +import io.github.tritium_launcher.launcher.ui.project.editor.EditorArea import io.qt.core.Qt import io.qt.gui.QIcon +import io.qt.widgets.QWidget /** * Provides a dockable side panel for a project window. */ interface SidePanelProvider: Registrable { + /** + * Which dock areas this side panel is allowed to be placed in. Defaults to left/right/bottom for + * backwards compatibility. + */ + val allowedDockAreas: Set + get() = setOf( + Qt.DockWidgetArea.LeftDockWidgetArea, + Qt.DockWidgetArea.RightDockWidgetArea, + Qt.DockWidgetArea.BottomDockWidgetArea + ) + val displayName: String - val icon: QIcon + var icon: QIcon? val order: Int val closeable: Boolean get() = true val floatable: Boolean get() = true val preferredArea: Qt.DockWidgetArea get() = Qt.DockWidgetArea.LeftDockWidgetArea + val allowSplit: Boolean get() = true + val defaultVisible: Boolean get() = false + /** + * Create [DockWidget] from provided [ProjectBase] + */ fun create(project: ProjectBase): DockWidget + + /** + * Called after the dock widget is created and added to the window. + * Allows the provider to set up panel-specific behavior without + * the window having to hardcode per-panel logic. + * + * @param onStateChanged callback for signaling that serializable state has changed. + */ + fun onDockCreated(project: ProjectBase, editorArea: EditorArea, dock: DockWidget, onStateChanged: () -> Unit) {} +} + +interface SidePanelTitleBarAccessoryProvider { + fun createTitleBarAccessory(project: ProjectBase, dock: DockWidget, onStateChanged: () -> Unit): QWidget? } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/toolbar/icon/FileContext.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/toolbar/icon/FileContext.kt deleted file mode 100644 index a8d4757..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/project/toolbar/icon/FileContext.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.github.tritium_launcher.launcher.ui.project.toolbar.icon - -import io.github.tritium_launcher.launcher.core.project.ProjectBase -import io.github.tritium_launcher.launcher.core.project.ProjectMngr -import io.github.tritium_launcher.launcher.io.VPath - -data class FileContext( - val project: ProjectBase, - val path: VPath -) { - val isInRoot: Boolean - get() = path.parent().toAbsolute() == project.path - - val isDirectory: Boolean - get() = path.isDir() - - val isProjectName: Boolean - get() = path.isFileName(project.name) - - val hasSibling: (String) -> Boolean = { siblingName -> - path.parent().list().any { it.fileName() == siblingName } - } - - companion object { - fun fromFile(file: VPath): FileContext { - val project = ProjectMngr.activeProject - ?: error("No active project available for FileContext") - return FileContext(project, file) - } - } -} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/settings/SettingsView.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/settings/SettingsView.kt index 4d37926..28fccae 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/settings/SettingsView.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/settings/SettingsView.kt @@ -1,12 +1,15 @@ package io.github.tritium_launcher.launcher.ui.settings import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys +import io.github.tritium_launcher.launcher.keymap.KeymapMngr import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.m import io.github.tritium_launcher.launcher.onClicked import io.github.tritium_launcher.launcher.settings.* import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.InfoLineEditWidget import io.github.tritium_launcher.launcher.ui.widgets.TPushButton import io.github.tritium_launcher.launcher.ui.widgets.TToggleSwitch @@ -16,6 +19,10 @@ import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.vBox import io.qt.core.Qt import io.qt.core.Qt.ItemDataRole.UserRole import io.qt.widgets.* +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json /** * Reusable Settings UI for browsing categories and editing setting values. @@ -76,6 +83,8 @@ class SettingsView : QWidget() { init { objectName = "settingsView" + AnimatedScrollController.attach(categoryTree) + AnimatedScrollController.attach(settingsScroll) val mainLayout = hBoxLayout(this) { contentsMargins = 0.m @@ -91,14 +100,13 @@ class SettingsView : QWidget() { mainLayout.addWidget(splitter, 1) connectSignals() - reload() setThemedStyle { selector("#settingsView") { backgroundColor(TColors.Surface0) } selector("#settingsNav") { backgroundColor(TColors.Surface1) } selector("#settingsTree") { backgroundColor(TColors.Surface1) - border(1, TColors.Surface2) + border(1, TColors.Surface1) } selector("#settingsHeaderTitle") { fontSize(16); fontWeight(600) } selector("#settingsHeaderDesc") { fontSize(11); color(TColors.Subtext) } @@ -108,13 +116,13 @@ class SettingsView : QWidget() { selector("#settingsEmpty") { fontSize(12); color(TColors.Subtext) } selector("QLineEdit#settingsSearchInput") { backgroundColor(TColors.Surface1) - border(1, TColors.Surface2) + border(1, TColors.Surface1) borderRadius(4) padding(4, 6, 4, 6) } selector("QLineEdit#settingsInput") { backgroundColor(TColors.Surface1) - border(1, TColors.Surface2) + border(1, TColors.Surface1) borderRadius(4) padding(4, 6, 4, 6) } @@ -373,6 +381,7 @@ class SettingsView : QWidget() { clearLayout(settingsLayout) rowByNode.clear() currentRootNodes = emptyList() + var hasFillHeightRow = false if (settings.isEmpty()) { val emptyLabel = label(emptyMessage) { @@ -389,8 +398,14 @@ class SettingsView : QWidget() { currentRootNodes = roots val visited = HashSet>() - roots.forEach { buildSettingRecursive(it, 0, visited) } - settingsLayout.addStretch(1) + roots.forEach { + if (buildSettingRecursive(it, 0, visited)) { + hasFillHeightRow = true + } + } + if (!hasFillHeightRow) { + settingsLayout.addStretch(1) + } refreshAll() } @@ -402,14 +417,18 @@ class SettingsView : QWidget() { * @param indent Depth-based indentation level. * @param visited Set used to prevent duplicate/cyclic traversal. */ - private fun buildSettingRecursive(node: SettingNode<*>, indent: Int, visited: MutableSet>) { - if (!visited.add(node)) return + private fun buildSettingRecursive(node: SettingNode<*>, indent: Int, visited: MutableSet>): Boolean { + if (!visited.add(node)) return false val row = buildSettingRow(node, indent) rowByNode[node] = row - settingsLayout.addWidget(row.container) + settingsLayout.addWidget(row.container, if (row.fillHeight) 1 else 0) + var hasFillHeight = row.fillHeight node.children.forEach { child -> - buildSettingRecursive(child.node, indent + 1, visited) + if (buildSettingRecursive(child.node, indent + 1, visited)) { + hasFillHeight = true + } } + return hasFillHeight } /** @@ -506,6 +525,9 @@ class SettingsView : QWidget() { private fun applyStagedChanges() { if (pendingValues.isEmpty()) return val staged = pendingValues.entries.toList() + val stagedKeymapDraftRaw = staged.firstOrNull { (nodeAny, _) -> + nodeAny.key == CoreSettingKeys.KeymapActionsOverview + }?.value as? String staged.forEach { (nodeAny, valueAny) -> val node = nodeAny as SettingNode val validation = SettingsMngr.updateValue(node, valueAny) @@ -515,6 +537,17 @@ class SettingsView : QWidget() { pendingValues.remove(nodeAny) } } + if (!stagedKeymapDraftRaw.isNullOrBlank()) { + runCatching { + Json.decodeFromString( + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + stagedKeymapDraftRaw + ) + }.onSuccess { overrides -> + KeymapMngr.applyOverridesFromStrings(overrides) + KeymapMngr.reloadWithPersistence() + } + } updateActionButtons() refreshAll() } @@ -836,6 +869,22 @@ class SettingsView : QWidget() { setContentsMargins(indent * 16, 4, 4, 4) widgetSpacing = 4 } + val widget = createWidget(descriptor, node) + + if (descriptor.fullWidth) { + layout.addWidget(widget, 1) + container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + return SettingRow( + node = node, + container = container, + refresh = { + (widget as? RefreshableSettingWidget)?.refreshFromSettingValue() + }, + setEnabled = { enabled -> container.isEnabled = enabled }, + fillHeight = descriptor.fullHeight + ) + } val topRow = QWidget() val topLayout = hBoxLayout(topRow) { @@ -853,8 +902,6 @@ class SettingsView : QWidget() { minimumHeight = 25 } - val widget = createWidget(descriptor, node) - topLayout.addWidget(title, 1) topLayout.addStretch(1) topLayout.addWidget(widget, 0) @@ -889,7 +936,8 @@ class SettingsView : QWidget() { (widget as? RefreshableSettingWidget)?.refreshFromSettingValue() resetBtn.isEnabled = current != descriptor.defaultValue }, - setEnabled = { enabled -> container.isEnabled = enabled } + setEnabled = { enabled -> container.isEnabled = enabled }, + fillHeight = false ) } @@ -954,6 +1002,7 @@ class SettingsView : QWidget() { val node: SettingNode<*>, val container: QWidget, val refresh: () -> Unit, - val setEnabled: (Boolean) -> Unit + val setEnabled: (Boolean) -> Unit, + val fillHeight: Boolean = false ) } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/QtElementProperties.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/QtElementProperties.kt index 84527eb..dbe9697 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/QtElementProperties.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/QtElementProperties.kt @@ -4,6 +4,9 @@ import io.qt.core.QPoint import io.qt.widgets.QToolTip import io.qt.widgets.QWidget +/** + * Adds a property to this [QWidget] declaring it invalid + */ fun QWidget.setInvalid(state: Boolean, msg: String? = null) { this.setProperty("invalid", state) this.style()?.polish(this) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TColors.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TColors.kt index 4aeca7b..b665d6e 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TColors.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TColors.kt @@ -5,6 +5,7 @@ import io.qt.gui.QColor /** * Provides all colors provided by Themes. */ +@Suppress("unused") object TColors { object Editor { @@ -25,6 +26,7 @@ object TColors { val SelectedUI get() = hex("SelectedUI") val Subtext get() = hex("Subtext") val Accent get() = hex("Accent") + val Unsaved get() = hex("Unsaved") val Green get() = hex("Green") val Warning get() = hex("Warning") val Error get() = hex("Error") @@ -49,7 +51,31 @@ object TColors { val Tag get() = hex("Syntax.Tag") val Attribute get() = hex("Syntax.Attribute") val Operator get() = hex("Syntax.Operator") + val Property get() = hex("Syntax.Property") + val Namespace get() = hex("Syntax.Namespace") + val Macro get() = hex("Syntax.Macro") val Default get() = hex("Syntax.Default") + + fun tokenColor(tokenType: String): QColor? = when (tokenType) { + "String", "string" -> qColor("Syntax.String") + "Key", "key" -> qColor("Syntax.Key") + "Keyword", "keyword", "modifier" -> qColor("Syntax.Keyword") + "Number", "number" -> qColor("Syntax.Number") + "Punctuation", "punctuation", "operator" -> qColor("Syntax.Punctuation") + "Comment", "comment" -> qColor("Syntax.Comment") + "Function", "function", "method" -> qColor("Syntax.Function") + "Type", "type", "class", "interface", + "struct", "enum", "typeParameter" -> qColor("Syntax.Type") + "Variable", "variable", "parameter" -> qColor("Syntax.Variable") + "Constant", "constant", "enumMember" -> qColor("Syntax.Constant") + "Tag", "tag" -> qColor("Syntax.Tag") + "Attribute", "attribute" -> qColor("Syntax.Attribute") + "Operator" -> qColor("Syntax.Operator") + "Property", "property" -> qColor("Syntax.Property") + "Namespace", "namespace" -> qColor("Syntax.Namespace") + "Macro", "macro" -> qColor("Syntax.Macro") + else -> qColor("Syntax.Default") + } } fun hex(key: String): String = ThemeMngr.getColorHex(key) ?: "#ffffff" diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TIcons.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TIcons.kt index dd22d21..ad25a89 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TIcons.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TIcons.kt @@ -1,30 +1,20 @@ package io.github.tritium_launcher.launcher.ui.theme import io.github.tritium_launcher.launcher.currentDpr -import io.github.tritium_launcher.launcher.qs import io.github.tritium_launcher.launcher.referenceWidget -import io.qt.core.Qt -import io.qt.gui.QIcon +import io.github.tritium_launcher.launcher.ui.theme.TIcons.pix import io.qt.gui.QPixmap -import io.qt.widgets.QWidget import java.io.File import java.nio.file.Files -import java.util.concurrent.ConcurrentHashMap -import kotlin.math.ceil + /** * Default icons used throughout Tritium, including get methods. */ object TIcons { - private val cached = ConcurrentHashMap() - - init { - ThemeMngr.addListener { - cached.clear() - } - } val Tritium get() = pix("ui/tritium", 16, 16) + val TritiumGrayscale get() = pix("ui/tritium_grayscale", 16, 16) /* File Icons */ val File get() = pix("file/file", 16, 16) @@ -59,31 +49,45 @@ object TIcons { val Schematic get() = pix("file/schematic", 16, 16) val NBT get() = pix("file/nbt", 16, 16) - // Menu Icons - val CurseForge get() = pix("ui/curseforge") - val Modrinth get() = pix("ui/modrinth") + /* Menu Icons */ + + val CurseForge get() = pix("ui/curseforge", 16, 16) + val Modrinth get() = pix("ui/modrinth", 64, 64) - val Fabric get() = pix("ui/fabric") - val NeoForge get() = pix("ui/neoforge") + /** Key constants for account service icons — pass to [pix] with a target size. */ + const val CURSEFORGE = "ui/curseforge" + const val MODRINTH = "ui/modrinth" + const val MICROSOFT = "dashboard/microsoft" - val QuestionMark get() = pix("ui/question") + val Fabric get() = pix("ui/fabric", 16, 16) + val NeoForge get() = pix("ui/neoforge", 16, 16) + + val Prism get() = pix("ui/prism_launcher", 16, 16) + val GDL get() = pix("ui/gd_launcher", 16, 16) + val ATL get() = pix("ui/at_launcher", 16, 16) + val CFPack get() = pix("ui/curseforge_pack", 16, 16) + val MRPack get() = pix("ui/modrinth_pack", 16, 16) + + val QuestionMark get() = pix("ui/question", 16, 16) + val Unknown get() = pix("ui/unknown_question", 16, 16) val NewProject get() = pix("dashboard/new_project", 32, 32) val Import get() = pix("dashboard/folder_import", 32, 32) val Git get() = pix("dashboard/git", 32, 32) val Search get() = pix("dashboard/search", 32, 32) - val ListView get() = pix("dashboard/list_view") - val GridView get() = pix("dashboard/grid_view") - val CompactView get() = pix("dashboard/compact_view") - val Microsoft get() = pix("dashboard/microsoft", 32, 32) + val ListView get() = pix("dashboard/list_view", 16, 16) + val GridView get() = pix("dashboard/grid_view", 16, 16) + val CompactView get() = pix("dashboard/compact_view", 16, 16) + val Microsoft get() = pix("dashboard/microsoft", 64, 64) val SmallGrass get() = pix("dashboard/tiny_grass", 32, 32) - val Build get() = pix("menu/build", 16, 16) - val Run get() = pix("menu/run", 16, 16) - val Rerun get() = pix("menu/rerun", 16, 16) - val Stop get() = pix("menu/stop", 16, 16) - val ForceStop get() = pix("menu/force_stop", 16, 16) - val Download get() = pix("menu/download", 16, 16) + val Build get() = pix("menu/build", 12, 12) + val Run get() = pix("menu/run", 12, 12) + val Rerun get() = pix("menu/rerun", 12, 12) + val Stop get() = pix("menu/stop", 12, 12) + val ForceStop get() = pix("menu/force_stop", 12, 12) + val Download get() = pix("menu/download", 12, 12) + val Settings get() = pix("menu/settings", 12, 12) val Cross get() = pix("ui/cross", 16, 16) val SmallCross get() = pix("ui/small_cross", 16, 16) @@ -91,73 +95,40 @@ object TIcons { val SmallPause get() = pix("ui/small_pause", 16, 16) val SmallPlay get() = pix("ui/small_play", 16, 16) val SmallMenu get() = pix("ui/small_menu", 16, 16) + val ExternalArrow get() = pix("ui/external_arrow", 12, 12) - private fun icon(keyOrPath: String, width: Int? = null, height: Int? = null): QIcon { - val dpr = try { - currentDpr(referenceWidget) - } catch (_: Throwable) { 1.0 } - - val baseW = width ?: 16 - val baseH = height ?: baseW - - val physW = ceil(baseW * dpr).toInt().coerceAtLeast(1) - val physH = ceil(baseH * dpr).toInt().coerceAtLeast(1) - - val dprKey = String.format("%.3f", dpr) - val cacheKey = "$keyOrPath|${baseW}x${baseH}|${physW}x${physH}@$dprKey" - - return cached.computeIfAbsent(cacheKey) { - ThemeMngr.getIcon(keyOrPath, baseW, baseH, dpr) - ?: run { - val normalized = if(keyOrPath.startsWith("/")) keyOrPath else "/$keyOrPath" - val url = this::class.java.getResource(normalized) ?: this::class.java.classLoader.getResource(keyOrPath) - if(url != null) { - val pix = QPixmap(url.toString()) - if(!pix.isNull) { - val scaled = pix.scaled(qs(physW, physH), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - try { scaled.setDevicePixelRatio(dpr) } catch (_: Throwable) {} - QIcon(scaled) - } else QIcon() - } else QIcon() - } - } - } + /** + * Creates a [QPixmap] from specified Icon paths + */ + private fun pix(keyOrPath: String, width: Int, height: Int, useDpr: Boolean = true): QPixmap { + val dpr = if (useDpr) { + try { currentDpr(referenceWidget) } catch (_: Throwable) { 1.0 } + } else 1.0 - private fun pix(keyOrPath: String, width: Int = 16, height: Int = 16, useDpr: Boolean = true): QPixmap { - if(!useDpr) { - val icon = ThemeMngr.getIcon(keyOrPath, width, height, 1.0) ?: return QPixmap() - return icon.pixmap(width, height) - } - - val dpr = try { - currentDpr(referenceWidget) - } catch (_: Throwable) { 1.0 } - - val icon = ThemeMngr.getIcon(keyOrPath, width, height, dpr) ?: return QPixmap() - - return icon.pixmap(width, height) + return ThemeMngr.getPixmap(keyOrPath, width, height, dpr) ?: QPixmap() } - fun debugIcon(keyOrPath: String, baseW: Int, baseH: Int, widget: QWidget?) { - val dpr = try { currentDpr(widget) } catch (_: Throwable) { 1.0 } - val physW = ceil(baseW * dpr).toInt().coerceAtLeast(1) - val physH = ceil(baseH * dpr).toInt().coerceAtLeast(1) - println("DEBUG ICON: key=$keyOrPath dpr=$dpr base=${baseW}x$baseH phys=${physW}x${physH}") - - val ic = ThemeMngr.getIcon(keyOrPath, baseW, baseH, dpr) - println(" ThemeMngr.getIcon -> ${if (ic == null) "null" else "icon (isNull=${ic.isNull})"}") - ic?.let { - val pm = it.pixmap(baseW, baseH) - println(" icon.pixmap(logical) -> isNull=${pm.isNull} size=${pm.width()}x${pm.height()} dpr=${try { pm.devicePixelRatio() } catch(_: Throwable) { "?" }}") - } + fun pixForKey(key: String, width: Int, height: Int) = pix(key, width, height) + + /** Render a themed icon at the exact target [size] (square). */ + fun pix(key: String, size: Int): QPixmap { + val dpr = try { currentDpr(referenceWidget) } catch (_: Throwable) { 1.0 } + return ThemeMngr.getPixmap(key, size, size, dpr) ?: QPixmap() } + /** + * When generating a Project without specifying an Icon, use a generic icon + * TODO: Get rid of this for a better system + */ internal val defaultProjectIcon: String by lazy { resolveFileResource("/icons/folder.png") ?: renderFolderIconToTempPng() ?: "" } + /** + * Gets a file from bundled resources + */ private fun resolveFileResource(path: String): String? { val url = javaClass.getResource(path) ?: return null return try { @@ -167,9 +138,12 @@ object TIcons { } } + /** + * Temporary icon for rendering + */ private fun renderFolderIconToTempPng(): String? { return try { - val pix = ThemeMngr.getIcon("file/folder", 16, 16, 1.0)?.pixmap(16, 16) + val pix = ThemeMngr.getPixmap("file/folder", 16, 16, 1.0) if(pix == null || pix.isNull) { null } else { @@ -182,3 +156,7 @@ object TIcons { } } } + +typealias IconKey = String + +fun IconKey.icon(size: Int): QPixmap = TIcons.pix(this, size) diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeFile.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeFile.kt index 37a3583..961b652 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeFile.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeFile.kt @@ -3,6 +3,9 @@ package io.github.tritium_launcher.launcher.ui.theme import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Parsed Theme File Data + */ @Serializable data class ThemeFile( val meta: ThemeMeta, @@ -11,6 +14,9 @@ data class ThemeFile( val stylesheets: Map = emptyMap(), ) +/** + * Format for Theme Files + */ @Serializable data class ThemeMeta( val id: String, diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeLoader.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeLoader.kt index acdeccc..dc3e1cb 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeLoader.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeLoader.kt @@ -4,18 +4,32 @@ import io.github.tritium_launcher.launcher.io.VPath import kotlinx.serialization.json.Json import java.io.InputStream +/** + * Loads Themes from filesystem + */ object ThemeLoader { private val json = Json { ignoreUnknownKeys = true } + /** + * Load a [ThemeFile] from Stream + */ @Throws(Exception::class) fun loadFromStream(stream: InputStream): ThemeFile { val bytes = stream.readBytes() return json.decodeFromString(ThemeFile.serializer(), bytes.decodeToString()) } + /** + * Load a [ThemeFile] from filesystem path + */ @Throws(Exception::class) fun loadFromFile(path: VPath): ThemeFile = path.inputStream().use { return loadFromStream(it) } + /** + * Override one [ThemeFile]'s values with another. + * + * This is helpful when working with themes which only declare Colors or Icons. + */ fun merge(base: ThemeFile?, override: ThemeFile): ThemeFile { if(base == null) return override return ThemeFile( diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeMngr.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeMngr.kt index 5e48db8..6b65b41 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeMngr.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeMngr.kt @@ -8,23 +8,38 @@ import io.github.tritium_launcher.launcher.io.watch import io.github.tritium_launcher.launcher.logger import io.github.tritium_launcher.launcher.qs import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr.generateSchema import io.qt.core.QByteArray import io.qt.core.QRectF +import io.qt.core.QSize import io.qt.core.Qt -import io.qt.gui.* -import io.qt.svg.QSvgRenderer +import io.qt.gui.QColor +import io.qt.gui.QIcon +import io.qt.gui.QPalette +import io.qt.gui.QPixmap import io.qt.widgets.QApplication +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.* import kotlinx.serialization.json.* +import java.awt.RenderingHints.KEY_INTERPOLATION +import java.awt.RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR +import java.awt.image.BufferedImage +import java.awt.image.BufferedImage.TYPE_INT_ARGB +import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URLDecoder import java.nio.charset.StandardCharsets +import java.util.* import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList import java.util.jar.JarFile import java.util.prefs.Preferences +import javax.imageio.ImageIO +import javax.xml.parsers.DocumentBuilderFactory import kotlin.math.ceil +import kotlin.math.min import kotlin.text.Charsets.UTF_8 /** @@ -34,11 +49,10 @@ import kotlin.text.Charsets.UTF_8 * and provides icon/color lookup helpers for UI components. */ object ThemeMngr { - private val listeners = CopyOnWriteArrayList<() -> Unit>() - - @Volatile - var currentThemeId: String = "" - private set + private val _currentThemeId = MutableStateFlow("") + val currentThemeId: StateFlow = _currentThemeId.asStateFlow() + val currentThemeIdValue: String + get() = _currentThemeId.value private val themes = mutableMapOf() private val bundledThemes = mutableMapOf() @@ -50,7 +64,13 @@ object ThemeMngr { private val schemaFile: VPath = userThemesDir.resolve("schema.json") private var themeWatcher: VPathWatcher? = null - private val iconCache = ConcurrentHashMap, QIcon>() + private const val MAX_CACHE_ENTRIES = 500 + private val iconCache: MutableMap, QPixmap> = Collections.synchronizedMap( + object : LinkedHashMap, QPixmap>(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry, QPixmap>): Boolean = + size > MAX_CACHE_ENTRIES + } + ) // TODO: Eventually move this to whatever the main Settings system will be @@ -60,6 +80,17 @@ object ThemeMngr { private val json = Json { prettyPrint = true } internal val logger = logger() + /** + * Initialize the Theme Manager. + * + * Steps: + * - Load Default theme. + * - Load Bundled themes. + * - Load User themes. + * - Restore selected theme. If blank, sets active Theme as default theme. + * - Generate Theme JSON schema, if enabled. + * - Start watching User Themes directory + */ fun init() { logger.info("Initializing Theme Manager...") try { @@ -70,7 +101,7 @@ object ThemeMngr { loadUserThemes() restorePersistedSelectionIfAny() - if (currentThemeId.isBlank()) { + if (_currentThemeId.value.isBlank()) { val chosen = when { themes.containsKey(defaultTheme.meta.id) -> defaultTheme.meta.id themes.isNotEmpty() -> themes.keys.first() @@ -96,9 +127,9 @@ object ThemeMngr { } } - fun addListener(l: () -> Unit) = listeners.add(l) - fun removeListener(l: () -> Unit) = listeners.remove(l) - + /** + * Loads Default theme + */ fun loadDefault() { val resStream: InputStream = this::class.java.getResourceAsStream("/themes/default.json") ?: throw IllegalStateException("Missing bundled default theme at /themes/default.json") @@ -117,6 +148,9 @@ object ThemeMngr { } } + /** + * Load User Themes + */ private fun loadUserThemes() { try { val defaultId = defaultTheme.meta.id @@ -161,6 +195,9 @@ object ThemeMngr { } } + /** + * Load Themes bundled with install + */ private fun loadBundledThemes() { val pref = "themes/" val clazz = this::class.java @@ -247,24 +284,29 @@ object ThemeMngr { } } + /** + * Sets active Theme + */ fun setTheme(id: String) { val theme = themes[id] ?: run { logger.error("Requested theme '{}' not found", id) defaultTheme } - iconCache.clear() - currentThemeId = theme.meta.id - applyTheme(theme) - persistSelectedThemeId(currentThemeId) - - val snapshot = ArrayList(listeners) - runOnGuiThread { - for(l in snapshot) { - try { l() } catch (t: Throwable) { logger.warn("Theme listener failed: {}", t.message) } + val oldId = _currentThemeId.value + if (oldId.isNotBlank()) { + synchronized(iconCache) { + iconCache.keys.removeIf { it.second == oldId } } } + _currentThemeId.value = theme.meta.id + applyTheme(theme) + persistSelectedThemeId(theme.meta.id) + loadThemeIcons() } + /** + * Applies Theme to UI + */ private fun applyTheme(theme: ThemeFile) { runOnGuiThread { applyPalette(theme) @@ -272,6 +314,9 @@ object ThemeMngr { } } + /** + * Applies Theme values to [QPalette] + */ private fun applyPalette(theme: ThemeFile) { val base = QApplication.palette() val pal = QPalette(base) @@ -343,6 +388,9 @@ object ThemeMngr { QApplication.setPalette(pal) } + /** + * Apply Theme values to Stylesheets + */ private fun applyStylesheets(theme: ThemeFile) { try { val fallback = defaultForType(theme.meta.type) ?: defaultTheme @@ -360,8 +408,14 @@ object ThemeMngr { } } + /** + * Get Colors from specified Theme + */ private fun colorOf(theme: ThemeFile, key: String): String? = theme.colors[key] + /** + * Gets Hex values from Theme colors + */ private fun resolveColorHex(theme: ThemeFile, fallback: ThemeFile, key: String, hardFallback: String): String { return colorOf(theme, key) ?: fallback.colors[key] @@ -369,6 +423,9 @@ object ThemeMngr { ?: hardFallback } + /** + * Builds a global stylesheet for some Widgets + */ private fun buildBaseWidgetStylesheet(theme: ThemeFile, fallback: ThemeFile): String { val surface1 = resolveColorHex(theme, fallback, "Surface1", "#303030") val text = resolveColorHex(theme, fallback, "Text", "#F5F5F5") @@ -406,6 +463,9 @@ object ThemeMngr { """.trimIndent() } + /** + * Get a [QColor] from a Theme, using the color's key + */ private fun resolveColor(theme: ThemeFile, key: String): QColor? { val fallback = defaultForType(theme.meta.type) ?: defaultTheme val hex = colorOf(theme, key) ?: fallback.colors[key] ?: defaultTheme.colors[key] @@ -414,6 +474,9 @@ object ThemeMngr { } } + /** + * Create a "disabled" Color + */ private fun disabledColor(color: QColor, type: ThemeType): QColor { val c = QColor(color) val alpha = (c.alpha() * 0.6).toInt().coerceAtLeast(30) @@ -424,8 +487,11 @@ object ThemeMngr { } } - fun getIcon(iconKey: String, width: Int? = null, height: Int? = null, dpr: Double = 1.0): QIcon? { - val theme = themes[currentThemeId] ?: defaultTheme + /** + * Get an Icon from active Theme + */ + fun getPixmap(iconKey: String, width: Int? = null, height: Int? = null, dpr: Double = 1.0): QPixmap? { + val theme = themes[_currentThemeId.value] ?: defaultTheme val mapping = theme.icons[iconKey] ?: return null val baseW = width ?: 16 @@ -435,13 +501,21 @@ object ThemeMngr { val h = ceil(baseH * dpr).toInt().coerceAtLeast(1) val cacheKey = Quadruple(mapping, theme.meta.id, w, h) - return iconCache[cacheKey] ?: loadIconFromReference(mapping, theme, baseW, baseH, dpr)?.also { - iconCache[cacheKey] = it - } + iconCache[cacheKey]?.let { return it } + val pix = loadIconFromReference(mapping, theme, w, h, dpr) ?: return null + iconCache[cacheKey] = pix + return pix } + fun getIcon(iconKey: String, width: Int? = null, height: Int? = null, dpr: Double = 1.0): QIcon? { + return getPixmap(iconKey, width, height, dpr)?.let { QIcon(it) } + } + + /** + * Get Color hex value from active Theme, using color key + */ fun getColorHex(key: String): String? { - val active = themes[currentThemeId] ?: themes.values.firstOrNull() ?: defaultTheme + val active = themes[_currentThemeId.value] ?: themes.values.firstOrNull() ?: defaultTheme val fromActive = active.colors[key] if(!fromActive.isNullOrBlank()) return fromActive val typeFallback = defaultForType(active.meta.type) @@ -451,11 +525,17 @@ object ThemeMngr { return fromDefault?.takeIf { it.isNotBlank() } } + /** + * Gets a [ThemeFile] depending on provided [ThemeType] + */ private fun defaultForType(type: ThemeType): ThemeFile? = when(type) { ThemeType.Dark -> defaultTheme ThemeType.Light -> defaultLightTheme ?: defaultTheme } + /** + * Get [QColor] from active Theme, using color key + */ fun getQColor(key: String): QColor? { val hex = getColorHex(key) ?: return null return try { @@ -466,10 +546,11 @@ object ThemeMngr { } } - private fun loadIconFromReference(ref: String, theme: ThemeFile, baseW: Int, baseH: Int, dpr: Double = 1.0): QIcon? { + /** + * Loads an Icon from a provided Theme + */ + private fun loadIconFromReference(ref: String, theme: ThemeFile, physW: Int, physH: Int, dpr: Double = 1.0): QPixmap? { try { - val physW = ceil(baseW * dpr).toInt().coerceAtLeast(1) - val physH = ceil(baseH * dpr).toInt().coerceAtLeast(1) val tried = mutableListOf() val candidates = mutableListOf() @@ -606,18 +687,53 @@ object ThemeMngr { } } - val renderer = QSvgRenderer(QByteArray(svgText.toByteArray(UTF_8))) - - val pix = QPixmap(physW, physH) - pix.fill(Qt.GlobalColor.transparent) - val painter = QPainter(pix) - try { - renderer.render(painter, QRectF(0.0, 0.0, physW.toDouble(), physH.toDouble())) - } finally { - painter.end() + val doc = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(svgText.byteInputStream()) + val svgEl = doc.documentElement + + val nW = svgEl.getAttribute("width").toIntOrNull() ?: 16 + val nH = svgEl.getAttribute("height").toIntOrNull() ?: 16 + + val img = BufferedImage(nW, nH, TYPE_INT_ARGB) + + val rects = doc.getElementsByTagName("rect") + for(i in 0 until rects.length) { + val rect = rects.item(i) as org.w3c.dom.Element + val x = rect.getAttribute("x").toIntOrNull() ?: continue + val y = rect.getAttribute("y").toIntOrNull() ?: continue + val w = rect.getAttribute("width").toIntOrNull() ?: continue + val h = rect.getAttribute("height").toIntOrNull() ?: continue + val fill = rect.getAttribute("fill").takeIf { it.isNotBlank() && it != "none" } ?: continue + val color = try { java.awt.Color.decode(fill) } catch (_: Exception) { continue } + + val alpha = rect.getAttribute("fill-opacity").toFloatOrNull() + ?: rect.getAttribute("opacity").toFloatOrNull() + ?: 1f + val argb = (((alpha * 255).toInt() and 0xFF) shl 24) or + ((color.red and 0xFF) shl 16) or + ((color.green and 0xFF) shl 8) or + (color.blue and 0xFF) + + for (py in y until (y + h)) { + for (px in x until (x + w)) { + if (px < nW && py < nH) img.setRGB(px, py, argb) + } + } } - return QIcon(pix) + val scaledImg = BufferedImage(physW, physH, TYPE_INT_ARGB) + val g2 = scaledImg.createGraphics() + g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_NEAREST_NEIGHBOR) + g2.drawImage(img, 0, 0, physW, physH, null) + g2.dispose() + + val baos = ByteArrayOutputStream() + ImageIO.write(scaledImg, "PNG", baos) + val pix = QPixmap() + pix.loadFromData(QByteArray(baos.toByteArray())) + pix.setDevicePixelRatio(dpr) + return pix } else { val pix = QPixmap() val loaded = pix.loadFromData(raw) @@ -628,8 +744,9 @@ object ThemeMngr { val finalPix = if(physW > 0 && physH > 0) { pix.scaled(qs(physW, physH), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) } else pix + try { finalPix.setDevicePixelRatio(dpr) } catch (_: Throwable) {} - return QIcon(finalPix) + return finalPix } } catch (e: Exception) { logger.error("Failed to load icon reference '$ref': ${e.message}") @@ -637,6 +754,23 @@ object ThemeMngr { } } + /** + * Makes a Rect for Icons to fill + */ + private fun fitRect(sourceSize: QSize, targetW: Int, targetH: Int): QRectF { + val srcW = sourceSize.width().takeIf { it > 0 } ?: targetW + val srcH = sourceSize.height().takeIf { it > 0 } ?: targetH + val scale = min(targetW.toDouble() / srcW.toDouble(), targetH.toDouble() / srcH.toDouble()) + val drawW = srcW * scale + val drawH = srcH * scale + val x = (targetW - drawW) / 2.0 + val y = (targetH - drawH) / 2.0 + return QRectF(x, y, drawW, drawH) + } + + /** + * Start a thread for the user themes directory watcher + */ private fun startWatcherThread() { try { themeWatcher?.close() } catch (_: Exception) {} themeWatcher = try { @@ -657,7 +791,6 @@ object ThemeMngr { themes[theme.meta.id] = ThemeLoader.merge(base, theme) logger.info("Loaded user theme '${theme.meta.id}' from '$fileName'") - listeners.forEach { it() } } catch (e: Exception) { logger.error("Exception loading created theme '$fileName'", e) } @@ -674,7 +807,6 @@ object ThemeMngr { themes[theme.meta.id] = ThemeLoader.merge(base, theme) logger.info("Loaded user theme from '$fileName': '${theme.meta.id}'") - listeners.forEach { it() } } catch (e: Exception) { logger.error("Error loading created theme '$fileName'", e) } @@ -694,13 +826,10 @@ object ThemeMngr { logger.info("User theme removed, no bundled fallback removed. '$removedId'") } - if(removedId == currentThemeId) { + if(removedId == _currentThemeId.value) { val toApply = themes[removedId] ?: defaultForType(removedType ?: ThemeType.Dark) ?: defaultTheme applyTheme(toApply) - currentThemeId = toApply.meta.id - listeners.forEach { it() } - } else { - listeners.forEach { it() } + _currentThemeId.value = toApply.meta.id } } } catch (e: Exception) { @@ -713,7 +842,6 @@ object ThemeMngr { try { loadUserThemes() - listeners.forEach { it() } } catch (e: Exception) { logger.error("Failed to rescan themes after overflow", e) } @@ -729,6 +857,9 @@ object ThemeMngr { } } + /** + * Validate provided [ThemeFile] + */ private fun validateTheme(theme: ThemeFile) { val id = theme.meta.id val name = theme.meta.name @@ -736,6 +867,9 @@ object ThemeMngr { if (name.isBlank()) throw IllegalArgumentException("Theme meta.name must not be blank") } + /** + * Generate a JSON Schema + */ private fun generateSchema(condition: Boolean) { if (!condition) return try { @@ -795,6 +929,11 @@ object ThemeMngr { } } + /** + * Build Descriptor properties for JSON Schema + * + * @see generateSchema + */ private fun buildPropertiesForDescriptor(descriptor: SerialDescriptor): JsonObject { val properties = mutableMapOf() for (i in 0 until descriptor.elementsCount) { @@ -805,6 +944,9 @@ object ThemeMngr { return JsonObject(properties) } + /** + * Creates a [JsonElement] for theme schema + */ @OptIn(ExperimentalSerializationApi::class) private fun schemaForDescriptor(descriptor: SerialDescriptor): JsonElement { return when (val kind = descriptor.kind) { @@ -873,11 +1015,43 @@ object ThemeMngr { } } + /** + * Gather all available Theme IDs + */ + fun loadThemeIcons(sizes: List = listOf(16, 32), dpr: Double = 1.0) { + Thread { + val theme = themes[_currentThemeId.value] ?: return@Thread + for (key in theme.icons.keys) { + for (size in sizes) { + getPixmap(key, size, size, dpr) + } + } + }.apply { isDaemon = true; start() } + } + fun availableThemeIds(): List = themes.keys.toList() + /** + * Triggers theme re-application by emitting the current theme again. + */ + fun refresh() { + val theme = themes[_currentThemeId.value] ?: defaultTheme + applyTheme(theme) + } + + /** + * Get Theme name for ID + */ fun getThemeName(id: String): String? = themes[id]?.meta?.name + + /** + * Get [ThemeType] for ID + */ fun getThemeType(id: String): ThemeType? = themes[id]?.meta?.type + /** + * Gets a color hex value from provided theme ID and color key + */ fun getThemeColorHex(id: String, key: String): String? { val theme = themes[id] ?: return null val fallback = defaultForType(theme.meta.type) ?: defaultTheme @@ -886,6 +1060,9 @@ object ThemeMngr { ?: defaultTheme.colors[key] } + /** + * TODO: Replace prefs system + */ private fun persistSelectedThemeId(id: String) { try { prefs.put(PREF_SELECTED_THEME, id) @@ -896,6 +1073,9 @@ object ThemeMngr { } } + /** + * TODO: Replace prefs system + */ private fun loadPersistedThemeId(): String? = try { prefs.get(PREF_SELECTED_THEME, null) } catch (e: Exception) { @@ -903,6 +1083,9 @@ object ThemeMngr { null } + /** + * TODO: Replace prefs system + */ private fun restorePersistedSelectionIfAny() { val persisted = loadPersistedThemeId() if(!persisted.isNullOrBlank() && themes.containsKey(persisted)) { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeWidgetHelpers.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeWidgetHelpers.kt deleted file mode 100644 index f77738a..0000000 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/ThemeWidgetHelpers.kt +++ /dev/null @@ -1,186 +0,0 @@ -package io.github.tritium_launcher.launcher.ui.theme - -import io.github.tritium_launcher.launcher.TApp -import io.github.tritium_launcher.launcher.connect -import io.github.tritium_launcher.launcher.ui.theme.ColorPart.* -import io.qt.widgets.QWidget -import java.util.concurrent.atomic.AtomicInteger -import kotlin.math.abs - -private val autoObjNameCounter = AtomicInteger(0) - -enum class ColorPart { - Bg, - Fg, - Border, - Icon, - Placeholder, - SelectionBg, - SelectionFg, - HoverBg, - HoverFg, - PressedBg, - PressedFg; -} - -fun QWidget.applyThemeStyle(colorKey: String, vararg parts: ColorPart) = applyThemeStyleTo(this, colorKey, *parts) - -fun applyThemeStyleTo(widget: QWidget, colorKey: String, vararg parts: ColorPart): () -> Unit { - val objName = ensureObjName(widget) - val widgetMarkerStart = "/*theme-widget-style-start:$objName*/" - val widgetMarkerEnd = "/*theme-widget-style-end:$objName*/" - val globalMarkerStart = "/*theme-global-style-start:$objName*/" - val globalMarkerEnd = "/*theme-global-style-end:$objName*/" - - val apply: () -> Unit = letUnit@{ - try { - val hex = ThemeMngr.getColorHex(colorKey) - if(hex == null) { - removeInjectedFragment(widget, widgetMarkerStart, widgetMarkerEnd) - removeGlobalFragment(globalMarkerStart, globalMarkerEnd) - return@letUnit - } - - val baseParts = parts.filter { p -> - when(p) { - HoverBg, HoverFg, PressedBg, PressedFg -> false - else -> true - } - }.toTypedArray() - - if(baseParts.isNotEmpty()) { - val inlineFragment = buildStyleFragment(null, hex, baseParts) - injectFragment(widget, inlineFragment, widgetMarkerStart, widgetMarkerEnd) - } else { - removeInjectedFragment(widget, widgetMarkerStart, widgetMarkerEnd) - } - - val hoverParts = parts.filter { it == HoverBg || it == HoverFg }.toTypedArray() - val pressedParts = parts.filter { it == PressedBg || it == PressedFg }.toTypedArray() - - val globalBuilder = StringBuilder() - if(hoverParts.isNotEmpty()) { - globalBuilder.append(buildStyleFragment("#$objName:hover", hex, hoverParts)) - } - if(pressedParts.isNotEmpty()) { - globalBuilder.append(buildStyleFragment("#$objName:pressed", hex, pressedParts)) - } - - if(globalBuilder.isNotEmpty()) { - injectGlobalFragment(globalBuilder.toString(), globalMarkerStart, globalMarkerEnd) - } else { - removeGlobalFragment(globalMarkerStart, globalMarkerEnd) - } - - } catch (e: Exception) { - ThemeMngr.logger.warn("Failed to apply theme style for widget '{}': {}", widget, e.message) - } - } - - ThemeMngr.addListener(apply) - apply() - - try { - widget.destroyed.connect { - try { - ThemeMngr.removeListener(apply) - } catch (e: Exception) { - ThemeMngr.logger.warn("Failed to remove theme listener on destroy: {}", e.message) - } - removeInjectedFragment(widget, widgetMarkerStart, widgetMarkerEnd) - removeGlobalFragment(globalMarkerStart, globalMarkerEnd) - } - } catch (_: Throwable) {} - - return { - try { - ThemeMngr.removeListener(apply) - } catch (e: Exception) { - ThemeMngr.logger.debug("Error removing theme listener: {}", e.message) - } - removeInjectedFragment(widget, widgetMarkerStart, widgetMarkerEnd) - removeGlobalFragment(globalMarkerStart, globalMarkerEnd) - } -} - -private fun ensureObjName(widget: QWidget): String { - val current = widget.objectName - if(!current.isNullOrBlank()) return current - val generated = "theme_auto_${abs(System.identityHashCode(widget))}_${autoObjNameCounter.incrementAndGet()}" - widget.objectName = generated - return generated -} - -private fun buildStyleFragment(selector: String?, hex: String, parts: Array): String { - val colorValue = hex.trim() - val baseRules = mutableListOf() - - for(p in parts) { - baseRules += when(p) { - Bg -> "background-color: $colorValue;" - Fg -> "color: $colorValue;" - Border -> "border: 1px solid $colorValue;" - Icon -> "color: $colorValue;" - Placeholder -> "placeholder-text-color: $colorValue;" - SelectionBg -> "selection-background-color: $colorValue;" - SelectionFg -> "selection-color: $colorValue;" - HoverBg -> "background-color: $colorValue" - HoverFg -> "color: $colorValue" - PressedBg -> "background-color: $colorValue" - PressedFg -> "color: $colorValue" - } - } - - val combined = baseRules.joinToString(" ") - return if(selector.isNullOrEmpty()) combined else "$selector { $combined }" -} - -private fun injectFragment(widget: QWidget, qssFragment: String, markerStart: String, markerEnd: String) { - val existing = widget.styleSheet ?: "" - val newFragment = "$markerStart $qssFragment $markerEnd" - val updated = if (existing.contains(markerStart) && existing.contains(markerEnd)) { - val start = existing.indexOf(markerStart) - val end = existing.indexOf(markerEnd) + markerEnd.length - existing.replaceRange(start, end, newFragment) - } else { - if (existing.isBlank()) newFragment else (existing.trimEnd() + "\n" + newFragment) - } - widget.styleSheet = updated - try { - widget.update() - } catch (_: Throwable) {} -} - -private fun removeInjectedFragment(widget: QWidget, markerStart: String, markerEnd: String) { - val existing = widget.styleSheet ?: return - if (!existing.contains(markerStart) || !existing.contains(markerEnd)) return - val start = existing.indexOf(markerStart) - val end = existing.indexOf(markerEnd) + markerEnd.length - val cleaned = existing.removeRange(start, end).trim() - widget.styleSheet = cleaned - try { - widget.update() - } catch (_: Throwable) {} -} - -private fun injectGlobalFragment(qssFragment: String, markerStart: String, markerEnd: String) { - val existing = TApp.styleSheet ?: "" - val newFragment = "$markerStart $qssFragment $markerEnd" - val updated = if (existing.contains(markerStart) && existing.contains(markerEnd)) { - val start = existing.indexOf(markerStart) - val end = existing.indexOf(markerEnd) + markerEnd.length - existing.replaceRange(start, end, newFragment) - } else { - if (existing.isBlank()) newFragment else (existing.trimEnd() + "\n" + newFragment) - } - TApp.styleSheet = updated -} - -private fun removeGlobalFragment(markerStart: String, markerEnd: String) { - val existing = TApp.styleSheet ?: return - if (!existing.contains(markerStart) || !existing.contains(markerEnd)) return - val start = existing.indexOf(markerStart) - val end = existing.indexOf(markerEnd) + markerEnd.length - val cleaned = existing.removeRange(start, end).trim() - TApp.styleSheet = cleaned -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TritiumProxyStyle.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TritiumProxyStyle.kt index ac6de54..bc4072c 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TritiumProxyStyle.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/TritiumProxyStyle.kt @@ -8,8 +8,17 @@ import io.qt.core.Qt import io.qt.gui.* import io.qt.widgets.* +/** + * Proxy style used to inject theme-specific Qt primitive rendering. + * + * This style handles custom painting cases that are difficult to + * express purely through stylesheets. + */ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { + /** + * Reads a widget property as a double with a fallback for unsupported values. + */ private fun QWidget.propDouble(name: String, fallback: Double): Double { val v = this.property(name) ?: return fallback return when (v) { @@ -19,6 +28,9 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { } } + /** + * Reads a widget property as a boolean with a fallback for unsupported values. + */ private fun QWidget.propBool(name: String, fallback: Boolean): Boolean { val v = this.property(name) ?: return fallback return when (v) { @@ -29,6 +41,9 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { } } + /** + * Reads a widget property as a [QColor] with a fallback for unsupported values. + */ private fun QWidget.propColor(name: String, fallback: QColor): QColor { val v = this.property(name) ?: return fallback return when (v) { @@ -38,6 +53,9 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { } } + /** + * Converts a raw property value into a [QColor] when possible. + */ private fun parseColor(value: Any?): QColor? { return when (value) { is QColor -> value @@ -46,6 +64,12 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { } } + /** + * Walks up the object parent chain looking for a color-valued property. + * + * @param start Object to start searching from. + * @param key Property name to resolve. + */ private fun findColorProperty(start: QObject?, key: String): QColor? { var current = start while (current != null) { @@ -121,6 +145,11 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { super.drawPrimitive(element, option, painter, widget) } + /** + * Draws a property-configured rounded border/background for supported widgets. + * + * @return `true` when the primitive was fully handled and default painting should stop. + */ private fun drawBorder( w: QWidget, pe: PrimitiveElement, @@ -192,10 +221,17 @@ class TritiumProxyStyle(style: QStyle?): QProxyStyle(style) { else -> super.pixelMetric(metric, option, widget) } } - - } +/** + * Enables property-driven custom border rendering for this widget. + * + * @param radius Corner radius used by the proxy style. + * @param borderColor Border color used when the border is shown. + * @param borderWidth Border stroke width. + * @param showOnHover Whether the border should appear on hover. + * @param showOnPress Whether the border should appear while pressed. + */ fun QWidget.enableCustomBorder( radius: Double = 6.0, borderColor: QColor = QColor(0, 0, 0, 80), @@ -215,6 +251,9 @@ fun QWidget.enableCustomBorder( this.update() } +/** + * Disables custom border rendering for this widget. + */ fun QWidget.disableCustomBorder() { this.setProperty("customBorderEnabled", false) this.update() diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QIconUtils.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QIconUtils.kt index 951639b..2879731 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QIconUtils.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QIconUtils.kt @@ -6,6 +6,9 @@ import io.qt.gui.QIcon import io.qt.gui.QPainter import io.qt.gui.QPixmap +/** + * Makes a [QIcon] from this [QPixmap] + */ val QPixmap.icon: QIcon get() = QIcon(this) /** diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QtStyleBuilder.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QtStyleBuilder.kt index 7ada03c..0536207 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QtStyleBuilder.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/theme/qt/QtStyleBuilder.kt @@ -3,6 +3,9 @@ package io.github.tritium_launcher.launcher.ui.theme.qt import io.github.tritium_launcher.launcher.connect import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr import io.qt.widgets.QWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) @@ -166,15 +169,18 @@ fun QWidget.setThemedStyle(block: QtStyleSheet.() -> Unit): () -> Unit { qtStyle(block).applyTo(this) } catch (_: Throwable) {} } - ThemeMngr.addListener(apply) apply() + val job = CoroutineScope(Dispatchers.Main).launch { + ThemeMngr.currentThemeId.collect { apply() } + } + try { - this.destroyed.connect { ThemeMngr.removeListener(apply) } + this.destroyed.connect { job.cancel() } } catch (_: Throwable) {} return { - ThemeMngr.removeListener(apply) + job.cancel() } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/AnimatedScroll.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/AnimatedScroll.kt new file mode 100644 index 0000000..1e0353e --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/AnimatedScroll.kt @@ -0,0 +1,266 @@ +package io.github.tritium_launcher.launcher.ui.widgets + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.core.TritiumEvent +import io.github.tritium_launcher.launcher.core.onEvent +import io.github.tritium_launcher.launcher.extension.core.CoreSettingKeys +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.qt.Nullable +import io.qt.core.* +import io.qt.gui.QWheelEvent +import io.qt.widgets.QAbstractScrollArea +import io.qt.widgets.QScrollBar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlin.math.abs +import kotlin.math.exp + +/** + * Axis used by [AnimatedScrollController] when driving a scroll area. + */ +enum class AnimatedScrollAxis { + Vertical, + Horizontal +} + +/** + * Shared smooth-scrolling controller for Qt scroll-area widgets. + * + * The controller can optionally intercept wheel input directly, convert that + * input into pixel deltas, and animate the target scrollbar using inertia for + * free scrolling or a spring when moving toward an explicit target position. + * + * @param area Scroll area being controlled. + * @param axis Scrollbar axis to drive. + * @param interceptWheel Whether wheel events should be intercepted through an event filter. + */ +class AnimatedScrollController private constructor( + private val area: QAbstractScrollArea, + private val axis: AnimatedScrollAxis, + private val interceptWheel: Boolean +) : QObject(area) { + private var wheelAccumulator = 0 + private var scrollVelocity = 0.0 + private var scrollPos = 0.0 + private var targetPos: Double? = null + private val timer = QTimer(this).also { it.interval = 8 } + private val clock = QElapsedTimer() + private val scope = CoroutineScope(Dispatchers.Main) + + init { + if (interceptWheel) { + area.installEventFilter(this) + area.viewport()?.installEventFilter(this) + } + timer.timeout.connect { tick() } + scope.onEvent { event -> + val key = "${event.namespace}:${event.nodeKey}" + if (key == CoreSettingKeys.UiAnimateScrolling.toString()) { + runOnGuiThread { + if (!CoreSettingValues.uiAnimateScrolling) stop() + } + } + } + area.destroyed.connect { scope.cancel() } + } + + /** + * Intercepts wheel events when enabled and translates them into animated scroll movement. + */ + override fun eventFilter(watched: @Nullable QObject?, event: @Nullable QEvent?): Boolean { + if (!interceptWheel || event?.type() != QEvent.Type.Wheel) { + return super.eventFilter(watched, event) + } + + val wheel = event as? QWheelEvent ?: return super.eventFilter(watched, event) + if (wheel.modifiers().testFlag(Qt.KeyboardModifier.ControlModifier) || + wheel.modifiers().testFlag(Qt.KeyboardModifier.MetaModifier) || + wheel.modifiers().testFlag(Qt.KeyboardModifier.AltModifier) + ) { + return super.eventFilter(watched, event) + } + + if (!canScroll()) { + return super.eventFilter(watched, event) + } + + val deltaPx = wheelDeltaPx(wheel) + if (deltaPx == 0) { + return super.eventFilter(watched, event) + } + + nudgeBy(deltaPx) + wheel.accept() + return true + } + + /** + * Applies a wheel-style pixel delta to the controlled scrollbar. + * + * When animated scrolling is disabled, the scrollbar is moved immediately. + * + * @param deltaPx Signed pixel delta to apply. + */ + fun nudgeBy(deltaPx: Int) { + val bar = scrollBar() ?: return + + if (!CoreSettingValues.uiAnimateScrolling) { + stop() + val next = (bar.value.toDouble() + deltaPx).coerceIn(bar.minimum.toDouble(), bar.maximum.toDouble()) + bar.value = next.toInt() + scrollPos = next + return + } + + if (!timer.isActive) { + scrollPos = bar.value().toDouble() + scrollVelocity = 0.0 + clock.start() + } + targetPos = null + scrollVelocity += deltaPx * 35.0 + timer.start() + } + + /** + * Moves the controlled scrollbar to a specific position. + * + * @param target Target scrollbar value. + * @param animate Whether movement should use the animated path when enabled globally. + */ + fun scrollTo(target: Int, animate: Boolean = true) { + val bar = scrollBar() ?: return + val clamped = target.coerceIn(bar.minimum(), bar.maximum()).toDouble() + + if (!animate || !CoreSettingValues.uiAnimateScrolling) { + stop() + scrollPos = clamped + bar.value = clamped.toInt() + return + } + + if (!timer.isActive) { + scrollPos = bar.value().toDouble() + scrollVelocity = 0.0 + clock.start() + } + + targetPos = clamped + timer.start() + } + + /** + * Stops any active scroll animation and clears pending target motion. + */ + fun stop() { + timer.stop() + targetPos = null + scrollVelocity = 0.0 + } + + /** + * Advances one animation step and writes the updated position to the scrollbar. + */ + private fun tick() { + val bar = scrollBar() ?: return stop() + + val dt = (clock.restart().coerceAtLeast(1).toDouble() / 1000.0) + + val target = targetPos + if (target != null) { + val displacement = target - scrollPos + val spring = 90.0 + val damping = 18.0 + + val accel = (displacement * spring) - (scrollVelocity * damping) + scrollVelocity += accel * dt + scrollPos += scrollVelocity * dt + + if(abs(displacement) < 0.25 && abs(scrollVelocity) < 2.0) { + scrollPos = target + scrollVelocity = 0.0 + targetPos = null + } + } else { + val drag = 12.0 + scrollPos += scrollVelocity * dt + scrollVelocity *= exp(-drag * dt) + + if(abs(scrollVelocity) < 1.0) { + scrollVelocity = 0.0 + timer.stop() + } + } + + val min = bar.minimum().toDouble() + val max = bar.maximum().toDouble() + scrollPos = scrollPos.coerceIn(min, max) + + val intPos = scrollPos.toInt().coerceIn(bar.minimum, bar.maximum) + if(bar.value != intPos) bar.value = intPos + } + + /** + * Returns whether the controlled scrollbar has any scrollable range. + */ + private fun canScroll(): Boolean { + val bar = scrollBar() ?: return false + return bar.maximum() > bar.minimum() + } + + /** + * Returns the scrollbar associated with the configured [axis]. + */ + private fun scrollBar(): QScrollBar? = when (axis) { + AnimatedScrollAxis.Vertical -> area.verticalScrollBar() + AnimatedScrollAxis.Horizontal -> area.horizontalScrollBar() + } + + /** + * Converts a wheel event into a pixel delta for the configured [axis]. + * + * High-precision pixel deltas are used when available; otherwise angle deltas + * are accumulated into step-sized pixel movement. + */ + private fun wheelDeltaPx(event: QWheelEvent): Int { + val pixel = event.pixelDelta() + if (pixel.x() != 0 || pixel.y() != 0) { + return when (axis) { + AnimatedScrollAxis.Vertical -> if (pixel.y() != 0) pixel.y() else pixel.x() + AnimatedScrollAxis.Horizontal -> if (pixel.x() != 0) pixel.x() else pixel.y() + } + } + + val angle = event.angleDelta() + val angleDelta = when (axis) { + AnimatedScrollAxis.Vertical -> if (angle.y() != 0) angle.y() else angle.x() + AnimatedScrollAxis.Horizontal -> if (angle.x() != 0) angle.x() else angle.y() + } + + wheelAccumulator += angleDelta + val steps = wheelAccumulator / 120 + if (steps == 0) return 0 + wheelAccumulator -= steps * 120 + + return -steps * STEP_SIZE_PX + } + + companion object { + private const val STEP_SIZE_PX = 40 + + /** + * Creates and attaches a controller for the given scroll area. + * + * @param area Scroll area to control. + * @param axis Scrollbar axis to animate. + * @param interceptWheel Whether wheel events should be intercepted automatically. + */ + fun attach( + area: QAbstractScrollArea, + axis: AnimatedScrollAxis = AnimatedScrollAxis.Vertical, + interceptWheel: Boolean = true + ): AnimatedScrollController = AnimatedScrollController(area, axis, interceptWheel) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/BrowseLabel.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/BrowseLabel.kt index 433fd47..8902925 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/BrowseLabel.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/BrowseLabel.kt @@ -8,6 +8,9 @@ import io.qt.gui.QCursor import io.qt.widgets.QLabel import io.qt.widgets.QWidget +/** + * Displays provided URL, and opens URL in an external Browser when clicked + */ class BrowseLabel( private val url: String, parent: QWidget? = null diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/Extensions.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/Extensions.kt index 83d2b19..aca7f0b 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/Extensions.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/Extensions.kt @@ -1,12 +1,15 @@ package io.github.tritium_launcher.launcher.ui.widgets import io.github.tritium_launcher.launcher.connect -import io.github.tritium_launcher.launcher.hexToQColor -import io.github.tritium_launcher.launcher.logger -import io.github.tritium_launcher.launcher.ui.theme.TColors -import io.qt.core.* -import io.qt.widgets.* - +import io.qt.core.QEasingCurve +import io.qt.core.QPropertyAnimation +import io.qt.core.QTimer +import io.qt.widgets.QGraphicsOpacityEffect +import io.qt.widgets.QWidget + +/** + * Applies an opacity effect to temporarily show a [QWidget] + */ fun QWidget.showThenFade( showDurationMs: Int = 1200, fadeDurationMs: Int = 600 @@ -33,30 +36,4 @@ fun QWidget.showThenFade( anim.start() } -} - -fun QLineEdit.showErrorIfEmpty(msg: String) { - if(this.text != "") return - - val alreadyApplied = (this.property("validationOutlineApplied") as? Boolean) == true - if(!alreadyApplied) { - try { - val outline = QGraphicsDropShadowEffect(this).apply { - blurRadius = 10.0 - offset = QPointF(0.0, 0.0) - color = TColors.Error.hexToQColor() - } - this.setGraphicsEffect(outline) - this.setProperty("validationOutlineApplied", true) - } catch (t: Throwable) { - logger().debug("Issue", t) - } - - val globalPos = mapToGlobal(QPoint(width / 2, 0)) - QToolTip.showText(globalPos, msg, this) - } - - val globalPos = this.mapToGlobal(QPoint(this.width() / 2, 0)) - - QToolTip.showText(globalPos, msg, this) } \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/InfoLineEditWidget.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/InfoLineEditWidget.kt index 7b85044..fe489e6 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/InfoLineEditWidget.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/InfoLineEditWidget.kt @@ -56,7 +56,9 @@ class InfoLineEditWidget( return popup!! } - + /** + * Show tooltip + */ private fun showPinnedToolTip() { if(tipText.isBlank() || !isVisible) return @@ -76,10 +78,6 @@ class InfoLineEditWidget( p.raise() } - private fun hidePinnedTip() { - popup?.hide() - } - override fun focusInEvent(arg__1: @Nullable QFocusEvent?) { super.focusInEvent(arg__1) if(tipText.isNotBlank()) showPinnedToolTip() @@ -87,7 +85,7 @@ class InfoLineEditWidget( override fun focusOutEvent(arg__1: @Nullable QFocusEvent?) { super.focusOutEvent(arg__1) - hidePinnedTip() + popup?.hide() } override fun resizeEvent(event: @Nullable QResizeEvent?) { diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/LongPressButton.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/LongPressButton.kt new file mode 100644 index 0000000..7f01b48 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/LongPressButton.kt @@ -0,0 +1,106 @@ +package io.github.tritium_launcher.launcher.ui.widgets + +import io.github.tritium_launcher.launcher.ui.theme.TColors +import io.qt.core.QRectF +import io.qt.core.Qt +import io.qt.gui.* +import io.qt.widgets.QPushButton +import io.qt.widgets.QWidget +import kotlinx.coroutines.* +import kotlin.time.Duration.Companion.milliseconds + +class LongPressButton(parent: QWidget? = null) : QPushButton(parent) { + private val holdMs = 600L + private var pressStart = 0L + private var animProgress = 0f + private var animActive = false + private var longPressFired = false + private var holdActive = false + + var onNormalClick: (() -> Unit)? = null + var onLongPress: (() -> Unit)? = null + + private var holdJob: Job? = null + + override fun mousePressEvent(event: QMouseEvent?) { + if (event?.button() == Qt.MouseButton.LeftButton) { + val shiftHeld = QGuiApplication.queryKeyboardModifiers().testFlag(Qt.KeyboardModifier.ShiftModifier) + if (shiftHeld) { + holdActive = true + longPressFired = false + animActive = false + animProgress = 0f + pressStart = System.currentTimeMillis() + holdJob?.cancel() + holdJob = pressScope.launch { + while (isActive) { + val elapsed = System.currentTimeMillis() - pressStart + if (elapsed >= holdMs) { + animActive = false + animProgress = 1f + update() + onLongPress?.invoke() + longPressFired = true + return@launch + } + animActive = true + animProgress = (elapsed.toFloat() / holdMs).coerceIn(0f, 1f) + update() + delay(16.milliseconds) + } + } + } else { + holdActive = false + } + } + super.mousePressEvent(event) + } + + override fun mouseReleaseEvent(event: QMouseEvent?) { + if (event?.button() == Qt.MouseButton.LeftButton) { + if (holdActive) { + holdActive = false + holdJob?.cancel() + holdJob = null + animActive = false + animProgress = 0f + update() + if (!longPressFired) { + onNormalClick?.invoke() + } + blockSignals(true) + super.mouseReleaseEvent(event) + blockSignals(false) + return + } + onNormalClick?.invoke() + } + super.mouseReleaseEvent(event) + } + + override fun paintEvent(event: QPaintEvent?) { + super.paintEvent(event) + if (animActive && animProgress > 0.01f) { + val painter = QPainter(this) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + val pen = QPen(QColor(TColors.Accent), 2.5) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + val w = width() + val h = height() + val diameter = minOf(w, h) - 8 + val r = diameter / 2.0 + val cx = w / 2.0 + val cy = h / 2.0 + val rect = QRectF(cx - r, cy - r, diameter.toDouble(), diameter.toDouble()) + val startAngle = 90 * 16 + val span = -(360.0 * animProgress * 16).toInt() + painter.drawArc(rect, startAngle, span) + painter.end() + } + } + + companion object { + private val pressScope = CoroutineScope(Dispatchers.Main) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/RemoteImageTextBrowser.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/RemoteImageTextBrowser.kt new file mode 100644 index 0000000..42e62d8 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/RemoteImageTextBrowser.kt @@ -0,0 +1,83 @@ +package io.github.tritium_launcher.launcher.ui.widgets + +import io.github.tritium_launcher.launcher.connect +import io.github.tritium_launcher.launcher.ui.helpers.runOnGuiThread +import io.qt.NonNull +import io.qt.core.QUrl +import io.qt.core.Qt +import io.qt.gui.QPixmap +import io.qt.widgets.QTextBrowser +import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Used to render Images in rich text environments + */ +class RemoteImageTextBrowser( + private val fetchBytes: suspend (String) -> ByteArray +): QTextBrowser() { + + private val imageCache = ConcurrentHashMap() + private val pixmapCache = ConcurrentHashMap() + private val pending = ConcurrentHashMap.newKeySet() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var currentHtml: String = "" + + private val imgTagRegex = Regex("""(?i)]*>""") + private val dimAttrRegex = Regex("""\s+(?:width|height)\s*=\s*["'][^"']*["']""") + + init { + destroyed.connect { scope.cancel() } + } + + fun setHtmlContent(html: String) { + val cleaned = html.replace(imgTagRegex) { match -> + match.value.replace(dimAttrRegex, "") + } + currentHtml = cleaned + setHtml(cleaned) + } + + override fun loadResource(type: Int, url: @NonNull QUrl): Any? { + if(type != 2) return super.loadResource(type, url) + val urlStr = url.toString() + if(!urlStr.startsWith("http")) return super.loadResource(type, url) + + val cachedPixmap = pixmapCache[urlStr] + if(cachedPixmap != null) return cachedPixmap + + val cached = imageCache[urlStr] + if(cached != null) { + val pixmap = QPixmap() + if (pixmap.loadFromData(cached)) { + val vp = viewport() + val maxW = (vp?.width()?.coerceAtLeast(100) ?: 600) - 10 + val maxH = ((vp?.height()?.coerceAtLeast(100) ?: 600) * 0.6).toInt().coerceAtMost(400).coerceAtLeast(200) + val scaled = if (pixmap.width() > maxW || pixmap.height() > maxH) { + pixmap.scaled(maxW, maxH, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + } else pixmap + pixmapCache[urlStr] = scaled + return scaled + } + return pixmap + } + + if(pending.add(urlStr)) { + scope.launch { + val bytes = runCatching { fetchBytes(urlStr) }.getOrNull() + pending.remove(urlStr) + if(bytes != null && bytes.isNotEmpty()) { + imageCache[urlStr] = bytes + runOnGuiThread { + val bar = verticalScrollBar() + val pos = bar?.value ?: 0 + html = currentHtml + bar?.value = pos + } + } + } + } + + return null + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TComboBox.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TComboBox.kt index 5813da0..4588245 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TComboBox.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TComboBox.kt @@ -1,5 +1,6 @@ package io.github.tritium_launcher.launcher.ui.widgets +import io.github.tritium_launcher.launcher.connect import io.github.tritium_launcher.launcher.currentDpr import io.github.tritium_launcher.launcher.qs import io.github.tritium_launcher.launcher.ui.theme.TColors @@ -10,18 +11,20 @@ import io.github.tritium_launcher.launcher.ui.widgets.pixel.pixelSkin import io.qt.Nullable import io.qt.core.QEvent import io.qt.core.Qt -import io.qt.gui.QMoveEvent -import io.qt.gui.QPaintEvent -import io.qt.gui.QPainter -import io.qt.gui.QShowEvent +import io.qt.gui.* import io.qt.widgets.* +import io.qt.widgets.QSizePolicy.Policy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.min /** - * Sprite-style combo box. + * Minecraft-styled combo box. */ -class TComboBox(parent: QWidget? = null) : QComboBox(parent) { +open class TComboBox(parent: QWidget? = null) : QComboBox(parent) { private var popupVisible = false private var lastDpr: Double = -1.0 @@ -31,9 +34,12 @@ class TComboBox(parent: QWidget? = null) : QComboBox(parent) { minimumHeight = 34 val listView = QListView() + listView.objectName = "tComboBoxPopupList" listView.frameShape = QFrame.Shape.NoFrame - listView.alternatingRowColors = true + listView.alternatingRowColors = false + listView.verticalScrollMode = QAbstractItemView.ScrollMode.ScrollPerPixel setView(listView) + AnimatedScrollController.attach(listView) applyPopupStyle() } @@ -63,11 +69,7 @@ class TComboBox(parent: QWidget? = null) : QComboBox(parent) { else -> State.Normal } - val dpr = try { - currentDpr(this) - } catch (_: Throwable) { - 1.0 - } + val dpr = currentDpr(this) handleDprChange(dpr) val bg = skin.render(state.key, w, h, dpr) if (!bg.isNull) { @@ -127,6 +129,9 @@ class TComboBox(parent: QWidget? = null) : QComboBox(parent) { handleDprChange(currentDpr(this)) } + /** + * When DPR changes, update button to new values + */ private fun handleDprChange(dpr: Double) { if (lastDpr < 0.0 || abs(lastDpr - dpr) > 0.001) { skin.clearCache(disposePixmaps = true) @@ -135,36 +140,63 @@ class TComboBox(parent: QWidget? = null) : QComboBox(parent) { } } + /** + * Apply styling to option list popup + */ private fun applyPopupStyle() { view()?.setThemedStyle { - selector("QListView") { - border(2, TColors.Surface0) - borderRadius(4) - backgroundColor(TColors.Surface1) + selector("QListView#tComboBoxPopupList") { + backgroundColor(TColors.Surface0) color(TColors.Text) - padding(2) - any("alternate-background-color", TColors.Surface0) + border(1, TColors.Button0) + padding(3) + any("show-decoration-selected", "1") + any("outline", "none") } - selector("QListView::item") { + selector("QListView#tComboBoxPopupList::item") { + backgroundColor("transparent") + color(TColors.Text) border() - padding(4, 6, 4, 6) + padding(6, 8, 6, 8) + margin(0, 0, 1, 0) + } + selector("QListView#tComboBoxPopupList::item:hover") { + backgroundColor(TColors.Surface1) + color(TColors.Text) + } + selector("QListView#tComboBoxPopupList::item:selected") { + backgroundColor(TColors.SelectedUI) + color(TColors.SelectedText) + } + selector("QListView#tComboBoxPopupList::item:selected:hover") { + backgroundColor(TColors.SelectedUI) + color(TColors.SelectedText) } } } + /** + * Button states + */ private enum class State(val key: String) { Normal("normal"), Pressed("pressed"), Disabled("disabled") } companion object { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var skin = buildSkin() init { - ThemeMngr.addListener { - val prev = skin - skin = buildSkin() - prev.clearCache(disposePixmaps = true) + scope.launch { + ThemeMngr.currentThemeId.collect { + val prev = skin + skin = buildSkin() + prev.clearCache(disposePixmaps = true) + } } } + /** + * Build sprites + */ private fun buildSkin() = pixelSkin { pixelSize = 2 palette { @@ -224,3 +256,314 @@ class TComboBox(parent: QWidget? = null) : QComboBox(parent) { TComboBox(parent).apply(block) } } + +/** + * Combo box that opens a custom popup for including or excluding category ids. + * + * The visible combo text acts as a summary of the current include/exclude state, + * while the popup provides per-category toggle buttons. + */ +class TMultiStateCategoryComboBox(parent: QWidget? = null) : TComboBox(parent) { + /** + * Selection state tracked for each category entry + */ + enum class State { NEUTRAL, INCLUDE, EXCLUDE } + + /** + * Mutable popup row model for a single category. + * + * @property id Stable category id. + * @property label User-facing category label. + * @property state Current include/exclude state. + */ + data class Entry( + val id: String, + val label: String, + val iconUrl: String? = null, + var state: State = State.NEUTRAL, + var pixmap: QPixmap? = null + ) + + private val popup = QMenu(this) + private val popupScroll = QScrollArea() + private val popupContent = QWidget() + private val popupLayout = QVBoxLayout(popupContent) + private val entries = linkedMapOf() + private var popupVisible = false + + var onSelectionChanged: (() -> Unit)? = null + + init { + isEditable = false + minimumHeight = 26 + maximumHeight = 26 + sizePolicy = QSizePolicy(Policy.Expanding, Policy.Fixed) + popup.objectName = "tMultiStateComboPopup" + popupScroll.objectName = "tMultiStateComboScroll" + popupContent.objectName = "tMultiStateComboContent" + popupScroll.apply { + widgetResizable = true + frameShape = QFrame.Shape.NoFrame + setWidget(popupContent) + minimumWidth = 280 + minimumHeight = 180 + } + AnimatedScrollController.attach(popupScroll) + popupLayout.apply { + setContentsMargins(0, 0, 0, 0) + setSpacing(0) + } + val action = QWidgetAction(popup) + action.setDefaultWidget(popupScroll) + popup.addAction(action) + popup.setThemedStyle { + selector("QMenu#tMultiStateComboPopup") { + backgroundColor(TColors.Surface0) + color(TColors.Text) + border(1, TColors.Button0) + padding(0) + } + selector("QScrollArea#tMultiStateComboScroll") { + backgroundColor(TColors.Surface0) + border() + } + selector("#tMultiStateComboContent") { + backgroundColor(TColors.Surface0) + border() + } + selector("#tMultiStateComboRow") { + backgroundColor(TColors.Surface0) + border(1, TColors.Button0, "bottom") + } + selector("#tMultiStateComboRow QLabel") { + color(TColors.Text) + fontWeight(600) + } + selector("QPushButton#tMultiStateComboAction") { + minWidth(68) + border(1, TColors.Button0) + borderRadius(3) + padding(5, 10, 5, 10) + backgroundColor(TColors.Surface1) + color(TColors.Subtext) + } + selector("QPushButton#tMultiStateComboAction:hover") { + backgroundColor(TColors.Surface2) + color(TColors.Text) + } + selector("QPushButton#tMultiStateComboAction[categoryState=\"active\"]") { + backgroundColor(TColors.SelectedUI) + color(TColors.SelectedText) + border(1, TColors.Button3) + } + selector("QPushButton#tMultiStateComboAction[categoryState=\"active\"]:hover") { + backgroundColor(TColors.SelectedUI) + color(TColors.SelectedText) + } + } + refreshSummary() + } + + override fun showPopup() { + refreshRows() + val pos = mapToGlobal(rect().bottomLeft()) + popup.minimumWidth = width().coerceAtLeast(320) + popupVisible = true + update() + popup.exec(pos) + popupVisible = false + update() + } + + override fun hidePopup() { + popupVisible = false + update() + super.hidePopup() + } + + override fun paintEvent(event: QPaintEvent?) { + val painter = QPainter(this) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, true) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, true) + + val contentRect = rect().adjusted(0, 0, -1, -1) + + val borderColor = QColor(TColors.Button0) + borderColor.setAlpha(if (popupVisible) 220 else 160) + painter.setPen(borderColor) + painter.drawLine(contentRect.left(), 0, contentRect.right(), 0) + + val labelText = currentText().ifBlank { "All categories" }.uppercase() + val labelColor = QColor(if (popupVisible) TColors.Text else TColors.Subtext) + if (!isEnabled) { + labelColor.setAlpha(120) + } else if (!popupVisible) { + labelColor.setAlpha(210) + } + + painter.setPen(labelColor) + val metrics = painter.fontMetrics() + val textRect = contentRect.adjusted(0, 0, -18, 0) + painter.drawText(textRect, Qt.AlignmentFlag.AlignLeft.value() or Qt.AlignmentFlag.AlignVCenter.value(), labelText) + + val lineLeft = metrics.horizontalAdvance(labelText) + 10 + val lineRight = contentRect.right() - 16 + if (lineRight > lineLeft) { + val dividerColor = QColor(TColors.Button0) + dividerColor.setAlpha(if (popupVisible) 220 else 150) + painter.setPen(dividerColor) + val y = contentRect.center().y() + painter.drawLine(lineLeft, y, lineRight, y) + } + + val arrow = TIcons.SmallArrowDown + if (!arrow.isNull) { + val scaled = arrow.scaled( + qs(10, 10), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + val x = contentRect.right() - scaled.width() + val y = contentRect.center().y() - (scaled.height() / 2) + painter.drawPixmap(x, y, scaled) + } + + painter.end() + } + + /** + * Replaces the available category entries with icon URLs while preserving the prior selection state by id. + * + * @param values Triples of category id, display label, and optional icon URL. + */ + fun setEntries(values: List>) { + val preserved = entries.mapValues { it.value.state } + entries.clear() + values.forEach { (id, label, iconUrl) -> + entries[id] = Entry(id = id, label = label, iconUrl = iconUrl, state = preserved[id] ?: State.NEUTRAL) + } + refreshRows() + refreshSummary() + } + + /** + * Returns ids currently marked for inclusion. + */ + fun includedIds(): Set = entries.values.filter { it.state == State.INCLUDE }.map { it.id }.toSet() + + /** + * Returns ids currently marked for exclusion. + */ + fun excludedIds(): Set = entries.values.filter { it.state == State.EXCLUDE }.map { it.id }.toSet() + + /** + * Rebuilds popup rows to reflect the current entry states. + */ + private fun refreshRows() { + while (popupLayout.count() > 0) { + val item = popupLayout.takeAt(0) + item?.widget()?.let { widget -> + widget.hide() + widget.setParent(null) + widget.dispose() + } + } + entries.values.forEach { entry -> + val row = QWidget() + row.objectName = "tMultiStateComboRow" + val layout = QHBoxLayout(row) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(8) + val iconLabel = QLabel() + iconLabel.setFixedSize(48, 48) + iconLabel.objectName = "tMultiStateComboIcon" + iconLabel.visible = false + entry.iconUrl?.let { url -> + iconLabel.setProperty("iconUrl", url) + } + entry.pixmap?.let { pixmap -> + iconLabel.pixmap = pixmap.scaled(48, 48, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) + iconLabel.visible = true + } + val label = QLabel(entry.label) + label.sizePolicy = QSizePolicy(Policy.Expanding, Policy.Preferred) + label.wordWrap = false + val include = QPushButton("Include") + val exclude = QPushButton("Exclude") + include.objectName = "tMultiStateComboAction" + exclude.objectName = "tMultiStateComboAction" + include.setFixedWidth(72) + exclude.setFixedWidth(72) + updateButtonState(include, entry.state == State.INCLUDE) + updateButtonState(exclude, entry.state == State.EXCLUDE) + include.clicked.connect { _ -> + entry.state = if (entry.state == State.INCLUDE) State.NEUTRAL else State.INCLUDE + refreshRows() + refreshSummary() + onSelectionChanged?.invoke() + } + exclude.clicked.connect { _ -> + entry.state = if (entry.state == State.EXCLUDE) State.NEUTRAL else State.EXCLUDE + refreshRows() + refreshSummary() + onSelectionChanged?.invoke() + } + layout.addWidget(iconLabel, 0) + layout.addSpacing(4) + layout.addWidget(label, 1) + layout.addSpacing(8) + layout.addWidget(include, 0, Qt.AlignmentFlag.AlignRight) + layout.addWidget(exclude, 0, Qt.AlignmentFlag.AlignRight) + popupLayout.addWidget(row) + } + popupLayout.addStretch(1) + } + + /** + * Updates the combo's single visible item to summarize the current selection state. + */ + private fun refreshSummary() { + clear() + val includeCount = includedIds().size + val excludeCount = excludedIds().size + val text = when { + includeCount == 0 && excludeCount == 0 -> "All categories" + excludeCount == 0 -> "$includeCount included" + includeCount == 0 -> "$excludeCount excluded" + else -> "$includeCount included, $excludeCount excluded" + } + addItem(text) + currentIndex = 0 + } + + /** + * Sets an icon pixmap on the row for the given category id. + */ + fun setEntryIcon(id: String, pixmap: QPixmap) { + entries[id]?.pixmap = pixmap + val entryUrl = entries[id]?.iconUrl + val scaled = pixmap.scaled(48, 48, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) + for (i in 0 until popupLayout.count()) { + val item = popupLayout.itemAt(i) + val widget = item?.widget() ?: continue + val iconLabel = widget.findChild(QLabel::class.java, "tMultiStateComboIcon") ?: continue + val labelUrl = iconLabel.property("iconUrl")?.toString() + if (labelUrl != entryUrl) continue + iconLabel.pixmap = scaled + iconLabel.visible = true + } + } + + /** + * Applies visual state to a popup action button. + * + * @param button Button to restyle. + * @param active Whether the button represents the currently active state. + */ + private fun updateButtonState(button: QPushButton, active: Boolean) { + button.isFlat = !active + button.setProperty("categoryState", if (active) "active" else "idle") + button.style()?.unpolish(button) + button.style()?.polish(button) + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TPushButton.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TPushButton.kt index 621b502..daf3d54 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TPushButton.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TPushButton.kt @@ -4,26 +4,48 @@ import io.github.tritium_launcher.launcher.connect import io.github.tritium_launcher.launcher.currentDpr import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.ThemeMngr +import io.github.tritium_launcher.launcher.ui.widgets.pixel.PixelSkin import io.github.tritium_launcher.launcher.ui.widgets.pixel.pixelSkin import io.qt.Nullable import io.qt.core.QEvent -import io.qt.gui.QMoveEvent -import io.qt.gui.QPaintEvent -import io.qt.gui.QPainter -import io.qt.gui.QShowEvent +import io.qt.gui.* import io.qt.widgets.QPushButton import io.qt.widgets.QStyle import io.qt.widgets.QStyleOptionButton import io.qt.widgets.QWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlin.math.abs /** * Minecraft-styled push button. + * + * @param parent Parent widget. + * @param tint Optional hex color string to tint the button (e.g. [TColors.Green]). + * Preserves the original button's lightness gradients while applying the tint's + * hue and saturation. */ class TPushButton( - parent: QWidget? = null + parent: QWidget? = null, + tint: String? = null ) : QPushButton(parent) { private var lastDpr: Double = -1.0 + + /** + * Optional hex color string to tint the button. + * Preserves lightness from the theme button colors while applying this color's + * hue and saturation. + */ + var tint: String? = tint + set(value) { + if (field != value) { + field = value + update() + } + } + /** * Additional Y offset applied to the button label. * @@ -59,10 +81,11 @@ class TPushButton( else -> State.Normal } - val dpr = detectDpr(this) + val dpr = currentDpr(this) handleDprChange(dpr) - val bg = skin.render(state.key, w, h, dpr) + val s = if (tint != null) getTintedSkin(tint!!) else skin + val bg = s.render(state.key, w, h, dpr) if(!bg.isNull) { painter.drawPixmap(0, 0, bg) @@ -73,6 +96,9 @@ class TPushButton( painter.end() } + /** + * Draw standard Qt button label on top of sprite + */ private fun drawLabel(painter: QPainter, dpr: Double) { val opt = QStyleOptionButton() initStyleOption(opt) @@ -95,6 +121,8 @@ class TPushButton( QEvent.Type.DevicePixelRatioChange, QEvent.Type.ScreenChangeInternal -> { skin.clearCache(disposePixmaps = true) + tintedSkins.values.forEach { it.clearCache(disposePixmaps = true) } + tintedSkins.clear() lastDpr = -1.0 update() } @@ -119,35 +147,121 @@ class TPushButton( handleDprChange(currentDpr(this)) } - private fun detectDpr(widget: QWidget?): Double { - return try { - currentDpr(widget) - } catch (_: Throwable) { - 1.0 - } - } - + /** + * When DPR changes, update button to new values + */ private fun handleDprChange(dpr: Double) { if (lastDpr < 0.0 || abs(lastDpr - dpr) > 0.001) { skin.clearCache(disposePixmaps = true) + tintedSkins.values.forEach { it.clearCache(disposePixmaps = true) } lastDpr = dpr update() } } + /** + * Button states + */ enum class State(val key: String) { Normal("normal"), Pressed("pressed"), Disabled("disabled") } companion object { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var skin = buildSkin() + private val tintedSkins = mutableMapOf() init { - ThemeMngr.addListener { - val prev = skin - skin = buildSkin() - prev.clearCache(disposePixmaps = true) + scope.launch { + ThemeMngr.currentThemeId.collect { + val prev = skin + skin = buildSkin() + prev.clearCache(disposePixmaps = true) + tintedSkins.values.forEach { it.clearCache(disposePixmaps = true) } + tintedSkins.clear() + } } } + /** + * Return the tinted skin for [tintHex], building it on first use. + */ + private fun getTintedSkin(tintHex: String): PixelSkin = + tintedSkins.getOrPut(tintHex) { buildTintedSkin(tintHex) } + + /** + * Apply [tintHex] hue and saturation to each button color while preserving + * the original lightness. + */ + private fun buildTintedSkin(tintHex: String) = pixelSkin { + pixelSize = 2 + palette { + color("border", tintColor(TColors.Button0, tintHex)) + color("shadow", tintColor(TColors.Button1, tintHex)) + color("primary", tintColor(TColors.Button2, tintHex)) + color("bright", tintColor(TColors.Button3, tintHex)) + color("disabled", TColors.ButtonDisabled0) + color("disabledBorder", TColors.ButtonDisabled1) + } + + state("normal") { + draw { + val p = px + val w = width + val h = height + fillRect(0, 0, w, h, "border") + fillRect(p, p, w - p * 2, h - p * 2, "primary") + fillRect(p, p, w - p * 2, p, "bright") + fillRect(p, p, p, h - p * 5, "bright") + fillRect(w - p * 2, p, p, h - p * 5, "bright") + fillRect(p, h - p * 4, w - p * 2, p, "bright") + fillRect(p, h - p * 3, w - p * 2, p * 2, "shadow") + } + } + + state("pressed") { + draw { + val p = px + val w = width + val h = height + fillRect(0, p, w, h - p, "border") + fillRect(p, p + 2, w - p * 2, h - p - 4, "primary") + fillRect(p, p + 2, w - p * 2, p, "bright") + fillRect(p, p + 2, p, h - p * 4, "bright") + fillRect(w - p * 2, p + 2, p, h - p * 4, "bright") + fillRect(p, h - p * 2, w - p * 2, p, "bright") + } + } + + state("disabled") { + draw { + val p = px + val w = width + val h = height + fillRect(0, 0, w, h, "disabledBorder") + fillRect(p, p, w - p * 2, h - p * 2, "disabled") + fillRect(p, p, w - p * 2, p, "disabled") + fillRect(p, p, p, h - p * 3, "disabled") + fillRect(w - p * 2, p, p, h - p * 3, "disabled") + fillRect(p, h - p * 2, w - p * 2, p, "disabled") + } + } + } + + /** + * Replace the lightness of [originalHex] with the hue+saturation of [tintHex]. + */ + private fun tintColor(originalHex: String, tintHex: String): String { + val src = QColor(originalHex) + val t = QColor(tintHex) + val l = src.lightness() + val h = t.hslHue() + val s = t.hslSaturation() + val effectiveHue = if (h < 0) src.hslHue() else h + return QColor.fromHsl(effectiveHue, s, l).name() + } + + /** + * Build Sprite + */ private fun buildSkin() = pixelSkin { pixelSize = 2 palette { @@ -203,7 +317,7 @@ class TPushButton( } } - operator fun invoke(parent: QWidget? = null, block: TPushButton.() -> Unit = {}): TPushButton = - TPushButton(parent).apply(block) + operator fun invoke(parent: QWidget? = null, tint: String? = null, block: TPushButton.() -> Unit = {}): TPushButton = + TPushButton(parent, tint).apply(block) } } diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TToggleSwitch.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TToggleSwitch.kt index 2f90b71..98e0848 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TToggleSwitch.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TToggleSwitch.kt @@ -14,6 +14,10 @@ import io.qt.core.Qt import io.qt.gui.* import io.qt.widgets.QSizePolicy import io.qt.widgets.QWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Minecraft-styled on/off switch widget. @@ -120,6 +124,9 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { override fun sizeHint(): QSize = qs(DEFAULT_WIDTH, DEFAULT_HEIGHT) + /** + * Toggle States + */ private enum class State(val key: String) { Off("off"), On("on"), @@ -128,6 +135,7 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { } companion object { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private const val SPRITE_WIDTH = 20 private const val SPRITE_HEIGHT = 12 private const val DEFAULT_WIDTH = 40 @@ -136,13 +144,18 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { private var skin = buildSkin() init { - ThemeMngr.addListener { - val prev = skin - skin = buildSkin() - prev.clearCache(disposePixmaps = true) + scope.launch { + ThemeMngr.currentThemeId.collect { + val prev = skin + skin = buildSkin() + prev.clearCache(disposePixmaps = true) + } } } + /** + * Build sprites + */ private fun buildSkin() = pixelSkin { pixelSize = 1 @@ -189,7 +202,6 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { } } - // Exact ON sprite shape from provided reference. private val ON_ROWS = arrayOf( "00000000011111111111", "00000000012222222221", @@ -205,7 +217,6 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { "11111111111111111111" ) - // Exact OFF sprite shape from provided reference. private val OFF_ROWS = arrayOf( "00000000000111111111", "02222222220333333333", @@ -275,6 +286,9 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { '7' to "disabledShadow" ) + /** + * Create Sprite runs + */ private fun compileRuns(rows: Array): List { val runs = ArrayList(rows.size * 8) rows.forEachIndexed { y, row -> @@ -295,7 +309,10 @@ class TToggleSwitch(parent: QWidget? = null) : QWidget(parent) { } return runs } - + + /** + * Draw Sprite runs from provided values + */ private fun PixelDrawScope.drawSpriteRuns( runs: List, symbolColors: Map diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TTooltip.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TTooltip.kt new file mode 100644 index 0000000..0978d34 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/TTooltip.kt @@ -0,0 +1,177 @@ +package io.github.tritium_launcher.launcher.ui.widgets + +import io.qt.core.QPoint +import io.qt.core.QRect +import io.qt.core.QSize +import io.qt.core.Qt +import io.qt.gui.* +import io.qt.widgets.QApplication +import io.qt.widgets.QWidget +import kotlin.math.max + +data class TTooltipStyle( + val background: QColor = QColor(16, 0, 16, 240), + val borderTop: QColor = QColor(80, 0, 255, 110), + val borderBottom: QColor = QColor(40, 0, 127, 110), + val text: QColor = QColor(255, 255, 255) +) + +object TTooltip { + private val popup = TTooltipPopup() + + fun show(globalPos: QPoint, text: String, style: TTooltipStyle = TTooltipStyle()) { + val lines = text.lines().filter { it.isNotBlank() } + if (lines.isEmpty()) { + hide() + return + } + + popup.setContent(lines, style) + popup.adjustSize() + + val screen = QApplication.screenAt(globalPos) ?: QApplication.primaryScreen() + val available = screen?.availableGeometry() + var x = globalPos.x() + 12 + var y = globalPos.y() + 12 + + if (available != null) { + if (x + popup.width() > available.right()) { + x = globalPos.x() - popup.width() - 12 + } + if (y + popup.height() > available.bottom()) { + y = globalPos.y() - popup.height() - 12 + } + x = x.coerceIn(available.left(), max(available.left(), available.right() - popup.width())) + y = y.coerceIn(available.top(), max(available.top(), available.bottom() - popup.height())) + } + + popup.move(x, y) + popup.show() + popup.raise() + } + + fun renderPixmap( + text: String, + style: TTooltipStyle = TTooltipStyle(), + icon: QPixmap? = null, + iconSize: Int = 16 + ): QPixmap { + val lines = text.lines().filter { it.isNotBlank() }.ifEmpty { listOf(text) } + return TTooltipPopup.render(lines, style, icon, iconSize) + } + + fun show(event: QHelpEvent, text: String, style: TTooltipStyle = TTooltipStyle(), globalPos: QPoint = event.globalPos()) { + show(globalPos, text, style) + } + + fun hide() { + popup.hide() + } +} + +private class TTooltipPopup : QWidget(null as QWidget?) { + private var lines: List = emptyList() + private var tooltipStyle: TTooltipStyle = TTooltipStyle() + private val tooltipFont = QFont("SansSerif", 10) + + init { + setWindowFlag(Qt.WindowType.ToolTip) + setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, true) + setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, true) + } + + fun setContent(nextLines: List, style: TTooltipStyle) { + lines = nextLines + tooltipStyle = style + updateGeometry() + update() + } + + override fun sizeHint(): QSize { + return sizeFor(lines, tooltipFont) + } + + override fun paintEvent(event: QPaintEvent?) { + val painter = QPainter(this) + paint(painter, width(), height(), lines, tooltipFont, tooltipStyle) + painter.end() + } + + companion object { + private const val PADDING_X = 8 + private const val PADDING_Y = 6 + + fun render(lines: List, style: TTooltipStyle, icon: QPixmap? = null, iconSize: Int = 16): QPixmap { + val font = QFont("SansSerif", 10) + val size = sizeFor(lines, font, icon, iconSize) + val pixmap = QPixmap(size) + pixmap.fill(QColor(0, 0, 0, 0)) + val painter = QPainter(pixmap) + paint(painter, size.width(), size.height(), lines, font, style, icon, iconSize) + painter.end() + return pixmap + } + + private fun sizeFor(lines: List, font: QFont, icon: QPixmap? = null, iconSize: Int = 16): QSize { + val metrics = QFontMetrics(font) + val width = lines.maxOfOrNull { metrics.horizontalAdvance(it) } ?: 0 + val lineHeight = metrics.height() + val gap = if (lines.size > 1) (lines.size - 1) * 2 else 0 + val iconWidth = if (icon != null) iconSize + 5 else 0 + val contentHeight = maxOf(lineHeight * lines.size + gap, if (icon != null) iconSize else 0) + return QSize(width + iconWidth + PADDING_X * 2, contentHeight + PADDING_Y * 2) + } + + private fun paint( + painter: QPainter, + width: Int, + height: Int, + lines: List, + font: QFont, + style: TTooltipStyle, + icon: QPixmap? = null, + iconSize: Int = 16 + ) { + painter.setRenderHint(QPainter.RenderHint.Antialiasing, false) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, false) + + painter.fillRect(2, 2, width - 4, height - 4, style.background) + painter.fillRect(3, 1, width - 6, 1, style.borderTop) + painter.fillRect(3, height - 2, width - 6, 1, style.borderBottom) + painter.fillRect(1, 3, 1, height - 6, style.borderTop) + painter.fillRect(width - 2, 3, 1, height - 6, style.borderBottom) + painter.fillRect(2, 2, 1, 1, style.borderTop) + painter.fillRect(width - 3, 2, 1, 1, style.borderTop) + painter.fillRect(2, height - 3, 1, 1, style.borderBottom) + painter.fillRect(width - 3, height - 3, 1, 1, style.borderBottom) + + painter.setFont(font) + painter.setPen(style.text) + val metrics = QFontMetrics(font) + val textX = PADDING_X + if (icon != null) iconSize + 5 else 0 + if (icon != null) { + val iconY = (height - iconSize) / 2 + painter.drawPixmap( + PADDING_X, + iconY, + icon.scaled( + iconSize, + iconSize, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation + ) + ) + } + + var y = PADDING_Y + metrics.ascent() + for (line in lines) { + painter.drawText( + QRect(textX, y - metrics.ascent(), width - textX - PADDING_X, metrics.height()), + 0, + line + ) + y += metrics.height() + 2 + } + } + } +} diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/constructor_functions/ConstructorFunctions.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/constructor_functions/ConstructorFunctions.kt index 5407435..0e8fa17 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/constructor_functions/ConstructorFunctions.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/widgets/constructor_functions/ConstructorFunctions.kt @@ -2,95 +2,91 @@ * Provides methods to make Qt Widgets without having to use .apply everywhere. It looks much cleaner this way! */ +@file:Suppress("unused") + package io.github.tritium_launcher.launcher.ui.widgets.constructor_functions import io.qt.core.Qt +import io.qt.gui.QAction +import io.qt.gui.QIcon import io.qt.widgets.* -/** - * Made for visibility in IntelliJ. - */ -@DslMarker -@Target(AnnotationTarget.FUNCTION) -annotation class QObj -@QObj fun qWidget(parent: QWidget? = null, block: QWidget.() -> Unit = {}): QWidget = QWidget(parent).apply(block) -@QObj fun formLayout(parent: QWidget, block: QFormLayout.() -> Unit = {}): QFormLayout = QFormLayout(parent).apply(block) -@QObj fun formLayout(block: QFormLayout.() -> Unit = {}): QFormLayout = QFormLayout().apply(block) -@QObj fun gridLayout(parent: QWidget, block: QGridLayout.() -> Unit = {}): QGridLayout = QGridLayout(parent).apply(block) -@QObj fun gridLayout(block: QGridLayout.() -> Unit = {}): QGridLayout = QGridLayout().apply(block) -@QObj fun stackedLayout(parent: QWidget, block: QStackedLayout.() -> Unit = {}): QStackedLayout = QStackedLayout(parent).apply(block) -@QObj fun stackedLayout(parentLayout: QLayout, block: QStackedLayout.() -> Unit = {}): QStackedLayout = QStackedLayout(parentLayout).apply(block) -@QObj fun stackedLayout(block: QStackedLayout.() -> Unit = {}): QStackedLayout = QStackedLayout().apply(block) -@QObj fun vBoxLayout(parent: QWidget, block: QVBoxLayout.() -> Unit = {}): QVBoxLayout = QVBoxLayout(parent).apply(block) -@QObj fun vBoxLayout( block: QVBoxLayout.() -> Unit = {}): QVBoxLayout = QVBoxLayout().apply(block) -@QObj fun hBoxLayout(parent: QWidget, block: QHBoxLayout.() -> Unit = {}): QHBoxLayout = QHBoxLayout(parent).apply(block) -@QObj fun hBoxLayout(block: QHBoxLayout.() -> Unit = {}): QHBoxLayout = QHBoxLayout().apply(block) - -@QObj fun widget(parent: QWidget? = null, block: QWidget.() -> Unit = {}): QWidget = QWidget(parent).apply(block) -@QObj fun widget(parent: QWidget? = null, windowFlags: Qt.WindowFlags, block: QWidget.() -> Unit = {}): QWidget = QWidget(parent, windowFlags).apply(block) -@QObj fun frame(parent: QWidget? = null, block: QFrame.() -> Unit = {}): QFrame = QFrame(parent).apply(block) -@QObj fun frame(parent: QWidget? = null, windowFlags: Qt.WindowFlags, block: QFrame.() -> Unit = {}): QFrame = QFrame(parent, windowFlags).apply(block) -@QObj fun label(parent: QWidget? = null, block: QLabel.() -> Unit = {}): QLabel = QLabel(parent).apply(block) -@QObj fun label(text: String, parent: QWidget? = null, block: QLabel.() -> Unit = {}): QLabel = QLabel(text, parent).apply(block) -@QObj +fun pushButton(text: String, parent: QWidget? = null, block: QPushButton.() -> Unit = {}): QPushButton = + QPushButton(text, parent).apply(block) + fun pushButton(parent: QWidget? = null, block: QPushButton.() -> Unit = {}): QPushButton = QPushButton(parent).apply(block) -@QObj fun toolButton(parent: QWidget? = null, block: QToolButton.() -> Unit = {}): QToolButton = - QToolButton(parent).apply(block) \ No newline at end of file + QToolButton(parent).apply(block) + + +fun qAction(parent: QWidget? = null, block: QAction.() -> Unit = {}) = + QAction(parent).apply(block) + +fun qAction(text: String, block: QAction.() -> Unit = {}) = + QAction(text).apply(block) + +fun qAction(text: String, parent: QWidget? = null, block: QAction.() -> Unit = {}) = + QAction(text, parent).apply(block) + +fun qAction(text: String, icon: QIcon?, block: QAction.() -> Unit = {}) = + QAction(text).apply(block).apply { icon?.let { setIcon(it) } } + +fun qAction(text: String, icon: QIcon?, parent: QWidget? = null, block: QAction.() -> Unit = {}) = + QAction(text, parent).apply(block).apply { icon?.let { setIcon(it) } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/wizard/NewProjectDialog.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/wizard/NewProjectDialog.kt index dee6696..05f096c 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/ui/wizard/NewProjectDialog.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/ui/wizard/NewProjectDialog.kt @@ -7,9 +7,9 @@ import io.github.tritium_launcher.launcher.core.project.ProjectType import io.github.tritium_launcher.launcher.coroutines.UIDispatcher import io.github.tritium_launcher.launcher.extension.core.BuiltinRegistries import io.github.tritium_launcher.launcher.io.VPath -import io.github.tritium_launcher.launcher.ui.dashboard.Dashboard import io.github.tritium_launcher.launcher.ui.theme.TColors import io.github.tritium_launcher.launcher.ui.theme.qt.setThemedStyle +import io.github.tritium_launcher.launcher.ui.widgets.AnimatedScrollController import io.github.tritium_launcher.launcher.ui.widgets.TPushButton import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.hBoxLayout import io.github.tritium_launcher.launcher.ui.widgets.constructor_functions.label @@ -75,9 +75,10 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa frameShape = QFrame.Shape.NoFrame frameShadow = QFrame.Shadow.Plain } + AnimatedScrollController.attach(list) leftLayout.addWidget(list) - val rightPanel = widget { + val rightPanel = widget { objectName = "rightPanel" autoFillBackground = true setAttribute(Qt.WidgetAttribute.WA_StyledBackground, true) @@ -90,16 +91,18 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa rightLayout.addWidget(statusLabel) createButton.apply { + tint = TColors.Green text = "Create" minimumHeight = 36 } cancelButton.apply { + tint = TColors.Warning text = "Cancel" minimumHeight = 36 } val btnRow = QWidget() - val btnLayout = hBoxLayout(btnRow) { + hBoxLayout(btnRow) { addStretch(1) addWidget(createButton) addWidget(cancelButton) @@ -157,6 +160,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa connectSignals() } + /** + * Get registered [ProjectType]s and create their UI + */ private fun setupTypes() { val types = BuiltinRegistries.ProjectType logger.info("Registered Project Types: ${types.toListString()}") @@ -188,6 +194,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa } } + /** + * If a [ProjectType] is unusable, display as such + */ private fun unavailableTypeWidget(displayName: String, reason: String): QWidget { val panel = QWidget() vBoxLayout(panel) { @@ -200,6 +209,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa return panel } + /** + * UI Actions + */ private fun connectSignals() { list.currentRowChanged.connect { row -> if (row >= 0) stacked.currentIndex = row @@ -218,6 +230,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa } } + /** + * Generate Project + */ private fun startCreate() { if (currentJob?.isActive == true) { logger.info("Create requested while generation is active; ignoring duplicate request") @@ -347,14 +362,6 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa logger.warn("Failed to open project window for {}", project.name, t) } - try { - Dashboard.I?.let { dash -> - try { dash.close() } catch (_: Throwable) {} - } - } catch (t: Throwable) { - logger.debug("Failed closing dashboard post-create", t) - } - finishDialog(accepted = true) }, onFailure = { err -> @@ -375,6 +382,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa } } + /** + * Cancel Project generation + */ private fun requestCancellation(closeWhenDone: Boolean) { if (currentJob?.isActive != true) { if (closeWhenDone) finishDialog(accepted = false) @@ -393,6 +403,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa } } + /** + * Get the generated Project's root directory + */ private fun inferProjectRoot(vars: Map): VPath? { fun value(key: String): String? = vars[key]?.trim()?.takeIf { it.isNotEmpty() } @@ -420,6 +433,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa return null } + /** + * Cleanup canceled Project on filesystem + */ private fun cleanupCancelledMalformedProject(projectRoot: VPath?, rootExistedBeforeCreate: Boolean): Boolean { val root = projectRoot ?: return false if (rootExistedBeforeCreate) { @@ -455,6 +471,9 @@ class NewProjectDialog internal constructor(parent: QWidget? = null): QDialog(pa } } + /** + * Actions when closing [NewProjectDialog] + */ private fun finishDialog(accepted: Boolean) { if (isClosing) return isClosing = true diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/util/ByteUtils.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/util/ByteUtils.kt index 1f166af..b0d36b2 100644 --- a/src/main/kotlin/io/github/tritium_launcher/launcher/util/ByteUtils.kt +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/util/ByteUtils.kt @@ -2,6 +2,9 @@ package io.github.tritium_launcher.launcher.util import java.nio.ByteBuffer +/** + * General Byte utilities + */ object ByteUtils { fun toByteArray(buf: ByteBuffer?): ByteArray? { if(buf == null) return null diff --git a/src/main/kotlin/io/github/tritium_launcher/launcher/util/SeasonalEvents.kt b/src/main/kotlin/io/github/tritium_launcher/launcher/util/SeasonalEvents.kt new file mode 100644 index 0000000..3519a40 --- /dev/null +++ b/src/main/kotlin/io/github/tritium_launcher/launcher/util/SeasonalEvents.kt @@ -0,0 +1,14 @@ +package io.github.tritium_launcher.launcher.util + +import io.github.tritium_launcher.launcher.extension.core.CoreSettingValues.seasonalEventsEnabled +import java.time.LocalDate +import java.time.Month + +object SeasonalEvents { + + fun isPrideMonth(): Boolean { + if (!seasonalEventsEnabled) return false + val now = LocalDate.now() + return now.month == Month.JUNE + } +} diff --git a/src/main/resources/META-INF/services/io.github.tritium_launcher.launcher.extension.Extension b/src/main/resources/META-INF/services/io.github.tritium_launcher.launcher.extension.Extension new file mode 100644 index 0000000..f19112c --- /dev/null +++ b/src/main/resources/META-INF/services/io.github.tritium_launcher.launcher.extension.Extension @@ -0,0 +1 @@ +io.github.tritium_launcher.launcher.extension.kubejs.KubeJSExtension diff --git a/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory b/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory new file mode 100644 index 0000000..bb97222 --- /dev/null +++ b/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory @@ -0,0 +1 @@ +io.github.tritium_launcher.launcher.coroutines.QtMainDispatcherFactory diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index ebe4e65..45ffe64 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -2,6 +2,7 @@ + diff --git a/src/main/resources/themes/default.json b/src/main/resources/themes/default.json index b13733b..fe3412c 100644 --- a/src/main/resources/themes/default.json +++ b/src/main/resources/themes/default.json @@ -20,6 +20,7 @@ "SelectedText": "#F0F4F8", "SelectedUI": "#2E436E", "Accent": "#1E8C64", + "Unsaved": "#E7944B", "Green": "#3A8325", "Success": "#10B981", "Warning": "#F59E0B", @@ -43,6 +44,9 @@ "Syntax.Error": "#F14C4C", "Syntax.Warning": "#CCA700", "Syntax.Info": "#3794EF", + "Syntax.Property": "#9CDCFE", + "Syntax.Namespace": "#4EC9B0", + "Syntax.Macro": "#569CD6", "Editor.Tab.Text": "#4db440" }, @@ -79,10 +83,18 @@ "file/log": "icons/file/log.svg", "ui/tritium": "icons/ui/tritium.svg", + "ui/tritium_grayscale": "icons/ui/tritium_grayscale.svg", "ui/curseforge": "icons/ui/curseforge.svg", "ui/modrinth": "icons/ui/modrinth.svg", + "ui/at_launcher": "icons/ui/at_launcher.svg", + "ui/gd_launcher": "icons/ui/gd_launcher.svg", + "ui/prism_launcher": "icons/ui/prism_launcher.svg", + "ui/curseforge_pack": "icons/ui/curseforge_pack.svg", + "ui/modrinth_pack": "icons/ui/modrinth_pack.svg", "ui/fabric": "icons/ui/fabric.svg", + "ui/neoforge": "icons/ui/neoforge.svg", "ui/question": "icons/ui/question.svg", + "ui/unknown_question": "icons/ui/unknown_question.svg", "ui/enabled_button": "icons/ui/enabled_button.svg", "ui/disabled_button": "icons/ui/disabled_button.svg", "ui/pressed_button": "icons/ui/pressed_button.svg", @@ -92,6 +104,7 @@ "ui/small_pause": "icons/ui/small_pause.svg", "ui/small_play": "icons/ui/small_play.svg", "ui/small_menu": "icons/ui/small_menu.svg", + "ui/external_arrow": "icons/ui/external_arrow.svg", "dashboard/new_project": "icons/dashboard/new_project.svg", "dashboard/folder_import": "icons/dashboard/folder_import.svg", @@ -107,6 +120,7 @@ "menu/rerun": "icons/menu/rerun.svg", "menu/stop": "icons/menu/stop.svg", "menu/force_stop": "icons/menu/force_stop.svg", - "menu/download": "icons/menu/download.svg" + "menu/download": "icons/menu/download.svg", + "menu/settings": "icons/menu/settings.svg" } } diff --git a/src/main/resources/themes/default/icons/file/archive.svg b/src/main/resources/themes/default/icons/file/archive.svg index 93051c5..1cdcc3d 100644 --- a/src/main/resources/themes/default/icons/file/archive.svg +++ b/src/main/resources/themes/default/icons/file/archive.svg @@ -1,16 +1,16 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -21,8 +21,8 @@ - - + + @@ -33,8 +33,8 @@ - - + + @@ -45,8 +45,8 @@ - - + + @@ -57,8 +57,8 @@ - - + + @@ -69,8 +69,8 @@ - - + + @@ -81,8 +81,8 @@ - - + + @@ -93,8 +93,8 @@ - - + + @@ -105,8 +105,8 @@ - - + + @@ -117,8 +117,8 @@ - - + + @@ -129,15 +129,15 @@ - - - - - - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/build.png b/src/main/resources/themes/default/icons/menu/build.png new file mode 100644 index 0000000..4ab8bb7 Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/build.png differ diff --git a/src/main/resources/themes/default/icons/menu/build.svg b/src/main/resources/themes/default/icons/menu/build.svg index 82df4b0..0e91a38 100644 --- a/src/main/resources/themes/default/icons/menu/build.svg +++ b/src/main/resources/themes/default/icons/menu/build.svg @@ -1,74 +1,74 @@ - - + + + + + + + + + + + + - - + + + + + + - - + + + + + - - - - + + + + + - - - - + + + + - - - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/download.png b/src/main/resources/themes/default/icons/menu/download.png new file mode 100644 index 0000000..ec4fef2 Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/download.png differ diff --git a/src/main/resources/themes/default/icons/menu/download.svg b/src/main/resources/themes/default/icons/menu/download.svg index 5e0f29d..a803d3a 100644 --- a/src/main/resources/themes/default/icons/menu/download.svg +++ b/src/main/resources/themes/default/icons/menu/download.svg @@ -1,45 +1,70 @@ - + + + + + + + + + + - - - - - - + + + + + + + + + + + - - + + + + - - - + - - - - - + - - - - - - - - - + + + + + + + + + + - - + + + + + + + + + + + + + + + + - + @@ -47,29 +72,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/force_stop.png b/src/main/resources/themes/default/icons/menu/force_stop.png new file mode 100644 index 0000000..f7c94c8 Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/force_stop.png differ diff --git a/src/main/resources/themes/default/icons/menu/force_stop.svg b/src/main/resources/themes/default/icons/menu/force_stop.svg index 0fa4bec..322f127 100644 --- a/src/main/resources/themes/default/icons/menu/force_stop.svg +++ b/src/main/resources/themes/default/icons/menu/force_stop.svg @@ -1,143 +1,99 @@ - - - - - - - + + + + + + + + + + + + + + + + + - - - + + - + - - - - - + + + - + - - - - - + + + - + - - - - + + - + - - - - - - + + + + - + - + - - - - - - + + + + - + - - - - + + - + - - - - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/rerun.png b/src/main/resources/themes/default/icons/menu/rerun.png new file mode 100644 index 0000000..23811cf Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/rerun.png differ diff --git a/src/main/resources/themes/default/icons/menu/rerun.svg b/src/main/resources/themes/default/icons/menu/rerun.svg index 93d067c..93a96a1 100644 --- a/src/main/resources/themes/default/icons/menu/rerun.svg +++ b/src/main/resources/themes/default/icons/menu/rerun.svg @@ -1,83 +1,73 @@ - - - - + + + + + + + + + + + + + + + + - - + + + + - - - + + + - - - + + + - - - - + + - + - - + - - + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/run.png b/src/main/resources/themes/default/icons/menu/run.png new file mode 100644 index 0000000..015e47b Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/run.png differ diff --git a/src/main/resources/themes/default/icons/menu/run.svg b/src/main/resources/themes/default/icons/menu/run.svg index 91ed936..c80cd51 100644 --- a/src/main/resources/themes/default/icons/menu/run.svg +++ b/src/main/resources/themes/default/icons/menu/run.svg @@ -1,95 +1,69 @@ - - + + + + + + - - + + + + - + - + + + - + - + - + + + - + - + - + + - + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - + + + + - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/settings.png b/src/main/resources/themes/default/icons/menu/settings.png new file mode 100644 index 0000000..41655c9 Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/settings.png differ diff --git a/src/main/resources/themes/default/icons/menu/settings.svg b/src/main/resources/themes/default/icons/menu/settings.svg new file mode 100644 index 0000000..b4f6228 --- /dev/null +++ b/src/main/resources/themes/default/icons/menu/settings.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/menu/stop.png b/src/main/resources/themes/default/icons/menu/stop.png new file mode 100644 index 0000000..3462082 Binary files /dev/null and b/src/main/resources/themes/default/icons/menu/stop.png differ diff --git a/src/main/resources/themes/default/icons/menu/stop.svg b/src/main/resources/themes/default/icons/menu/stop.svg index 73ea051..dc26555 100644 --- a/src/main/resources/themes/default/icons/menu/stop.svg +++ b/src/main/resources/themes/default/icons/menu/stop.svg @@ -1,16 +1,25 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + - - - + + @@ -18,11 +27,9 @@ - - - - - + + + @@ -30,11 +37,9 @@ - - - - - + + + @@ -42,11 +47,9 @@ - - - - - + + + @@ -54,11 +57,9 @@ - - - - - + + + @@ -66,11 +67,9 @@ - - - - - + + + @@ -78,11 +77,9 @@ - - - - - + + + @@ -90,54 +87,13 @@ - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/at_launcher.png b/src/main/resources/themes/default/icons/ui/at_launcher.png new file mode 100644 index 0000000..850b2d0 Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/at_launcher.png differ diff --git a/src/main/resources/themes/default/icons/ui/at_launcher.svg b/src/main/resources/themes/default/icons/ui/at_launcher.svg new file mode 100644 index 0000000..9ec2669 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/at_launcher.svg @@ -0,0 +1,8703 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/curseforge.png b/src/main/resources/themes/default/icons/ui/curseforge.png new file mode 100644 index 0000000..8d6829c Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/curseforge.png differ diff --git a/src/main/resources/themes/default/icons/ui/curseforge_pack.png b/src/main/resources/themes/default/icons/ui/curseforge_pack.png new file mode 100644 index 0000000..7f2e3b3 Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/curseforge_pack.png differ diff --git a/src/main/resources/themes/default/icons/ui/curseforge_pack.svg b/src/main/resources/themes/default/icons/ui/curseforge_pack.svg new file mode 100644 index 0000000..c81af69 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/curseforge_pack.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/external_arrow.png b/src/main/resources/themes/default/icons/ui/external_arrow.png new file mode 100644 index 0000000..b9dd3d2 Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/external_arrow.png differ diff --git a/src/main/resources/themes/default/icons/ui/external_arrow.svg b/src/main/resources/themes/default/icons/ui/external_arrow.svg new file mode 100644 index 0000000..2ae84f7 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/external_arrow.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/gd_launcher.png b/src/main/resources/themes/default/icons/ui/gd_launcher.png new file mode 100644 index 0000000..d60933f Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/gd_launcher.png differ diff --git a/src/main/resources/themes/default/icons/ui/gd_launcher.svg b/src/main/resources/themes/default/icons/ui/gd_launcher.svg new file mode 100644 index 0000000..bc094fa --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/gd_launcher.svg @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/modrinth_pack.png b/src/main/resources/themes/default/icons/ui/modrinth_pack.png new file mode 100644 index 0000000..4cf8ef6 Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/modrinth_pack.png differ diff --git a/src/main/resources/themes/default/icons/ui/modrinth_pack.svg b/src/main/resources/themes/default/icons/ui/modrinth_pack.svg new file mode 100644 index 0000000..de63472 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/modrinth_pack.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/prism_launcher.png b/src/main/resources/themes/default/icons/ui/prism_launcher.png new file mode 100644 index 0000000..4693fde Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/prism_launcher.png differ diff --git a/src/main/resources/themes/default/icons/ui/prism_launcher.svg b/src/main/resources/themes/default/icons/ui/prism_launcher.svg new file mode 100644 index 0000000..30f95bf --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/prism_launcher.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/prism_launcher_alt.png b/src/main/resources/themes/default/icons/ui/prism_launcher_alt.png new file mode 100644 index 0000000..5f2dfde Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/prism_launcher_alt.png differ diff --git a/src/main/resources/themes/default/icons/ui/prism_launcher_alt.svg b/src/main/resources/themes/default/icons/ui/prism_launcher_alt.svg new file mode 100644 index 0000000..204c783 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/prism_launcher_alt.svg @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/question.svg b/src/main/resources/themes/default/icons/ui/question.svg index aaef59c..c4b683a 100644 --- a/src/main/resources/themes/default/icons/ui/question.svg +++ b/src/main/resources/themes/default/icons/ui/question.svg @@ -15,19 +15,19 @@ - - - + + + - + - + @@ -37,7 +37,7 @@ - + @@ -48,7 +48,7 @@ - + @@ -59,7 +59,7 @@ - + @@ -68,7 +68,7 @@ - + diff --git a/src/main/resources/themes/default/icons/ui/tritium_grayscale.svg b/src/main/resources/themes/default/icons/ui/tritium_grayscale.svg new file mode 100644 index 0000000..eb377d4 --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/tritium_grayscale.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/tritium_rainbow.svg b/src/main/resources/themes/default/icons/ui/tritium_rainbow.svg new file mode 100644 index 0000000..88edaae --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/tritium_rainbow.svg @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/themes/default/icons/ui/unknown_question.png b/src/main/resources/themes/default/icons/ui/unknown_question.png new file mode 100644 index 0000000..64a7996 Binary files /dev/null and b/src/main/resources/themes/default/icons/ui/unknown_question.png differ diff --git a/src/main/resources/themes/default/icons/ui/unknown_question.svg b/src/main/resources/themes/default/icons/ui/unknown_question.svg new file mode 100644 index 0000000..b515cbe --- /dev/null +++ b/src/main/resources/themes/default/icons/ui/unknown_question.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/registry-builder/Cargo.lock b/tools/registry-builder/Cargo.lock new file mode 100644 index 0000000..7fef92c --- /dev/null +++ b/tools/registry-builder/Cargo.lock @@ -0,0 +1,254 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags 1.3.2", + "rustc_version", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "registry-builder" +version = "0.1.0" +dependencies = [ + "anyhow", + "flatbuffers", + "rusqlite", + "serde", + "serde_json", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/registry-builder/Cargo.toml b/tools/registry-builder/Cargo.toml new file mode 100644 index 0000000..bae2a28 --- /dev/null +++ b/tools/registry-builder/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "registry-builder" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +flatbuffers = "24.3.25" diff --git a/tools/registry-builder/kubejs_typings.fbs b/tools/registry-builder/kubejs_typings.fbs new file mode 100644 index 0000000..8700854 --- /dev/null +++ b/tools/registry-builder/kubejs_typings.fbs @@ -0,0 +1,96 @@ +// KubeJS Typings FlatBuffers schema for Tritium +// Used by CompanionMod (dump, Java) and registry-builder (consume, Rust) +// Field declaration order MUST match /tmp/tritium-fb/kubejs_typings.fbs +// for binary compatibility (FlatBuffers field IDs are positional). +namespace io.github.tritium_launcher.kubejs; + +enum TypeKind : byte { + Primitive = 0, + Class = 1, + Interface = 2, + Array = 3, + Event = 4, +} + +table Parameter { + name: string; + type: string; +} + +table Constructor { + parameters: [Parameter]; + documentation: string; +} + +table Method { + name: string; + return_type: string; + type_params: [string]; + parameters: [Parameter]; + is_static: bool; + is_deprecated: bool; + documentation: string; +} + +table Field { + name: string; + type: string; + is_static: bool; + is_deprecated: bool; + documentation: string; +} + +table ClassDefinition { + full_name: string; + simple_name: string; + kind: TypeKind = Class; + type_params: [string]; + methods: [Method]; + fields: [Field]; + constructors: [Constructor]; + documentation: string; + super_class: string; + interfaces: [string]; +} + +table Binding { + name: string; + type: string; + documentation: string; + side: string; +} + +table RecipeKeyInfo { + name: string; + type: string; + optional: bool; +} + +table RecipeSchemaBinding { + namespace: string; + schema_id: string; + recipe_class: string; + keys: [RecipeKeyInfo]; + documentation: string; +} + +table EventBinding { + group_name: string; + event_name: string; + event_class: string; + side: string; + extra_type: string; + target_required: bool; + documentation: string; +} + +table KubeTypings { + minecraft_version: string; + loader: string; + classes: [ClassDefinition]; + bindings: [Binding]; + events: [EventBinding]; + recipes: [RecipeSchemaBinding]; +} + +root_type KubeTypings; diff --git a/tools/registry-builder/src/kubejs_typings_generated.rs b/tools/registry-builder/src/kubejs_typings_generated.rs new file mode 100644 index 0000000..e798251 --- /dev/null +++ b/tools/registry-builder/src/kubejs_typings_generated.rs @@ -0,0 +1,1851 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated + +extern crate alloc; + +#[allow(unused_imports, dead_code)] +pub mod io { + +extern crate alloc; +#[allow(unused_imports, dead_code)] +pub mod github { + +extern crate alloc; +#[allow(unused_imports, dead_code)] +pub mod tritium_launcher { + +extern crate alloc; +#[allow(unused_imports, dead_code)] +pub mod kubejs { + +extern crate alloc; + +#[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] +pub const ENUM_MIN_TYPE_KIND: i8 = 0; +#[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] +pub const ENUM_MAX_TYPE_KIND: i8 = 4; +#[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_TYPE_KIND: [TypeKind; 5] = [ + TypeKind::Primitive, + TypeKind::Class, + TypeKind::Interface, + TypeKind::Array, + TypeKind::Event, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct TypeKind(pub i8); +#[allow(non_upper_case_globals)] +impl TypeKind { + pub const Primitive: Self = Self(0); + pub const Class: Self = Self(1); + pub const Interface: Self = Self(2); + pub const Array: Self = Self(3); + pub const Event: Self = Self(4); + + pub const ENUM_MIN: i8 = 0; + pub const ENUM_MAX: i8 = 4; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::Primitive, + Self::Class, + Self::Interface, + Self::Array, + Self::Event, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::Primitive => Some("Primitive"), + Self::Class => Some("Class"), + Self::Interface => Some("Interface"), + Self::Array => Some("Array"), + Self::Event => Some("Event"), + _ => None, + } + } +} +impl ::core::fmt::Debug for TypeKind { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for TypeKind { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for TypeKind { + type Output = TypeKind; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for TypeKind { + type Scalar = i8; + #[inline] + fn to_little_endian(self) -> i8 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: i8) -> Self { + let b = i8::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for TypeKind { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + i8::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for TypeKind {} +pub enum ParameterOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct Parameter<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for Parameter<'a> { + type Inner = Parameter<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> Parameter<'a> { + pub const VT_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_TYPE_: ::flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + Parameter { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ParameterArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = ParameterBuilder::new(_fbb); + if let Some(x) = args.type_ { builder.add_type_(x); } + if let Some(x) = args.name { builder.add_name(x); } + builder.finish() + } + + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Parameter::VT_NAME, None)} + } + #[inline] + pub fn type_(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Parameter::VT_TYPE_, None)} + } +} + +impl ::flatbuffers::Verifiable for Parameter<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("name", Self::VT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("type_", Self::VT_TYPE_, false)? + .finish(); + Ok(()) + } +} +pub struct ParameterArgs<'a> { + pub name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub type_: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for ParameterArgs<'a> { + #[inline] + fn default() -> Self { + ParameterArgs { + name: None, + type_: None, + } + } +} + +pub struct ParameterBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ParameterBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Parameter::VT_NAME, name); + } + #[inline] + pub fn add_type_(&mut self, type_: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Parameter::VT_TYPE_, type_); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> ParameterBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ParameterBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for Parameter<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("Parameter"); + ds.field("name", &self.name()); + ds.field("type_", &self.type_()); + ds.finish() + } +} +pub enum ConstructorOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct Constructor<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for Constructor<'a> { + type Inner = Constructor<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> Constructor<'a> { + pub const VT_PARAMETERS: ::flatbuffers::VOffsetT = 4; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + Constructor { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ConstructorArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = ConstructorBuilder::new(_fbb); + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.parameters { builder.add_parameters(x); } + builder.finish() + } + + + #[inline] + pub fn parameters(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(Constructor::VT_PARAMETERS, None)} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Constructor::VT_DOCUMENTATION, None)} + } +} + +impl ::flatbuffers::Verifiable for Constructor<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("parameters", Self::VT_PARAMETERS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .finish(); + Ok(()) + } +} +pub struct ConstructorArgs<'a> { + pub parameters: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for ConstructorArgs<'a> { + #[inline] + fn default() -> Self { + ConstructorArgs { + parameters: None, + documentation: None, + } + } +} + +pub struct ConstructorBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ConstructorBuilder<'a, 'b, A> { + #[inline] + pub fn add_parameters(&mut self, parameters: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Constructor::VT_PARAMETERS, parameters); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Constructor::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> ConstructorBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ConstructorBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for Constructor<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("Constructor"); + ds.field("parameters", &self.parameters()); + ds.field("documentation", &self.documentation()); + ds.finish() + } +} +pub enum MethodOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct Method<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for Method<'a> { + type Inner = Method<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> Method<'a> { + pub const VT_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_RETURN_TYPE: ::flatbuffers::VOffsetT = 6; + pub const VT_TYPE_PARAMS: ::flatbuffers::VOffsetT = 8; + pub const VT_PARAMETERS: ::flatbuffers::VOffsetT = 10; + pub const VT_IS_STATIC: ::flatbuffers::VOffsetT = 12; + pub const VT_IS_DEPRECATED: ::flatbuffers::VOffsetT = 14; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 16; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + Method { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args MethodArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = MethodBuilder::new(_fbb); + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.parameters { builder.add_parameters(x); } + if let Some(x) = args.type_params { builder.add_type_params(x); } + if let Some(x) = args.return_type { builder.add_return_type(x); } + if let Some(x) = args.name { builder.add_name(x); } + builder.add_is_deprecated(args.is_deprecated); + builder.add_is_static(args.is_static); + builder.finish() + } + + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Method::VT_NAME, None)} + } + #[inline] + pub fn return_type(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Method::VT_RETURN_TYPE, None)} + } + #[inline] + pub fn type_params(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>(Method::VT_TYPE_PARAMS, None)} + } + #[inline] + pub fn parameters(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(Method::VT_PARAMETERS, None)} + } + #[inline] + pub fn is_static(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Method::VT_IS_STATIC, Some(false)).unwrap()} + } + #[inline] + pub fn is_deprecated(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Method::VT_IS_DEPRECATED, Some(false)).unwrap()} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Method::VT_DOCUMENTATION, None)} + } +} + +impl ::flatbuffers::Verifiable for Method<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("name", Self::VT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("return_type", Self::VT_RETURN_TYPE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("type_params", Self::VT_TYPE_PARAMS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("parameters", Self::VT_PARAMETERS, false)? + .visit_field::("is_static", Self::VT_IS_STATIC, false)? + .visit_field::("is_deprecated", Self::VT_IS_DEPRECATED, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .finish(); + Ok(()) + } +} +pub struct MethodArgs<'a> { + pub name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub return_type: Option<::flatbuffers::WIPOffset<&'a str>>, + pub type_params: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, + pub parameters: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub is_static: bool, + pub is_deprecated: bool, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for MethodArgs<'a> { + #[inline] + fn default() -> Self { + MethodArgs { + name: None, + return_type: None, + type_params: None, + parameters: None, + is_static: false, + is_deprecated: false, + documentation: None, + } + } +} + +pub struct MethodBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> MethodBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Method::VT_NAME, name); + } + #[inline] + pub fn add_return_type(&mut self, return_type: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Method::VT_RETURN_TYPE, return_type); + } + #[inline] + pub fn add_type_params(&mut self, type_params: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset<&'b str>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Method::VT_TYPE_PARAMS, type_params); + } + #[inline] + pub fn add_parameters(&mut self, parameters: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Method::VT_PARAMETERS, parameters); + } + #[inline] + pub fn add_is_static(&mut self, is_static: bool) { + self.fbb_.push_slot::(Method::VT_IS_STATIC, is_static, false); + } + #[inline] + pub fn add_is_deprecated(&mut self, is_deprecated: bool) { + self.fbb_.push_slot::(Method::VT_IS_DEPRECATED, is_deprecated, false); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Method::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> MethodBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + MethodBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for Method<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("Method"); + ds.field("name", &self.name()); + ds.field("return_type", &self.return_type()); + ds.field("type_params", &self.type_params()); + ds.field("parameters", &self.parameters()); + ds.field("is_static", &self.is_static()); + ds.field("is_deprecated", &self.is_deprecated()); + ds.field("documentation", &self.documentation()); + ds.finish() + } +} +pub enum FieldOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct Field<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for Field<'a> { + type Inner = Field<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> Field<'a> { + pub const VT_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_TYPE_: ::flatbuffers::VOffsetT = 6; + pub const VT_IS_STATIC: ::flatbuffers::VOffsetT = 8; + pub const VT_IS_DEPRECATED: ::flatbuffers::VOffsetT = 10; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 12; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + Field { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args FieldArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = FieldBuilder::new(_fbb); + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.type_ { builder.add_type_(x); } + if let Some(x) = args.name { builder.add_name(x); } + builder.add_is_deprecated(args.is_deprecated); + builder.add_is_static(args.is_static); + builder.finish() + } + + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Field::VT_NAME, None)} + } + #[inline] + pub fn type_(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Field::VT_TYPE_, None)} + } + #[inline] + pub fn is_static(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Field::VT_IS_STATIC, Some(false)).unwrap()} + } + #[inline] + pub fn is_deprecated(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Field::VT_IS_DEPRECATED, Some(false)).unwrap()} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Field::VT_DOCUMENTATION, None)} + } +} + +impl ::flatbuffers::Verifiable for Field<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("name", Self::VT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("type_", Self::VT_TYPE_, false)? + .visit_field::("is_static", Self::VT_IS_STATIC, false)? + .visit_field::("is_deprecated", Self::VT_IS_DEPRECATED, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .finish(); + Ok(()) + } +} +pub struct FieldArgs<'a> { + pub name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub type_: Option<::flatbuffers::WIPOffset<&'a str>>, + pub is_static: bool, + pub is_deprecated: bool, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for FieldArgs<'a> { + #[inline] + fn default() -> Self { + FieldArgs { + name: None, + type_: None, + is_static: false, + is_deprecated: false, + documentation: None, + } + } +} + +pub struct FieldBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> FieldBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Field::VT_NAME, name); + } + #[inline] + pub fn add_type_(&mut self, type_: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Field::VT_TYPE_, type_); + } + #[inline] + pub fn add_is_static(&mut self, is_static: bool) { + self.fbb_.push_slot::(Field::VT_IS_STATIC, is_static, false); + } + #[inline] + pub fn add_is_deprecated(&mut self, is_deprecated: bool) { + self.fbb_.push_slot::(Field::VT_IS_DEPRECATED, is_deprecated, false); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Field::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> FieldBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + FieldBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for Field<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("Field"); + ds.field("name", &self.name()); + ds.field("type_", &self.type_()); + ds.field("is_static", &self.is_static()); + ds.field("is_deprecated", &self.is_deprecated()); + ds.field("documentation", &self.documentation()); + ds.finish() + } +} +pub enum ClassDefinitionOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct ClassDefinition<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for ClassDefinition<'a> { + type Inner = ClassDefinition<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> ClassDefinition<'a> { + pub const VT_FULL_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_SIMPLE_NAME: ::flatbuffers::VOffsetT = 6; + pub const VT_KIND: ::flatbuffers::VOffsetT = 8; + pub const VT_TYPE_PARAMS: ::flatbuffers::VOffsetT = 10; + pub const VT_METHODS: ::flatbuffers::VOffsetT = 12; + pub const VT_FIELDS: ::flatbuffers::VOffsetT = 14; + pub const VT_CONSTRUCTORS: ::flatbuffers::VOffsetT = 16; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 18; + pub const VT_SUPER_CLASS: ::flatbuffers::VOffsetT = 20; + pub const VT_INTERFACES: ::flatbuffers::VOffsetT = 22; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + ClassDefinition { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ClassDefinitionArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = ClassDefinitionBuilder::new(_fbb); + if let Some(x) = args.interfaces { builder.add_interfaces(x); } + if let Some(x) = args.super_class { builder.add_super_class(x); } + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.constructors { builder.add_constructors(x); } + if let Some(x) = args.fields { builder.add_fields(x); } + if let Some(x) = args.methods { builder.add_methods(x); } + if let Some(x) = args.type_params { builder.add_type_params(x); } + if let Some(x) = args.simple_name { builder.add_simple_name(x); } + if let Some(x) = args.full_name { builder.add_full_name(x); } + builder.add_kind(args.kind); + builder.finish() + } + + + #[inline] + pub fn full_name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(ClassDefinition::VT_FULL_NAME, None)} + } + #[inline] + pub fn simple_name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(ClassDefinition::VT_SIMPLE_NAME, None)} + } + #[inline] + pub fn kind(&self) -> TypeKind { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(ClassDefinition::VT_KIND, Some(TypeKind::Class)).unwrap()} + } + #[inline] + pub fn type_params(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>(ClassDefinition::VT_TYPE_PARAMS, None)} + } + #[inline] + pub fn methods(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(ClassDefinition::VT_METHODS, None)} + } + #[inline] + pub fn fields(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(ClassDefinition::VT_FIELDS, None)} + } + #[inline] + pub fn constructors(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(ClassDefinition::VT_CONSTRUCTORS, None)} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(ClassDefinition::VT_DOCUMENTATION, None)} + } + #[inline] + pub fn super_class(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(ClassDefinition::VT_SUPER_CLASS, None)} + } + #[inline] + pub fn interfaces(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>(ClassDefinition::VT_INTERFACES, None)} + } +} + +impl ::flatbuffers::Verifiable for ClassDefinition<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("full_name", Self::VT_FULL_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("simple_name", Self::VT_SIMPLE_NAME, false)? + .visit_field::("kind", Self::VT_KIND, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("type_params", Self::VT_TYPE_PARAMS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("methods", Self::VT_METHODS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("fields", Self::VT_FIELDS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("constructors", Self::VT_CONSTRUCTORS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("super_class", Self::VT_SUPER_CLASS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("interfaces", Self::VT_INTERFACES, false)? + .finish(); + Ok(()) + } +} +pub struct ClassDefinitionArgs<'a> { + pub full_name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub simple_name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub kind: TypeKind, + pub type_params: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, + pub methods: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub fields: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub constructors: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, + pub super_class: Option<::flatbuffers::WIPOffset<&'a str>>, + pub interfaces: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, +} +impl<'a> Default for ClassDefinitionArgs<'a> { + #[inline] + fn default() -> Self { + ClassDefinitionArgs { + full_name: None, + simple_name: None, + kind: TypeKind::Class, + type_params: None, + methods: None, + fields: None, + constructors: None, + documentation: None, + super_class: None, + interfaces: None, + } + } +} + +pub struct ClassDefinitionBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ClassDefinitionBuilder<'a, 'b, A> { + #[inline] + pub fn add_full_name(&mut self, full_name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_FULL_NAME, full_name); + } + #[inline] + pub fn add_simple_name(&mut self, simple_name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_SIMPLE_NAME, simple_name); + } + #[inline] + pub fn add_kind(&mut self, kind: TypeKind) { + self.fbb_.push_slot::(ClassDefinition::VT_KIND, kind, TypeKind::Class); + } + #[inline] + pub fn add_type_params(&mut self, type_params: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset<&'b str>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_TYPE_PARAMS, type_params); + } + #[inline] + pub fn add_methods(&mut self, methods: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_METHODS, methods); + } + #[inline] + pub fn add_fields(&mut self, fields: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_FIELDS, fields); + } + #[inline] + pub fn add_constructors(&mut self, constructors: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_CONSTRUCTORS, constructors); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn add_super_class(&mut self, super_class: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_SUPER_CLASS, super_class); + } + #[inline] + pub fn add_interfaces(&mut self, interfaces: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset<&'b str>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(ClassDefinition::VT_INTERFACES, interfaces); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> ClassDefinitionBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ClassDefinitionBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for ClassDefinition<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("ClassDefinition"); + ds.field("full_name", &self.full_name()); + ds.field("simple_name", &self.simple_name()); + ds.field("kind", &self.kind()); + ds.field("type_params", &self.type_params()); + ds.field("methods", &self.methods()); + ds.field("fields", &self.fields()); + ds.field("constructors", &self.constructors()); + ds.field("documentation", &self.documentation()); + ds.field("super_class", &self.super_class()); + ds.field("interfaces", &self.interfaces()); + ds.finish() + } +} +pub enum BindingOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct Binding<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for Binding<'a> { + type Inner = Binding<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> Binding<'a> { + pub const VT_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_TYPE_: ::flatbuffers::VOffsetT = 6; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 8; + pub const VT_SIDE: ::flatbuffers::VOffsetT = 10; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + Binding { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args BindingArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = BindingBuilder::new(_fbb); + if let Some(x) = args.side { builder.add_side(x); } + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.type_ { builder.add_type_(x); } + if let Some(x) = args.name { builder.add_name(x); } + builder.finish() + } + + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Binding::VT_NAME, None)} + } + #[inline] + pub fn type_(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Binding::VT_TYPE_, None)} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Binding::VT_DOCUMENTATION, None)} + } + #[inline] + pub fn side(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(Binding::VT_SIDE, None)} + } +} + +impl ::flatbuffers::Verifiable for Binding<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("name", Self::VT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("type_", Self::VT_TYPE_, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("side", Self::VT_SIDE, false)? + .finish(); + Ok(()) + } +} +pub struct BindingArgs<'a> { + pub name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub type_: Option<::flatbuffers::WIPOffset<&'a str>>, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, + pub side: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for BindingArgs<'a> { + #[inline] + fn default() -> Self { + BindingArgs { + name: None, + type_: None, + documentation: None, + side: None, + } + } +} + +pub struct BindingBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> BindingBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Binding::VT_NAME, name); + } + #[inline] + pub fn add_type_(&mut self, type_: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Binding::VT_TYPE_, type_); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Binding::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn add_side(&mut self, side: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Binding::VT_SIDE, side); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> BindingBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + BindingBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for Binding<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("Binding"); + ds.field("name", &self.name()); + ds.field("type_", &self.type_()); + ds.field("documentation", &self.documentation()); + ds.field("side", &self.side()); + ds.finish() + } +} +pub enum RecipeKeyInfoOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct RecipeKeyInfo<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for RecipeKeyInfo<'a> { + type Inner = RecipeKeyInfo<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> RecipeKeyInfo<'a> { + pub const VT_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_TYPE_: ::flatbuffers::VOffsetT = 6; + pub const VT_OPTIONAL: ::flatbuffers::VOffsetT = 8; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + RecipeKeyInfo { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args RecipeKeyInfoArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = RecipeKeyInfoBuilder::new(_fbb); + if let Some(x) = args.type_ { builder.add_type_(x); } + if let Some(x) = args.name { builder.add_name(x); } + builder.add_optional(args.optional); + builder.finish() + } + + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeKeyInfo::VT_NAME, None)} + } + #[inline] + pub fn type_(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeKeyInfo::VT_TYPE_, None)} + } + #[inline] + pub fn optional(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(RecipeKeyInfo::VT_OPTIONAL, Some(false)).unwrap()} + } +} + +impl ::flatbuffers::Verifiable for RecipeKeyInfo<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("name", Self::VT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("type_", Self::VT_TYPE_, false)? + .visit_field::("optional", Self::VT_OPTIONAL, false)? + .finish(); + Ok(()) + } +} +pub struct RecipeKeyInfoArgs<'a> { + pub name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub type_: Option<::flatbuffers::WIPOffset<&'a str>>, + pub optional: bool, +} +impl<'a> Default for RecipeKeyInfoArgs<'a> { + #[inline] + fn default() -> Self { + RecipeKeyInfoArgs { + name: None, + type_: None, + optional: false, + } + } +} + +pub struct RecipeKeyInfoBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> RecipeKeyInfoBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeKeyInfo::VT_NAME, name); + } + #[inline] + pub fn add_type_(&mut self, type_: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeKeyInfo::VT_TYPE_, type_); + } + #[inline] + pub fn add_optional(&mut self, optional: bool) { + self.fbb_.push_slot::(RecipeKeyInfo::VT_OPTIONAL, optional, false); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> RecipeKeyInfoBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + RecipeKeyInfoBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for RecipeKeyInfo<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("RecipeKeyInfo"); + ds.field("name", &self.name()); + ds.field("type_", &self.type_()); + ds.field("optional", &self.optional()); + ds.finish() + } +} +pub enum RecipeSchemaBindingOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct RecipeSchemaBinding<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for RecipeSchemaBinding<'a> { + type Inner = RecipeSchemaBinding<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> RecipeSchemaBinding<'a> { + pub const VT_NAMESPACE: ::flatbuffers::VOffsetT = 4; + pub const VT_SCHEMA_ID: ::flatbuffers::VOffsetT = 6; + pub const VT_RECIPE_CLASS: ::flatbuffers::VOffsetT = 8; + pub const VT_KEYS: ::flatbuffers::VOffsetT = 10; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 12; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + RecipeSchemaBinding { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args RecipeSchemaBindingArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = RecipeSchemaBindingBuilder::new(_fbb); + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.keys { builder.add_keys(x); } + if let Some(x) = args.recipe_class { builder.add_recipe_class(x); } + if let Some(x) = args.schema_id { builder.add_schema_id(x); } + if let Some(x) = args.namespace { builder.add_namespace(x); } + builder.finish() + } + + + #[inline] + pub fn namespace(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeSchemaBinding::VT_NAMESPACE, None)} + } + #[inline] + pub fn schema_id(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeSchemaBinding::VT_SCHEMA_ID, None)} + } + #[inline] + pub fn recipe_class(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeSchemaBinding::VT_RECIPE_CLASS, None)} + } + #[inline] + pub fn keys(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(RecipeSchemaBinding::VT_KEYS, None)} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(RecipeSchemaBinding::VT_DOCUMENTATION, None)} + } +} + +impl ::flatbuffers::Verifiable for RecipeSchemaBinding<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("namespace", Self::VT_NAMESPACE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("schema_id", Self::VT_SCHEMA_ID, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("recipe_class", Self::VT_RECIPE_CLASS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("keys", Self::VT_KEYS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .finish(); + Ok(()) + } +} +pub struct RecipeSchemaBindingArgs<'a> { + pub namespace: Option<::flatbuffers::WIPOffset<&'a str>>, + pub schema_id: Option<::flatbuffers::WIPOffset<&'a str>>, + pub recipe_class: Option<::flatbuffers::WIPOffset<&'a str>>, + pub keys: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for RecipeSchemaBindingArgs<'a> { + #[inline] + fn default() -> Self { + RecipeSchemaBindingArgs { + namespace: None, + schema_id: None, + recipe_class: None, + keys: None, + documentation: None, + } + } +} + +pub struct RecipeSchemaBindingBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> RecipeSchemaBindingBuilder<'a, 'b, A> { + #[inline] + pub fn add_namespace(&mut self, namespace: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeSchemaBinding::VT_NAMESPACE, namespace); + } + #[inline] + pub fn add_schema_id(&mut self, schema_id: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeSchemaBinding::VT_SCHEMA_ID, schema_id); + } + #[inline] + pub fn add_recipe_class(&mut self, recipe_class: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeSchemaBinding::VT_RECIPE_CLASS, recipe_class); + } + #[inline] + pub fn add_keys(&mut self, keys: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeSchemaBinding::VT_KEYS, keys); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(RecipeSchemaBinding::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> RecipeSchemaBindingBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + RecipeSchemaBindingBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for RecipeSchemaBinding<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("RecipeSchemaBinding"); + ds.field("namespace", &self.namespace()); + ds.field("schema_id", &self.schema_id()); + ds.field("recipe_class", &self.recipe_class()); + ds.field("keys", &self.keys()); + ds.field("documentation", &self.documentation()); + ds.finish() + } +} +pub enum EventBindingOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct EventBinding<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for EventBinding<'a> { + type Inner = EventBinding<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> EventBinding<'a> { + pub const VT_GROUP_NAME: ::flatbuffers::VOffsetT = 4; + pub const VT_EVENT_NAME: ::flatbuffers::VOffsetT = 6; + pub const VT_EVENT_CLASS: ::flatbuffers::VOffsetT = 8; + pub const VT_SIDE: ::flatbuffers::VOffsetT = 10; + pub const VT_EXTRA_TYPE: ::flatbuffers::VOffsetT = 12; + pub const VT_TARGET_REQUIRED: ::flatbuffers::VOffsetT = 14; + pub const VT_DOCUMENTATION: ::flatbuffers::VOffsetT = 16; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + EventBinding { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args EventBindingArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = EventBindingBuilder::new(_fbb); + if let Some(x) = args.documentation { builder.add_documentation(x); } + if let Some(x) = args.extra_type { builder.add_extra_type(x); } + if let Some(x) = args.side { builder.add_side(x); } + if let Some(x) = args.event_class { builder.add_event_class(x); } + if let Some(x) = args.event_name { builder.add_event_name(x); } + if let Some(x) = args.group_name { builder.add_group_name(x); } + builder.add_target_required(args.target_required); + builder.finish() + } + + + #[inline] + pub fn group_name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_GROUP_NAME, None)} + } + #[inline] + pub fn event_name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_EVENT_NAME, None)} + } + #[inline] + pub fn event_class(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_EVENT_CLASS, None)} + } + #[inline] + pub fn side(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_SIDE, None)} + } + #[inline] + pub fn extra_type(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_EXTRA_TYPE, None)} + } + #[inline] + pub fn target_required(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(EventBinding::VT_TARGET_REQUIRED, Some(false)).unwrap()} + } + #[inline] + pub fn documentation(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(EventBinding::VT_DOCUMENTATION, None)} + } +} + +impl ::flatbuffers::Verifiable for EventBinding<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("group_name", Self::VT_GROUP_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("event_name", Self::VT_EVENT_NAME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("event_class", Self::VT_EVENT_CLASS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("side", Self::VT_SIDE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("extra_type", Self::VT_EXTRA_TYPE, false)? + .visit_field::("target_required", Self::VT_TARGET_REQUIRED, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("documentation", Self::VT_DOCUMENTATION, false)? + .finish(); + Ok(()) + } +} +pub struct EventBindingArgs<'a> { + pub group_name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub event_name: Option<::flatbuffers::WIPOffset<&'a str>>, + pub event_class: Option<::flatbuffers::WIPOffset<&'a str>>, + pub side: Option<::flatbuffers::WIPOffset<&'a str>>, + pub extra_type: Option<::flatbuffers::WIPOffset<&'a str>>, + pub target_required: bool, + pub documentation: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for EventBindingArgs<'a> { + #[inline] + fn default() -> Self { + EventBindingArgs { + group_name: None, + event_name: None, + event_class: None, + side: None, + extra_type: None, + target_required: false, + documentation: None, + } + } +} + +pub struct EventBindingBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> EventBindingBuilder<'a, 'b, A> { + #[inline] + pub fn add_group_name(&mut self, group_name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_GROUP_NAME, group_name); + } + #[inline] + pub fn add_event_name(&mut self, event_name: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_EVENT_NAME, event_name); + } + #[inline] + pub fn add_event_class(&mut self, event_class: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_EVENT_CLASS, event_class); + } + #[inline] + pub fn add_side(&mut self, side: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_SIDE, side); + } + #[inline] + pub fn add_extra_type(&mut self, extra_type: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_EXTRA_TYPE, extra_type); + } + #[inline] + pub fn add_target_required(&mut self, target_required: bool) { + self.fbb_.push_slot::(EventBinding::VT_TARGET_REQUIRED, target_required, false); + } + #[inline] + pub fn add_documentation(&mut self, documentation: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(EventBinding::VT_DOCUMENTATION, documentation); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> EventBindingBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + EventBindingBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for EventBinding<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("EventBinding"); + ds.field("group_name", &self.group_name()); + ds.field("event_name", &self.event_name()); + ds.field("event_class", &self.event_class()); + ds.field("side", &self.side()); + ds.field("extra_type", &self.extra_type()); + ds.field("target_required", &self.target_required()); + ds.field("documentation", &self.documentation()); + ds.finish() + } +} +pub enum KubeTypingsOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct KubeTypings<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for KubeTypings<'a> { + type Inner = KubeTypings<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: unsafe { ::flatbuffers::Table::new(buf, loc) } } + } +} + +impl<'a> KubeTypings<'a> { + pub const VT_MINECRAFT_VERSION: ::flatbuffers::VOffsetT = 4; + pub const VT_LOADER: ::flatbuffers::VOffsetT = 6; + pub const VT_CLASSES: ::flatbuffers::VOffsetT = 8; + pub const VT_BINDINGS: ::flatbuffers::VOffsetT = 10; + pub const VT_EVENTS: ::flatbuffers::VOffsetT = 12; + pub const VT_RECIPES: ::flatbuffers::VOffsetT = 14; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + KubeTypings { _tab: table } + } + #[allow(unused_mut)] + pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args KubeTypingsArgs<'args> + ) -> ::flatbuffers::WIPOffset> { + let mut builder = KubeTypingsBuilder::new(_fbb); + if let Some(x) = args.recipes { builder.add_recipes(x); } + if let Some(x) = args.events { builder.add_events(x); } + if let Some(x) = args.bindings { builder.add_bindings(x); } + if let Some(x) = args.classes { builder.add_classes(x); } + if let Some(x) = args.loader { builder.add_loader(x); } + if let Some(x) = args.minecraft_version { builder.add_minecraft_version(x); } + builder.finish() + } + + + #[inline] + pub fn minecraft_version(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(KubeTypings::VT_MINECRAFT_VERSION, None)} + } + #[inline] + pub fn loader(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>(KubeTypings::VT_LOADER, None)} + } + #[inline] + pub fn classes(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(KubeTypings::VT_CLASSES, None)} + } + #[inline] + pub fn bindings(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(KubeTypings::VT_BINDINGS, None)} + } + #[inline] + pub fn events(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(KubeTypings::VT_EVENTS, None)} + } + #[inline] + pub fn recipes(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(KubeTypings::VT_RECIPES, None)} + } +} + +impl ::flatbuffers::Verifiable for KubeTypings<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, pos: usize + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("minecraft_version", Self::VT_MINECRAFT_VERSION, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("loader", Self::VT_LOADER, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("classes", Self::VT_CLASSES, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("bindings", Self::VT_BINDINGS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("events", Self::VT_EVENTS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("recipes", Self::VT_RECIPES, false)? + .finish(); + Ok(()) + } +} +pub struct KubeTypingsArgs<'a> { + pub minecraft_version: Option<::flatbuffers::WIPOffset<&'a str>>, + pub loader: Option<::flatbuffers::WIPOffset<&'a str>>, + pub classes: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub bindings: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub events: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub recipes: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, +} +impl<'a> Default for KubeTypingsArgs<'a> { + #[inline] + fn default() -> Self { + KubeTypingsArgs { + minecraft_version: None, + loader: None, + classes: None, + bindings: None, + events: None, + recipes: None, + } + } +} + +pub struct KubeTypingsBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> KubeTypingsBuilder<'a, 'b, A> { + #[inline] + pub fn add_minecraft_version(&mut self, minecraft_version: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_MINECRAFT_VERSION, minecraft_version); + } + #[inline] + pub fn add_loader(&mut self, loader: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_LOADER, loader); + } + #[inline] + pub fn add_classes(&mut self, classes: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_CLASSES, classes); + } + #[inline] + pub fn add_bindings(&mut self, bindings: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_BINDINGS, bindings); + } + #[inline] + pub fn add_events(&mut self, events: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_EVENTS, events); + } + #[inline] + pub fn add_recipes(&mut self, recipes: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(KubeTypings::VT_RECIPES, recipes); + } + #[inline] + pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> KubeTypingsBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + KubeTypingsBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for KubeTypings<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("KubeTypings"); + ds.field("minecraft_version", &self.minecraft_version()); + ds.field("loader", &self.loader()); + ds.field("classes", &self.classes()); + ds.field("bindings", &self.bindings()); + ds.field("events", &self.events()); + ds.field("recipes", &self.recipes()); + ds.finish() + } +} +#[inline] +/// Verifies that a buffer of bytes contains a `KubeTypings` +/// and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_kube_typings_unchecked`. +pub fn root_as_kube_typings(buf: &[u8]) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root::(buf) +} +#[inline] +/// Verifies that a buffer of bytes contains a size prefixed +/// `KubeTypings` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `size_prefixed_root_as_kube_typings_unchecked`. +pub fn size_prefixed_root_as_kube_typings(buf: &[u8]) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root::(buf) +} +#[inline] +/// Verifies, with the given options, that a buffer of bytes +/// contains a `KubeTypings` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_kube_typings_unchecked`. +pub fn root_as_kube_typings_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root_with_opts::>(opts, buf) +} +#[inline] +/// Verifies, with the given verifier options, that a buffer of +/// bytes contains a size prefixed `KubeTypings` and returns +/// it. Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_kube_typings_unchecked`. +pub fn size_prefixed_root_as_kube_typings_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root_with_opts::>(opts, buf) +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a KubeTypings and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid `KubeTypings`. +pub unsafe fn root_as_kube_typings_unchecked(buf: &[u8]) -> KubeTypings<'_> { + unsafe { ::flatbuffers::root_unchecked::(buf) } +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a size prefixed KubeTypings and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid size prefixed `KubeTypings`. +pub unsafe fn size_prefixed_root_as_kube_typings_unchecked(buf: &[u8]) -> KubeTypings<'_> { + unsafe { ::flatbuffers::size_prefixed_root_unchecked::(buf) } +} +#[inline] +pub fn finish_kube_typings_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>( + fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + root: ::flatbuffers::WIPOffset>) { + fbb.finish(root, None); +} + +#[inline] +pub fn finish_size_prefixed_kube_typings_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>(fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, root: ::flatbuffers::WIPOffset>) { + fbb.finish_size_prefixed(root, None); +} +} // pub mod kubejs +} // pub mod tritium_launcher +} // pub mod github +} // pub mod io + diff --git a/tools/registry-builder/src/main.rs b/tools/registry-builder/src/main.rs new file mode 100644 index 0000000..069c97f --- /dev/null +++ b/tools/registry-builder/src/main.rs @@ -0,0 +1,1189 @@ +use anyhow::{Context, Result, anyhow, bail}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Current schema version for the game registry database. +const REGISTRY_DB_SCHEMA_VERSION: i64 = 1; +const RECIPE_PRODUCT_KEYS: &[&str] = &["result", "results", "output", "outputs"]; +const RECIPE_INGREDIENT_KEYS: &[&str] = &[ + "ingredient", + "ingredients", + "key", + "input", + "inputs", + "base", + "addition", + "template", + "material", + "materials", + "catalyst", +]; + +/// Entry point for the registry builder. +/// +/// Reads a Companion mod snapshot (JSON files) and writes `game_registry.db` +fn main() -> Result<()> { + let config = Config::from_env()?; + let snapshot = SnapshotInput::load(&config.input)?; + + if snapshot.manifest.schema_version != 1 { + bail!( + "unsupported manifest schema version: {}", + snapshot.manifest.schema_version + ); + } + if !snapshot.manifest.complete { + bail!("refusing to build from incomplete snapshot"); + } + + if let Some(parent) = config.output.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create output directory {}", parent.display()) + })?; + } + + let mut conn = Connection::open(&config.output) + .with_context(|| format!("failed to open sqlite database {}", config.output.display()))?; + + init_db(&mut conn)?; + populate_db_incremental(&mut conn, &snapshot)?; + + println!( + "Updated {} from snapshot {}", + config.output.display(), + snapshot.manifest.snapshot_id + ); + + Ok(()) +} + +/// CLI configuration parsed from `--input` and `--output` flags. +struct Config { + input: PathBuf, + output: PathBuf, +} + +impl Config { + /// Parses command-line arguments. + /// + /// Defaults: + /// - `--input` → `registryObjs` + /// - `--output` → `game_registry.db` + fn from_env() -> Result { + let mut args = env::args().skip(1); + let mut input: Option = None; + let mut output: Option = None; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--input" => { + let value = args.next().context("missing value for --input")?; + input = Some(PathBuf::from(value)); + } + "--output" => { + let value = args.next().context("missing value for --output")?; + output = Some(PathBuf::from(value)); + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => bail!("unknown argument: {other}"), + } + } + + let input = input.unwrap_or_else(|| PathBuf::from("registryObjs")); + let output = output.unwrap_or_else(|| PathBuf::from("game_registry.db")); + + Ok(Self { input, output }) + } +} + +fn print_help() { + println!("registry-builder --input --output "); +} + +/// A resolved snapshot directory with its parsed manifest. +struct SnapshotInput { + snapshot_dir: PathBuf, + manifest: Manifest, +} + +impl SnapshotInput { + fn load(path: &Path) -> Result { + let snapshot_dir = resolve_snapshot_dir(path)?; + let manifest_path = snapshot_dir.join("manifest.json"); + let manifest_text = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed reading {}", manifest_path.display()))?; + let manifest: Manifest = serde_json::from_str(&manifest_text) + .with_context(|| format!("failed parsing {}", manifest_path.display()))?; + + Ok(Self { + snapshot_dir, + manifest, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LatestPointer { + path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Manifest { + schema_version: i64, + snapshot_id: String, + created_at: String, + complete: bool, + minecraft_version: Option, + loader: Option, + environment: Option, + counts: Map, + files: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct ManifestFile { + path: String, + kind: String, + #[serde(rename = "type")] + file_type: String, + id: String, + sha256: String, + size: i64, +} + +/// Resolves the snapshot directory path from a given input. +fn resolve_snapshot_dir(input: &Path) -> Result { + if input.is_file() { + if input.file_name().and_then(|s| s.to_str()) == Some("latest.json") { + return resolve_from_latest(input); + } + bail!("unsupported input file: {}", input.display()); + } + + if input.is_dir() { + let manifest_path = input.join("manifest.json"); + if manifest_path.is_file() { + return Ok(input.to_path_buf()); + } + + let latest = input.join("latest.json"); + if latest.is_file() { + return resolve_from_latest(&latest); + } + } + + bail!( + "input must be registryObjs, latest.json, or a snapshot directory: {}", + input.display() + ); +} + +/// Reads `latest.json` and resolves the snapshot directory it points to. +fn resolve_from_latest(latest_path: &Path) -> Result { + let latest_text = fs::read_to_string(latest_path) + .with_context(|| format!("failed reading {}", latest_path.display()))?; + let latest: LatestPointer = serde_json::from_str(&latest_text) + .with_context(|| format!("failed parsing {}", latest_path.display()))?; + let root = latest_path + .parent() + .ok_or_else(|| anyhow!("latest.json has no parent directory"))?; + Ok(root.join(latest.path)) +} + +/// Initializes the game registry database schema. +/// +/// Creates all tables, indexes, and views on first run; existing tables are +/// preserved so migration can happen incrementally via `ensure_column`. +fn init_db(conn: &mut Connection) -> Result<()> { + //language=sqlite + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS source_files ( + path TEXT PRIMARY KEY, + kind TEXT NOT NULL, + type TEXT NOT NULL, + object_id TEXT NOT NULL, + sha256 TEXT NOT NULL, + size_bytes INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS registry_entries ( + registry_type TEXT NOT NULL, + id TEXT NOT NULL, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE, + PRIMARY KEY (registry_type, id) + ); + + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + display_name TEXT, + display_name_lower TEXT, + max_count INTEGER, + max_damage INTEGER, + rarity TEXT, + enchantability INTEGER, + texture_ref TEXT, + texture_path TEXT, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS recipe_types ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + display_name TEXT, + input_slots INTEGER, + fuel_slots INTEGER, + output_slots INTEGER, + input_tanks INTEGER, + output_tanks INTEGER, + energy_cells INTEGER, + note TEXT, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS recipes ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + recipe_type TEXT, + recipe_type_lower TEXT, + group_name TEXT, + group_name_lower TEXT, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS recipe_links ( + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + role TEXT NOT NULL, + value_kind TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (recipe_id, role, value_kind, value) + ); + + CREATE TABLE IF NOT EXISTS value_types ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + display_name TEXT, + icon_texture TEXT, + browseable INTEGER NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS custom_values ( + type_id TEXT NOT NULL, + id TEXT NOT NULL, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + display_name TEXT, + texture_path TEXT, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE, + PRIMARY KEY (type_id, id) + ); + + CREATE TABLE IF NOT EXISTS tags ( + registry_type TEXT NOT NULL, + id TEXT NOT NULL, + namespace TEXT NOT NULL, + path TEXT NOT NULL, + id_lower TEXT NOT NULL, + replace_flag INTEGER NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT NOT NULL REFERENCES source_files(path) ON DELETE CASCADE, + PRIMARY KEY (registry_type, id) + ); + + CREATE TABLE IF NOT EXISTS tag_values ( + registry_type TEXT NOT NULL, + tag_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (registry_type, tag_id, ordinal), + FOREIGN KEY (registry_type, tag_id) REFERENCES tags(registry_type, id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_registry_entries_namespace ON registry_entries(namespace); + CREATE INDEX IF NOT EXISTS idx_registry_entries_type ON registry_entries(registry_type); + CREATE INDEX IF NOT EXISTS idx_registry_entries_id_lower ON registry_entries(id_lower); + CREATE INDEX IF NOT EXISTS idx_items_namespace ON items(namespace); + CREATE INDEX IF NOT EXISTS idx_items_id_lower ON items(id_lower); + CREATE INDEX IF NOT EXISTS idx_items_display_name ON items(display_name); + CREATE INDEX IF NOT EXISTS idx_items_display_name_lower ON items(display_name_lower); + CREATE INDEX IF NOT EXISTS idx_recipe_types_namespace ON recipe_types(namespace); + CREATE INDEX IF NOT EXISTS idx_recipe_types_id_lower ON recipe_types(id_lower); + CREATE INDEX IF NOT EXISTS idx_recipes_namespace ON recipes(namespace); + CREATE INDEX IF NOT EXISTS idx_recipes_id_lower ON recipes(id_lower); + CREATE INDEX IF NOT EXISTS idx_recipes_recipe_type ON recipes(recipe_type); + CREATE INDEX IF NOT EXISTS idx_recipes_recipe_type_lower ON recipes(recipe_type_lower); + CREATE INDEX IF NOT EXISTS idx_recipe_links_role_kind_value ON recipe_links(role, value_kind, value); + CREATE INDEX IF NOT EXISTS idx_recipe_links_recipe_id ON recipe_links(recipe_id); + CREATE INDEX IF NOT EXISTS idx_value_types_namespace ON value_types(namespace); + CREATE INDEX IF NOT EXISTS idx_value_types_id_lower ON value_types(id_lower); + CREATE INDEX IF NOT EXISTS idx_custom_values_type_id ON custom_values(type_id); + CREATE INDEX IF NOT EXISTS idx_custom_values_id_lower ON custom_values(id_lower); + CREATE INDEX IF NOT EXISTS idx_tags_namespace ON tags(namespace); + CREATE INDEX IF NOT EXISTS idx_tags_id_lower ON tags(id_lower); + CREATE INDEX IF NOT EXISTS idx_tag_values_value ON tag_values(value); + CREATE INDEX IF NOT EXISTS idx_tag_values_registry_value ON tag_values(registry_type, value); + + DROP VIEW IF EXISTS v_registry_counts; + CREATE VIEW v_registry_counts AS + SELECT registry_type, COUNT(*) AS entry_count + FROM registry_entries + GROUP BY registry_type + ORDER BY registry_type; + + DROP VIEW IF EXISTS v_mod_counts; + CREATE VIEW v_mod_counts AS + WITH namespaces AS ( + SELECT namespace FROM items + UNION + SELECT namespace FROM recipes + UNION + SELECT namespace FROM recipe_types + UNION + SELECT namespace FROM value_types + UNION + SELECT namespace FROM custom_values + UNION + SELECT namespace FROM tags + UNION + SELECT namespace FROM registry_entries + ) + SELECT + ns.namespace AS namespace, + COALESCE((SELECT COUNT(*) FROM items i WHERE i.namespace = ns.namespace), 0) AS item_count, + COALESCE((SELECT COUNT(*) FROM recipes r WHERE r.namespace = ns.namespace), 0) AS recipe_count, + COALESCE((SELECT COUNT(*) FROM recipe_types rt WHERE rt.namespace = ns.namespace), 0) AS recipe_type_count, + COALESCE((SELECT COUNT(*) FROM tags t WHERE t.namespace = ns.namespace), 0) AS tag_count, + COALESCE((SELECT COUNT(*) FROM registry_entries re WHERE re.namespace = ns.namespace), 0) AS registry_entry_count, + ( + COALESCE((SELECT COUNT(*) FROM items i WHERE i.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM recipes r WHERE r.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM recipe_types rt WHERE rt.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM value_types vt WHERE vt.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM custom_values cv WHERE cv.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM tags t WHERE t.namespace = ns.namespace), 0) + + COALESCE((SELECT COUNT(*) FROM registry_entries re WHERE re.namespace = ns.namespace), 0) + ) AS total_count + FROM namespaces ns + ORDER BY ns.namespace; + + DROP VIEW IF EXISTS v_item_browser; + CREATE VIEW v_item_browser AS + SELECT + i.namespace, + i.id, + i.path, + i.display_name, + i.max_count, + i.max_damage, + i.rarity, + i.enchantability, + i.texture_path, + COALESCE(GROUP_CONCAT(tv.tag_id, char(10)), '') AS tag_values + FROM items i + LEFT JOIN tag_values tv + ON tv.registry_type = 'item' + AND tv.value = i.id + GROUP BY + i.namespace, i.id, i.path, i.display_name, i.max_count, i.max_damage, i.rarity, i.enchantability, i.texture_path; + + DROP VIEW IF EXISTS v_recipe_browser; + CREATE VIEW v_recipe_browser AS + SELECT + r.namespace, + r.id, + r.path, + r.recipe_type, + r.group_name, + COALESCE(rt.input_slots, 0) AS input_slots, + COALESCE(rt.output_slots, 0) AS output_slots, + COALESCE(rt.fuel_slots, 0) AS fuel_slots + FROM recipes r + LEFT JOIN recipe_types rt ON rt.id = r.recipe_type; + "#, + )?; + + ensure_column(conn, "items", "texture_ref", "TEXT")?; + ensure_column(conn, "items", "texture_path", "TEXT")?; + ensure_column(conn, "recipe_types", "display_name", "TEXT")?; + + Ok(()) +} + +/// Adds a column to a table if it does not already exist. +fn ensure_column(conn: &Connection, table: &str, column: &str, column_sql: &str) -> Result<()> { + let pragma = format!("PRAGMA table_info({table})"); + let mut stmt = conn.prepare(&pragma)?; + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::>()?; + if columns.iter().any(|existing| existing == column) { + return Ok(()); + } + + conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"), + [], + )?; + Ok(()) +} + +/// Performs an incremental update of the game registry database from a snapshot. +/// +/// Compares file hashes against what's already stored; only processes files +/// whose content has changed. Orphaned files (removed from the snapshot since +/// the last build) are deleted. A full rebuild is triggered when the schema +/// version has been bumped. +fn populate_db_incremental(conn: &mut Connection, snapshot: &SnapshotInput) -> Result<()> { + let existing_schema_version = read_schema_version(conn)?; + let full_rebuild = existing_schema_version != Some(REGISTRY_DB_SCHEMA_VERSION); + + let existing_files: std::collections::HashMap = if full_rebuild { + HashMap::new() + } else { + //language=sqlite + let mut stmt = conn.prepare("SELECT path, sha256 FROM source_files")?; + stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? + .collect::>()? + }; + + let tx = conn.transaction()?; + if full_rebuild { + //language=sqlite + tx.execute("DELETE FROM source_files", [])?; + } + + insert_metadata(&tx, snapshot)?; + + let mut current_paths = HashSet::new(); + for file in &snapshot.manifest.files { + current_paths.insert(file.path.clone()); + + let needs_update = match existing_files.get(&file.path) { + Some(existing_hash) => existing_hash != &file.sha256, + None => true, + }; + + if needs_update { + //language=sqlite + tx.execute("DELETE FROM source_files WHERE path = ?1", params![file.path])?; + + insert_source_file(&tx, file)?; + route_file(&tx, &snapshot.snapshot_dir, file)?; + } + } + + for path in existing_files.keys() { + if !current_paths.contains(path) { + //language=sqlite + tx.execute("DELETE FROM source_files WHERE path = ?1", params![path])?; + } + } + + tx.commit()?; + Ok(()) +} + +/// Writes manifest metadata into the registry database. +fn insert_metadata(tx: &Transaction<'_>, snapshot: &SnapshotInput) -> Result<()> { + insert_meta(tx, "schema_version", REGISTRY_DB_SCHEMA_VERSION.to_string())?; + insert_meta(tx, "snapshot_id", snapshot.manifest.snapshot_id.clone())?; + insert_meta(tx, "created_at", snapshot.manifest.created_at.clone())?; + insert_meta( + tx, + "minecraft_version", + snapshot + .manifest + .minecraft_version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + )?; + insert_meta( + tx, + "loader", + snapshot + .manifest + .loader + .clone() + .unwrap_or_else(|| "unknown".to_string()), + )?; + insert_meta( + tx, + "environment", + snapshot + .manifest + .environment + .clone() + .unwrap_or_else(|| "unknown".to_string()), + )?; + insert_meta(tx, "complete", snapshot.manifest.complete.to_string())?; + insert_meta(tx, "counts_json", Value::Object(snapshot.manifest.counts.clone()).to_string())?; + Ok(()) +} + +/// Inserts or replaces a single metadata row. +fn insert_meta(tx: &Transaction<'_>, key: &str, value: String) -> Result<()> { + tx.execute( + //language=sqlite + "INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) +} + +/// Reads the persisted schema version from the metadata table. +/// +/// Returns `None` when the database has not been initialised yet. +fn read_schema_version(conn: &Connection) -> Result> { + let has_metadata = conn + .query_row( + //language=sqlite + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'metadata' LIMIT 1", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + + if !has_metadata { + return Ok(None); + } + + let value: Option = conn + .query_row( + //language=sqlite + "SELECT value FROM metadata WHERE key = 'schema_version' LIMIT 1", + [], + |row| row.get(0), + ) + .optional()?; + + Ok(value.and_then(|it| it.parse::().ok())) +} + +fn insert_source_file(tx: &Transaction<'_>, file: &ManifestFile) -> Result<()> { + //language=sqlite + tx.execute( + r#" + INSERT INTO source_files (path, kind, type, object_id, sha256, size_bytes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "#, + params![ + file.path, + file.kind, + file.file_type, + file.id, + file.sha256, + file.size + ], + )?; + Ok(()) +} + +/// Routes a snapshot file to the correct table inserter based on its path prefix. +fn route_file(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + match file.path.as_str() { + path if path.starts_with("data/registry/") => insert_registry_entry(tx, snapshot_dir, file), + path if path.starts_with("data/items/") => insert_item(tx, snapshot_dir, file), + path if path.starts_with("data/recipe_types/") => insert_recipe_type(tx, snapshot_dir, file), + path if path.starts_with("data/recipes/") => insert_recipe(tx, snapshot_dir, file), + path if path.starts_with("data/value_types/") => insert_value_type(tx, snapshot_dir, file), + path if path.starts_with("data/values/") => insert_custom_value(tx, snapshot_dir, file), + path if path.starts_with("data/tags/") => insert_tag(tx, snapshot_dir, file), + _ => Ok(()), + } +} + +fn insert_registry_entry(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + + //language=sqlite + tx.execute( + r#" + INSERT INTO registry_entries ( + registry_type, id, namespace, path, id_lower, raw_json, source_path + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "#, + params![ + file.file_type, + file.id, + namespace, + path, + lowercase(&file.id), + json.to_string(), + file.path + ], + )?; + + Ok(()) +} + +fn insert_item(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + let texture_ref = resolve_item_texture_ref(snapshot_dir, &file.id); + let texture_path = texture_ref.as_ref().map(|value| texture_ref_to_asset_path(value)); + + tx.execute( + //language=sqlite + r#" + INSERT INTO items ( + id, namespace, path, id_lower, display_name, display_name_lower, + max_count, max_damage, rarity, enchantability, texture_ref, texture_path, raw_json, source_path + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + "#, + params![ + file.id, + namespace, + path, + lowercase(&file.id), + json_get_string(&json, "displayName"), + json_get_string(&json, "displayName").map(|v| lowercase(&v)), + json_get_i64(&json, "maxCount"), + json_get_i64(&json, "maxDamage"), + json_get_string(&json, "rarity"), + json_get_i64(&json, "enchantability"), + texture_ref, + texture_path, + json.to_string(), + file.path + ], + )?; + + Ok(()) +} + +fn insert_recipe_type(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + + + tx.execute( + //language=sqlite + r#" + INSERT INTO recipe_types ( + id, namespace, path, id_lower, display_name, input_slots, fuel_slots, output_slots, input_tanks, + output_tanks, energy_cells, note, raw_json, source_path + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + "#, + params![ + file.id, + namespace, + path, + lowercase(&file.id), + json_get_string(&json, "displayName"), + json_get_i64(&json, "inputSlots"), + json_get_i64(&json, "fuelSlots"), + json_get_i64(&json, "outputSlots"), + json_get_i64(&json, "inputTanks"), + json_get_i64(&json, "outputTanks"), + json_get_i64(&json, "energyCells"), + json_get_string(&json, "note"), + json.to_string(), + file.path + ], + )?; + + Ok(()) +} + +fn insert_recipe(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + let recipe_type = json_get_string(&json, "recipeType") + .or_else(|| json_get_string(&json, "type")); + let group_name = json_get_string(&json, "group"); + + tx.execute( + //language=sqlite + r#" + INSERT INTO recipes ( + id, namespace, path, id_lower, recipe_type, recipe_type_lower, group_name, group_name_lower, raw_json, source_path + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + "#, + params![ + file.id, + namespace, + path, + lowercase(&file.id), + recipe_type, + recipe_type.as_ref().map(|v| lowercase(v)), + group_name, + group_name.as_ref().map(|v| lowercase(v)), + json.to_string(), + file.path + ], + )?; + + insert_recipe_links(tx, &file.id, &json)?; + + Ok(()) +} + +fn insert_value_type(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + + tx.execute( + //language=sqlite + r#" + INSERT INTO value_types ( + id, namespace, path, id_lower, display_name, icon_texture, browseable, raw_json, source_path + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + params![ + file.id, + namespace, + path, + lowercase(&file.id), + json_get_string(&json, "displayName"), + json_get_string(&json, "iconTexture"), + if json.get("browseable").and_then(Value::as_bool).unwrap_or(true) { 1_i64 } else { 0_i64 }, + json.to_string(), + file.path + ], + )?; + + Ok(()) +} + +fn insert_custom_value(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let id = json_get_string(&json, "id").unwrap_or_else(|| file.id.clone()); + let type_id = json_get_string(&json, "typeId") + .or_else(|| extract_value_type_from_path(&file.path)) + .unwrap_or_else(|| "unknown".to_string()); + let (namespace, path) = split_id(&id)?; + + tx.execute( + //language=sqlite + r#" + INSERT INTO custom_values ( + type_id, id, namespace, path, id_lower, display_name, texture_path, raw_json, source_path + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + params![ + type_id, + id, + namespace, + path, + lowercase(&id), + json_get_string(&json, "displayName"), + json_get_string(&json, "texturePath"), + json.to_string(), + file.path + ], + )?; + + Ok(()) +} + +/// Extracts ingredient and product links from a recipe JSON. +fn insert_recipe_links(tx: &Transaction<'_>, recipe_id: &str, json: &Value) -> Result<()> { + let mut links = HashSet::new(); + collect_recipe_links(json, &mut links); + + for link in links { + tx.execute( + //language=sqlite + r#" + INSERT INTO recipe_links (recipe_id, role, value_kind, value) + VALUES (?1, ?2, ?3, ?4) + "#, + params![recipe_id, link.role.as_str(), link.value_kind.as_str(), link.value], + )?; + } + + Ok(()) +} + +fn insert_tag(tx: &Transaction<'_>, snapshot_dir: &Path, file: &ManifestFile) -> Result<()> { + let json = read_json(snapshot_dir, &file.path)?; + let (namespace, path) = split_id(&file.id)?; + let registry_type = file.file_type.clone(); + let replace_flag = json + .get("replace") + .and_then(Value::as_bool) + .unwrap_or(false); + + tx.execute( + //language=sqlite + r#" + INSERT INTO tags (registry_type, id, namespace, path, id_lower, replace_flag, raw_json, source_path) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "#, + params![ + registry_type, + file.id, + namespace, + path, + lowercase(&file.id), + if replace_flag { 1_i64 } else { 0_i64 }, + json.to_string(), + file.path + ], + )?; + + if let Some(values) = json.get("values").and_then(Value::as_array) { + for (ordinal, value) in values.iter().enumerate() { + if let Some(value_text) = value.as_str() { + tx.execute( + //language=sqlite + r#" + INSERT INTO tag_values (registry_type, tag_id, ordinal, value) + VALUES (?1, ?2, ?3, ?4) + "#, + params![registry_type, file.id, ordinal as i64, value_text], + )?; + } + } + } + + Ok(()) +} + +/// Reads a JSON file relative to the snapshot directory. +fn read_json(snapshot_dir: &Path, relative_path: &str) -> Result { + let full_path = snapshot_dir.join(relative_path); + let text = fs::read_to_string(&full_path) + .with_context(|| format!("failed reading {}", full_path.display()))?; + let json = serde_json::from_str(&text) + .with_context(|| format!("failed parsing {}", full_path.display()))?; + Ok(json) +} + +/// Resolves the texture reference for an item by walking its model hierarchy. +/// +/// Follows the `parent` chain and collects `textures` blocks until a known +/// texture key (e.g. `layer0`, `particle`) is found. Returns a Minecraft +/// texture identifier like `"minecraft:block/dirt"`. +fn resolve_item_texture_ref(snapshot_dir: &Path, item_id: &str) -> Option { + let mut visited = HashSet::new(); + let mut inherited = HashMap::new(); + resolve_item_texture_ref_from_model(snapshot_dir, item_model_path(item_id)?, &mut visited, &mut inherited) +} + +fn resolve_item_texture_ref_from_model( + snapshot_dir: &Path, + model_path: String, + visited: &mut HashSet, + inherited: &mut HashMap, +) -> Option { + if !visited.insert(model_path.clone()) { + return None; + } + + let json = read_json(snapshot_dir, &model_path).ok()?; + let mut textures = inherited.clone(); + if let Some(obj) = json.get("textures").and_then(Value::as_object) { + obj.iter().for_each(|(key, value)| { + if let Some(text) = value.as_str() { + textures.insert(key.clone(), text.to_string()); + } + }); + } + + for key in ["layer0", "layer1", "particle", "all", "top", "side", "end", "texture"] { + if let Some(value) = textures.get(key) { + if let Some(resolved) = resolve_texture_alias(value, &textures) { + return Some(resolved); + } + } + } + + if let Some(parent_id) = json.get("parent").and_then(Value::as_str) { + let parent_path = model_id_to_asset_path(parent_id)?; + return resolve_item_texture_ref_from_model(snapshot_dir, parent_path, visited, &mut textures); + } + + None +} + +/// Resolves texture aliases (strings starting with `#`) by following the +/// lookup chain through the collected textures map. +fn resolve_texture_alias(value: &str, textures: &HashMap) -> Option { + let mut current = value; + let mut visited = HashSet::new(); + + loop { + if let Some(alias) = current.strip_prefix('#') { + if !visited.insert(alias.to_string()) { + return None; + } + current = textures.get(alias)?.as_str(); + continue; + } + return Some(current.to_string()); + } +} + +fn item_model_path(item_id: &str) -> Option { + let (namespace, path) = split_id(item_id).ok()?; + Some(format!("assets/models/item/{namespace}/{path}.json")) +} + +fn model_id_to_asset_path(model_id: &str) -> Option { + let (namespace, path) = split_id_or_default_namespace(model_id, "minecraft"); + let (model_kind, model_path) = path.split_once('/')?; + match model_kind { + "item" | "block" => Some(format!("assets/models/{model_kind}/{namespace}/{model_path}.json")), + _ => None, + } +} + +fn texture_ref_to_asset_path(texture_ref: &str) -> String { + let (namespace, path) = split_id_or_default_namespace(texture_ref, "minecraft"); + format!("assets/textures/{namespace}/{path}.png") +} + +fn split_id_or_default_namespace(id: &str, default_namespace: &str) -> (String, String) { + match id.split_once(':') { + Some((namespace, path)) => (namespace.to_string(), path.to_string()), + None => (default_namespace.to_string(), id.to_string()), + } +} + +fn split_id(id: &str) -> Result<(String, String)> { + let (namespace, path) = id + .split_once(':') + .ok_or_else(|| anyhow!("invalid namespaced id: {id}"))?; + Ok((namespace.to_string(), path.to_string())) +} + +fn json_get_string(json: &Value, key: &str) -> Option { + json.get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn json_get_i64(json: &Value, key: &str) -> Option { + json.get(key).and_then(Value::as_i64) +} + +fn lowercase(value: &str) -> String { + value.to_lowercase() +} + +/// Whether a recipe link represents an input or an output. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum RecipeLinkRole { + Input, + Output, +} + +impl RecipeLinkRole { + fn as_str(self) -> &'static str { + match self { + Self::Input => "input", + Self::Output => "output", + } + } +} + +/// The type of value a recipe link points to. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum RecipeLinkKind { + Item, + Tag, + Value, +} + +impl RecipeLinkKind { + fn as_str(self) -> &'static str { + match self { + Self::Item => "item", + Self::Tag => "tag", + Self::Value => "value", + } + } +} + +/// A single link between a recipe and an item/tag/value that participates in it. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct RecipeLink { + role: RecipeLinkRole, + value_kind: RecipeLinkKind, + value: String, +} + +/// Collects all recipe links from a recipe JSON. +fn collect_recipe_links(value: &Value, links: &mut HashSet) { + if let Some(display) = value.get("display").and_then(Value::as_object) { + if let Some(inputs) = display.get("inputs") { + collect_rendered_values(inputs, RecipeLinkRole::Input, links); + } + if let Some(outputs) = display.get("outputs") { + collect_rendered_values(outputs, RecipeLinkRole::Output, links); + } + } + + if links.is_empty() { + let legacy_root = value.get("sourceJson").unwrap_or(value); + collect_legacy_recipe_links(legacy_root, links); + } +} + +/// Collects links from the structured `{id, refType, valueType}` display format. +fn collect_rendered_values(value: &Value, role: RecipeLinkRole, links: &mut HashSet) { + match value { + Value::Object(object) => { + if let Some(id) = object.get("id").and_then(Value::as_str) { + let ref_type = object.get("refType").and_then(Value::as_str).unwrap_or("value"); + let value_type = object.get("valueType").and_then(Value::as_str).unwrap_or("value"); + let value_kind = match ref_type { + "item" if value_type == "item" => RecipeLinkKind::Item, + "tag" if value_type == "item" => RecipeLinkKind::Tag, + _ => RecipeLinkKind::Value, + }; + links.insert(RecipeLink { + role, + value_kind, + value: id.to_string(), + }); + } + } + Value::Array(values) => { + for child in values { + collect_rendered_values(child, role, links); + } + } + _ => {} + } +} + +fn collect_legacy_recipe_links(value: &Value, links: &mut HashSet) { + match value { + Value::Object(object) => { + for (key, child) in object { + if RECIPE_PRODUCT_KEYS.contains(&key.as_str()) { + collect_legacy_role_links(child, RecipeLinkRole::Output, links); + } + if RECIPE_INGREDIENT_KEYS.contains(&key.as_str()) { + collect_legacy_role_links(child, RecipeLinkRole::Input, links); + } + collect_legacy_recipe_links(child, links); + } + } + Value::Array(values) => { + for child in values { + collect_legacy_recipe_links(child, links); + } + } + _ => {} + } +} + +fn collect_legacy_role_links(value: &Value, role: RecipeLinkRole, links: &mut HashSet) { + match value { + Value::Object(object) => { + if let Some(item_id) = object.get("item").and_then(Value::as_str) { + links.insert(RecipeLink { + role, + value_kind: RecipeLinkKind::Item, + value: item_id.to_string(), + }); + } + if let Some(tag_id) = object.get("tag").and_then(Value::as_str) { + links.insert(RecipeLink { + role, + value_kind: RecipeLinkKind::Tag, + value: tag_id.to_string(), + }); + } + if let Some(item_id) = object + .get("id") + .and_then(Value::as_str) + .filter(|_| object.contains_key("count") || object.contains_key("item")) + { + links.insert(RecipeLink { + role, + value_kind: RecipeLinkKind::Item, + value: item_id.to_string(), + }); + } + for child in object.values() { + collect_legacy_role_links(child, role, links); + } + } + Value::Array(values) => { + for child in values { + collect_legacy_role_links(child, role, links); + } + } + Value::String(text) => { + if let Some(tag_id) = text.strip_prefix('#') { + if is_namespaced_id(tag_id) { + links.insert(RecipeLink { + role, + value_kind: RecipeLinkKind::Tag, + value: tag_id.to_string(), + }); + } + } else if is_namespaced_id(text) { + links.insert(RecipeLink { + role, + value_kind: RecipeLinkKind::Item, + value: text.to_string(), + }); + } + } + _ => {} + } +} + +fn extract_value_type_from_path(path: &str) -> Option { + let mut parts = path.split('/'); + let _ = parts.next(); + let _ = parts.next(); + let namespace = parts.next()?; + let type_path = parts.next()?; + Some(format!("{namespace}:{type_path}")) +} + +fn is_namespaced_id(value: &str) -> bool { + let Some((namespace, path)) = value.split_once(':') else { + return false; + }; + + !namespace.is_empty() && !path.is_empty() +} diff --git a/tree-sitter-javascript b/tree-sitter-javascript new file mode 160000 index 0000000..58404d8 --- /dev/null +++ b/tree-sitter-javascript @@ -0,0 +1 @@ +Subproject commit 58404d8cf191d69f2674a8fd507bd5776f46cb11