diff --git a/plugin-playground/src/main/kotlin/ChatboxHelmets/HiscoresModels.kt b/plugin-playground/src/main/kotlin/ChatboxHelmets/HiscoresModels.kt new file mode 100644 index 0000000..786bf68 --- /dev/null +++ b/plugin-playground/src/main/kotlin/ChatboxHelmets/HiscoresModels.kt @@ -0,0 +1,19 @@ +package ChatboxHelmets + +// Lifted from KondoHiscores view +data class HiscoresResponse( + val info: PlayerInfo, + val skills: List +) + +data class PlayerInfo( + val exp_multiplier: String, + val iron_mode: String +) + +data class Skill( + val id: String, + val dynamic: String, + val experience: String, + val static: String +) \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/ChatboxHelmets/SpecialEscapes.kt b/plugin-playground/src/main/kotlin/ChatboxHelmets/SpecialEscapes.kt new file mode 100644 index 0000000..726de70 --- /dev/null +++ b/plugin-playground/src/main/kotlin/ChatboxHelmets/SpecialEscapes.kt @@ -0,0 +1,198 @@ +package ChatboxHelmets + +// See JagString.java parse() in the client, There is special encoding +// and this correctly resolves specials. +val SPECIAL_ESCAPES: Map = hashMapOf( + ' ' to "(P", + '!' to "(Q", + '"' to "(R", + '#' to "(S", + '$' to "(T", + '%' to "(U", + '&' to "(V", + '\'' to "(W", + '(' to "(X", + ')' to "(Y", + '*' to "(Z", + '+' to ")0", + ',' to ")1", + '-' to ")2", + '.' to ")3", + '/' to ")4", + ':' to "(j", + ';' to "(k", + '=' to ")B", + '?' to ")D", + '@' to ")E", + '[' to "*5", + '\\' to "*6", + ']' to "*7", + '^' to "*8", + '_' to "*9", + '`' to ")e", + '{' to "*U", + '|' to "*V", + '}' to "*W", + '~' to "*X", + '\u0000' to "(0", + '\u0001' to "(1", + '\u0002' to "(2", + '\u0003' to "(3", + '\u0004' to "(4", + '\u0005' to "(5", + '\u0006' to "(6", + '\u0007' to "(7", + '\b' to "(8", + '\t' to "(9", + '\n' to "(:", + '\u000B' to "(;", + '\u000C' to "(<", + '\r' to "(=", + '\u000E' to "(>", + '\u000F' to "(?", + '\u0010' to "(@", + '\u0011' to "(A", + '\u0012' to "(B", + '\u0013' to "(C", + '\u0014' to "(D", + '\u0015' to "(E", + '\u0016' to "(F", + '\u0017' to "(G", + '\u0018' to "(H", + '\u0019' to "(I", + '\u001A' to "(J", + '\u001B' to "(K", + '\u001C' to "(L", + '\u001D' to "(M", + '\u001E' to "(N", + '\u001F' to "(O", + '\u007F' to "*Y", + '\u0080' to "*Z", + '\u0081' to "+0", + '\u0082' to "+1", + '\u0083' to "+2", + '\u0084' to "+3", + '\u0085' to "+4", + '\u0086' to "+5", + '\u0087' to "+6", + '\u0088' to "+7", + '\u0089' to "+8", + '\u008A' to "+9", + '\u008B' to "*e", + '\u008C' to "*f", + '\u008D' to "*g", + '\u008E' to "*h", + '\u008F' to "*i", + '\u0090' to "*j", + '\u0091' to "*k", + '\u0092' to "+A", + '\u0093' to "+B", + '\u0094' to "+C", + '\u0095' to "+D", + '\u0096' to "+E", + '\u0097' to "+F", + '\u0098' to "+G", + '\u0099' to "+H", + '\u009A' to "+I", + '\u009B' to "+J", + '\u009C' to "+K", + '\u009D' to "+L", + '\u009E' to "+M", + '\u009F' to "+N", + '\u00A0' to "+O", + '\u00A1' to "+P", + '\u00A2' to "+Q", + '\u00A3' to "+R", + '\u00A4' to "+S", + '\u00A5' to "+T", + '\u00A6' to "+U", + '\u00A7' to "+V", + '\u00A8' to "+W", + '\u00A9' to "+X", + '\u00AA' to "+Y", + '\u00AB' to "+Z", + '\u00AC' to ",0", + '\u00AD' to ",1", + '\u00AE' to ",2", + '\u00AF' to ",3", + '\u00B0' to ",4", + '\u00B1' to ",5", + '\u00B2' to ",6", + '\u00B3' to ",7", + '\u00B4' to ",8", + '\u00B5' to ",9", + '\u00B6' to "+e", + '\u00B7' to "+f", + '\u00B8' to "+g", + '\u00B9' to "+h", + '\u00BA' to "+i", + '\u00BB' to "+j", + '\u00BC' to "+k", + '\u00BD' to ",A", + '\u00BE' to ",B", + '\u00BF' to ",C", + '\u00C0' to ",D", + '\u00C1' to ",E", + '\u00C2' to ",F", + '\u00C3' to ",G", + '\u00C4' to ",H", + '\u00C5' to ",I", + '\u00C6' to ",J", + '\u00C7' to ",K", + '\u00C8' to ",L", + '\u00C9' to ",M", + '\u00CA' to ",N", + '\u00CB' to ",O", + '\u00CC' to ",P", + '\u00CD' to ",Q", + '\u00CE' to ",R", + '\u00CF' to ",S", + '\u00D0' to ",T", + '\u00D1' to ",U", + '\u00D2' to ",V", + '\u00D3' to ",W", + '\u00D4' to ",X", + '\u00D5' to ",Y", + '\u00D6' to ",Z", + '\u00D7' to "-0", + '\u00D8' to "-1", + '\u00D9' to "-2", + '\u00DA' to "-3", + '\u00DB' to "-4", + '\u00DC' to "-5", + '\u00DD' to "-6", + '\u00DE' to "-7", + '\u00DF' to "-8", + '\u00E0' to "-9", + '\u00E1' to ",e", + '\u00E2' to ",f", + '\u00E3' to ",g", + '\u00E4' to ",h", + '\u00E5' to ",i", + '\u00E6' to ",j", + '\u00E7' to ",k", + '\u00E8' to "-A", + '\u00E9' to "-B", + '\u00EA' to "-C", + '\u00EB' to "-D", + '\u00EC' to "-E", + '\u00ED' to "-F", + '\u00EE' to "-G", + '\u00EF' to "-H", + '\u00F0' to "-I", + '\u00F1' to "-J", + '\u00F2' to "-K", + '\u00F3' to "-L", + '\u00F4' to "-M", + '\u00F5' to "-N", + '\u00F6' to "-O", + '\u00F7' to "-P", + '\u00F8' to "-Q", + '\u00F9' to "-R", + '\u00FA' to "-S", + '\u00FB' to "-T", + '\u00FC' to "-U", + '\u00FD' to "-V", + '\u00FE' to "-W", + '\u00FF' to "-X" +) \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.kt b/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.kt new file mode 100644 index 0000000..8bf2de4 --- /dev/null +++ b/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.kt @@ -0,0 +1,192 @@ +package ChatboxHelmets + +import KondoKit.Exposed +import plugin.Plugin +import plugin.api.API +import rt4.Component +import rt4.JagString +import rt4.Player +import java.nio.charset.StandardCharsets +import com.google.gson.Gson +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +class plugin : Plugin() { + + private val TARGET_COMPONENT_ID = 8978483 + private val TARGET_COMPONENT_INDEX = 51 + private val SETTINGS_KEY = "chat-helms" + private val BUBBLE_ICON = "" + private val gson = Gson() + + val TYPE_ICONS: Map = hashMapOf( + 0 to "", + 1 to "", // IM + 2 to "", // HCIM + 3 to "", // UIM + ) + + @Exposed(description = "Username to account type mappings (0=Normal, 1=IM, 2=HCIM, 3=UIM)") + private var usernameMatches = HashMap() + + private var ACCOUNT_TYPE = 0 + private var component: Component? = null + + override fun Init() { + // Load username matches from local storage + val storedData = API.GetData(SETTINGS_KEY) + usernameMatches = when (storedData) { + is String -> { + try { + gson.fromJson(storedData, HashMap::class.java) as HashMap + } catch (e: Exception) { + println("Failed to deserialize username matches: ${e.message}") + HashMap() + } + } + else -> HashMap() + } + getConfig() + } + + override fun OnPluginsReloaded(): Boolean { + cleanup() + return false + } + + override fun OnLogin() { + Init() + } + + override fun Draw(timeDelta: Long) { + replace() + } + + override fun ComponentDraw(componentIndex: Int, component: Component?, screenX: Int, screenY: Int) { + if (component?.id == TARGET_COMPONENT_ID && componentIndex == TARGET_COMPONENT_INDEX) { + this.component = component + } + } + + // Allow setting from the chatbox + override fun ProcessCommand(commandStr: String?, args: Array?) { + super.ProcessCommand(commandStr, args) + when(commandStr) { + "::chathelm" -> { + if (args != null) { + if(args.isEmpty()) return + val type = args[0].toIntOrNull() ?: return + ACCOUNT_TYPE = type + usernameMatches[getCleanUserName().toLowerCase()] = ACCOUNT_TYPE + storeData() + } + } + } + } + + fun OnKondoValueUpdated() { + storeData() + Init() + } + + private fun cleanup() { + ACCOUNT_TYPE = 0 + replace() + component = null + } + + private fun replace() { + if (component != null && component?.id == TARGET_COMPONENT_ID) { + // Unmodified login name is used here to display it however the user types it + // caps and spaces respected. + val modifiedStr = replaceUsernameInBytes(component!!.text.chars, Player.usernameInput.toString()) + component?.text = JagString.parse(modifiedStr) + } + } + + private fun getCleanUserName(): String { + return Player.usernameInput.toString().replace(" ", "_") + } + + private fun getConfig() { + val cleanUsername = getCleanUserName() + + //println(cleanUsername) + + // Abort if we are pre-login + if (cleanUsername.isEmpty()) return + + // Check if we already have the account type for this user + val lowercaseUsername = cleanUsername.toLowerCase() + if (usernameMatches.containsKey(lowercaseUsername)) { + ACCOUNT_TYPE = usernameMatches[lowercaseUsername]!! + return + } + + // No match found, check the hiscores (live server) + fetchAccountTypeFromAPI(lowercaseUsername) + } + + private fun fetchAccountTypeFromAPI(lowercaseUsername : String){ + val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${lowercaseUsername}" + Thread { + try { + val url = URL(apiUrl) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + + // If a request take longer than 5 seconds timeout. + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = reader.use { it.readText() } + reader.close() + updatePlayerData(response, lowercaseUsername) + } + } catch (_: Exception) { } + }.start() + } + + private fun updatePlayerData(jsonResponse: String, lowercaseUsername: String) { + val hiscoresResponse = gson.fromJson(jsonResponse, HiscoresResponse::class.java) + ACCOUNT_TYPE = hiscoresResponse.info.iron_mode.toInt() + usernameMatches[lowercaseUsername] = ACCOUNT_TYPE + storeData() + } + + private fun replaceUsernameInBytes(bytes: ByteArray, username: String): String { + // Convert bytes to string to work with them + val text = String(bytes, StandardCharsets.ISO_8859_1) + val colonIndex = text.indexOf(": ") + if (colonIndex != -1) { + val suffix = text.substring(colonIndex) + // modify and add the rest (username is always before the first ':' ) + val newText = "${TYPE_ICONS.getOrDefault(ACCOUNT_TYPE, "")}$username$BUBBLE_ICON$suffix" + return encodeToJagStr(newText) + } + return encodeToJagStr(text) + } + + private fun storeData() { + val jsonString = gson.toJson(usernameMatches) + API.StoreData(SETTINGS_KEY, jsonString) + } + + private fun encodeToJagStr(str: String): String { + val result = StringBuilder() + for (char in str) { + val escaped = SPECIAL_ESCAPES[char] + if (escaped != null) { + result.append(escaped) + } else { + result.append(char) + } + } + return result.toString() + } +} \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.properties b/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.properties new file mode 100644 index 0000000..439b02b --- /dev/null +++ b/plugin-playground/src/main/kotlin/ChatboxHelmets/plugin.properties @@ -0,0 +1,5 @@ +AUTHOR='downthecrop' +DESCRIPTION='Displays account type icons next to usernames in chat pulled from the live server API. \ + Can be set with a chat command for single player with ::chathelm 1 (0=Normal, 1=IM, 2=HCIM, 3=UIM) \ + or via KondoKit settings.' +VERSION=1.0 \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/KondoKit/HiscoresView.kt b/plugin-playground/src/main/kotlin/KondoKit/HiscoresView.kt deleted file mode 100644 index 0b48ab3..0000000 --- a/plugin-playground/src/main/kotlin/KondoKit/HiscoresView.kt +++ /dev/null @@ -1,510 +0,0 @@ -package KondoKit - -import KondoKit.Constants.COLOR_BACKGROUND_DARK -import KondoKit.Constants.SKILL_DISPLAY_ORDER -import KondoKit.Constants.SKILL_SPRITE_DIMENSION -import KondoKit.Helpers.formatHtmlLabelText -import KondoKit.Helpers.getSpriteId -import KondoKit.Helpers.showToast -import KondoKit.SpriteToBufferedImage.getBufferedImageFromSprite -import KondoKit.plugin.Companion.POPUP_FOREGROUND -import KondoKit.plugin.Companion.TOOLTIP_BACKGROUND -import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR -import KondoKit.plugin.Companion.WIDGET_COLOR -import KondoKit.plugin.Companion.primaryColor -import KondoKit.plugin.Companion.secondaryColor -import KondoKit.plugin.StateManager.focusedView -import com.google.gson.Gson -import plugin.api.API -import rt4.Sprites -import java.awt.* -import java.awt.datatransfer.DataFlavor -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.SocketTimeoutException -import java.net.URL -import javax.swing.* -import javax.swing.border.MatteBorder -import kotlin.math.floor - -object Constants { - // Sprite IDs - const val COMBAT_LVL_SPRITE = 168 - const val IRONMAN_SPRITE = 4 - const val MAG_SPRITE = 1423 - const val LVL_BAR_SPRITE = 898 - - // Dimensions - val SEARCH_FIELD_DIMENSION = Dimension(230, 30) - val ICON_DIMENSION_SMALL = Dimension(12, 12) - val ICON_DIMENSION_LARGE = Dimension(30, 30) - val HISCORE_PANEL_DIMENSION = Dimension(230, 500) - val FILTER_PANEL_DIMENSION = Dimension(230, 30) - val SKILLS_PANEL_DIMENSION = Dimension(230, 290) - val TOTAL_COMBAT_PANEL_DIMENSION = Dimension(230, 30) - val SKILL_PANEL_DIMENSION = Dimension(76, 35) - val IMAGE_CANVAS_DIMENSION = Dimension(20, 20) - val SKILL_SPRITE_DIMENSION = Dimension(14, 14) - val NUMBER_LABEL_DIMENSION = Dimension(20, 20) - - // Colors - val COLOR_BACKGROUND_DARK = WIDGET_COLOR - val COLOR_BACKGROUND_MEDIUM = VIEW_BACKGROUND_COLOR - val COLOR_FOREGROUND_LIGHT = POPUP_FOREGROUND - - // Fonts - val FONT_ARIAL_PLAIN_14 = Font("Arial", Font.PLAIN, 14) - val FONT_ARIAL_PLAIN_12 = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - val FONT_ARIAL_BOLD_12 = Font("Arial", Font.BOLD, 12) - val SKILL_DISPLAY_ORDER = arrayOf(0,3,14,2,16,13,1,15,10,4,17,7,5,12,11,6,9,8,20,18,19,22,21,23) -} - -var text: String = "" - -object HiscoresView { - - const val VIEW_NAME = "HISCORE_SEARCH_VIEW" - var hiScoreView: JPanel? = null - class CustomSearchField(private val hiscoresPanel: JPanel) : Canvas() { - - private var cursorVisible: Boolean = true - private val gson = Gson() - - private val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(Constants.MAG_SPRITE)) - private val imageCanvas = bufferedImageSprite.let { - ImageCanvas(it).apply { - preferredSize = Constants.ICON_DIMENSION_SMALL - size = preferredSize - minimumSize = preferredSize - maximumSize = preferredSize - fillColor = COLOR_BACKGROUND_DARK - } - } - - init { - preferredSize = Constants.SEARCH_FIELD_DIMENSION - background = Constants.COLOR_BACKGROUND_DARK - foreground = Constants.COLOR_FOREGROUND_LIGHT - font = Constants.FONT_ARIAL_PLAIN_14 - minimumSize = preferredSize - maximumSize = preferredSize - - addKeyListener(object : KeyAdapter() { - override fun keyTyped(e: KeyEvent) { - // Prevent null character from being typed on Ctrl+A & Ctrl+V - if (e.isControlDown && (e.keyChar == '\u0001' || e.keyChar == '\u0016')) { - e.consume() - return - } - if (e.keyChar == '\b') { - if (text.isNotEmpty()) { - text = text.dropLast(1) - } - } else if (e.keyChar == '\n') { - searchPlayer(text) - } else { - text += e.keyChar - } - SwingUtilities.invokeLater { - repaint() - } - } - override fun keyPressed(e: KeyEvent) { - if (e.isControlDown) { - when (e.keyCode) { - KeyEvent.VK_A -> { - text = "" - repaint() - } - KeyEvent.VK_V -> { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - val pasteText = clipboard.getData(DataFlavor.stringFlavor) as String - text += pasteText - SwingUtilities.invokeLater { - repaint() - } - } - } - } - } - }) - - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.x > width - 20 && e.y < 20) { - text = "" - SwingUtilities.invokeLater { - repaint() - } - } - } - }) - - Timer(500) { - cursorVisible = !cursorVisible - if(focusedView == VIEW_NAME) - SwingUtilities.invokeLater { - repaint() - } - }.start() - } - - override fun paint(g: Graphics) { - super.paint(g) - g.color = foreground - g.font = font - - val fm = g.fontMetrics - val cursorX = fm.stringWidth(text) + 30 - - imageCanvas.let { canvas -> - val imgG = g.create(5, 5, canvas.width, canvas.height) - canvas.paint(imgG) - imgG.dispose() - } - - g.drawString(text, 30, 20) - - if (cursorVisible && hasFocus()) { - g.drawLine(cursorX, 5, cursorX, 25) - } - - if (text.isNotEmpty()) { - g.color = Color.RED - g.drawString("x", width - 20, 20) - } - } - - fun searchPlayer(username: String) { - text = username.replace(" ", "_") - val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${text.toLowerCase()}" - - updateHiscoresView(null, "Searching...") - - Thread { - try { - val url = URL(apiUrl) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - - // If a request take longer than 5 seconds timeout. - connection.connectTimeout = 5000 - connection.readTimeout = 5000 - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val response = reader.use { it.readText() } - reader.close() - - SwingUtilities.invokeLater { - updatePlayerData(response, username) - } - } else { - SwingUtilities.invokeLater { - showToast(hiscoresPanel, "Player not found!", JOptionPane.ERROR_MESSAGE) - } - } - } catch (e: SocketTimeoutException) { - SwingUtilities.invokeLater { - showToast(hiscoresPanel, "Request timed out", JOptionPane.ERROR_MESSAGE) - } - } catch (e: Exception) { - // Handle other errors - SwingUtilities.invokeLater { - showToast(hiscoresPanel, "Error fetching data!", JOptionPane.ERROR_MESSAGE) - } - } - }.start() - } - - - private fun updatePlayerData(jsonResponse: String, username: String) { - val hiscoresResponse = gson.fromJson(jsonResponse, HiscoresResponse::class.java) - updateHiscoresView(hiscoresResponse, username) - } - - private fun updateHiscoresView(data: HiscoresResponse?, username: String) { - val playerNameLabel = findComponentByName(hiscoresPanel, "playerNameLabel") as? JPanel - playerNameLabel?.removeAll() // Clear previous components - var nameLabel = JLabel(formatHtmlLabelText(username, secondaryColor, "", primaryColor), JLabel.CENTER).apply { - font = Constants.FONT_ARIAL_BOLD_12 - foreground = Constants.COLOR_FOREGROUND_LIGHT - border = BorderFactory.createEmptyBorder(0, 6, 0, 0) // Top, Left, Bottom, Right padding - } - playerNameLabel?.add(nameLabel) - playerNameLabel?.revalidate() - playerNameLabel?.repaint() - - if(data == null) return - - playerNameLabel?.removeAll() - - val ironMode = data.info.iron_mode - - if (ironMode != "0") { - val ironmanBufferedImage = getBufferedImageFromSprite(Sprites.nameIcons[Constants.IRONMAN_SPRITE + ironMode.toInt() - 1]) - val imageCanvas = ironmanBufferedImage.let { - ImageCanvas(it).apply { - preferredSize = Constants.IMAGE_CANVAS_DIMENSION - size = Constants.IMAGE_CANVAS_DIMENSION - } - } - - playerNameLabel?.add(imageCanvas) - } - - val exp_multiplier = data.info.exp_multiplier - nameLabel = JLabel(formatHtmlLabelText(username, secondaryColor, " (${exp_multiplier}x)", primaryColor), JLabel.CENTER).apply { - font = Constants.FONT_ARIAL_BOLD_12 - foreground = Constants.COLOR_FOREGROUND_LIGHT - border = BorderFactory.createEmptyBorder(0, 6, 0, 0) // Top, Left, Bottom, Right padding - } - - - playerNameLabel?.add(nameLabel) - - playerNameLabel?.revalidate() - playerNameLabel?.repaint() - - // Update skill labels - data.skills.forEachIndexed { index, skill -> - val labelName = "skillLabel_$index" - val numberLabel = findComponentByName(hiscoresPanel, labelName) as? JLabel - numberLabel?.text = skill.static - } - - updateTotalAndCombatLevel(data.skills, true) - - hiscoresPanel.revalidate() - hiscoresPanel.repaint() - } - - private fun updateTotalAndCombatLevel(skills: List, isMemberWorld: Boolean) { - val totalLevel = skills.sumBy { it.static.toInt() } - val totalLevelLabel = findComponentByName(hiscoresPanel, "totalLevelLabel") as? JLabel - totalLevelLabel?.text = totalLevel.toString() - - val attack = skills.find { it.id == "0" }?.static?.toInt() ?: 1 - val defence = skills.find { it.id == "1" }?.static?.toInt() ?: 1 - val strength = skills.find { it.id == "2" }?.static?.toInt() ?: 1 - val hitpoints = skills.find { it.id == "3" }?.static?.toInt() ?: 1 - val ranged = skills.find { it.id == "4" }?.static?.toInt() ?: 1 - val prayer = skills.find { it.id == "5" }?.static?.toInt() ?: 1 - val magic = skills.find { it.id == "6" }?.static?.toInt() ?: 1 - val summoning = skills.find { it.id == "23" }?.static?.toInt() ?: 1 - - val combatLevel = calculateCombatLevel(attack, defence, strength, hitpoints, prayer, ranged, magic, summoning, true) - val combatLevelLabel = findComponentByName(hiscoresPanel, "combatLevelLabel") as? JLabel - combatLevelLabel?.text = combatLevel.toString() - } - - private fun calculateCombatLevel( - attack: Int, - defence: Int, - strength: Int, - hitpoints: Int, - prayer: Int, - ranged: Int, - magic: Int, - summoning: Int, - isMemberWorld: Boolean - ): Double { - val base = (defence + hitpoints + floor(prayer.toDouble() / 2)) * 0.25 - val melee = (attack + strength) * 0.325 - val range = floor(ranged * 1.5) * 0.325 - val mage = floor(magic * 1.5) * 0.325 - val maxCombatType = maxOf(melee, range, mage) - - val summoningFactor = if (isMemberWorld) floor(summoning.toDouble() / 8) else 0.0 - return Math.round((base + maxCombatType + summoningFactor) * 1000.0) / 1000.0 - } - - private fun findComponentByName(container: Container, name: String): Component? { - for (component in container.components) { - if (name == component.name) { - return component - } - if (component is Container) { - val child = findComponentByName(component, name) - if (child != null) { - return child - } - } - } - return null - } - } - - fun createHiscoreSearchView() { - val hiscorePanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - name = VIEW_NAME - background = Constants.COLOR_BACKGROUND_MEDIUM - preferredSize = Constants.HISCORE_PANEL_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - } - - val customSearchField = CustomSearchField(hiscorePanel) - - val searchFieldWrapper = JPanel().apply { - layout = BoxLayout(this, BoxLayout.X_AXIS) - background = Constants.COLOR_BACKGROUND_MEDIUM - preferredSize = Constants.SEARCH_FIELD_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - alignmentX = Component.CENTER_ALIGNMENT - add(customSearchField) - } - - val searchPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - background = Constants.COLOR_BACKGROUND_MEDIUM - add(searchFieldWrapper) - } - - hiscorePanel.add(Box.createVerticalStrut(10)) - hiscorePanel.add(searchPanel) - hiscorePanel.add(Box.createVerticalStrut(10)) - - val playerNamePanel = JPanel().apply { - layout = GridBagLayout() // This will center the JLabel both vertically and horizontally - background = TOOLTIP_BACKGROUND.darker() - preferredSize = Constants.FILTER_PANEL_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - name = "playerNameLabel" - } - - hiscorePanel.add(playerNamePanel) - hiscorePanel.add(Box.createVerticalStrut(10)) - - val skillsPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply { - background = Constants.COLOR_BACKGROUND_MEDIUM - preferredSize = Constants.SKILLS_PANEL_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - } - - for (i in SKILL_DISPLAY_ORDER) { - val skillPanel = JPanel().apply { - layout = BorderLayout() - background = COLOR_BACKGROUND_DARK - preferredSize = Constants.SKILL_PANEL_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - border = MatteBorder(5, 0, 0, 0, COLOR_BACKGROUND_DARK) - } - - val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(getSpriteId(i))) - - val imageCanvas = bufferedImageSprite.let { - ImageCanvas(it).apply { - preferredSize = SKILL_SPRITE_DIMENSION - size = SKILL_SPRITE_DIMENSION - fillColor = COLOR_BACKGROUND_DARK - } - } - - val numberLabel = JLabel("", JLabel.RIGHT).apply { - name = "skillLabel_$i" - foreground = Constants.COLOR_FOREGROUND_LIGHT - font = Constants.FONT_ARIAL_PLAIN_12 - preferredSize = Constants.NUMBER_LABEL_DIMENSION - minimumSize = Constants.NUMBER_LABEL_DIMENSION - } - - val imageContainer = JPanel(FlowLayout(FlowLayout.CENTER, 5, 0)).apply { - background = COLOR_BACKGROUND_DARK - add(imageCanvas) - add(numberLabel) - } - - skillPanel.add(imageContainer, BorderLayout.CENTER) - skillsPanel.add(skillPanel) - } - - hiscorePanel.add(skillsPanel) - - val totalCombatPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply { - background = COLOR_BACKGROUND_DARK - preferredSize = Constants.TOTAL_COMBAT_PANEL_DIMENSION - maximumSize = preferredSize - minimumSize = preferredSize - } - - val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(Constants.LVL_BAR_SPRITE)) - - val totalLevelIcon = ImageCanvas(bufferedImageSprite).apply { - fillColor = COLOR_BACKGROUND_DARK - preferredSize = Constants.ICON_DIMENSION_LARGE - size = Constants.ICON_DIMENSION_LARGE - } - - val totalLevelLabel = JLabel("").apply { - name = "totalLevelLabel" - foreground = Constants.COLOR_FOREGROUND_LIGHT - font = Constants.FONT_ARIAL_BOLD_12 - horizontalAlignment = JLabel.LEFT - iconTextGap = 10 - } - - val totalLevelPanel = JPanel(FlowLayout(FlowLayout.LEFT)).apply { - background = Constants.COLOR_BACKGROUND_DARK - add(totalLevelIcon) - add(totalLevelLabel) - } - - val bufferedImageSprite2 = getBufferedImageFromSprite(API.GetSprite(Constants.COMBAT_LVL_SPRITE)) - - val combatLevelIcon = ImageCanvas(bufferedImageSprite2).apply { - fillColor = COLOR_BACKGROUND_DARK - preferredSize = Constants.ICON_DIMENSION_LARGE - size = Constants.ICON_DIMENSION_LARGE - } - - val combatLevelLabel = JLabel("").apply { - name = "combatLevelLabel" - foreground = Constants.COLOR_FOREGROUND_LIGHT - font = Constants.FONT_ARIAL_BOLD_12 - horizontalAlignment = JLabel.LEFT - iconTextGap = 10 - } - - val combatLevelPanel = JPanel(FlowLayout(FlowLayout.LEFT)).apply { - background = COLOR_BACKGROUND_DARK - add(combatLevelIcon) - add(combatLevelLabel) - } - - totalCombatPanel.add(totalLevelPanel) - totalCombatPanel.add(combatLevelPanel) - hiscorePanel.add(totalCombatPanel) - hiscorePanel.add(Box.createVerticalStrut(10)) - - hiScoreView = hiscorePanel - } - - data class HiscoresResponse( - val info: PlayerInfo, - val skills: List - ) - - data class PlayerInfo( - val exp_multiplier: String, - val iron_mode: String - ) - - data class Skill( - val id: String, - val dynamic: String, - val experience: String, - val static: String - ) -} \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/KondoKit/ReflectiveEditorView.kt b/plugin-playground/src/main/kotlin/KondoKit/ReflectiveEditorView.kt deleted file mode 100644 index 5b6cfd2..0000000 --- a/plugin-playground/src/main/kotlin/KondoKit/ReflectiveEditorView.kt +++ /dev/null @@ -1,324 +0,0 @@ -package KondoKit - -import KondoKit.Helpers.convertValue -import KondoKit.Helpers.showToast -import KondoKit.plugin.Companion.TITLE_BAR_COLOR -import KondoKit.plugin.Companion.TOOLTIP_BACKGROUND -import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR -import KondoKit.plugin.Companion.WIDGET_COLOR -import KondoKit.plugin.Companion.primaryColor -import KondoKit.plugin.Companion.secondaryColor -import KondoKit.plugin.StateManager.focusedView -import plugin.Plugin -import plugin.PluginInfo -import plugin.PluginRepository -import java.awt.* -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.util.* -import java.util.Timer -import javax.swing.* -import kotlin.math.ceil - -/* - This is used for the runtime editing of plugin variables. - To expose fields add the @Exposed annotation. - When they are applied this will trigger an invoke of OnKondoValueUpdated() - if it is implemented. Check GroundItems plugin for an example. - */ - - -object ReflectiveEditorView { - var reflectiveEditorView: JPanel? = null - private val loadedPlugins: MutableList = mutableListOf() - const val VIEW_NAME = "REFLECTIVE_EDITOR_VIEW" - fun createReflectiveEditorView() { - val reflectiveEditorPanel = JPanel(BorderLayout()) - reflectiveEditorPanel.background = VIEW_BACKGROUND_COLOR - reflectiveEditorPanel.add(Box.createVerticalStrut(5)) - reflectiveEditorPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) - reflectiveEditorView = reflectiveEditorPanel - addPlugins(reflectiveEditorView!!) - } - - fun addPlugins(reflectiveEditorView: JPanel) { - reflectiveEditorView.removeAll() // clear previous - loadedPlugins.clear() - try { - val loadedPluginsField = PluginRepository::class.java.getDeclaredField("loadedPlugins") - loadedPluginsField.isAccessible = true - val loadedPlugins = loadedPluginsField.get(null) as HashMap<*, *> - - for ((pluginInfo, plugin) in loadedPlugins) { - addPluginToEditor(reflectiveEditorView, pluginInfo as PluginInfo, plugin as Plugin) - } - } catch (e: Exception) { - e.printStackTrace() - } - - // Add a centered box for plugins that have no exposed fields - if (loadedPlugins.isNotEmpty()) { - val noExposedPanel = JPanel(BorderLayout()) - noExposedPanel.background = VIEW_BACKGROUND_COLOR - noExposedPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) - - val label = JLabel("Loaded Plugins without Exposed Fields", SwingConstants.CENTER) - label.font = Font("RuneScape Small", Font.PLAIN, 16) - label.foreground = primaryColor - noExposedPanel.add(label, BorderLayout.NORTH) - - val pluginsList = JList(loadedPlugins.toTypedArray()) - pluginsList.background = WIDGET_COLOR - pluginsList.foreground = secondaryColor - pluginsList.font = Font("RuneScape Small", Font.PLAIN, 16) - - // Wrap the JList in a JScrollPane with a fixed height - val maxScrollPaneHeight = 200 - val scrollPane = JScrollPane(pluginsList).apply { - verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_NEVER - } - - // Create a wrapper panel with BoxLayout to constrain the scroll pane - val scrollPaneWrapper = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(scrollPane) - } - - noExposedPanel.add(scrollPaneWrapper, BorderLayout.CENTER) - - // Center the panel within the reflectiveEditorView - val centeredPanel = JPanel().apply { - preferredSize = Dimension(240, maxScrollPaneHeight) - maximumSize = preferredSize - minimumSize = preferredSize - } - centeredPanel.layout = BoxLayout(centeredPanel, BoxLayout.Y_AXIS) - centeredPanel.add(Box.createVerticalGlue()) - centeredPanel.add(noExposedPanel) - centeredPanel.add(Box.createVerticalGlue()) - - reflectiveEditorView.add(Box.createVerticalStrut(10)) - reflectiveEditorView.add(centeredPanel) - } - - - reflectiveEditorView.revalidate() - if(focusedView == VIEW_NAME) - reflectiveEditorView.repaint() - } - - private fun addPluginToEditor(reflectiveEditorView: JPanel, pluginInfo : PluginInfo, plugin: Plugin) { - reflectiveEditorView.layout = BoxLayout(reflectiveEditorView, BoxLayout.Y_AXIS) - - val fieldNotifier = Helpers.FieldNotifier(plugin) - val exposedFields = plugin.javaClass.declaredFields.filter { field -> - field.annotations.any { annotation -> - annotation.annotationClass.simpleName == "Exposed" - } - } - - if (exposedFields.isNotEmpty()) { - - val packageName = plugin.javaClass.`package`.name - val version = pluginInfo.version - val labelPanel = JPanel(BorderLayout()) - labelPanel.maximumSize = Dimension(Int.MAX_VALUE, 30) - labelPanel.background = VIEW_BACKGROUND_COLOR - labelPanel.border = BorderFactory.createEmptyBorder(5, 0, 0, 0) - - val label = JLabel("$packageName v$version", SwingConstants.CENTER) - label.foreground = primaryColor - label.font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - labelPanel.add(label, BorderLayout.CENTER) - label.isOpaque = true - label.background = TITLE_BAR_COLOR - reflectiveEditorView.add(labelPanel) - - for (field in exposedFields) { - field.isAccessible = true - - // Get the "Exposed" annotation specifically and retrieve its description, if available - val exposedAnnotation = field.annotations.firstOrNull { annotation -> - annotation.annotationClass.simpleName == "Exposed" - } - - val description = exposedAnnotation?.let { annotation -> - try { - val descriptionField = annotation.annotationClass.java.getMethod("description") - descriptionField.invoke(annotation) as String - } catch (e: NoSuchMethodException) { - "" // No description method, return empty string - } - } ?: "" - - val fieldPanel = JPanel() - fieldPanel.layout = GridBagLayout() - fieldPanel.background = WIDGET_COLOR - fieldPanel.foreground = secondaryColor - fieldPanel.border = BorderFactory.createEmptyBorder(5, 0, 5, 0) - fieldPanel.maximumSize = Dimension(Int.MAX_VALUE, 40) - - val gbc = GridBagConstraints() - gbc.insets = Insets(0, 5, 0, 5) - - val label = JLabel(field.name.capitalize()) - label.foreground = secondaryColor - gbc.gridx = 0 - gbc.gridy = 0 - gbc.weightx = 0.0 - label.font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - gbc.anchor = GridBagConstraints.WEST - fieldPanel.add(label, gbc) - - // Create appropriate input component based on field type - val inputComponent: JComponent = when { - field.type == Boolean::class.javaPrimitiveType || field.type == java.lang.Boolean::class.java -> JCheckBox().apply { - isSelected = field.get(plugin) as Boolean - } - - field.type.isEnum -> JComboBox((field.type.enumConstants as Array>)).apply { - selectedItem = field.get(plugin) - } - - field.type == Int::class.javaPrimitiveType || field.type == Integer::class.java -> JSpinner(SpinnerNumberModel(field.get(plugin) as Int, Int.MIN_VALUE, Int.MAX_VALUE, 1)) - field.type == Float::class.javaPrimitiveType || field.type == Double::class.javaPrimitiveType || field.type == java.lang.Float::class.java || field.type == java.lang.Double::class.java -> JSpinner(SpinnerNumberModel((field.get(plugin) as Number).toDouble(), -Double.MAX_VALUE, Double.MAX_VALUE, 0.1)) - else -> JTextField(field.get(plugin)?.toString() ?: "") - } - - // Add mouse listener to the label only if a description is available - if (description.isNotBlank()) { - label.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - label.addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - showCustomToolTip(description, label) - } - - override fun mouseExited(e: MouseEvent) { - customToolTipWindow?.isVisible = false - } - }) - } - - gbc.gridx = 1 - gbc.gridy = 0 - gbc.weightx = 1.0 - gbc.fill = GridBagConstraints.HORIZONTAL - fieldPanel.add(inputComponent, gbc) - - val applyButton = JButton("\u2714").apply { - maximumSize = Dimension(Int.MAX_VALUE, 8) - } - gbc.gridx = 2 - gbc.gridy = 0 - gbc.weightx = 0.0 - gbc.fill = GridBagConstraints.NONE - applyButton.addActionListener { - try { - val newValue = when (inputComponent) { - is JCheckBox -> inputComponent.isSelected - is JComboBox<*> -> inputComponent.selectedItem - is JSpinner -> inputComponent.value - is JTextField -> convertValue(field.type, field.genericType, inputComponent.text) - else -> throw IllegalArgumentException("Unsupported input component type") - } - fieldNotifier.setFieldValue(field, newValue) - showToast( - reflectiveEditorView, - "${field.name} updated successfully!" - ) - } catch (e: Exception) { - showToast( - reflectiveEditorView, - "Failed to update ${field.name}: ${e.message}", - JOptionPane.ERROR_MESSAGE - ) - } - } - - fieldPanel.add(applyButton, gbc) - reflectiveEditorView.add(fieldPanel) - - // Track field changes in real-time and update UI - var previousValue = field.get(plugin)?.toString() - val timer = Timer() - timer.schedule(object : TimerTask() { - override fun run() { - val currentValue = field.get(plugin)?.toString() - if (currentValue != previousValue) { - previousValue = currentValue - SwingUtilities.invokeLater { - // Update the inputComponent based on the new value - when (inputComponent) { - is JCheckBox -> inputComponent.isSelected = field.get(plugin) as Boolean - is JComboBox<*> -> inputComponent.selectedItem = field.get(plugin) - is JSpinner -> inputComponent.value = field.get(plugin) - is JTextField -> inputComponent.text = field.get(plugin)?.toString() ?: "" - } - } - } - } - }, 0, 1000) // Poll every 1000 milliseconds (1 second) - } - - if (exposedFields.isNotEmpty()) { - reflectiveEditorView.add(Box.createVerticalStrut(5)) - } - } - else { - loadedPlugins.add(plugin.javaClass.`package`.name) - } - } - - var customToolTipWindow: JWindow? = null - - fun showCustomToolTip(text: String, component: JComponent) { - val _font = Font("RuneScape Small", Font.PLAIN, 16) - val maxWidth = 150 - val lineHeight = 16 - - // Create a dummy JLabel to get FontMetrics for the font used in the tooltip - val dummyLabel = JLabel() - dummyLabel.font = _font - val fontMetrics = dummyLabel.getFontMetrics(_font) - - // Calculate the approximate width of the text - val textWidth = fontMetrics.stringWidth(text) - - // Calculate the number of lines required based on the text width and max tooltip width - val numberOfLines = ceil(textWidth.toDouble() / maxWidth).toInt() - - // Calculate the required height of the tooltip - val requiredHeight = numberOfLines * lineHeight + 6 // Adding some padding - - if (customToolTipWindow == null) { - customToolTipWindow = JWindow().apply { - val bgColor = Helpers.colorToHex(TOOLTIP_BACKGROUND) - val textColor = Helpers.colorToHex(secondaryColor) - contentPane = JLabel("
$text
").apply { - border = BorderFactory.createLineBorder(Color.BLACK) - isOpaque = true - background = TOOLTIP_BACKGROUND - foreground = Color.WHITE - font = _font - maximumSize = Dimension(maxWidth, Int.MAX_VALUE) - preferredSize = Dimension(maxWidth, requiredHeight) - } - pack() - } - } else { - // Update the tooltip text - val label = customToolTipWindow!!.contentPane as JLabel - val bgColor = Helpers.colorToHex(TOOLTIP_BACKGROUND) - val textColor = Helpers.colorToHex(secondaryColor) - label.text = "
$text
" - label.preferredSize = Dimension(maxWidth, requiredHeight) - customToolTipWindow!!.pack() - } - - // Position the tooltip near the component - val locationOnScreen = component.locationOnScreen - customToolTipWindow!!.setLocation(locationOnScreen.x, locationOnScreen.y + 15) - customToolTipWindow!!.isVisible = true - } -} \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/KondoKit/XPTable.kt b/plugin-playground/src/main/kotlin/KondoKit/XPTable.kt deleted file mode 100644 index 78138fe..0000000 --- a/plugin-playground/src/main/kotlin/KondoKit/XPTable.kt +++ /dev/null @@ -1,66 +0,0 @@ -package KondoKit - -import plugin.api.API -import rt4.IntNode -import rt4.Node - -object XPTable { - - private const val MAX_LEVEL = 99 - private const val INVALID_LEVEL = -1 - private const val SKILLS_XP_TABLE = 716 - - private var xpTable: MutableList = mutableListOf() - - // Lazily load the XP table from the API if it's empty - private fun loadXpTable() { - if (xpTable.isEmpty()) { - // Add the initial entry for key 1 = 0 - xpTable.add(0) - - // Fetch XP table from the API - API.GetDataMap(SKILLS_XP_TABLE).table.nodes.forEach { bucket -> - var currentNode: Node = bucket.nextNode - while (currentNode !== bucket) { - if (currentNode is IntNode) { - xpTable.add(currentNode.value) - } - currentNode = currentNode.nextNode - } - } - } - } - - fun getXpRequiredForLevel(level: Int): Int { - loadXpTable() - if (level in 1..xpTable.size) { - return xpTable[level - 1] - } - return 0 - } - - fun getLevelForXp(xp: Int): Pair { - loadXpTable() - var lowIndex = 0 - var highIndex = xpTable.size - 1 - - if (xp >= xpTable[highIndex]) { - return Pair(MAX_LEVEL, xp - xpTable[highIndex]) // Level is max or above, return the highest level - } - - while (lowIndex <= highIndex) { - val midIndex = (lowIndex + highIndex) / 2 - when { - xp < xpTable[midIndex] -> highIndex = midIndex - 1 - xp >= xpTable[midIndex + 1] -> lowIndex = midIndex + 1 - else -> { - val currentLevel = midIndex + 1 - val xpGained = xp - xpTable[midIndex] - return Pair(currentLevel, xpGained) - } - } - } - - return Pair(INVALID_LEVEL, 0) // If xp is below all defined levels - } -} diff --git a/plugin-playground/src/main/kotlin/KondoKit/XPTrackerView.kt b/plugin-playground/src/main/kotlin/KondoKit/XPTrackerView.kt deleted file mode 100644 index b975194..0000000 --- a/plugin-playground/src/main/kotlin/KondoKit/XPTrackerView.kt +++ /dev/null @@ -1,444 +0,0 @@ -package KondoKit - -import KondoKit.Helpers.addMouseListenerToAll -import KondoKit.Helpers.formatHtmlLabelText -import KondoKit.Helpers.formatNumber -import KondoKit.Helpers.getProgressBarColor -import KondoKit.Helpers.getSpriteId -import KondoKit.SpriteToBufferedImage.getBufferedImageFromSprite -import KondoKit.plugin.Companion.IMAGE_SIZE -import KondoKit.plugin.Companion.LVL_ICON -import KondoKit.plugin.Companion.POPUP_BACKGROUND -import KondoKit.plugin.Companion.POPUP_FOREGROUND -import KondoKit.plugin.Companion.TOTAL_XP_WIDGET_SIZE -import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR -import KondoKit.plugin.Companion.WIDGET_COLOR -import KondoKit.plugin.Companion.WIDGET_SIZE -import KondoKit.plugin.Companion.playerXPMultiplier -import KondoKit.plugin.Companion.primaryColor -import KondoKit.plugin.Companion.secondaryColor -import KondoKit.plugin.StateManager.focusedView -import plugin.api.API -import java.awt.* -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.io.BufferedReader -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets -import javax.swing.* - -object XPTrackerView { - private val COMBAT_SKILLS = intArrayOf(0,1,2,3,4) - val xpWidgets: MutableMap = HashMap() - var totalXPWidget: XPWidget? = null - val initialXP: MutableMap = HashMap() - var xpTrackerView: JPanel? = null - const val VIEW_NAME = "XP_TRACKER_VIEW" - - - val npcHitpointsMap: Map = try { - BufferedReader(InputStreamReader(plugin::class.java.getResourceAsStream("res/npc_hitpoints_map.json"), StandardCharsets.UTF_8)) - .useLines { lines -> - val json = lines.joinToString("\n") - val pairs = json.trim().removeSurrounding("{", "}").split(",") - val map = mutableMapOf() - - for (pair in pairs) { - val keyValue = pair.split(":") - val id = keyValue[0].trim().trim('\"').toIntOrNull() - val hitpoints = keyValue[1].trim() - - if (id != null && hitpoints.isNotEmpty()) { - map[id] = hitpoints.toIntOrNull() ?: 0 - } - } - - map - } - } catch (e: Exception) { - println("XPTracker Error parsing NPC HP: ${e.message}") - emptyMap() - } - - fun updateWidget(xpWidget: XPWidget, xp: Int) { - val (currentLevel, xpGainedSinceLastLevel) = XPTable.getLevelForXp(xp) - - var xpGainedSinceLastUpdate = xp - xpWidget.previousXp - xpWidget.totalXpGained += xpGainedSinceLastUpdate - updateTotalXPWidget(xpGainedSinceLastUpdate) - - val progress: Double - if (currentLevel >= 99) { - progress = 100.0 // Set progress to 100% if the level is 99 or above - xpWidget.xpLeftLabel.text = "" // Hide XP Left when level is 99 - xpWidget.actionsRemainingLabel.text = "" - } else { - val nextLevelXp = XPTable.getXpRequiredForLevel(currentLevel + 1) - val xpLeft = nextLevelXp - xp - progress = xpGainedSinceLastLevel.toDouble() / (nextLevelXp - XPTable.getXpRequiredForLevel(currentLevel)) * 100 - val xpLeftstr = formatNumber(xpLeft) - xpWidget.xpLeftLabel.text = formatHtmlLabelText("XP Left: ", primaryColor, xpLeftstr, secondaryColor) - if(COMBAT_SKILLS.contains(xpWidget.skillId)) { - if(LootTrackerView.lastConfirmedKillNpcId != -1 && npcHitpointsMap.isNotEmpty()) { - val npcHP = npcHitpointsMap[LootTrackerView.lastConfirmedKillNpcId] - val xpPerKill = when (xpWidget.skillId) { - 3 -> playerXPMultiplier * (npcHP ?: 1) // Hitpoints - else -> playerXPMultiplier * (npcHP ?: 1) * 4 // Combat XP for other skills - } - val remainingKills = xpLeft / xpPerKill - xpWidget.actionsRemainingLabel.text = formatHtmlLabelText("Kills: ", primaryColor, remainingKills.toString(), secondaryColor) - } - } else { - if(xpGainedSinceLastUpdate == 0) - xpGainedSinceLastUpdate = 1 // Avoid possible divide by 0 - val remainingActions = (xpLeft / xpGainedSinceLastUpdate).coerceAtLeast(1) - xpWidget.actionsRemainingLabel.text = formatHtmlLabelText("Actions: ", primaryColor, remainingActions.toString(), secondaryColor) - } - } - - val formattedXp = formatNumber(xpWidget.totalXpGained) - xpWidget.xpGainedLabel.text = formatHtmlLabelText("XP Gained: ", primaryColor, formattedXp, secondaryColor) - - // Update the progress bar with current level, progress, and next level - xpWidget.progressBar.updateProgress(progress, currentLevel, if (currentLevel < 99) currentLevel + 1 else 99, focusedView == VIEW_NAME) - - xpWidget.previousXp = xp - if (focusedView == VIEW_NAME) - xpWidget.container.repaint() - } - - - private fun updateTotalXPWidget(xpGainedSinceLastUpdate: Int) { - val totalXPWidget = totalXPWidget ?: return - totalXPWidget.totalXpGained += xpGainedSinceLastUpdate - val formattedXp = formatNumber(totalXPWidget.totalXpGained) - totalXPWidget.xpGainedLabel.text = formatHtmlLabelText("Gained: ", primaryColor, formattedXp, secondaryColor) - if (focusedView == VIEW_NAME) - totalXPWidget.container.repaint() - } - - - fun resetXPTracker(xpTrackerView : JPanel){ - - // Redo logic here - xpTrackerView.removeAll() - val popupMenu = createResetMenu() - - // Create a custom MouseListener - val rightClickListener = object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - - override fun mouseReleased(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - } - - // Create the XP widget - totalXPWidget = createTotalXPWidget() - val wrapped = wrappedWidget(totalXPWidget!!.container) - addMouseListenerToAll(wrapped,rightClickListener) - wrapped.addMouseListener(rightClickListener) - xpTrackerView.add(Box.createVerticalStrut(5)) - xpTrackerView.add(wrapped) - xpTrackerView.add(Box.createVerticalStrut(5)) - - initialXP.clear() - xpWidgets.clear() - - xpTrackerView.revalidate() - if (focusedView == VIEW_NAME) - xpTrackerView.repaint() - } - - fun createTotalXPWidget(): XPWidget { - val widgetPanel = Panel().apply { - layout = BorderLayout(5, 5) - background = WIDGET_COLOR - preferredSize = TOTAL_XP_WIDGET_SIZE - maximumSize = TOTAL_XP_WIDGET_SIZE - minimumSize = TOTAL_XP_WIDGET_SIZE - } - - val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(LVL_ICON)) - - val imageContainer = Panel(FlowLayout()).apply { - preferredSize = Dimension(bufferedImageSprite.width, bufferedImageSprite.height) - maximumSize = preferredSize - minimumSize = preferredSize - size = preferredSize - } - - bufferedImageSprite.let { image -> - val imageCanvas = ImageCanvas(image).apply { - preferredSize = Dimension(bufferedImageSprite.width, bufferedImageSprite.height) - maximumSize = preferredSize - minimumSize = preferredSize - size = preferredSize - } - - imageContainer.add(imageCanvas) - imageContainer.size = Dimension(bufferedImageSprite.width, bufferedImageSprite.height) - - imageContainer.revalidate() - if(focusedView == VIEW_NAME) - imageContainer.repaint() - } - - val textPanel = Panel().apply { - layout = GridLayout(2, 1, 5, 0) - } - - val font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - - val xpGainedLabel = JLabel( - formatHtmlLabelText("Gained: ", primaryColor, "0", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - val xpPerHourLabel = JLabel( - formatHtmlLabelText("XP /hr: ", primaryColor, "0", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - textPanel.add(xpGainedLabel) - textPanel.add(xpPerHourLabel) - - widgetPanel.add(imageContainer, BorderLayout.WEST) - widgetPanel.add(textPanel, BorderLayout.CENTER) - - return XPWidget( - skillId = -1, - container = widgetPanel, - xpGainedLabel = xpGainedLabel, - xpLeftLabel = JLabel(formatHtmlLabelText("XP Left: ", primaryColor, "0", secondaryColor)).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - }, - xpPerHourLabel = xpPerHourLabel, - progressBar = ProgressBar(0.0, Color.BLACK), // Unused - totalXpGained = 0, - startTime = System.currentTimeMillis(), - previousXp = 0, - actionsRemainingLabel = JLabel(), - ) - } - - - fun createXPTrackerView(){ - val widgetViewPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - background = VIEW_BACKGROUND_COLOR - } - - val popupMenu = createResetMenu() - - // Create a custom MouseListener - val rightClickListener = object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - - override fun mouseReleased(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - } - - // Create the XP widget - totalXPWidget = createTotalXPWidget() - val wrapped = wrappedWidget(totalXPWidget!!.container) - addMouseListenerToAll(wrapped,rightClickListener) - wrapped.addMouseListener(rightClickListener) - widgetViewPanel.add(Box.createVerticalStrut(5)) - widgetViewPanel.add(wrapped) - widgetViewPanel.add(Box.createVerticalStrut(5)) - - xpTrackerView = widgetViewPanel - } - - - fun createResetMenu(): JPopupMenu { - // Create a popup menu - val popupMenu = JPopupMenu() - - val rFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - - popupMenu.background = POPUP_BACKGROUND - - // Create menu items with custom font and colors - val menuItem1 = JMenuItem("Reset Tracker").apply { - font = rFont // Set custom font - background = POPUP_BACKGROUND // Dark background for item - foreground = POPUP_FOREGROUND // Light text color for item - } - - // Add menu items to the popup menu - popupMenu.add(menuItem1) - - // Add action listeners to each menu item (optional) - menuItem1.addActionListener { plugin.registerDrawAction { resetXPTracker(xpTrackerView!!) } } - return popupMenu - } - - - fun createXPWidget(skillId: Int, previousXp: Int): XPWidget { - val widgetPanel = Panel().apply { - layout = BorderLayout(5, 5) - background = WIDGET_COLOR - preferredSize = WIDGET_SIZE - maximumSize = WIDGET_SIZE - minimumSize = WIDGET_SIZE - } - - val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(getSpriteId(skillId))) - val imageContainer = Panel(FlowLayout()).apply { - background = WIDGET_COLOR - preferredSize = IMAGE_SIZE - maximumSize = IMAGE_SIZE - minimumSize = IMAGE_SIZE - size = IMAGE_SIZE - } - - bufferedImageSprite.let { image -> - val imageCanvas = ImageCanvas(image).apply { - background = WIDGET_COLOR - preferredSize = Dimension(image.width, image.height) - maximumSize = Dimension(image.width, image.height) - minimumSize = Dimension(image.width, image.height) - size = Dimension(image.width, image.height) // Explicitly set the size - } - - imageContainer.add(imageCanvas) - imageContainer.size = Dimension(image.width, image.height) // Ensure container respects the image size - - imageContainer.revalidate() - if(focusedView == VIEW_NAME) - imageContainer.repaint() - } - - val textPanel = Panel().apply { - layout = GridLayout(2, 2, 5, 0) - background = WIDGET_COLOR - } - - val font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - - val xpGainedLabel = JLabel( - formatHtmlLabelText("XP Gained: ", primaryColor, "0", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - val xpLeftLabel = JLabel( - formatHtmlLabelText("XP Left: ", primaryColor, "0K", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - val xpPerHourLabel = JLabel( - formatHtmlLabelText("XP /hr: ", primaryColor, "0", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - val killsLabel = JLabel( - formatHtmlLabelText("Kills: ", primaryColor, "0", secondaryColor) - ).apply { - this.horizontalAlignment = JLabel.LEFT - this.font = font - } - - val levelPanel = Panel().apply { - layout = BorderLayout(5, 0) - background = WIDGET_COLOR - } - - val progressBarPanel = ProgressBar(0.0, getProgressBarColor(skillId)).apply { - preferredSize = Dimension(160, 22) - } - - levelPanel.add(progressBarPanel, BorderLayout.CENTER) - - textPanel.add(xpGainedLabel) - textPanel.add(xpLeftLabel) - textPanel.add(xpPerHourLabel) - textPanel.add(killsLabel) - - widgetPanel.add(imageContainer, BorderLayout.WEST) - widgetPanel.add(textPanel, BorderLayout.CENTER) - widgetPanel.add(levelPanel, BorderLayout.SOUTH) - - widgetPanel.revalidate() - if(focusedView == VIEW_NAME) - widgetPanel.repaint() - - return XPWidget( - skillId = skillId, - container = widgetPanel, - xpGainedLabel = xpGainedLabel, - xpLeftLabel = xpLeftLabel, - xpPerHourLabel = xpPerHourLabel, - progressBar = progressBarPanel, - totalXpGained = 0, - actionsRemainingLabel = killsLabel, - startTime = System.currentTimeMillis(), - previousXp = previousXp - ) - } - - fun wrappedWidget(component: Component, padding: Int = 7): Container { - val outerPanelSize = Dimension( - component.preferredSize.width + 2 * padding, - component.preferredSize.height + 2 * padding - ) - val outerPanel = JPanel(GridBagLayout()).apply { - background = WIDGET_COLOR - preferredSize = outerPanelSize - maximumSize = outerPanelSize - minimumSize = outerPanelSize - } - val innerPanel = JPanel(BorderLayout()).apply { - background = WIDGET_COLOR - preferredSize = component.preferredSize - maximumSize = component.preferredSize - minimumSize = component.preferredSize - add(component, BorderLayout.CENTER) - } - val gbc = GridBagConstraints().apply { - anchor = GridBagConstraints.CENTER - } - outerPanel.add(innerPanel, gbc) - return outerPanel - } -} - - - -data class XPWidget( - val container: Container, - val skillId: Int, - val xpGainedLabel: JLabel, - val xpLeftLabel: JLabel, - val xpPerHourLabel: JLabel, - val actionsRemainingLabel: JLabel, - val progressBar: ProgressBar, - var totalXpGained: Int = 0, - var startTime: Long = System.currentTimeMillis(), - var previousXp: Int = 0 -) \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/KondoKit/plugin.kt b/plugin-playground/src/main/kotlin/KondoKit/plugin.kt index fcf4666..a67175f 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/plugin.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/plugin.kt @@ -1,35 +1,22 @@ package KondoKit -import KondoKit.Constants.COMBAT_LVL_SPRITE -import KondoKit.Helpers.formatHtmlLabelText -import KondoKit.Helpers.formatNumber -import KondoKit.Helpers.getSpriteId -import KondoKit.Helpers.showAlert -import KondoKit.HiscoresView.createHiscoreSearchView -import KondoKit.HiscoresView.hiScoreView -import KondoKit.LootTrackerView.BAG_ICON -import KondoKit.LootTrackerView.createLootTrackerView -import KondoKit.LootTrackerView.lootTrackerView -import KondoKit.LootTrackerView.npcDeathSnapshots -import KondoKit.LootTrackerView.onPostClientTick -import KondoKit.LootTrackerView.takeGroundSnapshot -import KondoKit.ReflectiveEditorView.addPlugins -import KondoKit.ReflectiveEditorView.createReflectiveEditorView -import KondoKit.ReflectiveEditorView.reflectiveEditorView -import KondoKit.SpriteToBufferedImage.getBufferedImageFromSprite -import KondoKit.Themes.Theme -import KondoKit.Themes.ThemeType -import KondoKit.Themes.getTheme -import KondoKit.XPTrackerView.createXPTrackerView -import KondoKit.XPTrackerView.createXPWidget -import KondoKit.XPTrackerView.initialXP -import KondoKit.XPTrackerView.resetXPTracker -import KondoKit.XPTrackerView.totalXPWidget -import KondoKit.XPTrackerView.updateWidget -import KondoKit.XPTrackerView.wrappedWidget -import KondoKit.XPTrackerView.xpTrackerView -import KondoKit.XPTrackerView.xpWidgets -import KondoKit.plugin.StateManager.focusedView +import KondoKit.util.Helpers.getSpriteId +import KondoKit.util.Helpers.showAlert +import KondoKit.views.* +import KondoKit.ui.OnUpdateCallback +import KondoKit.ui.OnDrawCallback +import KondoKit.ui.OnXPUpdateCallback +import KondoKit.ui.OnKillingBlowNPCCallback +import KondoKit.ui.OnPostClientTickCallback +import KondoKit.util.AltCanvas +import KondoKit.util.SpriteToBufferedImage.getBufferedImageFromSprite +import KondoKit.util.ImageCanvas +import KondoKit.util.setFixedSize +import KondoKit.ui.ViewConstants +import KondoKit.ui.theme.Themes.Theme +import KondoKit.ui.theme.Themes.ThemeType +import KondoKit.ui.theme.Themes.getTheme +import KondoKit.ui.components.ScrollablePanel import plugin.Plugin import plugin.api.* import plugin.api.API.* @@ -46,6 +33,8 @@ import java.awt.event.ActionListener import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.* +import KondoKit.ui.View +import KondoKit.util.Helpers @Target(AnnotationTarget.FIELD) @@ -54,8 +43,6 @@ annotation class Exposed(val description: String = "") class plugin : Plugin() { companion object { - val WIDGET_SIZE = Dimension(220, 50) - val TOTAL_XP_WIDGET_SIZE = Dimension(220, 30) val IMAGE_SIZE = Dimension(25, 23) // Default Theme Colors @@ -98,9 +85,9 @@ class plugin : Plugin() { const val FIXED_HEIGHT = 503 private const val NAVBAR_WIDTH = 30 private const val MAIN_CONTENT_WIDTH = 242 - private const val WRENCH_ICON = 907 - private const val LOOT_ICON = 777 - private const val MAG_SPRITE = 1423 + const val WRENCH_ICON = 907 + const val LOOT_ICON = 777 + const val MAG_SPRITE = 1423 const val LVL_ICON = 898 private lateinit var cardLayout: CardLayout private lateinit var mainContentPanel: JPanel @@ -118,16 +105,41 @@ class plugin : Plugin() { private const val HIDDEN_VIEW = "HIDDEN" private var altCanvas: AltCanvas? = null private val drawActions = mutableListOf<() -> Unit>() + private val views = mutableListOf() + private val updateCallbacks = mutableListOf() + private val drawCallbacks = mutableListOf() + private val xpUpdateCallbacks = mutableListOf() + private val killingBlowNPCCallbacks = mutableListOf() + private val postClientTickCallbacks = mutableListOf() fun registerDrawAction(action: () -> Unit) { synchronized(drawActions) { drawActions.add(action) } } + + fun registerUpdateCallback(callback: OnUpdateCallback) { + updateCallbacks.add(callback) + } + + fun registerDrawCallback(callback: OnDrawCallback) { + drawCallbacks.add(callback) + } + + fun registerXPUpdateCallback(callback: OnXPUpdateCallback) { + xpUpdateCallbacks.add(callback) + } + + fun registerKillingBlowNPCCallback(callback: OnKillingBlowNPCCallback) { + killingBlowNPCCallbacks.add(callback) + } + + fun registerPostClientTickCallback(callback: OnPostClientTickCallback) { + postClientTickCallbacks.add(callback) + } } override fun Init() { - // Disable Font AA System.setProperty("sun.java2d.opengl", "false") System.setProperty("awt.useSystemAAFontSettings", "off") System.setProperty("swing.aatext", "false") @@ -135,9 +147,7 @@ class plugin : Plugin() { override fun OnLogin() { if (lastLogin != "" && lastLogin != Player.usernameInput.toString()) { - // if we logged in with a new character - // we need to reset the trackers - xpTrackerView?.let { resetXPTracker(it) } + XPTrackerView.xpTrackerView?.let { XPTrackerView.resetXPTracker(it) } } lastLogin = Player.usernameInput.toString() } @@ -147,15 +157,12 @@ class plugin : Plugin() { for ((index, entry) in currentEntries.withIndex()) { if (entry.type == MiniMenuType.PLAYER && index == currentEntries.size - 1) { val input = entry.subject - // Trim spaces, clean up tags, and remove the level info val cleanedInput = input - .trim() // Remove any leading/trailing spaces - .replace(Regex(""), "") // Remove color tags - .replace(Regex(""), "") // Remove image tags - .replace(Regex("\\(level: \\d+\\)"), "") // Remove level text e.g. (level: 44) - .trim() // Trim again to remove extra spaces after removing level text - - // Proceed with the full cleaned username + .trim() + .replace(Regex(""), "") + .replace(Regex(""), "") + .replace(Regex("\\(level: \\d+\\)"), "") + .trim() InsertMiniMenuEntry("Lookup", entry.subject, searchHiscore(cleanedInput)) } } @@ -164,41 +171,39 @@ class plugin : Plugin() { override fun OnPluginsReloaded(): Boolean { if (!initialized) return true - updateDisplaySettings() - frame.remove(rightPanelWrapper) - frame.layout = BorderLayout() - rightPanelWrapper?.let { frame.add(it, BorderLayout.EAST) } - frame.revalidate() + // Ensure Swing updates happen on the EDT to avoid flicker + SwingUtilities.invokeLater { + updateDisplaySettings() + rightPanelWrapper?.let { wrapper -> + wrapper.ignoreRepaint = true + try { + val parent = wrapper.parent + val wrapperNeedsAttach = parent != frame + if (wrapperNeedsAttach) { + parent?.remove(wrapper) + frame.layout = BorderLayout() + frame.add(wrapper, BorderLayout.EAST) + } + wrapper.revalidate() + wrapper.repaint() + } finally { + wrapper.ignoreRepaint = false + } + } + frame.revalidate() + frame.repaint() + + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } pluginsReloaded = true reloadInterfaces = true return true } override fun OnXPUpdate(skillId: Int, xp: Int) { - if (!initialXP.containsKey(skillId)) { - initialXP[skillId] = xp - return - } - var xpWidget = xpWidgets[skillId] - if (xpWidget != null) { - updateWidget(xpWidget, xp) - } else { - val previousXp = initialXP[skillId] ?: xp - if (xp == initialXP[skillId]) return - - xpWidget = createXPWidget(skillId, previousXp) - xpWidgets[skillId] = xpWidget - - xpTrackerView?.add(wrappedWidget(xpWidget.container)) - xpTrackerView?.add(Box.createVerticalStrut(5)) - - if(focusedView == XPTrackerView.VIEW_NAME) { - xpTrackerView?.revalidate() - xpTrackerView?.repaint() - } - - updateWidget(xpWidget, xp) - } + xpUpdateCallbacks.forEach { callback -> + callback.onXPUpdate(skillId, xp) + } } override fun Draw(timeDelta: Long) { @@ -208,7 +213,9 @@ class plugin : Plugin() { } if (pluginsReloaded) { - reflectiveEditorView?.let { addPlugins(it) } + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } pluginsReloaded = false } @@ -219,10 +226,16 @@ class plugin : Plugin() { accumulatedTime += timeDelta if (accumulatedTime >= TICK_INTERVAL) { - lootTrackerView?.let { onPostClientTick(it) } + postClientTickCallbacks.forEach { callback -> + callback.onPostClientTick() + } accumulatedTime = 0L } + drawCallbacks.forEach { callback -> + callback.onDraw(timeDelta) + } + // Draw synced actions (that require to be done between glBegin and glEnd) if (drawActions.isNotEmpty()) { synchronized(drawActions) { @@ -256,47 +269,29 @@ class plugin : Plugin() { } else { moveCanvasToFront() } - altCanvas?.updateGameImage() // Update the game image as needed + altCanvas?.updateGameImage() } override fun Update() { - - val widgets = xpWidgets.values - val totalXP = totalXPWidget - - widgets.forEach { xpWidget -> - val elapsedTime = (System.currentTimeMillis() - xpWidget.startTime) / 1000.0 / 60.0 / 60.0 - val xpPerHour = if (elapsedTime > 0) (xpWidget.totalXpGained / elapsedTime).toInt() else 0 - val formattedXpPerHour = formatNumber(xpPerHour) - xpWidget.xpPerHourLabel.text = - formatHtmlLabelText("XP /hr: ", primaryColor, formattedXpPerHour, secondaryColor) - xpWidget.container.repaint() - } - - totalXP?.let { totalXPWidget -> - val elapsedTime = (System.currentTimeMillis() - totalXPWidget.startTime) / 1000.0 / 60.0 / 60.0 - val totalXPPerHour = if (elapsedTime > 0) (totalXPWidget.totalXpGained / elapsedTime).toInt() else 0 - val formattedTotalXpPerHour = formatNumber(totalXPPerHour) - totalXPWidget.xpPerHourLabel.text = - formatHtmlLabelText("XP /hr: ", primaryColor, formattedTotalXpPerHour, secondaryColor) - totalXPWidget.container.repaint() + updateCallbacks.forEach { callback -> + callback.onUpdate() } } override fun OnKillingBlowNPC(npcID: Int, x: Int, z: Int) { - val preDeathSnapshot = takeGroundSnapshot(Pair(x,z)) - npcDeathSnapshots[npcID] = LootTrackerView.GroundSnapshot(preDeathSnapshot, Pair(x, z), 0) + killingBlowNPCCallbacks.forEach { callback -> + callback.onKillingBlowNPC(npcID, x, z) + } } private fun allSpritesLoaded() : Boolean { - // Check all skill sprites try{ for (i in 0 until 24) { if(!js5Archive8.isFileReady(getSpriteId(i))){ return false } } - val otherIcons = arrayOf(LVL_ICON, MAG_SPRITE, LOOT_ICON, WRENCH_ICON, COMBAT_LVL_SPRITE, BAG_ICON) + val otherIcons = arrayOf(LVL_ICON, MAG_SPRITE, LOOT_ICON, WRENCH_ICON, ViewConstants.COMBAT_LVL_SPRITE, LootTrackerView.BAG_ICON) for (icon in otherIcons) { if(!js5Archive8.isFileReady(icon)){ return false @@ -309,47 +304,77 @@ class plugin : Plugin() { } private fun updateDisplaySettings() { - val mode = GetWindowMode() - val currentScrollPaneWidth = if (mainContentPanel.isVisible) NAVBAR_WIDTH + MAIN_CONTENT_WIDTH else NAVBAR_WIDTH - lastUIOffset = uiOffset + val applyDisplaySettings = { + val mode = GetWindowMode() + val currentScrollPaneWidth = if (mainContentPanel.isVisible) NAVBAR_WIDTH + MAIN_CONTENT_WIDTH else NAVBAR_WIDTH + lastUIOffset = uiOffset - if(mode != WindowMode.FIXED) { - destroyAltCanvas() - } else if (useScaledFixed && altCanvas == null) { - initAltCanvas() - } - - when (mode) { - WindowMode.FIXED -> { - if (frame.width < FIXED_WIDTH + currentScrollPaneWidth + uiOffset) { - frame.setSize(FIXED_WIDTH + currentScrollPaneWidth + uiOffset, frame.height) - } - - val difference = frame.width - (uiOffset + currentScrollPaneWidth) - - if (useScaledFixed) { - GameShell.leftMargin = 0 - val canvasWidth = difference + uiOffset / 2 - val canvasHeight = frame.height - canvas.y // Restricting height to frame height - - altCanvas?.size = Dimension(canvasWidth, canvasHeight) - altCanvas?.setLocation(0, canvas.y) - canvas.setLocation(0, canvas.y) - } else { - val difference = frame.width - (FIXED_WIDTH + uiOffset + currentScrollPaneWidth) - GameShell.leftMargin = difference / 2 + // Ensure the scroll wrapper stays attached on the EAST edge even if the game resets the layout + rightPanelWrapper?.let { wrapper -> + val needsLayoutReset = frame.layout !is BorderLayout + val needsAttach = wrapper.parent != frame + if (needsLayoutReset || needsAttach) { + wrapper.parent?.remove(wrapper) + frame.layout = BorderLayout() + frame.add(wrapper, BorderLayout.EAST) + if (altCanvas != null) { + moveAltCanvasToFront() + } else { + moveCanvasToFront() + } } } - WindowMode.RESIZABLE -> { - GameShell.canvasWidth = frame.width - (currentScrollPaneWidth + uiOffset) + if(mode != WindowMode.FIXED) { + destroyAltCanvas() + } else if (useScaledFixed && altCanvas == null) { + initAltCanvas() + } else if (!useScaledFixed && altCanvas != null) { + // Was using scaled fixed but toggled the setting + // restore the original canvas + moveCanvasToFront() + destroyAltCanvas() } + + when (mode) { + WindowMode.FIXED -> { + if (frame.width < FIXED_WIDTH + currentScrollPaneWidth + uiOffset) { + frame.setSize(FIXED_WIDTH + currentScrollPaneWidth + uiOffset, frame.height) + } + + val difference = frame.width - (uiOffset + currentScrollPaneWidth) + + if (useScaledFixed) { + GameShell.leftMargin = 0 + val canvasWidth = difference + uiOffset / 2 + val canvasHeight = frame.height - canvas.y // Restricting height to frame height + + altCanvas?.size = Dimension(canvasWidth, canvasHeight) + altCanvas?.setLocation(0, canvas.y) + canvas.setLocation(0, canvas.y) + } else { + val difference = frame.width - (FIXED_WIDTH + uiOffset + currentScrollPaneWidth) + GameShell.leftMargin = difference / 2 + } + } + + WindowMode.RESIZABLE -> { + GameShell.canvasWidth = frame.width - (currentScrollPaneWidth + uiOffset) + } + } + + rightPanelWrapper?.preferredSize = Dimension(currentScrollPaneWidth, frame.height) + rightPanelWrapper?.isDoubleBuffered = true + rightPanelWrapper?.revalidate() + rightPanelWrapper?.repaint() + frame.validate() } - rightPanelWrapper?.preferredSize = Dimension(currentScrollPaneWidth, frame.height) - rightPanelWrapper?.isDoubleBuffered = true - rightPanelWrapper?.revalidate() - rightPanelWrapper?.repaint() + if (SwingUtilities.isEventDispatchThread()) { + applyDisplaySettings() + } else { + SwingUtilities.invokeLater { applyDisplaySettings() } + } } fun OnKondoValueUpdated(){ @@ -409,10 +434,10 @@ class plugin : Plugin() { private fun searchHiscore(username: String): Runnable { return Runnable { setActiveView(HiscoresView.VIEW_NAME) - val customSearchField = hiScoreView?.let { HiscoresView.CustomSearchField(it) } - - customSearchField?.searchPlayer(username) ?: run { - println("searchView is null or CustomSearchField creation failed.") + HiscoresView.hiScoreView?.let { hiscoresPanel -> + HiscoresView.searchPlayerForHiscores(username, hiscoresPanel) + } ?: run { + println("hiscoresPanel is null") } } } @@ -440,22 +465,36 @@ class plugin : Plugin() { cardLayout = CardLayout() mainContentPanel = JPanel(cardLayout).apply { - border = BorderFactory.createEmptyBorder(0, 0, 0, 0) // Removes any default border or padding + border = BorderFactory.createEmptyBorder(0, 0, 0, 0) background = VIEW_BACKGROUND_COLOR preferredSize = Dimension(MAIN_CONTENT_WIDTH, frame.height) isOpaque = true } - // Register Views - createXPTrackerView() - createHiscoreSearchView() - createLootTrackerView() - createReflectiveEditorView() + val xpTrackerView = XPTrackerView + val hiscoresView = HiscoresView + val lootTrackerView = LootTrackerView + val reflectiveEditorView = ReflectiveEditorView + + xpTrackerView.createView() + hiscoresView.createView() + lootTrackerView.createView() + reflectiveEditorView.createView() + + views.add(xpTrackerView) + views.add(hiscoresView) + views.add(lootTrackerView) + views.add(reflectiveEditorView) + + xpTrackerView.registerFunctions() + hiscoresView.registerFunctions() + lootTrackerView.registerFunctions() + reflectiveEditorView.registerFunctions() - mainContentPanel.add(ScrollablePanel(xpTrackerView!!), XPTrackerView.VIEW_NAME) - mainContentPanel.add(ScrollablePanel(hiScoreView!!), HiscoresView.VIEW_NAME) - mainContentPanel.add(ScrollablePanel(lootTrackerView!!), LootTrackerView.VIEW_NAME) - mainContentPanel.add(ScrollablePanel(reflectiveEditorView!!), ReflectiveEditorView.VIEW_NAME) + mainContentPanel.add(ScrollablePanel(xpTrackerView.panel), xpTrackerView.name) + mainContentPanel.add(ScrollablePanel(hiscoresView.panel), hiscoresView.name) + mainContentPanel.add(ScrollablePanel(lootTrackerView.panel), lootTrackerView.name) + mainContentPanel.add(ScrollablePanel(reflectiveEditorView.panel), reflectiveEditorView.name) val navPanel = Panel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -463,10 +502,10 @@ class plugin : Plugin() { preferredSize = Dimension(NAVBAR_WIDTH, frame.height) } - navPanel.add(createNavButton(LVL_ICON, XPTrackerView.VIEW_NAME)) - navPanel.add(createNavButton(MAG_SPRITE, HiscoresView.VIEW_NAME)) - navPanel.add(createNavButton(LOOT_ICON, LootTrackerView.VIEW_NAME)) - navPanel.add(createNavButton(WRENCH_ICON, ReflectiveEditorView.VIEW_NAME)) + navPanel.add(createNavButton(xpTrackerView.iconSpriteId, xpTrackerView.name)) + navPanel.add(createNavButton(hiscoresView.iconSpriteId, hiscoresView.name)) + navPanel.add(createNavButton(lootTrackerView.iconSpriteId, lootTrackerView.name)) + navPanel.add(createNavButton(reflectiveEditorView.iconSpriteId, reflectiveEditorView.name)) val rightPanel = Panel(BorderLayout()).apply { add(mainContentPanel, BorderLayout.CENTER) @@ -481,45 +520,80 @@ class plugin : Plugin() { verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_NEVER } - frame.layout = BorderLayout() - rightPanelWrapper?.let { - frame.add(it, BorderLayout.EAST) + val desiredView = if (launchMinimized) HIDDEN_VIEW else xpTrackerView.name + // Commit layout synchronously on the EDT to avoid initial misplacement + val commit = Runnable { + frame.layout = BorderLayout() + rightPanelWrapper?.let { frame.add(it, BorderLayout.EAST) } + setActiveView(desiredView) + frame.validate() + frame.repaint() } - - if(launchMinimized){ - setActiveView(HIDDEN_VIEW) + if (SwingUtilities.isEventDispatchThread()) { + commit.run() } else { - setActiveView(XPTrackerView.VIEW_NAME) + try { + SwingUtilities.invokeAndWait(commit) + } catch (e: Exception) { + // Fallback to async if invokeAndWait fails for any reason + SwingUtilities.invokeLater(commit) + } } initialized = true pluginsReloaded = true + updateDisplaySettings() } } private fun setActiveView(viewName: String) { - // Handle the visibility of the main content panel - if (viewName == HIDDEN_VIEW) { - mainContentPanel.isVisible = false - } else { - if (!mainContentPanel.isVisible) { - mainContentPanel.isVisible = true + val runUpdate: () -> Unit = { + // Track visibility change to decide if we need to resize/reload interfaces + val wasVisible = mainContentPanel.isVisible + + // Handle the visibility of the main content panel and card switch + if (viewName == HIDDEN_VIEW) { + mainContentPanel.isVisible = false + } else { + if (!mainContentPanel.isVisible) { + mainContentPanel.isVisible = true + } + cardLayout.show(mainContentPanel, viewName) } - cardLayout.show(mainContentPanel, viewName) + + val visibilityChanged = wasVisible != mainContentPanel.isVisible + + // Batch painting to avoid intermediate repaints + rightPanelWrapper?.ignoreRepaint = true + try { + if (visibilityChanged) { + // Only touch layout and client interfaces if width actually changes + updateDisplaySettings() + reloadInterfaces = true + rightPanelWrapper?.revalidate() + frame?.validate() + } else { + // Just a card switch; avoid full frame revalidate + mainContentPanel.revalidate() + } + } finally { + rightPanelWrapper?.ignoreRepaint = false + } + + // Targeted repaint for snappy feedback + if (visibilityChanged) { + rightPanelWrapper?.repaint() + frame?.repaint() + } else { + mainContentPanel.repaint() + } + StateManager.focusedView = viewName } - reloadInterfaces = true - updateDisplaySettings() - - // Revalidate and repaint necessary panels - mainContentPanel.revalidate() - rightPanelWrapper?.revalidate() - frame?.revalidate() - - mainContentPanel.repaint() - rightPanelWrapper?.repaint() - frame?.repaint() - - focusedView = viewName + if (SwingUtilities.isEventDispatchThread()) { + runUpdate() + } else { + SwingUtilities.invokeLater { runUpdate() } + } } private fun createNavButton(spriteId: Int, viewName: String): JPanel { @@ -535,36 +609,29 @@ class plugin : Plugin() { } lastClickTime = currentTime - if (focusedView == viewName) { + if (StateManager.focusedView == viewName) { setActiveView("HIDDEN") } else { setActiveView(viewName) } } - // ImageCanvas with forced size val imageCanvas = ImageCanvas(bufferedImageSprite).apply { background = WIDGET_COLOR - preferredSize = imageSize - maximumSize = imageSize - minimumSize = imageSize + setFixedSize(imageSize) } // Wrapping the ImageCanvas in another JPanel to prevent stretching val imageCanvasWrapper = JPanel().apply { layout = GridBagLayout() // Keeps the layout of the wrapped panel minimal - preferredSize = imageSize - maximumSize = imageSize - minimumSize = imageSize + setFixedSize(imageSize) isOpaque = false // No background for the wrapper add(imageCanvas) // Adding ImageCanvas directly, layout won't stretch it } val panelButton = JPanel().apply { layout = GridBagLayout() - preferredSize = buttonSize - maximumSize = buttonSize - minimumSize = buttonSize + setFixedSize(buttonSize) background = WIDGET_COLOR isOpaque = true @@ -575,7 +642,6 @@ class plugin : Plugin() { add(imageCanvasWrapper, gbc) - // Hover and click behavior val hoverListener = object : MouseAdapter() { override fun mouseEntered(e: MouseEvent?) { background = WIDGET_COLOR.darker() @@ -666,7 +732,7 @@ class plugin : Plugin() { } private fun loadFont(): Font? { - val fontStream = plugin::class.java.getResourceAsStream("res/runescape_small.ttf") + val fontStream = Helpers.openResource("res/runescape_small.ttf") return if (fontStream != null) { try { val font = Font.createFont(Font.TRUETYPE_FONT, fontStream) diff --git a/plugin-playground/src/main/kotlin/KondoKit/plugin.properties b/plugin-playground/src/main/kotlin/KondoKit/plugin.properties index 629c117..6d333e7 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/plugin.properties +++ b/plugin-playground/src/main/kotlin/KondoKit/plugin.properties @@ -1,3 +1,3 @@ AUTHOR='downthecrop' DESCRIPTION='A plugin that adds a right-side panel with custom widgets and navigation.' -VERSION=2.0 \ No newline at end of file +VERSION=2.1 \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabConfig.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabConfig.kt new file mode 100644 index 0000000..abc0fd0 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabConfig.kt @@ -0,0 +1,26 @@ +package KondoKit.pluginmanager + +object GitLabConfig { + const val GITLAB_PROJECT_ID = "38297322" + const val GITLAB_PROJECT_PATH = "2009scape/tools/client-plugins" + const val GITLAB_BRANCH = "master" + + const val DEBUG = false // Spams pretty hard + + fun getGitLabApiBaseUrl(): String = "https://gitlab.com/api/v4/projects/$GITLAB_PROJECT_ID" + + fun getRepositoryTreeUrl(): String = + "${getGitLabApiBaseUrl()}/repository/tree?ref=${GITLAB_BRANCH}&per_page=100" + + fun getRawFileUrl(filePath: String): String = + "${getGitLabApiBaseUrl()}/repository/files/${filePath.replace("/", "%2F")}/raw?ref=${GITLAB_BRANCH}" + + fun getArchiveBaseUrl(): String = + "https://gitlab.com/$GITLAB_PROJECT_PATH/-/archive/$GITLAB_BRANCH/${GITLAB_PROJECT_PATH.replace("/", "-")}-$GITLAB_BRANCH.zip" + + fun getArchiveUrl(pluginPath: String): String = + "${getArchiveBaseUrl()}?path=$pluginPath" + + fun getArchiveUrlWithRefType(pluginPath: String): String = + "${getArchiveBaseUrl()}?path=$pluginPath&ref_type=heads" +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabPluginFetcher.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabPluginFetcher.kt new file mode 100644 index 0000000..db8d27c --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/GitLabPluginFetcher.kt @@ -0,0 +1,134 @@ +package KondoKit.pluginmanager + +import KondoKit.pluginmanager.GitLabPlugin +import KondoKit.pluginmanager.PluginProperties +import KondoKit.util.HttpFetcher +import KondoKit.pluginmanager.GitLabConfig +import KondoKit.util.HttpStatusException +import KondoKit.util.JsonParser +import com.google.gson.JsonObject +import java.util.concurrent.* +import javax.swing.SwingUtilities + +object GitLabPluginFetcher { + private const val TAG = "GitLabPluginFetcher" + + private val executorService = Executors.newFixedThreadPool(5) + + private fun debugLog(message: String) = PluginLogger.debug(TAG, message) + + fun fetchGitLabPlugins(onComplete: (List) -> Unit) { + Thread { + val plugins = mutableListOf() + try { + debugLog("Starting to fetch GitLab plugins...") + val apiUrl = GitLabConfig.getRepositoryTreeUrl() + debugLog("API URL: $apiUrl") + + val response = HttpFetcher.fetchString(apiUrl) + debugLog("Response length: ${response.length} characters") + debugLog("Response preview: ${response.take(200)}...") + + val treeItems = JsonParser.fromJson(response, Array::class.java) + debugLog("Parsed ${treeItems.size} items from JSON") + + val pluginDirectories = mutableListOf>() + // Filter for directories (trees) by mode 040000 so we only consider plugin folders + for (jsonObject in treeItems) { + if (jsonObject["type"].asString == "tree" && jsonObject["mode"].asString == "040000") { + val folderId = jsonObject["id"].asString + val folderPath = jsonObject["path"].asString + pluginDirectories.add(folderId to folderPath) + debugLog("Found directory: $folderPath (ID: $folderId)") + } + } + debugLog("Found ${pluginDirectories.size} plugin directories") + + val pluginFutures = mutableListOf>() + for ((folderId, folderPath) in pluginDirectories) { + val future = executorService.submit(Callable { + try { + val pluginProperties = fetchPluginProperties(folderPath) + debugLog("Successfully fetched properties for: $folderPath") + GitLabPlugin(folderId, folderPath, pluginProperties) + } catch (e: Exception) { + debugLog("Error fetching plugin.properties for $folderPath: ${e.message}") + GitLabPlugin(folderId, folderPath, null) + } + }) + pluginFutures.add(future) + } + + for (future in pluginFutures) { + try { + plugins.add(future.get()) + } catch (e: Exception) { + debugLog("Error getting plugin result: ${e.message}") + } + } + } catch (e: HttpStatusException) { + debugLog("HTTP error ${e.statusCode} fetching plugin tree: ${e.body.take(200)}") + } catch (e: Exception) { + debugLog("Exception occurred: ${e.message}") + e.printStackTrace() + } + + debugLog("Completed fetching plugins. Total plugins: ${plugins.size}") + SwingUtilities.invokeLater { + onComplete(plugins) + } + }.start() + } + + private fun fetchPluginProperties(folderPath: String): PluginProperties { + val pluginFilePath = "$folderPath/plugin.properties" + val pluginUrl = GitLabConfig.getRawFileUrl(pluginFilePath) + debugLog("Fetching plugin.properties from: $pluginUrl") + + try { + val rawContent = HttpFetcher.fetchString(pluginUrl) + debugLog("plugin.properties content length: ${rawContent.length} characters") + debugLog("plugin.properties content preview: ${rawContent.take(200)}...") + + return parseProperties(rawContent) + } catch (e: HttpStatusException) { + debugLog("Failed to fetch plugin.properties. Response code: ${e.statusCode}") + debugLog("Error response for plugin.properties: ${e.body.take(200)}") + throw Exception("Plugin file not found for folder: $folderPath", e) + } + } + + private fun parseProperties(content: String): PluginProperties { + debugLog("Parsing plugin.properties content") + val lines = content.split("\n") + var author = "Unknown" + var version = "Unknown" + var description = "No description available" + + for (line in lines) { + val parts = line.split("=") + if (parts.size == 2) { + val key = parts[0].trim() + val value = parts[1].replace("\"", "").replace("'", "").trim() + + when { + key.startsWith("AUTHOR", ignoreCase = true) -> { + author = value + debugLog("Found author: $value") + } + key.startsWith("VERSION", ignoreCase = true) -> { + version = value + debugLog("Found version: $value") + } + key.startsWith("DESCRIPTION", ignoreCase = true) -> { + description = value + debugLog("Found description: $value") + } + } + } + } + + debugLog("Parsed properties - Author: $author, Version: $version, Description: $description") + return PluginProperties(author, version, description) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginDownloadManager.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginDownloadManager.kt new file mode 100644 index 0000000..55dddcc --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginDownloadManager.kt @@ -0,0 +1,339 @@ +package KondoKit.pluginmanager + +import KondoKit.util.HttpFetcher +import KondoKit.pluginmanager.GitLabConfig +import java.io.* +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import javax.swing.SwingUtilities + +/** + * Manages downloading and installing plugins from GitLab with concurrent support + */ +object PluginDownloadManager { + private const val TAG = "PluginDownloadManager" + private const val MAX_CONCURRENT_DOWNLOADS = 3 + + private val downloadExecutor = Executors.newFixedThreadPool(MAX_CONCURRENT_DOWNLOADS) + + interface DownloadProgressCallback { + fun onProgress(pluginName: String, progress: Int) + fun onComplete(pluginName: String, success: Boolean, errorMessage: String? = null) + } + + private fun debugLog(message: String) = PluginLogger.debug(TAG, message) + + fun getDownloadUrlForLogging(plugin: GitLabPlugin): String { + return GitLabConfig.getArchiveUrl(plugin.path) + } + + /** + * Download a single plugin with progress updates + */ + fun downloadPlugin(plugin: GitLabPlugin, callback: DownloadProgressCallback) { + downloadExecutor.submit { + try { + debugLog("Starting download for plugin: ${plugin.path}") + callback.onProgress(plugin.path, 0) + + val success = downloadAndExtractPlugin(plugin, callback) + + if (success) { + debugLog("Successfully downloaded and extracted plugin: ${plugin.path}") + SwingUtilities.invokeLater { + callback.onComplete(plugin.path, true) + } + } else { + debugLog("Failed to download plugin: ${plugin.path}") + SwingUtilities.invokeLater { + callback.onComplete(plugin.path, false, "Failed to download plugin") + } + } + } catch (e: Exception) { + debugLog("Exception during download of ${plugin.path}: ${e.message}") + e.printStackTrace() + SwingUtilities.invokeLater { + callback.onComplete(plugin.path, false, e.message) + } + } + } + } + + /** + * Download multiple plugins concurrently + */ + fun downloadPlugins(plugins: List, callback: (String, Boolean, String?) -> Unit) { + debugLog("Starting concurrent download of ${plugins.size} plugins") + + for (plugin in plugins) { + downloadPlugin(plugin, object : DownloadProgressCallback { + override fun onProgress(pluginName: String, progress: Int) { + } + + override fun onComplete(pluginName: String, success: Boolean, errorMessage: String?) { + callback(pluginName, success, errorMessage) + } + }) + } + } + + /** + * Download and extract a plugin from GitLab + */ + private fun downloadAndExtractPlugin(plugin: GitLabPlugin, callback: DownloadProgressCallback): Boolean { + try { + if (plugin.path.isBlank()) { + debugLog("Plugin path is blank for plugin: ${plugin.path}") + return false + } + + debugLog("Starting download for plugin: ${plugin.path}") + + // Try multiple URL formats since GitLab has changed their API + val downloadUrls = listOf( + // Format 1: Using ref_type parameter + GitLabConfig.getArchiveUrlWithRefType(plugin.path), + // Format 2: Using ref parameter + GitLabConfig.getArchiveUrl(plugin.path), + GitLabConfig.getArchiveBaseUrl() + ) + + for (downloadUrl in downloadUrls) { + debugLog("Trying download URL: $downloadUrl") + + val url = try { + URL(downloadUrl) + } catch (e: Exception) { + debugLog("Invalid URL: $downloadUrl for plugin: ${plugin.path}. Error: ${e.message}") + continue + } + + val connection = try { + HttpFetcher.openGetConnection(url.toString()) + } catch (e: Exception) { + debugLog("Failed to open connection for plugin: ${plugin.path} with URL: $downloadUrl. Error: ${e.message}") + continue + } + + debugLog("Connection opened for plugin: ${plugin.path}") + debugLog("Request headers - User-Agent: ${connection.getRequestProperty("User-Agent")}") + debugLog("Request headers - Accept: ${connection.getRequestProperty("Accept")}") + + val contentLength = connection.contentLength + debugLog("Content length: $contentLength bytes for plugin: ${plugin.path}") + + val responseCode = connection.responseCode + debugLog("Response code: $responseCode for plugin: ${plugin.path}") + + val responseMessage = connection.responseMessage + debugLog("Response message: $responseMessage for plugin: ${plugin.path}") + + if (responseCode == HttpURLConnection.HTTP_OK) { + val inputStream = connection.inputStream + if (inputStream == null) { + debugLog("Input stream is null for plugin: ${plugin.path}") + continue + } + + debugLog("Input stream available for plugin: ${plugin.path}") + + val pluginsDir = File(rt4.GlobalJsonConfig.instance.pluginsFolder) + debugLog("Plugins directory: ${pluginsDir.absolutePath}") + + if (!pluginsDir.exists()) { + debugLog("Plugins directory does not exist: ${pluginsDir.absolutePath}") + if (!pluginsDir.mkdirs()) { + debugLog("Failed to create plugins directory: ${pluginsDir.absolutePath}") + return false + } + debugLog("Created plugins directory: ${pluginsDir.absolutePath}") + } + + if (!pluginsDir.isDirectory) { + debugLog("Plugins path is not a directory: ${pluginsDir.absolutePath}") + return false + } + + val pluginDir = File(pluginsDir, plugin.path) + debugLog("Plugin directory: ${pluginDir.absolutePath}") + + if (!pluginDir.exists()) { + pluginDir.mkdirs() + debugLog("Created plugin directory: ${pluginDir.absolutePath}") + } + + val tempZipFile = File.createTempFile("plugin_", ".zip") + tempZipFile.deleteOnExit() + debugLog("Created temp file: ${tempZipFile.absolutePath}") + + var totalBytesRead = 0L + inputStream.use { input -> + FileOutputStream(tempZipFile).use { outputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + + debugLog("Starting to read data for plugin: ${plugin.path}") + + while (input.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Report progress if content length is known + if (contentLength > 0) { + val progress = (totalBytesRead * 100 / contentLength).toInt() + // Limit progress updates to avoid flooding the UI + if (progress % 5 == 0) { + SwingUtilities.invokeLater { + callback.onProgress(plugin.path, progress) + } + } + } + } + + debugLog("Finished reading data for plugin: ${plugin.path}. Total bytes: $totalBytesRead") + } + } + + debugLog("Downloaded ${totalBytesRead} bytes to ${tempZipFile.absolutePath}") + + if (extractZipFile(tempZipFile, pluginDir, plugin.path)) { + debugLog("Successfully extracted plugin to ${pluginDir.absolutePath}") + tempZipFile.delete() + return true + } else { + debugLog("Failed to extract plugin") + tempZipFile.delete() + continue // Try next URL format + } + } else { + debugLog("HTTP error: $responseCode for plugin: ${plugin.path} with URL: $downloadUrl") + + // Try to read error stream for more details + try { + val errorStream = connection.errorStream + if (errorStream != null) { + val errorReader = BufferedReader(InputStreamReader(errorStream)) + val errorResponse = errorReader.use { it.readText() } + debugLog("Error response: $errorResponse for plugin: ${plugin.path}") + } else { + debugLog("Error stream is null for plugin: ${plugin.path}") + } + } catch (e: Exception) { + debugLog("Exception while reading error stream: ${e.message} for plugin: ${plugin.path}") + } + + continue // Try next URL format + } + } + + debugLog("All URL formats failed for plugin: ${plugin.path}") + return false + } catch (e: Exception) { + debugLog("Exception during download and extraction for plugin ${plugin.path}: ${e.message}") + e.printStackTrace() + return false + } + } + + /** + * Extract a ZIP file to the specified directory + */ + private fun extractZipFile(zipFile: File, targetDir: File, pluginPath: String): Boolean { + try { + debugLog("Extracting ZIP file: ${zipFile.absolutePath} to ${targetDir.absolutePath}") + + if (!zipFile.exists()) { + debugLog("ZIP file does not exist: ${zipFile.absolutePath}") + return false + } + + if (!targetDir.exists() && !targetDir.mkdirs()) { + debugLog("Failed to create target directory: ${targetDir.absolutePath}") + return false + } + + ZipInputStream(FileInputStream(zipFile)).use { zis -> + var entry: ZipEntry? + var entryCount = 0 + + while (zis.nextEntry.also { entry = it } != null) { + entryCount++ + val entryName = entry!!.name + + debugLog("Processing ZIP entry $entryCount: $entryName") + + // Strip the top-level GitLab archive directory so we extract only the plugin contents + val relativePath = if (entryName.contains("/")) { + entryName.substring(entryName.indexOf("/") + 1) + } else { + entryName + } + + debugLog("Relative path for entry: $relativePath") + + if (relativePath.startsWith(pluginPath) && relativePath != pluginPath) { + val fileName = relativePath.substring(pluginPath.length + 1) // +1 for the trailing slash + + debugLog("File name to extract: $fileName") + + if (fileName.isNotEmpty()) { + val file = File(targetDir, fileName) + + debugLog("Target file path: ${file.absolutePath}") + + val parent = file.parentFile + if (parent != null && !parent.exists()) { + if (parent.mkdirs()) { + debugLog("Created parent directories: ${parent.absolutePath}") + } else { + debugLog("Failed to create parent directories: ${parent.absolutePath}") + zis.closeEntry() + continue + } + } + + if (entry!!.isDirectory) { + if (!file.exists()) { + if (file.mkdirs()) { + debugLog("Created directory: ${file.absolutePath}") + } else { + debugLog("Failed to create directory: ${file.absolutePath}") + } + } else { + debugLog("Directory already exists: ${file.absolutePath}") + } + } else { + try { + FileOutputStream(file).use { fos -> + zis.copyTo(fos) + } + debugLog("Extracted file: ${file.absolutePath}") + } catch (e: Exception) { + debugLog("Failed to extract file ${file.absolutePath}: ${e.message}") + } + } + } else { + debugLog("Skipping empty file name") + } + } else { + debugLog("Skipping entry (not part of plugin): $relativePath") + } + + zis.closeEntry() + } + + debugLog("Processed $entryCount entries from ZIP file") + } + + debugLog("Successfully extracted ZIP file") + return true + } catch (e: Exception) { + debugLog("Exception during ZIP extraction: ${e.message}") + e.printStackTrace() + return false + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginLogger.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginLogger.kt new file mode 100644 index 0000000..8e73b5c --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginLogger.kt @@ -0,0 +1,9 @@ +package KondoKit.pluginmanager + +object PluginLogger { + fun debug(tag: String, message: String) { + if (GitLabConfig.DEBUG) { + println("[$tag] $message") + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginModels.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginModels.kt new file mode 100644 index 0000000..0730430 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/PluginModels.kt @@ -0,0 +1,29 @@ +package KondoKit.pluginmanager + +data class GitLabPlugin( + val id: String, + val path: String, + val pluginProperties: PluginProperties? +) + +data class PluginProperties( + val author: String, + val version: String, + val description: String +) + +/** + * Data class representing a plugin with its installation status and update information + */ +data class PluginInfoWithStatus( + val name: String, + val installedVersion: String?, + val remoteVersion: String?, + val description: String?, + val author: String?, + val gitLabPlugin: GitLabPlugin?, + val isInstalled: Boolean, + val needsUpdate: Boolean, + val isDownloading: Boolean = false, + val downloadProgress: Int = 0 +) diff --git a/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/ReflectiveEditorPlugin.kt b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/ReflectiveEditorPlugin.kt new file mode 100644 index 0000000..1fc505d --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/pluginmanager/ReflectiveEditorPlugin.kt @@ -0,0 +1,405 @@ +package KondoKit.pluginmanager + +import KondoKit.util.FileUtils +import KondoKit.util.Helpers +import KondoKit.views.ReflectiveEditorView +import plugin.Plugin +import plugin.PluginInfo +import plugin.PluginRepository +import rt4.GlobalJsonConfig +import java.awt.Component +import java.io.File +import java.util.* +import javax.swing.* + +class ReflectiveEditorPlugin : Plugin() { + + private var gitLabPlugins: List = listOf() + private var pluginStatuses: List = listOf() + private var reloadPlugins = false // Flag for scheduled plugin reload to avoid crashes + private val pluginsDirectory: File = File(GlobalJsonConfig.instance.pluginsFolder) + + override fun Init() { + GitLabPluginFetcher.fetchGitLabPlugins { plugins -> + gitLabPlugins = plugins + updatePluginStatuses() + } + } + + fun getGitLabPlugins(): List = gitLabPlugins + + fun getPluginStatuses(): List = pluginStatuses + + fun updatePluginStatuses() { + val loadedPluginNames = getLoadedPluginNames() + val statuses = mutableListOf() + + val disabledPluginInfo = getDisabledPluginInfo() + + handleDuplicatePlugins(loadedPluginNames, disabledPluginInfo) + + for (gitLabPlugin in gitLabPlugins) { + val pluginName = gitLabPlugin.path + // Skip KondoKit since it should always be loaded + if (isKondoKit(pluginName)) { + continue + } + val remoteVersion = gitLabPlugin.pluginProperties?.version ?: "Unknown" + val description = gitLabPlugin.pluginProperties?.description ?: "No description available" + val author = gitLabPlugin.pluginProperties?.author ?: "Unknown" + + val isLoaded = loadedPluginNames.contains(pluginName) + val isDisabled = disabledPluginInfo.containsKey(pluginName) + val disabledVersion = disabledPluginInfo[pluginName] + + val existingStatus = pluginStatuses.find { it.name == pluginName } + val isDownloading = existingStatus?.isDownloading ?: false + val downloadProgress = existingStatus?.downloadProgress ?: 0 + + if (isLoaded) { + statuses.add(PluginInfoWithStatus( + name = pluginName, + installedVersion = remoteVersion, // We don't have the actual installed version, but we know it's loaded + remoteVersion = remoteVersion, + description = description, + author = author, + gitLabPlugin = gitLabPlugin, + isInstalled = true, + needsUpdate = false, // We assume it's up-to-date since it's loaded + isDownloading = isDownloading, + downloadProgress = downloadProgress + )) + } else if (isDisabled) { + val versionsMatch = disabledVersion != null && disabledVersion == remoteVersion + if (!versionsMatch) { + statuses.add(PluginInfoWithStatus( + name = pluginName, + installedVersion = disabledVersion, + remoteVersion = remoteVersion, + description = description, + author = author, + gitLabPlugin = gitLabPlugin, + isInstalled = true, // It's installed but disabled + needsUpdate = true, // Needs update since versions don't match + isDownloading = isDownloading, + downloadProgress = downloadProgress + )) + } + } else { + statuses.add(PluginInfoWithStatus( + name = pluginName, + installedVersion = null, + remoteVersion = remoteVersion, + description = description, + author = author, + gitLabPlugin = gitLabPlugin, + isInstalled = false, + needsUpdate = false, + isDownloading = isDownloading, + downloadProgress = downloadProgress + )) + } + } + + pluginStatuses = statuses + } + + private fun getLoadedPluginNames(): Set { + val loadedPluginNames = mutableSetOf() + + try { + val loadedPluginsField = PluginRepository::class.java.getDeclaredField("loadedPlugins") + loadedPluginsField.isAccessible = true + val loadedPlugins = loadedPluginsField.get(null) as HashMap<*, *> + + for ((_, plugin) in loadedPlugins) { + val pluginName = getPluginDirName(plugin as Plugin) + loadedPluginNames.add(pluginName) + } + } catch (e: Exception) { + e.printStackTrace() + } + + return loadedPluginNames + } + + private fun parsePluginProperties(content: String): PluginProperties { + var author = "Unknown" + var version = "Unknown" + var description = "No description available" + + val lines = content.split("\n") + for (line in lines) { + val parts = line.split("=") + if (parts.size == 2) { + val key = parts[0].trim() + val value = parts[1].replace("\"", "").replace("'", "").trim() + + when { + key.startsWith("AUTHOR", ignoreCase = true) -> { + author = value + } + key.startsWith("VERSION", ignoreCase = true) -> { + version = value + } + key.startsWith("DESCRIPTION", ignoreCase = true) -> { + description = value + } + } + } + } + + return PluginProperties(author, version, description) + } + + private fun getDisabledPluginInfo(): Map { + val disabledPluginInfo = mutableMapOf() + val disabledDir = File(pluginsDirectory, "disabled") + + if (disabledDir.exists() && disabledDir.isDirectory) { + val disabledPlugins = disabledDir.listFiles { file -> file.isDirectory } ?: arrayOf() + + for (pluginDir in disabledPlugins) { + try { + val propertiesFile = File(pluginDir, "plugin.properties") + if (propertiesFile.exists()) { + val content = propertiesFile.readText() + val properties = parsePluginProperties(content) + disabledPluginInfo[pluginDir.name] = properties.version + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + return disabledPluginInfo + } + + private fun handleDuplicatePlugins(loadedPluginNames: Set, disabledPluginInfo: Map) { + var needsReload = false + for (pluginName in loadedPluginNames) { + if (disabledPluginInfo.containsKey(pluginName)) { + try { + val disabledDir = File(pluginsDirectory, "disabled") + val pluginDir = File(disabledDir, pluginName) + if (pluginDir.exists() && pluginDir.isDirectory) { + if (FileUtils.deleteRecursively(pluginDir)) { + needsReload = true + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + // Reload plugins if we deleted any disabled duplicates + if (needsReload) { + PluginRepository.reloadPlugins() + } + } + + fun deletePlugin(pluginName: String, isDisabled: Boolean, mainPanel: Component) { + try { + val pluginDir = if (isDisabled) { + File(File(pluginsDirectory, "disabled"), pluginName) + } else { + File(pluginsDirectory, pluginName) + } + + if (pluginDir.exists() && pluginDir.isDirectory) { + if (FileUtils.deleteRecursively(pluginDir)) { + Helpers.showToast(mainPanel, "Plugin deleted successfully", JOptionPane.INFORMATION_MESSAGE) + + if (!isDisabled) { + reloadPlugins = true + } + + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + } else { + Helpers.showToast(mainPanel, "Failed to delete plugin", JOptionPane.ERROR_MESSAGE) + } + } else { + Helpers.showToast(mainPanel, "Plugin directory not found", JOptionPane.ERROR_MESSAGE) + } + } catch (e: Exception) { + e.printStackTrace() + Helpers.showToast(mainPanel, "Error deleting plugin: ${e.message}", JOptionPane.ERROR_MESSAGE) + } + } + + private fun isKondoKit(pluginName: String): Boolean { + return pluginName == "KondoKit" + } + + fun enablePlugin(pluginName: String, mainPanel: Component) { + try { + val disabledDir = File(pluginsDirectory, "disabled") + val sourceDir = File(disabledDir, pluginName) + val destDir = File(pluginsDirectory, pluginName) + + if (!sourceDir.exists()) { + Helpers.showToast(mainPanel, "Plugin directory not found: ${sourceDir.absolutePath}", JOptionPane.ERROR_MESSAGE) + return + } + + if (sourceDir.renameTo(destDir)) { + Helpers.showToast(mainPanel, "Plugin enabled") + + // Schedule plugin reload to avoid crashes + reloadPlugins = true + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + } else { + Helpers.showToast(mainPanel, "Failed to enable plugin", JOptionPane.ERROR_MESSAGE) + } + } catch (e: Exception) { + e.printStackTrace() + Helpers.showToast(mainPanel, "Error enabling plugin: ${e.message}", JOptionPane.ERROR_MESSAGE) + } + } + + fun togglePlugin(plugin: Plugin, pluginInfo: PluginInfo, toggleSwitch: Component, activated: Boolean, mainPanel: Component) { + try { + val pluginDirName = getPluginDirName(plugin) + + val sourceDir = if (activated) { + File(File(pluginsDirectory, "disabled"), pluginDirName) + } else { + File(pluginsDirectory, pluginDirName) + } + + val destDir = if (activated) { + File(pluginsDirectory, pluginDirName) + } else { + val disabledDir = File(pluginsDirectory, "disabled") + if (!disabledDir.exists()) { + disabledDir.mkdirs() + } + File(disabledDir, pluginDirName) + } + + if (!sourceDir.exists()) { + Helpers.showToast(mainPanel, "Plugin directory not found: ${sourceDir.absolutePath}", JOptionPane.ERROR_MESSAGE) + SwingUtilities.invokeLater { + if (toggleSwitch is JComponent) { + toggleSwitch.repaint() + } + } + return + } + + if (sourceDir.renameTo(destDir)) { + Helpers.showToast(mainPanel, if (activated) "Plugin enabled" else "Plugin disabled") + + // Schedule plugin reload to avoid crashes + reloadPlugins = true + + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + } else { + Helpers.showToast(mainPanel, "Failed to ${if (activated) "enable" else "disable"} plugin", JOptionPane.ERROR_MESSAGE) + SwingUtilities.invokeLater { + if (toggleSwitch is JComponent) { + toggleSwitch.repaint() + } + } + } + } catch (e: Exception) { + e.printStackTrace() + Helpers.showToast(mainPanel, "Error toggling plugin: ${e.message}", JOptionPane.ERROR_MESSAGE) + SwingUtilities.invokeLater { + if (toggleSwitch is JComponent) { + toggleSwitch.repaint() + } + } + } + } + + private fun getPluginDirName(plugin: Plugin): String { + // Extract the directory name from the plugin's package + // The package name is typically like "GroundItems.plugin" so we take the first part + val packageName = plugin.javaClass.`package`.name + return packageName.substringBefore(".") + } + + fun isPluginEnabled(plugin: Plugin, pluginInfo: PluginInfo): Boolean { + val pluginDirName = getPluginDirName(plugin) + val pluginDir = File(pluginsDirectory, pluginDirName) + return pluginDir.exists() && pluginDir.isDirectory + } + + fun startPluginDownload(pluginStatus: PluginInfoWithStatus, mainPanel: Component) { + val gitLabPlugin = pluginStatus.gitLabPlugin + if (gitLabPlugin == null) { + Helpers.showToast(mainPanel, "Plugin information not available", JOptionPane.ERROR_MESSAGE) + return + } + + val updatedStatuses = pluginStatuses.map { + if (it.name == pluginStatus.name) { + it.copy(isDownloading = true, downloadProgress = 0) + } else { + it + } + } + pluginStatuses = updatedStatuses + + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + + PluginDownloadManager.downloadPlugin(gitLabPlugin, object : PluginDownloadManager.DownloadProgressCallback { + override fun onProgress(pluginName: String, progress: Int) { + val updatedStatuses = pluginStatuses.map { + if (it.name == pluginName) { + it.copy(downloadProgress = progress) + } else { + it + } + } + pluginStatuses = updatedStatuses + + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + } + + override fun onComplete(pluginName: String, success: Boolean, errorMessage: String?) { + val updatedStatuses = pluginStatuses.map { + if (it.name == pluginName) { + it.copy(isDownloading = false, downloadProgress = if (success) 100 else 0) + } else { + it + } + } + pluginStatuses = updatedStatuses + + if (success) { + Helpers.showToast(mainPanel, "Plugin downloaded successfully!", JOptionPane.INFORMATION_MESSAGE) + // Reload plugins to make the newly downloaded plugin available + PluginRepository.reloadPlugins() + } else { + Helpers.showToast(mainPanel, "Failed to download plugin: ${errorMessage ?: "Unknown error"}", JOptionPane.ERROR_MESSAGE) + } + + SwingUtilities.invokeLater { + ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel) + } + } + }) + } + + fun getPluginsDirectory(): File = pluginsDirectory + + fun shouldReloadPlugins(): Boolean = reloadPlugins + + fun setReloadPlugins(reload: Boolean) { + reloadPlugins = reload + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/BaseView.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/BaseView.kt new file mode 100644 index 0000000..01e5df5 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/BaseView.kt @@ -0,0 +1,31 @@ +package KondoKit.ui + +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR +import KondoKit.util.setFixedSize +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JPanel + +open class BaseView( + private val viewName: String, + private val preferredWidth: Int = ViewConstants.DEFAULT_PANEL_SIZE.width, + private val addDefaultSpacing: Boolean = true +) : JPanel() { + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + name = viewName + background = VIEW_BACKGROUND_COLOR + border = BorderFactory.createEmptyBorder(0, 0, 0, 0) + + if (addDefaultSpacing) { + add(Box.createVerticalStrut(5)) + } + } + + fun setViewSize(height: Int) { + setFixedSize(preferredWidth, height) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/View.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/View.kt new file mode 100644 index 0000000..a6aaf82 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/View.kt @@ -0,0 +1,12 @@ +package KondoKit.ui + +import javax.swing.JPanel + +interface View { + val name: String + val iconSpriteId: Int + val panel: JPanel + + fun createView() + fun registerFunctions() +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/ViewCallbacks.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/ViewCallbacks.kt new file mode 100644 index 0000000..52a4e5d --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/ViewCallbacks.kt @@ -0,0 +1,21 @@ +package KondoKit.ui + +interface OnUpdateCallback { + fun onUpdate() +} + +interface OnDrawCallback { + fun onDraw(timeDelta: Long) +} + +interface OnXPUpdateCallback { + fun onXPUpdate(skillId: Int, xp: Int) +} + +interface OnKillingBlowNPCCallback { + fun onKillingBlowNPC(npcID: Int, x: Int, z: Int) +} + +interface OnPostClientTickCallback { + fun onPostClientTick() +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/ViewConstants.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/ViewConstants.kt new file mode 100644 index 0000000..ff5cda1 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/ViewConstants.kt @@ -0,0 +1,41 @@ +package KondoKit.ui + +import java.awt.Dimension +import java.awt.Font + +object ViewConstants { + val FONT_RUNESCAPE_SMALL_16 = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + val FONT_RUNESCAPE_SMALL_14 = Font("RuneScape Small", Font.PLAIN, 14) + val FONT_RUNESCAPE_SMALL_PLAIN_16 = Font("RuneScape Small", Font.PLAIN, 16) + val FONT_RUNESCAPE_SMALL_BOLD_16 = Font("RuneScape Small", Font.BOLD, 16) + val FONT_ARIAL_PLAIN_14 = Font("Arial", Font.PLAIN, 14) + val FONT_ARIAL_BOLD_12 = Font("Arial", Font.BOLD, 12) + + val DIMENSION_SMALL_ICON = Dimension(12, 12) + val DIMENSION_LARGE_ICON = Dimension(30, 30) + val DEFAULT_WIDGET_SIZE = Dimension(234, 50) + val PLUGIN_LIST_ITEM_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 36) + val TOGGLE_PLACEHOLDER_SIZE = Dimension(60, 24) + val TOTAL_XP_WIDGET_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 42) + val IMAGE_SIZE = Dimension(25, 23) + val SEARCH_FIELD_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 30) + val DEFAULT_PANEL_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 500) + val FILTER_PANEL_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 30) + val SKILLS_PANEL_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 290) + val TOTAL_COMBAT_PANEL_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width, 30) + val SKILL_PANEL_SIZE = Dimension(DEFAULT_WIDGET_SIZE.width / 3, 35) + val IMAGE_CANVAS_SIZE = Dimension(20, 20) + val SKILL_SPRITE_SIZE = Dimension(14, 14) + val NUMBER_LABEL_SIZE = Dimension(20, 20) + val PROGRESS_BAR_SIZE = Dimension(220, 16) + val TOGGLE_SWITCH_SIZE = Dimension(32, 20) + + val SKILL_DISPLAY_ORDER = arrayOf(0, 3, 14, 2, 16, 13, 1, 15, 10, 4, 17, 7, 5, 12, 11, 6, 9, 8, 20, 18, 19, 22, 21, 23) + + const val COMBAT_LVL_SPRITE = 168 + const val IRONMAN_SPRITE = 4 + const val MAG_SPRITE = 1423 + const val LVL_BAR_SPRITE = 898 + const val WRENCH_ICON = 907 + const val LOOT_ICON = 777 +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/ButtonPanel.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ButtonPanel.kt new file mode 100644 index 0000000..fdf0954 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ButtonPanel.kt @@ -0,0 +1,44 @@ +package KondoKit.ui.components + +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.TITLE_BAR_COLOR +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.ui.components.UiStyler.button +import KondoKit.ui.components.UiStyler.ButtonDefaults +import java.awt.* +import javax.swing.* + +class ButtonPanel( + private val alignment: Int = FlowLayout.CENTER, + private val hgap: Int = 5, + private val vgap: Int = 0 +) : JPanel() { + + init { + layout = FlowLayout(alignment, hgap, vgap) + background = WIDGET_COLOR + } + + fun addButton(text: String, action: () -> Unit): JButton = + createAndAddButton(text = text, action = action) + + fun addIcon(icon: Icon, action: () -> Unit): JButton = + createAndAddButton(icon = icon, action = action) + + private fun createAndAddButton( + text: String? = null, + icon: Icon? = null, + action: () -> Unit + ): JButton { + val defaults = ButtonDefaults( + background = TITLE_BAR_COLOR, + foreground = secondaryColor, + font = ViewConstants.FONT_RUNESCAPE_SMALL_14 + ) + + return button(text = text, icon = icon, defaults = defaults, onClick = action).also { + add(it) + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/IconComponent.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/IconComponent.kt new file mode 100644 index 0000000..8518bf0 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/IconComponent.kt @@ -0,0 +1,55 @@ +package KondoKit.ui.components + +import KondoKit.util.ImageCanvas +import KondoKit.util.SpriteToBufferedImage +import KondoKit.util.setFixedSize +import KondoKit.plugin.Companion.WIDGET_COLOR +import plugin.api.API +import java.awt.BorderLayout +import java.awt.Color +import javax.swing.BorderFactory +import javax.swing.JPanel + +class IconComponent( + spriteId: Int, + private val iconWidth: Int = 25, + private val iconHeight: Int = 23, + private val useThemeColors: Boolean = true, + private val tint: Color? = null, + private val grayscale: Boolean = false, + private val brightnessBoost: Float = 1.0f +) : JPanel() { + + private val imageCanvas: ImageCanvas + + init { + layout = BorderLayout() + background = if (useThemeColors) WIDGET_COLOR else Color.WHITE + + val bufferedImageSprite = SpriteToBufferedImage.getBufferedImageFromSprite( + API.GetSprite(spriteId), + tint, + grayscale, + brightnessBoost + ) + + imageCanvas = ImageCanvas(bufferedImageSprite).apply { + setFixedSize(iconWidth, iconHeight) + background = if (useThemeColors) WIDGET_COLOR else Color.WHITE + } + + add(imageCanvas, BorderLayout.CENTER) + border = BorderFactory.createEmptyBorder(2, 2, 2, 2) + } + + fun updateFillColor(color: Color) { + imageCanvas.fillColor = color + background = color + imageCanvas.repaint() + repaint() + } + + fun applyThemeColors() { + updateFillColor(WIDGET_COLOR) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/LabelComponent.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/LabelComponent.kt new file mode 100644 index 0000000..f6809bd --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/LabelComponent.kt @@ -0,0 +1,36 @@ +package KondoKit.ui.components + +import KondoKit.util.Helpers.formatHtmlLabelText +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.secondaryColor +import java.awt.Font +import java.awt.Color +import javax.swing.JLabel +import javax.swing.SwingConstants + +class LabelComponent( + text: String = "", + private val isHtml: Boolean = false, + private val alignment: Int = SwingConstants.LEFT +) : JLabel() { + + init { + this.text = text + this.horizontalAlignment = alignment + this.font = ViewConstants.FONT_RUNESCAPE_SMALL_16 + } + + fun updateText(plainText: String, color: Color = secondaryColor) { + this.text = plainText + this.foreground = color + } + + fun updateHtmlText(text1: String, color1: Color = primaryColor, text2: String = "", color2: Color = secondaryColor) { + this.text = formatHtmlLabelText(text1, color1, text2, color2) + } + + fun setAsHeader() { + font = ViewConstants.FONT_RUNESCAPE_SMALL_16.deriveFont(Font.BOLD) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/PopupMenuComponent.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/PopupMenuComponent.kt new file mode 100644 index 0000000..f324a97 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/PopupMenuComponent.kt @@ -0,0 +1,19 @@ +package KondoKit.ui.components + +import KondoKit.ui.components.UiStyler.menuItem +import KondoKit.plugin.Companion.POPUP_BACKGROUND +import javax.swing.JMenuItem +import javax.swing.JPopupMenu + +class PopupMenuComponent : JPopupMenu() { + + init { + background = POPUP_BACKGROUND + } + + fun addMenuItem(text: String, action: () -> Unit): JMenuItem { + val item = menuItem(text = text, onClick = action) + add(item) + return item + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ProgressBar.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ProgressBar.kt similarity index 72% rename from plugin-playground/src/main/kotlin/KondoKit/ProgressBar.kt rename to plugin-playground/src/main/kotlin/KondoKit/ui/components/ProgressBar.kt index 2c5546f..ba3e078 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/ProgressBar.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ProgressBar.kt @@ -1,11 +1,11 @@ -package KondoKit +package KondoKit.ui.components +import KondoKit.ui.ViewConstants import KondoKit.plugin.Companion.PROGRESS_BAR_FILL import KondoKit.plugin.Companion.secondaryColor import java.awt.Canvas import java.awt.Color import java.awt.Dimension -import java.awt.Font import java.awt.Graphics class ProgressBar( @@ -16,44 +16,38 @@ class ProgressBar( ) : Canvas() { init { - font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 } override fun paint(g: Graphics) { super.paint(g) - // Draw the filled part of the progress bar g.color = barColor val width = (progress * this.width / 100).toInt() g.fillRect(0, 0, width, this.height) - // Draw the unfilled part of the progress bar g.color = PROGRESS_BAR_FILL g.fillRect(width, 0, this.width - width, this.height) - // Variables for text position val textY = this.height / 2 + 6 - // Draw the current level on the far left drawTextWithShadow(g, "Lvl. $currentLevel", 5, textY, secondaryColor) - // Draw the percentage in the middle val percentageText = String.format("%.2f%%", progress) val percentageWidth = g.fontMetrics.stringWidth(percentageText) drawTextWithShadow(g, percentageText, (this.width - percentageWidth) / 2, textY, secondaryColor) - // Draw the next level on the far right val nextLevelText = "Lvl. $nextLevel" val nextLevelWidth = g.fontMetrics.stringWidth(nextLevelText) drawTextWithShadow(g, nextLevelText, this.width - nextLevelWidth - 5, textY, secondaryColor) } override fun getPreferredSize(): Dimension { - return Dimension(220, 16) // Force the height to 16px, width can be anything appropriate + return ViewConstants.PROGRESS_BAR_SIZE } override fun getMinimumSize(): Dimension { - return Dimension(220, 16) // Force the minimum height to 16px, width can be smaller + return ViewConstants.PROGRESS_BAR_SIZE } fun updateProgress(newProgress: Double, currentLevel: Int, nextLevel: Int, isVisible : Boolean) { @@ -64,13 +58,10 @@ class ProgressBar( repaint() } - // Helper function to draw text with a shadow effect private fun drawTextWithShadow(g: Graphics, text: String, x: Int, y: Int, textColor: Color) { - // Draw shadow (black text with -1 x and -1 y offset) g.color = Color(0, 0, 0) g.drawString(text, x + 1, y + 1) - // Draw actual text on top g.color = textColor g.drawString(text, x, y) } diff --git a/plugin-playground/src/main/kotlin/KondoKit/ScrollablePanel.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ScrollablePanel.kt similarity index 99% rename from plugin-playground/src/main/kotlin/KondoKit/ScrollablePanel.kt rename to plugin-playground/src/main/kotlin/KondoKit/ui/components/ScrollablePanel.kt index 0e772b9..319fb53 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/ScrollablePanel.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ScrollablePanel.kt @@ -1,4 +1,4 @@ -package KondoKit +package KondoKit.ui.components import KondoKit.plugin.Companion.SCROLL_BAR_COLOR import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/SearchField.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/SearchField.kt new file mode 100644 index 0000000..642b7d7 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/SearchField.kt @@ -0,0 +1,190 @@ +package KondoKit.ui.components + +import KondoKit.util.ImageCanvas +import KondoKit.ui.ViewConstants +import KondoKit.util.SpriteToBufferedImage +import KondoKit.util.setFixedSize +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.plugin.StateManager.focusedView +import plugin.api.API +import java.awt.* +import java.awt.datatransfer.DataFlavor +import java.awt.event.* +import javax.swing.* + +class SearchField( + private val parentPanel: JPanel, + private val onSearch: (String) -> Unit, + private val placeholderText: String = "Search...", + private val fieldWidth: Int = ViewConstants.SEARCH_FIELD_SIZE.width, + private val fieldHeight: Int = ViewConstants.SEARCH_FIELD_SIZE.height, + private val viewName: String? = "" +) : Canvas() { + + private var cursorVisible: Boolean = true + private var text: String = "" + + private val bufferedImageSprite = SpriteToBufferedImage.getBufferedImageFromSprite(API.GetSprite(1423)) // MAG_SPRITE + private val imageCanvas = bufferedImageSprite.let { + ImageCanvas(it).apply { + setFixedSize(ViewConstants.DIMENSION_SMALL_ICON) + size = preferredSize + fillColor = WIDGET_COLOR + } + } + + init { + val dimension = Dimension(fieldWidth, fieldHeight) + setFixedSize(dimension) + background = WIDGET_COLOR + foreground = secondaryColor + font = ViewConstants.FONT_ARIAL_PLAIN_14 + + isFocusable = true + focusTraversalKeysEnabled = false + + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) { + cursorVisible = true + repaintAsync() + } + + override fun focusLost(e: FocusEvent) { + cursorVisible = false + repaintAsync() + } + }) + + addKeyListener(object : KeyAdapter() { + override fun keyTyped(e: KeyEvent) { + // Prevent null character from being typed on Ctrl+A & Ctrl+V + if (e.isControlDown && (e.keyChar == '\u0001' || e.keyChar == '\u0016')) { + e.consume() + return + } + if (e.keyChar == '\b') { + if (text.isNotEmpty()) { + text = text.dropLast(1) + } + } else if (e.keyChar == '\n') { + // Handled in keyPressed to avoid duplicate searches + } else { + text += e.keyChar + } + repaintAsync() + } + + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + triggerSearch() + e.consume() + return + } + + if (e.isControlDown) { + when (e.keyCode) { + KeyEvent.VK_A -> { + // They probably want to clear the search box + text = "" + repaintAsync() + } + KeyEvent.VK_V -> { + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val pasteText = clipboard.getData(DataFlavor.stringFlavor) as String + text += pasteText + repaintAsync() + } catch (_: Exception) { } + } + } + } + } + }) + + addMouseListener(object : MouseAdapter() { + // Clicked the search field 'x' button + override fun mouseClicked(e: MouseEvent) { + requestFocusInWindow() + if (e.x > width - 20 && e.y < 20) { + text = "" + triggerSearch() + repaintAsync() + } + } + + override fun mousePressed(e: MouseEvent) { + requestFocusInWindow() + } + }) + + Timer(500) { _ -> + cursorVisible = !cursorVisible + // Only repaint if the view is active or if viewName is not specified + if (viewName == null || focusedView == viewName) { + repaintAsync() + } + }.start() + } + + override fun paint(g: Graphics) { + super.paint(g) + g.color = foreground + g.font = font + + val fm = g.fontMetrics + val cursorX = fm.stringWidth(text) + 30 + + // Draw magnifying glass icon + imageCanvas.let { canvas -> + val imgG = g.create(5, 5, canvas.width, canvas.height) + canvas.paint(imgG) + imgG.dispose() + } + + // Use a local copy of the text to avoid threading issues + val currentText = text + + // Draw placeholder text if field is empty, otherwise draw actual text + if (currentText.isEmpty()) { + g.color = Color.GRAY // lighter color for placeholder + g.drawString(placeholderText, 30, 20) + } else { + g.color = foreground // normal color for user entered text + g.drawString(currentText, 30, 20) + } + + if (cursorVisible && hasFocus()) { + g.color = foreground + g.drawLine(cursorX, 5, cursorX, 25) + } + + // Only draw the "x" button if there's text + if (currentText.isNotEmpty()) { + g.color = Color.RED + g.drawString("x", width - 20, 20) + } + } + + fun setText(newText: String) { + text = newText + repaintAsync() + } + + fun getText(): String = text + + private fun triggerSearch() { + val query = text.trim() + if (query.isNotEmpty()) { + text = query + repaintAsync() + onSearch(query) + } else { + onSearch(query) // Call with empty string for clearing filters + } + } + + private fun repaintAsync() { + SwingUtilities.invokeLater { repaint() } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/SettingsPanel.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/SettingsPanel.kt new file mode 100644 index 0000000..e186317 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/SettingsPanel.kt @@ -0,0 +1,488 @@ +package KondoKit.ui.components + +import KondoKit.util.Helpers +import KondoKit.util.Helpers.FieldNotifier +import KondoKit.ui.ViewConstants +import KondoKit.ui.components.UiStyler +import KondoKit.util.setFixedSize +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.secondaryColor +import plugin.Plugin +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.lang.reflect.Field +import java.util.* +import java.util.Timer +import javax.swing.* +import javax.swing.table.DefaultTableModel + +class SettingsPanel(private val plugin: Plugin) : JPanel() { + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = WIDGET_COLOR + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + } + + fun addSettingsFromPlugin() { + val fieldNotifier = FieldNotifier(plugin) + val exposedFields = plugin.javaClass.declaredFields.filter { field -> + field.annotations.any { annotation -> + annotation.annotationClass.simpleName == "Exposed" + } + } + + if (exposedFields.isNotEmpty()) { + for (field in exposedFields) { + field.isAccessible = true + + // Get the "Exposed" annotation specifically and retrieve its description, if available + val description = field.exposedDescription() + + val fieldPanel = JPanel().apply { + layout = GridBagLayout() + background = WIDGET_COLOR + foreground = secondaryColor + border = BorderFactory.createEmptyBorder(5, 0, 5, 0) + maximumSize = Dimension(Int.MAX_VALUE, 50) + } + + val gbc = GridBagConstraints().apply { + insets = Insets(0, 5, 0, 5) + } + + val label = JLabel(field.name.capitalize()).apply { + foreground = secondaryColor + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 + } + gbc.gridx = 0 + gbc.gridy = 0 + gbc.weightx = 0.0 + gbc.anchor = GridBagConstraints.WEST + fieldPanel.add(label, gbc) + + // Create appropriate input component based on field type + val inputComponent: JComponent + val isHashMapEditor: Boolean + when { + field.type == Boolean::class.javaPrimitiveType || field.type == java.lang.Boolean::class.java -> { + inputComponent = JCheckBox().apply { + isSelected = field.get(plugin) as Boolean + } + isHashMapEditor = false + } + + field.type.isEnum -> { + val enumConstants = field.type.enumConstants + inputComponent = JComboBox(enumConstants as Array>).apply { + selectedItem = field.get(plugin) + } + isHashMapEditor = false + } + + field.type == Int::class.javaPrimitiveType || field.type == Integer::class.java -> { + inputComponent = JSpinner(SpinnerNumberModel(field.get(plugin) as Int, Int.MIN_VALUE, Int.MAX_VALUE, 1)) + isHashMapEditor = false + } + + field.type == Float::class.javaPrimitiveType || + field.type == Double::class.javaPrimitiveType || + field.type == java.lang.Float::class.java || + field.type == java.lang.Double::class.java -> { + inputComponent = JSpinner(SpinnerNumberModel((field.get(plugin) as Number).toDouble(), -Double.MAX_VALUE, Double.MAX_VALUE, 0.1)) + isHashMapEditor = false + } + + // Check if the field is a HashMap + field.isHashMapType() -> { + inputComponent = createHashMapEditor(field, plugin, fieldNotifier) + isHashMapEditor = true + } + + else -> { + inputComponent = JTextField(field.get(plugin)?.toString() ?: "") + isHashMapEditor = false + } + } + + // Handle HashMap editors differently - they don't use the fieldPanel layout + if (isHashMapEditor) { + // Add the HashMap editor directly to the settings panel + add(inputComponent) + } else { + // Add mouse listener to the label only if a description is available + if (description.isNotBlank()) { + label.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + label.addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + showCustomToolTip(description, label) + } + + override fun mouseExited(e: MouseEvent) { + customToolTipWindow?.isVisible = false + } + }) + } + + gbc.gridx = 1 + gbc.gridy = 0 + gbc.weightx = 1.0 + gbc.fill = GridBagConstraints.HORIZONTAL + fieldPanel.add(inputComponent, gbc) + + // Add apply button for non-HashMap editors + val applyButton = UiStyler.button( + text = "=>", + onClick = { + try { + val newValue = when (inputComponent) { + is JCheckBox -> inputComponent.isSelected + is JComboBox<*> -> inputComponent.selectedItem + is JSpinner -> inputComponent.value + is JTextField -> Helpers.convertValue(field.type, field.genericType, inputComponent.text) + else -> throw IllegalArgumentException("Unsupported input component type") + } + fieldNotifier.setFieldValue(field, newValue) + Helpers.showToast( + this@SettingsPanel, + "${field.name} updated successfully!" + ) + } catch (e: Exception) { + Helpers.showToast( + this@SettingsPanel, + "Failed to update ${field.name}: ${e.message}", + JOptionPane.ERROR_MESSAGE + ) + } + } + ).also { + it.maximumSize = Dimension(Int.MAX_VALUE, 8) + } + + gbc.gridx = 2 + gbc.gridy = 0 + gbc.weightx = 0.0 + gbc.fill = GridBagConstraints.NONE + + fieldPanel.add(applyButton, gbc) + add(fieldPanel) + } + + // Track field changes in real-time and update UI (skip HashMap fields) + if (!field.isHashMapType()) { + var previousValue = field.get(plugin)?.toString() + val timer = Timer() + timer.schedule(object : TimerTask() { + override fun run() { + val currentValue = field.get(plugin)?.toString() + if (currentValue != previousValue) { + previousValue = currentValue + SwingUtilities.invokeLater { + // Update the inputComponent based on the new value + when (inputComponent) { + is JCheckBox -> inputComponent.isSelected = field.get(plugin) as Boolean + is JComboBox<*> -> inputComponent.selectedItem = field.get(plugin) + is JSpinner -> inputComponent.value = field.get(plugin) + is JTextField -> inputComponent.text = field.get(plugin)?.toString() ?: "" + } + } + } + } + }, 0, 1000) // Poll every 1000 milliseconds (1 second) + } + } + + if (exposedFields.isNotEmpty()) { + add(Box.createVerticalStrut(5)) + } + } + } + + private fun createHashMapEditor(field: Field, plugin: Plugin, fieldNotifier: FieldNotifier): JComponent { + // Create a panel to hold the table and buttons + val editorPanel = JPanel() + editorPanel.layout = BoxLayout(editorPanel, BoxLayout.Y_AXIS) + editorPanel.background = WIDGET_COLOR + editorPanel.border = BorderFactory.createEmptyBorder(5, 0, 5, 0) + editorPanel.maximumSize = Dimension(Int.MAX_VALUE, 250) + + val description = field.exposedDescription() + + // Create title label + val titleLabel = JLabel("${field.name} (Key-Value Pairs)").apply { + foreground = secondaryColor + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 + alignmentX = Component.LEFT_ALIGNMENT + + // Add mouse listener to the label only if a description is available + if (description.isNotBlank()) { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + showCustomToolTip(description, this@apply) + } + + override fun mouseExited(e: MouseEvent) { + SettingsPanel.customToolTipWindow?.isVisible = false + } + }) + } + } + + // Get the current HashMap value + val hashMap = field.get(plugin) as? HashMap<*, *> ?: HashMap() + + // Create a table model for the HashMap + val tableModel = object : DefaultTableModel() { + init { + val columnVector = java.util.Vector() + columnVector.add("Key") + columnVector.add("Value") + columnIdentifiers = columnVector + hashMap.forEach { (key, value) -> + val vector = java.util.Vector() + vector.add(key.toString()) + vector.add(value.toString()) + addRow(vector) + } + } + + override fun isCellEditable(row: Int, column: Int): Boolean { + return true + } + + override fun getColumnClass(columnIndex: Int): Class<*> { + return String::class.java + } + } + + // Create the table + val table = JTable(tableModel).apply { + background = WIDGET_COLOR + foreground = secondaryColor + gridColor = primaryColor + tableHeader.background = WIDGET_COLOR + tableHeader.foreground = secondaryColor + preferredScrollableViewportSize = Dimension(Int.MAX_VALUE, 150) + setFillsViewportHeight(true) + } + + // Create a scroll pane for the table with fixed size + val scrollPane = JScrollPane(table).apply { + setFixedSize(Dimension(Int.MAX_VALUE, 150)) + background = WIDGET_COLOR + verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_NEVER + alignmentX = Component.LEFT_ALIGNMENT + } + + // Create buttons panel + val buttonsPanel = JPanel(FlowLayout(FlowLayout.LEFT, 5, 5)).apply { + background = WIDGET_COLOR + alignmentX = Component.LEFT_ALIGNMENT + } + + // Add button + val addButton = UiStyler.button( + text = "+", + onClick = { + val vector = java.util.Vector() + vector.add("") + vector.add("") + tableModel.addRow(vector) + // Select the new row for editing + val newRow = tableModel.rowCount - 1 + table.setRowSelectionInterval(newRow, newRow) + table.editCellAt(newRow, 0) + table.editorComponent?.requestFocus() + } + ).apply { + setFixedSize(40, 30) + } + + // Remove button + val removeButton = UiStyler.button( + text = "-", + onClick = { + val selectedRow = table.selectedRow + if (selectedRow >= 0) { + tableModel.removeRow(selectedRow) + } else { + Helpers.showToast( + this@SettingsPanel, + "Please select a row to remove", + JOptionPane.WARNING_MESSAGE + ) + } + } + ).apply { + setFixedSize(40, 30) + } + + // Apply button + val applyButton = UiStyler.button( + text = "Apply Changes", + onClick = { + try { + // Commit any active cell editing before reading values + if (table.isEditing) { + table.cellEditor.stopCellEditing() + } + + // Get the current HashMap from the field and modify it in place + val currentHashMap = field.get(plugin) as? HashMap ?: HashMap() + + // Clear the current HashMap + currentHashMap.clear() + + // Add the new entries from the table to the existing HashMap + for (i in 0 until tableModel.rowCount) { + val key = tableModel.getValueAt(i, 0).toString() + val value = tableModel.getValueAt(i, 1).toString() + + if (key.isBlank()) { + continue + } + + if (value.isBlank()) { + Helpers.showToast( + this@SettingsPanel, + "Skipping entry with empty value for key '$key'", + JOptionPane.WARNING_MESSAGE + ) + continue + } + + val convertedValue = try { + val fieldTypeName = field.genericType.toString() + when { + fieldTypeName.contains("java.util.HashMap") || + fieldTypeName.contains("HashMap") || + fieldTypeName.contains("HashMap") -> { + try { + value.toInt() + } catch (numberFormat: NumberFormatException) { + Helpers.showToast( + this@SettingsPanel, + "Invalid number format for key '$key': '$value'. Using 0 as default.", + JOptionPane.WARNING_MESSAGE + ) + 0 + } + } + fieldTypeName.contains("java.lang.Integer") || fieldTypeName.contains("int") -> value.toInt() + fieldTypeName.contains("java.lang.Double") || fieldTypeName.contains("double") -> value.toDouble() + fieldTypeName.contains("java.lang.Float") || fieldTypeName.contains("float") -> value.toFloat() + fieldTypeName.contains("java.lang.Boolean") || fieldTypeName.contains("boolean") -> value.toBoolean() + else -> value + } + } catch (e: Exception) { + value + } + + currentHashMap[key] = convertedValue + } + + fieldNotifier.setFieldValue(field, currentHashMap) + Helpers.showToast( + this@SettingsPanel, + "${field.name} updated successfully!" + ) + } catch (e: Exception) { + Helpers.showToast( + this@SettingsPanel, + "Failed to update ${field.name}: ${e.message}", + JOptionPane.ERROR_MESSAGE + ) + } + } + ) + + buttonsPanel.add(addButton) + buttonsPanel.add(removeButton) + buttonsPanel.add(Box.createHorizontalStrut(20)) + buttonsPanel.add(applyButton) + + // Add components to the editor panel in vertical stack + editorPanel.add(titleLabel) + editorPanel.add(Box.createVerticalStrut(5)) + editorPanel.add(scrollPane) + editorPanel.add(Box.createVerticalStrut(5)) + editorPanel.add(buttonsPanel) + + return editorPanel + } + + private fun Field.exposedDescription(): String { + val exposedAnnotation = annotations.firstOrNull { + it.annotationClass.simpleName == "Exposed" + } ?: return "" + + return try { + val descriptionMethod = exposedAnnotation.annotationClass.java.getMethod("description") + descriptionMethod.invoke(exposedAnnotation) as? String ?: "" + } catch (_: Exception) { + "" + } + } + + private fun Field.isHashMapType(): Boolean { + return type == HashMap::class.java || type.simpleName == "HashMap" + } + + companion object { + var customToolTipWindow: JWindow? = null + + private var customToolTipLabel: JLabel? = null + + fun showCustomToolTip(text: String, component: JComponent) { + val tooltipFont = ViewConstants.FONT_RUNESCAPE_SMALL_PLAIN_16 + val maxWidth = 150 + + val bgColor = Helpers.colorToHex(KondoKit.plugin.Companion.TOOLTIP_BACKGROUND) + val textColor = Helpers.colorToHex(KondoKit.plugin.Companion.secondaryColor) + // Constrain width via HTML so Swing's renderer wraps the text naturally. + val safeText = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + val html = """ + + + $safeText + + + """.trimIndent() + + val (window, label) = if (customToolTipWindow == null || customToolTipLabel == null) { + val lbl = JLabel().apply { + border = BorderFactory.createLineBorder(Color.BLACK) + isOpaque = true + background = KondoKit.plugin.Companion.TOOLTIP_BACKGROUND + foreground = Color.WHITE + font = tooltipFont + horizontalAlignment = SwingConstants.LEFT + verticalAlignment = SwingConstants.TOP + } + val win = JWindow().apply { contentPane.add(lbl) } + customToolTipWindow = win + customToolTipLabel = lbl + win to lbl + } else { + customToolTipWindow!! to customToolTipLabel!! + } + + label.text = html + label.font = tooltipFont + label.preferredSize = null // let HTML + pack decide size from width constraint + window.pack() + + // Position the tooltip near the component + val locationOnScreen = component.locationOnScreen + customToolTipWindow!!.setLocation(locationOnScreen.x, locationOnScreen.y + 15) + customToolTipWindow!!.isVisible = true + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/ToggleSwitch.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ToggleSwitch.kt new file mode 100644 index 0000000..e5c090b --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ToggleSwitch.kt @@ -0,0 +1,128 @@ +package KondoKit.ui.components + +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.PROGRESS_BAR_FILL +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.util.setFixedSize +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.JPanel + +class ToggleSwitch : JPanel() { + private var activated = false + private var switchColor = primaryColor + private var buttonColor = secondaryColor + private var borderColor = WIDGET_COLOR + private var activeSwitch = PROGRESS_BAR_FILL + private var puffer: BufferedImage? = null + private var g: Graphics2D? = null + + var onToggleListener: ((Boolean) -> Unit)? = null + + init { + isVisible = true + addMouseListener(object : MouseAdapter() { + override fun mouseReleased(arg0: MouseEvent) { + activated = !activated + onToggleListener?.invoke(activated) + repaint() + } + }) + cursor = Cursor(Cursor.HAND_CURSOR) + setFixedSize(ViewConstants.TOGGLE_SWITCH_SIZE) + isOpaque = false + } + + override fun paint(gr: Graphics) { + if (g == null || puffer?.width != width || puffer?.height != height) { + puffer = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + g = puffer?.createGraphics() + val rh = RenderingHints( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON + ) + g?.setRenderingHints(rh) + } + + g?.color = Color(0, 0, 0, 0) + g?.fillRect(0, 0, width, height) + + val trackHeight = height - 2 + val trackWidth = width - 2 + val trackArc = trackHeight + + g?.color = if (activated) activeSwitch else switchColor + g?.fillRoundRect(1, 1, trackWidth, trackHeight, trackArc, trackArc) + g?.color = borderColor + g?.drawRoundRect(1, 1, trackWidth, trackHeight, trackArc, trackArc) + + val thumbDiameter = trackHeight - 4 + val thumbX = if (activated) { + width - thumbDiameter - 3 // Right side when activated + } else { + 3 // Left side when not activated + } + val thumbY = (height - thumbDiameter) / 2 + + g?.color = buttonColor + g?.fillOval(thumbX, thumbY, thumbDiameter, thumbDiameter) + g?.color = borderColor + g?.drawOval(thumbX, thumbY, thumbDiameter, thumbDiameter) + + gr.drawImage(puffer, 0, 0, null) + } + + fun isActivated(): Boolean { + return activated + } + + fun setActivated(activated: Boolean) { + this.activated = activated + repaint() + } + + fun getSwitchColor(): Color { + return switchColor + } + + /** + * Unactivated Background Color of switch + */ + fun setSwitchColor(switchColor: Color) { + this.switchColor = switchColor + } + + fun getButtonColor(): Color { + return buttonColor + } + + /** + * Switch-Button color + */ + fun setButtonColor(buttonColor: Color) { + this.buttonColor = buttonColor + } + + fun getBorderColor(): Color { + return borderColor + } + + /** + * Border-color of whole switch and switch-button + */ + fun setBorderColor(borderColor: Color) { + this.borderColor = borderColor + } + + fun getActiveSwitch(): Color { + return activeSwitch + } + + fun setActiveSwitch(activeSwitch: Color) { + this.activeSwitch = activeSwitch + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/UiStyler.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/UiStyler.kt new file mode 100644 index 0000000..d20f068 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/UiStyler.kt @@ -0,0 +1,74 @@ +package KondoKit.ui.components + +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.POPUP_BACKGROUND +import KondoKit.plugin.Companion.POPUP_FOREGROUND +import KondoKit.plugin.Companion.TITLE_BAR_COLOR +import KondoKit.plugin.Companion.secondaryColor +import java.awt.Color +import java.awt.Font +import java.awt.Insets +import javax.swing.AbstractButton +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JMenuItem + +/** + * Centralises common Swing styling so buttons and menu items share a + * consistent look and the setup code remains in one place. + */ +object UiStyler { + + data class ButtonDefaults( + val background: Color = TITLE_BAR_COLOR, + val foreground: Color = secondaryColor, + val font: Font = ViewConstants.FONT_RUNESCAPE_SMALL_14, + val margin: Insets? = null + ) + + data class MenuItemDefaults( + val background: Color = POPUP_BACKGROUND, + val foreground: Color = POPUP_FOREGROUND, + val font: Font = ViewConstants.FONT_RUNESCAPE_SMALL_16 + ) + + fun T.applyDefaults( + defaults: ButtonDefaults = ButtonDefaults(), + onClick: (() -> Unit)? = null + ): T { + background = defaults.background + foreground = defaults.foreground + font = defaults.font + defaults.margin?.let { margin = it } + onClick?.let { addActionListener { _ -> it() } } + return this + } + + fun button( + text: String? = null, + icon: Icon? = null, + defaults: ButtonDefaults = ButtonDefaults(), + onClick: (() -> Unit)? = null + ): JButton { + return JButton().apply { + text?.let { this.text = it } + icon?.let { this.icon = it } + isOpaque = false + applyDefaults(defaults, onClick) + } + } + + fun menuItem( + text: String, + defaults: MenuItemDefaults = MenuItemDefaults(), + onClick: (() -> Unit)? = null + ): JMenuItem { + return JMenuItem(text).apply { + background = defaults.background + foreground = defaults.foreground + font = defaults.font + onClick?.let { addActionListener { _ -> it() } } + } + } + +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/ViewHeader.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ViewHeader.kt new file mode 100644 index 0000000..2045073 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/ViewHeader.kt @@ -0,0 +1,33 @@ +package KondoKit.ui.components + +import KondoKit.ui.ViewConstants +import KondoKit.util.setFixedSize +import KondoKit.plugin.Companion.TITLE_BAR_COLOR +import KondoKit.plugin.Companion.secondaryColor +import java.awt.* +import javax.swing.* + +class ViewHeader( + title: String, + private val headerHeight: Int = 40 +) : JPanel() { + + private val titleLabel = JLabel(title) + + init { + background = TITLE_BAR_COLOR + setFixedSize(Int.MAX_VALUE, headerHeight) + border = BorderFactory.createEmptyBorder(5, 10, 5, 10) + layout = BorderLayout() + + titleLabel.foreground = secondaryColor + titleLabel.font = ViewConstants.FONT_RUNESCAPE_SMALL_PLAIN_16 + titleLabel.horizontalAlignment = SwingConstants.CENTER + + add(titleLabel, BorderLayout.CENTER) + } + + fun setTitle(title: String) { + titleLabel.text = title + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/WidgetPanel.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/WidgetPanel.kt new file mode 100644 index 0000000..443e933 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/WidgetPanel.kt @@ -0,0 +1,43 @@ +package KondoKit.ui.components + +import KondoKit.ui.ViewConstants +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.util.setFixedSize +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.BorderFactory +import javax.swing.JPanel + +class WidgetPanel( + private val widgetWidth: Int = ViewConstants.DEFAULT_WIDGET_SIZE.width, + private val widgetHeight: Int = ViewConstants.DEFAULT_WIDGET_SIZE.height, + private val addDefaultPadding: Boolean = true, + private val paddingTop: Int? = null, + private val paddingLeft: Int? = null, + private val paddingBottom: Int? = null, + private val paddingRight: Int? = null +) : JPanel() { + + init { + layout = BorderLayout( + if (addDefaultPadding) 5 else 0, + if (addDefaultPadding) 5 else 0 + ) + background = WIDGET_COLOR + + val size = Dimension(widgetWidth, widgetHeight) + setFixedSize(size) + + val top = paddingTop ?: if (addDefaultPadding) 5 else 0 + val left = paddingLeft ?: if (addDefaultPadding) 5 else 0 + val bottom = paddingBottom ?: if (addDefaultPadding) 5 else 0 + val right = paddingRight ?: if (addDefaultPadding) 5 else 0 + + border = BorderFactory.createEmptyBorder(top, left, bottom, right) + } + + fun setFixedSize(width: Int, height: Int) { + val size = Dimension(width, height) + this@WidgetPanel.setFixedSize(size) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/ui/components/XPWidget.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/components/XPWidget.kt new file mode 100644 index 0000000..d8f67e0 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/components/XPWidget.kt @@ -0,0 +1,20 @@ +package KondoKit.ui.components + +import java.awt.Container +import javax.swing.JLabel + +/** + * Shared widget state used by XP-related features so the structure isn't duplicated. + */ +data class XPWidget( + val container: Container, + val skillId: Int, + val xpGainedLabel: JLabel, + val xpLeftLabel: JLabel, + val xpPerHourLabel: JLabel, + val actionsRemainingLabel: JLabel, + val progressBar: ProgressBar, + var totalXpGained: Int = 0, + var startTime: Long = System.currentTimeMillis(), + var previousXp: Int = 0 +) diff --git a/plugin-playground/src/main/kotlin/KondoKit/Themes.kt b/plugin-playground/src/main/kotlin/KondoKit/ui/theme/Themes.kt similarity index 99% rename from plugin-playground/src/main/kotlin/KondoKit/Themes.kt rename to plugin-playground/src/main/kotlin/KondoKit/ui/theme/Themes.kt index 1b7988d..5937248 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/Themes.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/ui/theme/Themes.kt @@ -1,4 +1,4 @@ -package KondoKit +package KondoKit.ui.theme import java.awt.Color @@ -75,7 +75,6 @@ object Themes { } } - data class Theme( val widgetColor: Color, val titleBarColor: Color, diff --git a/plugin-playground/src/main/kotlin/KondoKit/AltCanvas.kt b/plugin-playground/src/main/kotlin/KondoKit/util/AltCanvas.kt similarity index 99% rename from plugin-playground/src/main/kotlin/KondoKit/AltCanvas.kt rename to plugin-playground/src/main/kotlin/KondoKit/util/AltCanvas.kt index af5c01d..62607fd 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/AltCanvas.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/util/AltCanvas.kt @@ -1,4 +1,4 @@ -package KondoKit +package KondoKit.util import KondoKit.plugin.Companion.FIXED_HEIGHT import KondoKit.plugin.Companion.FIXED_WIDTH @@ -163,4 +163,4 @@ class AltCanvas : Canvas() { dispose() } } -} \ No newline at end of file +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/ComponentExtensions.kt b/plugin-playground/src/main/kotlin/KondoKit/util/ComponentExtensions.kt new file mode 100644 index 0000000..721ab53 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/ComponentExtensions.kt @@ -0,0 +1,39 @@ +package KondoKit.util + +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPopupMenu +import KondoKit.util.Helpers + +fun T.setFixedSize(width: Int, height: Int): T = + setFixedSize(Dimension(width, height)) + +fun T.setFixedSize(size: Dimension): T { + preferredSize = size + minimumSize = size + maximumSize = size + return this +} + +fun Component.attachPopupMenu(popupMenu: JPopupMenu, includeChildren: Boolean = false) { + val listener = object : MouseAdapter() { + private fun maybeShow(e: MouseEvent) { + if (e.isPopupTrigger) { + popupMenu.show(e.component, e.x, e.y) + } + } + + override fun mousePressed(e: MouseEvent) = maybeShow(e) + + override fun mouseReleased(e: MouseEvent) = maybeShow(e) + } + + addMouseListener(listener) + + if (includeChildren && this is Container) { + Helpers.addMouseListenerToAll(this, listener) + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/FileUtils.kt b/plugin-playground/src/main/kotlin/KondoKit/util/FileUtils.kt new file mode 100644 index 0000000..587173c --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/FileUtils.kt @@ -0,0 +1,16 @@ +package KondoKit.util + +import java.io.File + +object FileUtils { + fun deleteRecursively(file: File): Boolean { + if (file.isDirectory) { + file.listFiles()?.forEach { child -> + if (!deleteRecursively(child)) { + return false + } + } + } + return file.delete() + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/GEPriceService.kt b/plugin-playground/src/main/kotlin/KondoKit/util/GEPriceService.kt new file mode 100644 index 0000000..892e77c --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/GEPriceService.kt @@ -0,0 +1,50 @@ +package KondoKit.util + +/** + * Shared loader for GE price JSON from remote or bundled sources. + */ +object GEPriceService { + private const val REMOTE_GE_URL = "https://cdn.2009scape.org/gedata/latest.json" + + private data class RemoteGEItem( + val item_id: String?, + val value: String? + ) + + private data class LocalGEItem( + val id: String?, + val grand_exchange_price: String? + ) + + private fun mapToPriceMap( + items: Array, + idSelector: (T) -> String?, + priceSelector: (T) -> String? + ): Map { + return items.mapNotNull { item -> + val id = idSelector(item) + val price = priceSelector(item) + if (id != null && price != null) id to price else null + }.toMap() + } + + fun loadRemotePrices(): Map { + return try { + val payload = HttpFetcher.fetchString(REMOTE_GE_URL) + val items = JsonParser.fromJson>(payload) + mapToPriceMap(items, { it.item_id }, { it.value }) + } catch (e: Exception) { + emptyMap() + } + } + + fun loadLocalPrices(jsonProvider: () -> String?): Map { + return try { + val json = jsonProvider() ?: return emptyMap() + val items = JsonParser.fromJson>(json) + mapToPriceMap(items, { it.id }, { it.grand_exchange_price }) + } catch (e: Exception) { + emptyMap() + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/Helpers.kt b/plugin-playground/src/main/kotlin/KondoKit/util/Helpers.kt similarity index 84% rename from plugin-playground/src/main/kotlin/KondoKit/Helpers.kt rename to plugin-playground/src/main/kotlin/KondoKit/util/Helpers.kt index 4f6e3c4..bbe1fd9 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/Helpers.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/util/Helpers.kt @@ -1,8 +1,12 @@ -package KondoKit +package KondoKit.util +import KondoKit.plugin import rt4.GameShell import java.awt.* +import java.awt.image.BufferedImage import java.awt.event.MouseListener +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets import java.lang.reflect.Field import java.lang.reflect.ParameterizedType import java.lang.reflect.Type @@ -11,6 +15,44 @@ import java.util.Timer import javax.swing.* object Helpers { + fun openResource(path: String) = plugin::class.java.getResourceAsStream(path) + + // Read a bundled resource as text using the given charset (UTF-8 by default) + fun readResourceText(path: String, charset: Charset = StandardCharsets.UTF_8): String? { + val stream = openResource(path) ?: return null + stream.use { s -> + return s.reader(charset).use { it.readText() } + } + } + + data class ImageCanvasComponents(val canvas: ImageCanvas, val container: JPanel) + + fun createImageCanvasComponents( + bufferedImage: BufferedImage, + background: Color = plugin.WIDGET_COLOR, + size: Dimension = Dimension(bufferedImage.width, bufferedImage.height), + borderInsets: Insets = Insets(-2, 0, 0, 2), + componentPosition: String = BorderLayout.NORTH + ): ImageCanvasComponents { + val imageCanvas = ImageCanvas(bufferedImage).apply { + setFixedSize(size) + this.size = size + this.background = background + } + + val container = JPanel(BorderLayout()).apply { + this.background = background + add(imageCanvas, componentPosition) + border = BorderFactory.createEmptyBorder( + borderInsets.top, + borderInsets.left, + borderInsets.bottom, + borderInsets.right + ) + } + + return ImageCanvasComponents(imageCanvas, container) + } fun convertValue(type: Class<*>, genericType: Type?, value: String): Any { return when { @@ -233,4 +275,4 @@ object Helpers { else -> Color(128, 128, 128) // Default grey for unhandled skill IDs } } -} \ No newline at end of file +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/HttpFetcher.kt b/plugin-playground/src/main/kotlin/KondoKit/util/HttpFetcher.kt new file mode 100644 index 0000000..8f0ff40 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/HttpFetcher.kt @@ -0,0 +1,57 @@ +package KondoKit.util + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +object HttpFetcher { + const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + + private val defaultHeaders = mapOf( + "User-Agent" to DEFAULT_USER_AGENT, + "Accept" to "*/*" + ) + + fun openGetConnection( + url: String, + headers: Map = emptyMap(), + connectTimeoutMillis: Int? = null, + readTimeoutMillis: Int? = null + ): HttpURLConnection { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + (defaultHeaders + headers).forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + connectTimeoutMillis?.let { connection.connectTimeout = it } + readTimeoutMillis?.let { connection.readTimeout = it } + return connection + } + + fun fetchString( + url: String, + headers: Map = emptyMap(), + connectTimeoutMillis: Int? = null, + readTimeoutMillis: Int? = null + ): String { + val connection = openGetConnection(url, headers, connectTimeoutMillis, readTimeoutMillis) + val responseCode = connection.responseCode + val responseStream = if (responseCode in 200..299) { + connection.inputStream + } else { + connection.errorStream + } ?: throw IOException("No response stream available for $url (HTTP $responseCode)") + + val payload = responseStream.bufferedReader().use { it.readText() } + if (responseCode !in 200..299) { + throw HttpStatusException(url, responseCode, payload) + } + return payload + } +} + +class HttpStatusException( + val url: String, + val statusCode: Int, + val body: String +) : IOException("Request to $url failed with HTTP $statusCode") diff --git a/plugin-playground/src/main/kotlin/KondoKit/ImageCanvas.kt b/plugin-playground/src/main/kotlin/KondoKit/util/ImageCanvas.kt similarity index 97% rename from plugin-playground/src/main/kotlin/KondoKit/ImageCanvas.kt rename to plugin-playground/src/main/kotlin/KondoKit/util/ImageCanvas.kt index 3a9a9f3..5ff600f 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/ImageCanvas.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/util/ImageCanvas.kt @@ -1,4 +1,4 @@ -package KondoKit +package KondoKit.util import KondoKit.plugin.Companion.WIDGET_COLOR import java.awt.Canvas @@ -35,4 +35,4 @@ class ImageCanvas(private val image: BufferedImage) : Canvas() { override fun getPreferredSize(): Dimension { return Dimension(image.width, image.height) } -} \ No newline at end of file +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/JsonParser.kt b/plugin-playground/src/main/kotlin/KondoKit/util/JsonParser.kt new file mode 100644 index 0000000..5829df9 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/JsonParser.kt @@ -0,0 +1,17 @@ +package KondoKit.util + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +object JsonParser { + val gson: Gson = Gson() + + inline fun fromJson(json: String): T { + val type = object : TypeToken() {}.type + return gson.fromJson(json, type) + } + + fun fromJson(json: String, classOfT: Class): T = gson.fromJson(json, classOfT) + + fun toJson(src: Any): String = gson.toJson(src) +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/SpriteToBufferedImage.kt b/plugin-playground/src/main/kotlin/KondoKit/util/SpriteToBufferedImage.kt similarity index 98% rename from plugin-playground/src/main/kotlin/KondoKit/SpriteToBufferedImage.kt rename to plugin-playground/src/main/kotlin/KondoKit/util/SpriteToBufferedImage.kt index 0258838..3383231 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/SpriteToBufferedImage.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/util/SpriteToBufferedImage.kt @@ -1,4 +1,4 @@ -package KondoKit +package KondoKit.util import rt4.GlIndexedSprite import rt4.GlSprite @@ -7,7 +7,6 @@ import rt4.SoftwareSprite import java.awt.Color import java.awt.image.BufferedImage -// Define interfaces for common sprite types interface BaseSprite { val width: Int val height: Int @@ -22,7 +21,6 @@ interface NonIndexedSprite : BaseSprite { val pixels: IntArray? } -// Adapter functions for existing sprite types fun adaptSoftwareSprite(sprite: SoftwareSprite): NonIndexedSprite { return object : NonIndexedSprite { override val width: Int = sprite.width @@ -196,4 +194,4 @@ object SpriteToBufferedImage { else -> BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) // Default empty image for unsupported types } } -} \ No newline at end of file +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/util/XPTable.kt b/plugin-playground/src/main/kotlin/KondoKit/util/XPTable.kt new file mode 100644 index 0000000..2869985 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/util/XPTable.kt @@ -0,0 +1,28 @@ +package KondoKit.util + +object XPTable { + private val xpForLevels: IntArray = IntArray(100) + + init { + var points = 0.0 + for (level in 1..99) { + points += Math.floor(level + 300.0 * Math.pow(2.0, level / 7.0)) + xpForLevels[level] = Math.floor(points / 4).toInt() + } + } + + fun getLevelForXp(xp: Int): Pair { + for (i in 1..99) { + if (xp < xpForLevels[i]) { + return Pair(i - 1, xp - xpForLevels[i - 1]) + } + } + return Pair(99, 0) + } + + fun getXpRequiredForLevel(level: Int): Int { + if (level <= 0) return 0 + if (level >= 99) return xpForLevels[99] + return xpForLevels[level] + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/views/HiscoresView.kt b/plugin-playground/src/main/kotlin/KondoKit/views/HiscoresView.kt new file mode 100644 index 0000000..2fa70ea --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/views/HiscoresView.kt @@ -0,0 +1,383 @@ +package KondoKit.views + +import KondoKit.util.Helpers.getSpriteId +import KondoKit.util.Helpers.showToast +import KondoKit.util.ImageCanvas +import KondoKit.ui.BaseView +import KondoKit.ui.ViewConstants +import KondoKit.ui.View +import KondoKit.util.SpriteToBufferedImage.getBufferedImageFromSprite +import KondoKit.ui.components.LabelComponent +import KondoKit.ui.components.WidgetPanel +import KondoKit.ui.components.SearchField +import KondoKit.views.ViewLayoutHelpers.createSearchFieldSection +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR +import KondoKit.plugin.Companion.POPUP_FOREGROUND +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.TOOLTIP_BACKGROUND +import KondoKit.util.JsonParser +import plugin.api.API +import rt4.Sprites +import java.awt.* +import KondoKit.util.HttpFetcher +import KondoKit.util.HttpStatusException +import java.net.SocketTimeoutException +import javax.swing.* +import javax.swing.border.MatteBorder +import kotlin.math.floor + +object HiscoresView : View { + + const val VIEW_NAME = "HISCORE_SEARCH_VIEW" + var hiScoreView: JPanel? = null + var customSearchField: SearchField? = null + override val name: String = VIEW_NAME + override val iconSpriteId: Int = ViewConstants.MAG_SPRITE + + override val panel: JPanel + get() = hiScoreView ?: JPanel() + + override fun createView() { + createHiscoreSearchView() + } + + override fun registerFunctions() { + } + + fun createHiscoreSearchView() { + val hiscorePanel = BaseView(VIEW_NAME, addDefaultSpacing = false).apply { + background = VIEW_BACKGROUND_COLOR + setViewSize(ViewConstants.DEFAULT_PANEL_SIZE.height) + } + + val searchSection = createSearchFieldSection( + parent = hiscorePanel, + placeholderText = "Search player...", + viewName = VIEW_NAME + ) { username -> + searchPlayerForHiscores(username, hiscorePanel) + } + + customSearchField = searchSection.searchField + + hiscorePanel.add(Box.createVerticalStrut(5)) + hiscorePanel.add(searchSection.wrapper) + hiscorePanel.add(Box.createVerticalStrut(5)) + + val playerNamePanel = WidgetPanel( + widgetWidth = ViewConstants.FILTER_PANEL_SIZE.width, + widgetHeight = ViewConstants.FILTER_PANEL_SIZE.height, + addDefaultPadding = false + ).apply { + layout = GridBagLayout() + background = TOOLTIP_BACKGROUND.darker() + name = "playerNameLabel" + } + + hiscorePanel.add(playerNamePanel) + hiscorePanel.add(Box.createVerticalStrut(5)) + + val skillsPanel = WidgetPanel( + widgetWidth = ViewConstants.SKILLS_PANEL_SIZE.width, + widgetHeight = ViewConstants.SKILLS_PANEL_SIZE.height, + addDefaultPadding = false + ).apply { + layout = FlowLayout(FlowLayout.CENTER, 0, 0) + background = VIEW_BACKGROUND_COLOR + } + + for (i in ViewConstants.SKILL_DISPLAY_ORDER) { + val skillPanel = WidgetPanel( + widgetWidth = ViewConstants.SKILL_PANEL_SIZE.width, + widgetHeight = ViewConstants.SKILL_PANEL_SIZE.height, + addDefaultPadding = false + ).apply { + layout = BorderLayout() + background = WIDGET_COLOR + border = MatteBorder(5, 0, 0, 0, WIDGET_COLOR) + } + + val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(getSpriteId(i))) + + val imageCanvas = bufferedImageSprite.let { + ImageCanvas(it).apply { + preferredSize = ViewConstants.SKILL_SPRITE_SIZE + size = ViewConstants.SKILL_SPRITE_SIZE + fillColor = WIDGET_COLOR + } + } + + val numberLabel = JLabel(" ", JLabel.RIGHT).apply { + name = "skillLabel_$i" + foreground = POPUP_FOREGROUND + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 + preferredSize = ViewConstants.NUMBER_LABEL_SIZE + minimumSize = ViewConstants.NUMBER_LABEL_SIZE + } + + val imageContainer = JPanel(FlowLayout(FlowLayout.CENTER, 5, 0)).apply { + background = WIDGET_COLOR + add(imageCanvas) + add(numberLabel) + } + + skillPanel.add(imageContainer, BorderLayout.CENTER) + skillsPanel.add(skillPanel) + } + + hiscorePanel.add(skillsPanel) + + val totalCombatPanel = WidgetPanel( + widgetWidth = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.width, + widgetHeight = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.height, + addDefaultPadding = false + ).apply { + layout = FlowLayout(FlowLayout.CENTER, 0, 0) + background = WIDGET_COLOR + } + + val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(ViewConstants.LVL_BAR_SPRITE)) + + val totalLevelIcon = ImageCanvas(bufferedImageSprite).apply { + fillColor = WIDGET_COLOR + preferredSize = ViewConstants.DIMENSION_LARGE_ICON + size = ViewConstants.DIMENSION_LARGE_ICON + } + + val totalLevelLabel = JLabel(" ").apply { + name = "totalLevelLabel" + foreground = POPUP_FOREGROUND + font = ViewConstants.FONT_ARIAL_BOLD_12 + horizontalAlignment = JLabel.LEFT + iconTextGap = 10 + } + + val totalLevelPanel = WidgetPanel( + widgetWidth = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.width / 2, + widgetHeight = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.height, + addDefaultPadding = false, + paddingLeft = 45 + ).apply { + layout = FlowLayout(FlowLayout.LEFT, 5, 5) + background = WIDGET_COLOR + add(totalLevelIcon) + add(totalLevelLabel) + } + + val bufferedImageSprite2 = getBufferedImageFromSprite(API.GetSprite(ViewConstants.COMBAT_LVL_SPRITE)) + + val combatLevelIcon = ImageCanvas(bufferedImageSprite2).apply { + fillColor = WIDGET_COLOR + preferredSize = ViewConstants.DIMENSION_LARGE_ICON + size = ViewConstants.DIMENSION_LARGE_ICON + } + + val combatLevelLabel = JLabel("").apply { + name = "combatLevelLabel" + foreground = POPUP_FOREGROUND + font = ViewConstants.FONT_ARIAL_BOLD_12 + horizontalAlignment = JLabel.LEFT + iconTextGap = 10 + } + + val combatLevelPanel = WidgetPanel( + widgetWidth = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.width / 2, + widgetHeight = ViewConstants.TOTAL_COMBAT_PANEL_SIZE.height, + addDefaultPadding = false + ).apply { + layout = FlowLayout(FlowLayout.LEFT, 5, 6) + background = WIDGET_COLOR + add(combatLevelIcon) + add(combatLevelLabel) + } + + totalCombatPanel.add(totalLevelPanel) + totalCombatPanel.add(combatLevelPanel) + hiscorePanel.add(totalCombatPanel) + hiscorePanel.add(Box.createVerticalStrut(10)) + + hiScoreView = hiscorePanel + } + + data class HiscoresResponse( + val info: PlayerInfo, + val skills: List + ) + + data class PlayerInfo( + val exp_multiplier: String, + val iron_mode: String + ) + + data class Skill( + val id: String, + val static: String + ) + + + fun searchPlayerForHiscores(username: String, hiscoresPanel: JPanel) { + + if(username.isEmpty() || username.length < 3) { + return + } + + val cleanUsername = username.replace(" ", "_") + val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${cleanUsername.toLowerCase()}" + + customSearchField?.setText(username) + updateHiscoresViewStandalone(hiscoresPanel, null, "Searching...") + + Thread { + try { + val response = HttpFetcher.fetchString( + url = apiUrl, + connectTimeoutMillis = 5000, + readTimeoutMillis = 5000 + ) + + SwingUtilities.invokeLater { + updatePlayerDataStandalone(response, username, hiscoresPanel) + } + } catch (e: SocketTimeoutException) { + SwingUtilities.invokeLater { + showToast(hiscoresPanel, "Request timed out", JOptionPane.ERROR_MESSAGE) + } + } catch (e: HttpStatusException) { + SwingUtilities.invokeLater { + showToast(hiscoresPanel, "Player not found!", JOptionPane.ERROR_MESSAGE) + } + } catch (e: Exception) { + SwingUtilities.invokeLater { + showToast(hiscoresPanel, "Error fetching data!", JOptionPane.ERROR_MESSAGE) + } + } + }.start() + } + + private fun updatePlayerDataStandalone(jsonResponse: String, username: String, hiscoresPanel: JPanel) { + val hiscoresResponse = JsonParser.fromJson(jsonResponse) + updateHiscoresViewStandalone(hiscoresPanel, hiscoresResponse, username) + } + + private fun updateHiscoresViewStandalone(hiscoresPanel: JPanel, data: HiscoresResponse?, username: String) { + val playerNameLabel = findComponentByNameStandalone(hiscoresPanel, "playerNameLabel") as? JPanel + playerNameLabel?.removeAll() + var nameLabel = LabelComponent().apply { + updateHtmlText(username, secondaryColor, "", primaryColor) + font = ViewConstants.FONT_ARIAL_BOLD_12 + foreground = POPUP_FOREGROUND + border = BorderFactory.createEmptyBorder(0, 6, 0, 0) + horizontalAlignment = JLabel.CENTER + } + playerNameLabel?.add(nameLabel) + playerNameLabel?.revalidate() + playerNameLabel?.repaint() + + if (data == null) return + + playerNameLabel?.removeAll() + + val ironMode = data.info.iron_mode + + if (ironMode != "0") { + val ironmanBufferedImage = + getBufferedImageFromSprite(Sprites.nameIcons[ViewConstants.IRONMAN_SPRITE + ironMode.toInt() - 1]) + val imageCanvas = ironmanBufferedImage.let { + ImageCanvas(it).apply { + preferredSize = ViewConstants.IMAGE_CANVAS_SIZE + size = ViewConstants.IMAGE_CANVAS_SIZE + } + } + + playerNameLabel?.add(imageCanvas) + } + + val exp_multiplier = data.info.exp_multiplier + nameLabel = LabelComponent().apply { + updateHtmlText(username, secondaryColor, " (${exp_multiplier}x)", primaryColor) + font = ViewConstants.FONT_ARIAL_BOLD_12 + foreground = POPUP_FOREGROUND + border = BorderFactory.createEmptyBorder(0, 6, 0, 0) + horizontalAlignment = JLabel.CENTER + } + + + playerNameLabel?.add(nameLabel) + + playerNameLabel?.revalidate() + playerNameLabel?.repaint() + + data.skills.forEachIndexed { index, skill -> + val labelName = "skillLabel_$index" + val numberLabel = findComponentByNameStandalone(hiscoresPanel, labelName) as? JLabel + numberLabel?.text = skill.static.toInt().toString() + } + + updateTotalAndCombatLevelStandalone(data.skills, hiscoresPanel, true) + + hiscoresPanel.revalidate() + hiscoresPanel.repaint() + } + + private fun updateTotalAndCombatLevelStandalone( + skills: List, + hiscoresPanel: JPanel, + isMemberWorld: Boolean + ) { + val totalLevel = skills.sumBy { it.static.toInt() } + val totalLevelLabel = findComponentByNameStandalone(hiscoresPanel, "totalLevelLabel") as? JLabel + totalLevelLabel?.text = totalLevel.toString() + + val attack = skills.find { it.id == "0" }?.static?.toInt() ?: 1 + val defence = skills.find { it.id == "1" }?.static?.toInt() ?: 1 + val strength = skills.find { it.id == "2" }?.static?.toInt() ?: 1 + val hitpoints = skills.find { it.id == "3" }?.static?.toInt() ?: 1 + val ranged = skills.find { it.id == "4" }?.static?.toInt() ?: 1 + val prayer = skills.find { it.id == "5" }?.static?.toInt() ?: 1 + val magic = skills.find { it.id == "6" }?.static?.toInt() ?: 1 + val summoning = skills.find { it.id == "23" }?.static?.toInt() ?: 1 + + val combatLevel = + calculateCombatLevel(attack, defence, strength, hitpoints, prayer, ranged, magic, summoning, true) + val combatLevelLabel = findComponentByNameStandalone(hiscoresPanel, "combatLevelLabel") as? JLabel + combatLevelLabel?.text = combatLevel.toString() + } + + private fun findComponentByNameStandalone(container: Container, name: String): Component? { + for (component in container.components) { + if (name == component.name) { + return component + } + if (component is Container) { + val child = findComponentByNameStandalone(component, name) + if (child != null) { + return child + } + } + } + return null + } + + private fun calculateCombatLevel( + attack: Int, + defence: Int, + strength: Int, + hitpoints: Int, + prayer: Int, + ranged: Int, + magic: Int, + summoning: Int, + isMemberWorld: Boolean + ): Double { + val base = (defence + hitpoints + floor(prayer.toDouble() / 2)) * 0.25 + val melee = (attack + strength) * 0.325 + val range = floor(ranged * 1.5) * 0.325 + val mage = floor(magic * 1.5) * 0.325 + val maxCombatType = maxOf(melee, range, mage) + + val summoningFactor = if (isMemberWorld) floor(summoning.toDouble() / 8) else 0.0 + return Math.round((base + maxCombatType + summoningFactor) * 1000.0) / 1000.0 + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/LootTrackerView.kt b/plugin-playground/src/main/kotlin/KondoKit/views/LootTrackerView.kt similarity index 61% rename from plugin-playground/src/main/kotlin/KondoKit/LootTrackerView.kt rename to plugin-playground/src/main/kotlin/KondoKit/views/LootTrackerView.kt index cfdb4a2..cd45de8 100644 --- a/plugin-playground/src/main/kotlin/KondoKit/LootTrackerView.kt +++ b/plugin-playground/src/main/kotlin/KondoKit/views/LootTrackerView.kt @@ -1,38 +1,46 @@ -package KondoKit +package KondoKit.views -import KondoKit.Helpers.addMouseListenerToAll -import KondoKit.Helpers.formatHtmlLabelText -import KondoKit.SpriteToBufferedImage.getBufferedImageFromSprite -import KondoKit.XPTrackerView.wrappedWidget -import KondoKit.plugin.Companion.POPUP_BACKGROUND -import KondoKit.plugin.Companion.POPUP_FOREGROUND +import KondoKit.util.Helpers +import KondoKit.util.Helpers.formatHtmlLabelText +import KondoKit.util.ImageCanvas +import KondoKit.util.SpriteToBufferedImage.getBufferedImageFromSprite +import KondoKit.ui.BaseView +import KondoKit.ui.OnKillingBlowNPCCallback +import KondoKit.ui.OnPostClientTickCallback +import KondoKit.ui.ViewConstants +import KondoKit.ui.View +import KondoKit.util.attachPopupMenu +import KondoKit.ui.components.XPWidget +import KondoKit.util.setFixedSize +import KondoKit.views.XPTrackerView.wrappedWidget +import KondoKit.ui.components.PopupMenuComponent +import KondoKit.ui.components.ProgressBar +import KondoKit.ui.components.LabelComponent +import KondoKit.ui.components.WidgetPanel import KondoKit.plugin.Companion.TITLE_BAR_COLOR import KondoKit.plugin.Companion.TOOLTIP_BACKGROUND -import KondoKit.plugin.Companion.TOTAL_XP_WIDGET_SIZE -import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR import KondoKit.plugin.Companion.WIDGET_COLOR import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.registerDrawAction import KondoKit.plugin.Companion.secondaryColor +import KondoKit.plugin.Companion.useLiveGEPrices import KondoKit.plugin.StateManager.focusedView import plugin.api.API import rt4.* import java.awt.* -import java.awt.Font +import java.awt.Component import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.image.BufferedImage -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL -import java.nio.charset.StandardCharsets +import KondoKit.util.GEPriceService import java.text.DecimalFormat import javax.swing.* import kotlin.math.ceil -object LootTrackerView { +object LootTrackerView : View, OnPostClientTickCallback, OnKillingBlowNPCCallback { private const val SNAPSHOT_LIFESPAN = 10 const val BAG_ICON = 900 + const val OPEN_BAG = 777 val npcDeathSnapshots = mutableMapOf() var gePriceMap = loadGEPrices() const val VIEW_NAME = "LOOT_TRACKER_VIEW" @@ -42,94 +50,50 @@ object LootTrackerView { var lastConfirmedKillNpcId = -1 private var customToolTipWindow: JWindow? = null var lootTrackerView: JPanel? = null + override val name: String = VIEW_NAME + override val iconSpriteId: Int = OPEN_BAG - fun loadGEPrices(): Map { - return if (plugin.useLiveGEPrices) { - try { - println("LootTracker: Loading Remote GE Prices") - val url = URL("https://cdn.2009scape.org/gedata/latest.json") - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0") + override val panel: JPanel + get() = lootTrackerView ?: JPanel() - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val inputStream = connection.inputStream - val content = inputStream.bufferedReader().use(BufferedReader::readText) + override fun createView() { + createLootTrackerView() + } - val items = content.trim().removeSurrounding("[", "]").split("},").map { it.trim() + "}" } - val gePrices = mutableMapOf() + override fun registerFunctions() { + KondoKit.plugin.registerPostClientTickCallback(this) + KondoKit.plugin.registerKillingBlowNPCCallback(this) + } - for (item in items) { - val pairs = item.removeSurrounding("{", "}").split(",") - val itemId = pairs.find { it.trim().startsWith("\"item_id\"") }?.split(":")?.get(1)?.trim()?.trim('\"') - val value = pairs.find { it.trim().startsWith("\"value\"") }?.split(":")?.get(1)?.trim()?.trim('\"') - if (itemId != null && value != null) { - gePrices[itemId] = value - } - } + override fun onPostClientTick() { + lootTrackerView?.let { onPostClientTick(it) } + } - gePrices - } else { - emptyMap() - } - } catch (e: Exception) { - emptyMap() - } + override fun onKillingBlowNPC(npcID: Int, x: Int, z: Int) { + val preDeathSnapshot = takeGroundSnapshot(Pair(x,z)) + npcDeathSnapshots[npcID] = GroundSnapshot(preDeathSnapshot, Pair(x, z), 0) + } + + fun loadGEPrices(): Map { + return if (useLiveGEPrices) { + println("LootTracker: Loading Remote GE Prices") + GEPriceService.loadRemotePrices() } else { - try { - println("LootTracker: Loading Local GE Prices") - BufferedReader(InputStreamReader(plugin::class.java.getResourceAsStream("res/item_configs.json"), StandardCharsets.UTF_8)) - .useLines { lines -> - val json = lines.joinToString("\n") - val items = json.trim().removeSurrounding("[", "]").split("},").map { it.trim() + "}" } - val gePrices = mutableMapOf() - - for (item in items) { - val pairs = item.removeSurrounding("{", "}").split(",") - val id = pairs.find { it.trim().startsWith("\"id\"") }?.split(":")?.get(1)?.trim()?.trim('\"') - val grandExchangePrice = pairs.find { it.trim().startsWith("\"grand_exchange_price\"") }?.split(":")?.get(1)?.trim()?.trim('\"') - if (id != null && grandExchangePrice != null) { - gePrices[id] = grandExchangePrice - } - } - - gePrices - } - } catch (e: Exception) { - emptyMap() - } + println("LootTracker: Loading Local GE Prices") + GEPriceService.loadLocalPrices { Helpers.readResourceText("res/item_configs.json") } } } fun createLootTrackerView() { - lootTrackerView = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) // Use BoxLayout on Y axis to stack widgets vertically - background = VIEW_BACKGROUND_COLOR + lootTrackerView = BaseView(VIEW_NAME, addDefaultSpacing = false).apply { add(Box.createVerticalStrut(5)) totalTrackerWidget = createTotalLootWidget() - val wrapped = wrappedWidget(totalTrackerWidget!!.container) + val wrapped = wrappedWidget(totalTrackerWidget!!.container, padding = 0) val popupMenu = resetLootTrackerMenu() - - // Create a custom MouseListener - val rightClickListener = object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - - override fun mouseReleased(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - } - addMouseListenerToAll(wrapped,rightClickListener) - wrapped.addMouseListener(rightClickListener) + wrapped.attachPopupMenu(popupMenu, includeChildren = true) add(wrapped) add(Box.createVerticalStrut(8)) revalidate() @@ -172,42 +136,41 @@ object LootTrackerView { ) } - private fun createWidgetPanel(bufferedImageSprite: BufferedImage, l1 : JLabel, l2 : JLabel): Panel { - val imageCanvas = ImageCanvas(bufferedImageSprite).apply { - preferredSize = Dimension(bufferedImageSprite.width, bufferedImageSprite.height) - minimumSize = preferredSize - maximumSize = preferredSize - size = preferredSize - background = WIDGET_COLOR - } + private fun createWidgetPanel(bufferedImageSprite: BufferedImage, l1 : JLabel, l2 : JLabel): WidgetPanel { + val (_, imageContainer) = Helpers.createImageCanvasComponents( + bufferedImageSprite, + borderInsets = Insets(-2, 0, 0, 0) + ) - val imageContainer = Panel(BorderLayout()).apply { - background = WIDGET_COLOR - add(imageCanvas, BorderLayout.NORTH) - } - - return Panel(BorderLayout(5, 0)).apply { - background = WIDGET_COLOR - preferredSize = TOTAL_XP_WIDGET_SIZE + return WidgetPanel( + widgetWidth = ViewConstants.DEFAULT_WIDGET_SIZE.width, + widgetHeight = ViewConstants.TOTAL_XP_WIDGET_SIZE.height, + addDefaultPadding = false, + paddingTop = 10, + paddingBottom = 10, + paddingRight = 10, + paddingLeft = 10 + ).apply { + layout = BorderLayout(5, 0) + setFixedSize( + ViewConstants.DEFAULT_WIDGET_SIZE.width, + ViewConstants.TOTAL_XP_WIDGET_SIZE.height + ) add(imageContainer, BorderLayout.WEST) add(createTextPanel(l1,l2), BorderLayout.CENTER) } } - private fun createTextPanel(l1 : JLabel, l2: JLabel): Panel { - return Panel(GridLayout(2, 1, 5, 0)).apply { + private fun createTextPanel(l1 : JLabel, l2: JLabel): JPanel { + return JPanel(GridLayout(2, 1, 5, 0)).apply { background = WIDGET_COLOR add(l1) add(l2) } } - private fun createLabel(text: String): JLabel { - return JLabel(text).apply { - font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - horizontalAlignment = JLabel.LEFT - } - } + private fun createLabel(text: String): LabelComponent = + LabelComponent(text, alignment = JLabel.LEFT) private fun addItemToLootPanel(lootTrackerView: JPanel, drop: Item, npcName: String) { findLootItemsPanel(lootTrackerView, npcName)?.let { lootPanel -> @@ -218,15 +181,12 @@ object LootTrackerView { ?.let { updateItemPanelIcon(it, drop.id, newQuantity) } ?: addNewItemToPanel(lootPanel, drop, newQuantity) - // Recalculate lootPanel size based on the number of unique items. val totalItems = lootItemPanels[npcName]?.size ?: 0 val rowsNeeded = ceil(totalItems / 6.0).toInt() val lootPanelHeight = rowsNeeded * (40) - val size = Dimension(lootPanel.width,lootPanelHeight+32) - lootPanel.parent.preferredSize = size - lootPanel.parent.minimumSize = size - lootPanel.parent.maximumSize = size + val size = Dimension(lootPanel.width, lootPanelHeight + 32) + (lootPanel.parent as? Component)?.setFixedSize(size) lootPanel.parent.revalidate() lootPanel.parent.repaint() lootPanel.revalidate() @@ -244,35 +204,24 @@ object LootTrackerView { private fun createItemPanel(itemId: Int, quantity: Int): JPanel { val bufferedImageSprite = getBufferedImageFromSprite(API.GetObjSprite(itemId, quantity, true, 1, 3153952)) - // Create the panel for the item val itemPanel = FixedSizePanel(Dimension(36, 32)).apply { - preferredSize = Dimension(36, 32) background = WIDGET_COLOR - minimumSize = preferredSize - maximumSize = preferredSize val imageCanvas = ImageCanvas(bufferedImageSprite).apply { - preferredSize = Dimension(36, 32) + setFixedSize(36, 32) background = WIDGET_COLOR - minimumSize = preferredSize - maximumSize = preferredSize } - // Add the imageCanvas to the panel add(imageCanvas, BorderLayout.CENTER) - // Put the itemId as a property for reference putClientProperty("itemId", itemId) - // Add mouse listener for custom hover text imageCanvas.addMouseListener(object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { - // Show custom tooltip when the mouse enters the component showCustomToolTip(e.point, itemId,quantity,imageCanvas) } override fun mouseExited(e: MouseEvent) { - // Hide tooltip when mouse exits hideCustomToolTip() } }) @@ -281,7 +230,6 @@ object LootTrackerView { return itemPanel } - // Function to show the custom tooltip fun showCustomToolTip(location: Point, itemId: Int, quantity: Int, parentComponent: ImageCanvas) { val itemDef = ObjTypeList.get(itemId) val gePricePerItem = gePriceMap[itemDef.id.toString()]?.toInt() ?: 0 @@ -296,7 +244,7 @@ object LootTrackerView { "GE: ${formatValue(totalGePrice)} ${geText}
" + "HA: ${formatValue(totalHaPrice)} ${haText}" - val _font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + val tooltipFont = ViewConstants.FONT_RUNESCAPE_SMALL_16 if (customToolTipWindow == null) { customToolTipWindow = JWindow().apply { contentPane = JLabel(text).apply { @@ -304,19 +252,17 @@ object LootTrackerView { isOpaque = true background = TOOLTIP_BACKGROUND foreground = Color.WHITE - font = _font + font = tooltipFont } pack() } } - // Calculate the tooltip location relative to the parent component val screenLocation = parentComponent.locationOnScreen customToolTipWindow!!.setLocation(screenLocation.x + location.x, screenLocation.y + location.y + 20) customToolTipWindow!!.isVisible = true } - // Function to hide the custom tooltip fun hideCustomToolTip() { customToolTipWindow?.isVisible = false customToolTipWindow = null // Nullify the global instance @@ -376,7 +322,8 @@ object LootTrackerView { fun onPostClientTick(lootTrackerView: JPanel) { val toRemove = mutableListOf() - npcDeathSnapshots.entries.forEach { (npcId, snapshot) -> + npcDeathSnapshots.entries.forEach { entry -> + val (npcId, snapshot) = entry val postDeathSnapshot = takeGroundSnapshot(Pair(snapshot.location.first, snapshot.location.second)) val newDrops = postDeathSnapshot.subtract(snapshot.items) @@ -392,7 +339,9 @@ object LootTrackerView { } } - toRemove.forEach { npcDeathSnapshots.remove(it) } + toRemove.forEach { npcId -> + npcDeathSnapshots.remove(npcId) + } } @@ -427,7 +376,7 @@ object LootTrackerView { newDrops.forEach { drop -> val geValue = (gePriceMap[drop.id.toString()]?.toInt() ?: 0) * drop.quantity updateValueLabel(lootTrackerView, geValue.toString(), npcName) - plugin.registerDrawAction { addItemToLootPanel(lootTrackerView, drop, npcName) } + registerDrawAction { addItemToLootPanel(lootTrackerView, drop, npcName) } updateTotalValue(geValue) } } @@ -436,30 +385,28 @@ object LootTrackerView { val childFramePanel = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) background = WIDGET_COLOR - minimumSize = Dimension(230, 0) - maximumSize = Dimension(230, 700) + minimumSize = Dimension(ViewConstants.DEFAULT_WIDGET_SIZE.width, 0) + maximumSize = Dimension(ViewConstants.DEFAULT_WIDGET_SIZE.width, 700) name = "HELLO_WORLD" } val labelPanel = JPanel(BorderLayout()).apply { background = TITLE_BAR_COLOR border = BorderFactory.createEmptyBorder(5, 5, 5, 5) - maximumSize = Dimension(230, 24) - minimumSize = maximumSize - preferredSize = maximumSize + setFixedSize(ViewConstants.DEFAULT_WIDGET_SIZE.width, 24) } val killCount = npcKillCounts.getOrPut(npcName) { 0 } val countLabel = JLabel(formatHtmlLabelText(npcName, secondaryColor, " x $killCount", primaryColor)).apply { foreground = secondaryColor - font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 horizontalAlignment = JLabel.LEFT name = "killCountLabel_$npcName" } val valueLabel = JLabel("0 gp").apply { foreground = secondaryColor - font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + font = ViewConstants.FONT_RUNESCAPE_SMALL_16 horizontalAlignment = JLabel.RIGHT name = "valueLabel_$npcName" } @@ -467,7 +414,6 @@ object LootTrackerView { labelPanel.add(countLabel, BorderLayout.WEST) labelPanel.add(valueLabel, BorderLayout.EAST) - // Panel to hold loot items, using GridLayout to manage rows and columns. val lootPanel = JPanel().apply { background = WIDGET_COLOR border = BorderFactory.createLineBorder(WIDGET_COLOR, 5) @@ -481,41 +427,13 @@ object LootTrackerView { childFramePanel.add(lootPanel) val popupMenu = removeLootFrameMenu(childFramePanel, npcName) - - // Create a custom MouseListener - val rightClickListener = object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - - override fun mouseReleased(e: MouseEvent) { - if (e.isPopupTrigger) { - popupMenu.show(e.component, e.x, e.y) - } - } - } - - labelPanel.addMouseListener(rightClickListener) + labelPanel.attachPopupMenu(popupMenu) return childFramePanel } private fun removeLootFrameMenu(toRemove: JPanel, npcName: String): JPopupMenu { - // Create a popup menu - val popupMenu = JPopupMenu() - val rFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) - - popupMenu.background = POPUP_BACKGROUND - - // Create menu items with custom font and colors - val menuItem1 = JMenuItem("Remove").apply { - font = rFont // Set custom font - background = POPUP_BACKGROUND // Dark background for item - foreground = POPUP_FOREGROUND // Light text color for item - } - popupMenu.add(menuItem1) - menuItem1.addActionListener { + val popupMenu = PopupMenuComponent() + popupMenu.addMenuItem("Remove") { lootItemPanels[npcName]?.clear() npcKillCounts[npcName] = 0 lootTrackerView?.let { parent -> @@ -524,8 +442,6 @@ object LootTrackerView { if (toRemoveIndex >= 0 && toRemoveIndex < components.size - 1) { val nextComponent = components[toRemoveIndex + 1] if (nextComponent is Box.Filler) { - // Nasty way to remove the Box.createVerticalStrut(8) after - // the lootpanel. parent.remove(nextComponent) } } @@ -539,21 +455,10 @@ object LootTrackerView { private fun resetLootTrackerMenu(): JPopupMenu { - // Create a popup menu - val popupMenu = JPopupMenu() - val rFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16) + val popupMenu = PopupMenuComponent() - popupMenu.background = POPUP_BACKGROUND - - // Create menu items with custom font and colors - val menuItem1 = JMenuItem("Reset Loot Tracker").apply { - font = rFont // Set custom font - background = POPUP_BACKGROUND // Dark background for item - foreground = POPUP_FOREGROUND // Light text color for item - } - popupMenu.add(menuItem1) - menuItem1.addActionListener { - plugin.registerDrawAction { + popupMenu.addMenuItem("Reset Loot Tracker") { + registerDrawAction { resetLootTracker() } } @@ -566,25 +471,9 @@ object LootTrackerView { lootItemPanels.clear() totalTrackerWidget = createTotalLootWidget() - val wrapped = wrappedWidget(totalTrackerWidget!!.container) + val wrapped = wrappedWidget(totalTrackerWidget!!.container, padding = 0) val _popupMenu = resetLootTrackerMenu() - - // Create a custom MouseListener - val rightClickListener = object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (e.isPopupTrigger) { - _popupMenu.show(e.component, e.x, e.y) - } - } - - override fun mouseReleased(e: MouseEvent) { - if (e.isPopupTrigger) { - _popupMenu.show(e.component, e.x, e.y) - } - } - } - addMouseListenerToAll(wrapped,rightClickListener) - wrapped.addMouseListener(rightClickListener) + wrapped.attachPopupMenu(_popupMenu, includeChildren = true) lootTrackerView?.add(Box.createVerticalStrut(5)) lootTrackerView?.add(wrapped) lootTrackerView?.add(Box.createVerticalStrut(8)) diff --git a/plugin-playground/src/main/kotlin/KondoKit/views/ReflectiveEditorView.kt b/plugin-playground/src/main/kotlin/KondoKit/views/ReflectiveEditorView.kt new file mode 100644 index 0000000..e4775b4 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/views/ReflectiveEditorView.kt @@ -0,0 +1,760 @@ +package KondoKit.views + +import KondoKit.pluginmanager.ReflectiveEditorPlugin +import KondoKit.util.attachPopupMenu +import KondoKit.ui.components.* +import KondoKit.pluginmanager.PluginInfoWithStatus +import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin.Companion.WRENCH_ICON +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.util.setFixedSize +import KondoKit.ui.BaseView +import KondoKit.ui.View +import KondoKit.ui.ViewConstants +import KondoKit.util.Helpers.showToast +import KondoKit.views.ViewLayoutHelpers.createSearchFieldSection +import plugin.Plugin +import plugin.PluginInfo +import plugin.PluginRepository +import java.awt.* +import java.io.File +import java.util.* +import javax.swing.* +import kotlin.math.ceil + +/* + This is used for the runtime editing of plugin variables. + To expose fields add the @Exposed annotation. + When they are applied this will trigger an invoke of OnKondoValueUpdated() + if it is implemented. Check GroundItems plugin for an example. + */ + +object ReflectiveEditorView : View { + var reflectiveEditorView: JPanel? = null + const val VIEW_NAME = "REFLECTIVE_EDITOR_VIEW" + const val PLUGIN_LIST_VIEW = "PLUGIN_LIST" + const val PLUGIN_DETAIL_VIEW = "PLUGIN_DETAIL" + + private val reflectiveEditorPlugin = ReflectiveEditorPlugin() + + private var searchField: SearchField? = null + + private fun isKondoKit(pluginName: String): Boolean { + return pluginName == "KondoKit" + } + + private lateinit var cardLayout: CardLayout + private lateinit var mainPanel: JPanel + private var currentPluginInfo: PluginInfo? = null + private var currentPlugin: Plugin? = null + + private var pluginSearchText: String = "" + private var searchFieldWrapper: JPanel? = null + private var pluginListContentPanel: JPanel? = null + private var pluginListScrollablePanel: ScrollablePanel? = null + + override val name: String = VIEW_NAME + override val iconSpriteId: Int = WRENCH_ICON + override val panel: JPanel + get() = reflectiveEditorView ?: JPanel() + + override fun createView() { + reflectiveEditorPlugin.Init() + createReflectiveEditorView() + } + + override fun registerFunctions() { + } + + fun createReflectiveEditorView() { + cardLayout = CardLayout() + mainPanel = JPanel(cardLayout) + mainPanel.background = VIEW_BACKGROUND_COLOR + mainPanel.border = BorderFactory.createEmptyBorder(0, 0, 0, 0) + mainPanel.isDoubleBuffered = true + + val pluginListView = createPluginListView() + pluginListView.name = PLUGIN_LIST_VIEW + mainPanel.add(pluginListView, PLUGIN_LIST_VIEW) + + val pluginDetailView = BaseView(PLUGIN_DETAIL_VIEW).apply { + layout = BorderLayout() + background = VIEW_BACKGROUND_COLOR + } + mainPanel.add(pluginDetailView, PLUGIN_DETAIL_VIEW) + + reflectiveEditorView = mainPanel + + cardLayout.show(mainPanel, PLUGIN_LIST_VIEW) + } + + private fun createPluginListView(): JPanel { + val panel = BaseView(PLUGIN_LIST_VIEW, addDefaultSpacing = false).apply { + background = VIEW_BACKGROUND_COLOR + } + + val searchSection = createSearchFieldSection( + parent = panel, + placeholderText = "Search plugins...", + viewName = VIEW_NAME, + initialText = pluginSearchText + ) { searchText -> + pluginSearchText = searchText + SwingUtilities.invokeLater { + addPlugins(reflectiveEditorView!!) + } + } + this.searchField = searchSection.searchField + searchFieldWrapper = searchSection.wrapper + + panel.add(Box.createVerticalStrut(5)) + panel.add(searchSection.wrapper) + panel.add(Box.createVerticalStrut(10)) + + pluginListContentPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = VIEW_BACKGROUND_COLOR + alignmentX = Component.CENTER_ALIGNMENT + } + panel.add(pluginListContentPanel) + + val scrollablePanel = ScrollablePanel(panel) + pluginListScrollablePanel = scrollablePanel + + val container = JPanel(BorderLayout()) + container.background = VIEW_BACKGROUND_COLOR + container.add(scrollablePanel, BorderLayout.CENTER) + + populatePluginListContent() + + SwingUtilities.invokeLater { + resetScrollablePanel(scrollablePanel) + } + + return container + } + + private fun populatePluginListContent() { + val contentPanel = pluginListContentPanel ?: return + + val treeLock = contentPanel.treeLock + synchronized(treeLock) { + contentPanel.removeAll() + + try { + val loadedPluginsField = PluginRepository::class.java.getDeclaredField("loadedPlugins") + loadedPluginsField.isAccessible = true + val loadedPlugins = loadedPluginsField.get(null) as HashMap<*, *> + + val pluginsWithExposed = mutableListOf>() + val pluginsWithoutExposed = mutableListOf>() + + for ((pluginInfo, plugin) in loadedPlugins) { + val exposedFields = (plugin as Plugin).javaClass.declaredFields.filter { field -> + field.annotations.any { annotation -> + annotation.annotationClass.simpleName == "Exposed" + } + } + + val pluginName = plugin.javaClass.`package`.name + if (pluginSearchText.isBlank() || pluginName.contains(pluginSearchText, ignoreCase = true)) { + if (exposedFields.isNotEmpty()) { + pluginsWithExposed.add(pluginInfo as PluginInfo to plugin) + } else { + pluginsWithoutExposed.add(pluginInfo as PluginInfo to plugin) + } + } + } + + pluginsWithExposed.sortWith(compareBy( + { if (isKondoKit(it.second.javaClass.`package`.name)) 0 else 1 }, + { it.second.javaClass.`package`.name } + )) + pluginsWithoutExposed.sortWith(compareBy( + { if (isKondoKit(it.second.javaClass.`package`.name)) 0 else 1 }, + { it.second.javaClass.`package`.name } + )) + + for ((pluginInfo, plugin) in pluginsWithExposed) { + val pluginPanel = createPluginItemPanel(pluginInfo, plugin) + contentPanel.add(pluginPanel) + contentPanel.add(Box.createVerticalStrut(5)) + } + + if (pluginsWithExposed.isNotEmpty() && pluginsWithoutExposed.isNotEmpty()) { + val separator = JPanel() + separator.background = VIEW_BACKGROUND_COLOR + separator.preferredSize = Dimension(Int.MAX_VALUE, 1) + separator.maximumSize = Dimension(Int.MAX_VALUE, 1) + contentPanel.add(separator) + contentPanel.add(Box.createVerticalStrut(5)) + } + + for ((pluginInfo, plugin) in pluginsWithoutExposed) { + val pluginPanel = createPluginItemPanel(pluginInfo, plugin) + contentPanel.add(pluginPanel) + contentPanel.add(Box.createVerticalStrut(5)) + } + } catch (e: Exception) { + e.printStackTrace() + } + + val disabledDir = File(reflectiveEditorPlugin.getPluginsDirectory(), "disabled") + if (disabledDir.exists() && disabledDir.isDirectory) { + val disabledPlugins = disabledDir.listFiles { file -> file.isDirectory } ?: arrayOf() + + for (pluginDir in disabledPlugins.sortedBy { it.name }) { + if (isKondoKit(pluginDir.name)) { + continue + } + if (pluginSearchText.isBlank() || pluginDir.name.contains(pluginSearchText, ignoreCase = true)) { + val pluginPanel = createDisabledPluginItemPanel(pluginDir.name) + contentPanel.add(pluginPanel) + contentPanel.add(Box.createVerticalStrut(5)) + } + } + } + + if (pluginSearchText.isNotBlank()) { + val matchingPluginStatuses = reflectiveEditorPlugin.getPluginStatuses().filter { pluginStatus -> + !pluginStatus.isInstalled && + !isKondoKit(pluginStatus.name) && + (pluginStatus.name.contains(pluginSearchText, ignoreCase = true) || + (pluginStatus.description?.contains(pluginSearchText, ignoreCase = true) ?: false)) + } + + if (matchingPluginStatuses.isNotEmpty()) { + val separator = JPanel().apply { + background = VIEW_BACKGROUND_COLOR + preferredSize = Dimension(Int.MAX_VALUE, 1) + maximumSize = preferredSize + } + contentPanel.add(Box.createVerticalStrut(10)) + contentPanel.add(separator) + contentPanel.add(Box.createVerticalStrut(5)) + + val headerPanel = JPanel(FlowLayout(FlowLayout.LEFT)).apply { + background = VIEW_BACKGROUND_COLOR + add(JLabel("Available Plugins").apply { + foreground = secondaryColor + font = ViewConstants.FONT_RUNESCAPE_SMALL_BOLD_16 + }) + } + contentPanel.add(headerPanel) + contentPanel.add(Box.createVerticalStrut(5)) + + matchingPluginStatuses.forEach { pluginStatus -> + val pluginPanel = createPluginStatusItemPanel(pluginStatus) + contentPanel.add(pluginPanel) + contentPanel.add(Box.createVerticalStrut(5)) + } + } + } + } + + contentPanel.revalidate() + contentPanel.repaint() + } + + private fun createPluginItemPanel(pluginInfo: PluginInfo, plugin: Plugin): JPanel { + val panel = createPluginWidgetPanel().apply { + layout = BorderLayout() + } + + val packageName = plugin.javaClass.`package`.name + val nameLabel = JLabel(packageName) + nameLabel.foreground = secondaryColor + nameLabel.font = ViewConstants.FONT_RUNESCAPE_SMALL_PLAIN_16 + + val exposedFields = plugin.javaClass.declaredFields.filter { field -> + field.annotations.any { annotation -> + annotation.annotationClass.simpleName == "Exposed" + } + } + + val editButton = exposedFields.takeIf { it.isNotEmpty() }?.let { + UiStyler.button(text = "Edit") { + showPluginDetails(pluginInfo, plugin) + } + } + + val toggleSwitch = if (!isKondoKit(packageName)) { + createToggleSwitch(plugin, pluginInfo) + } else { + val placeholder = JPanel() + placeholder.background = WIDGET_COLOR + placeholder.setFixedSize(ViewConstants.TOGGLE_PLACEHOLDER_SIZE) + placeholder.isVisible = false + placeholder + } + + val infoPanel = JPanel(BorderLayout()) + infoPanel.background = WIDGET_COLOR + infoPanel.add(nameLabel, BorderLayout.WEST) + + val controlsPanel = JPanel(FlowLayout(FlowLayout.RIGHT, 5, -3)) + controlsPanel.background = WIDGET_COLOR + + editButton?.let { controlsPanel.add(it) } + + if (!isKondoKit(packageName)) { + controlsPanel.add(toggleSwitch) + } + + panel.add(infoPanel, BorderLayout.CENTER) + panel.add(controlsPanel, BorderLayout.EAST) + + if (!isKondoKit(packageName)) { + val popupMenu = createPluginContextMenu(plugin, pluginInfo, packageName, false) + panel.attachPopupMenu(popupMenu) + } + + return panel + } + + private fun createDisabledPluginItemPanel(pluginName: String): JPanel { + val panel = createPluginWidgetPanel().apply { + layout = BorderLayout() + } + + val nameLabel = JLabel(pluginName) + nameLabel.foreground = secondaryColor + nameLabel.font = ViewConstants.FONT_RUNESCAPE_SMALL_PLAIN_16 + + val toggleSwitch = ToggleSwitch() + toggleSwitch.setActivated(false) + toggleSwitch.onToggleListener = { activated -> + if (activated) { + reflectiveEditorPlugin.enablePlugin(pluginName, mainPanel ?: JPanel()) + } + else { + toggleSwitch.setActivated(false) + } + } + + val infoPanel = JPanel(BorderLayout()) + infoPanel.background = WIDGET_COLOR + infoPanel.add(nameLabel, BorderLayout.WEST) + + val controlsPanel = JPanel(FlowLayout(FlowLayout.RIGHT, 0, 0)) + controlsPanel.background = WIDGET_COLOR + controlsPanel.add(toggleSwitch) + + panel.add(infoPanel, BorderLayout.CENTER) + panel.add(controlsPanel, BorderLayout.EAST) + + if (!isKondoKit(pluginName)) { + val popupMenu = createPluginContextMenu(null, null, pluginName, true) + panel.attachPopupMenu(popupMenu) + } + + return panel + } + + private fun createPluginStatusItemPanel(pluginStatus: PluginInfoWithStatus): JPanel { + val panel = createPluginWidgetPanel().apply { + layout = BorderLayout() + } + + val nameLabel = JLabel(pluginStatus.name) + nameLabel.foreground = secondaryColor + nameLabel.font = ViewConstants.FONT_RUNESCAPE_SMALL_PLAIN_16 + + val actionButton = UiStyler.button() + val progressBar = JProgressBar(0, 100) + progressBar.isStringPainted = true + progressBar.isVisible = false + progressBar.string = "Downloading..." + + if (pluginStatus.isDownloading) { + actionButton.isVisible = false + progressBar.isVisible = true + progressBar.value = pluginStatus.downloadProgress + } else if (pluginStatus.isInstalled) { + if (pluginStatus.needsUpdate) { + actionButton.text = "Update" + actionButton.addActionListener { + startPluginDownload(pluginStatus) + } + } else { + actionButton.text = "Installed" + actionButton.isEnabled = false + } + } else { + actionButton.text = "Download" + actionButton.addActionListener { + startPluginDownload(pluginStatus) + } + } + + val toggleSwitch = ToggleSwitch() + + // Skip the toggle switch for KondoKit since it should always be enabled + if (isKondoKit(pluginStatus.name)) { + toggleSwitch.isVisible = false + } else { + toggleSwitch.setActivated(pluginStatus.isInstalled && !pluginStatus.needsUpdate) + toggleSwitch.isEnabled = pluginStatus.isInstalled && !pluginStatus.needsUpdate + + if (pluginStatus.isInstalled && !pluginStatus.needsUpdate) { + toggleSwitch.onToggleListener = { activated -> + val loadedPluginsField = PluginRepository::class.java.getDeclaredField("loadedPlugins") + loadedPluginsField.isAccessible = true + val loadedPlugins = loadedPluginsField.get(null) as HashMap<*, *> + + var foundPlugin: Plugin? = null + var foundPluginInfo: PluginInfo? = null + + for ((pluginInfo, plugin) in loadedPlugins) { + if (getPluginDirName(plugin as Plugin) == pluginStatus.name) { + foundPlugin = plugin + foundPluginInfo = pluginInfo as PluginInfo + break + } + } + + if (foundPlugin != null && foundPluginInfo != null) { + reflectiveEditorPlugin.togglePlugin(foundPlugin, foundPluginInfo, toggleSwitch, activated, mainPanel ?: JPanel()) + } else { + val pluginDirName = pluginStatus.name + val sourceDir = if (activated) { + File(File(reflectiveEditorPlugin.getPluginsDirectory(), "disabled"), pluginDirName) + } else { + File(reflectiveEditorPlugin.getPluginsDirectory(), pluginDirName) + } + + val destDir = if (activated) { + File(reflectiveEditorPlugin.getPluginsDirectory(), pluginDirName) + } else { + val disabledDir = File(reflectiveEditorPlugin.getPluginsDirectory(), "disabled") + if (!disabledDir.exists()) { + disabledDir.mkdirs() + } + File(disabledDir, pluginDirName) + } + + if (!sourceDir.exists()) { + showToast(mainPanel, "Plugin directory not found: ${sourceDir.absolutePath}", JOptionPane.ERROR_MESSAGE) + toggleSwitch.setActivated(!activated) + } else { + if (sourceDir.renameTo(destDir)) { + showToast(mainPanel, if (activated) "Plugin enabled" else "Plugin disabled") + + reflectiveEditorPlugin.setReloadPlugins(true) // Schedule plugin reload to avoid crashes + + SwingUtilities.invokeLater { + addPlugins(reflectiveEditorView!!) + } + } else { + showToast(mainPanel, "Failed to ${if (activated) "enable" else "disable"} plugin", JOptionPane.ERROR_MESSAGE) + toggleSwitch.setActivated(!activated) // Reset toggle to previous state on failure + } + } + } + } + } else { + toggleSwitch.isVisible = false + } + } + + val infoPanel = JPanel(BorderLayout()) + infoPanel.background = WIDGET_COLOR + infoPanel.add(nameLabel, BorderLayout.WEST) + + val controlsPanel = JPanel(FlowLayout(FlowLayout.RIGHT, 0, 0)) + controlsPanel.background = WIDGET_COLOR + controlsPanel.add(actionButton) + controlsPanel.add(progressBar) + if (toggleSwitch.isVisible) { + controlsPanel.add(toggleSwitch) + } + + if (!pluginStatus.isInstalled) { + val tooltipText = buildInstallableTooltip(pluginStatus) + panel.toolTipText = tooltipText + actionButton.toolTipText = tooltipText + progressBar.toolTipText = tooltipText + } else { + panel.toolTipText = null + actionButton.toolTipText = null + progressBar.toolTipText = null + } + + panel.add(infoPanel, BorderLayout.CENTER) + panel.add(controlsPanel, BorderLayout.EAST) + + if (!isKondoKit(pluginStatus.name) && pluginStatus.isInstalled) { + val popupMenu = createPluginContextMenu(null, null, pluginStatus.name, !pluginStatus.needsUpdate) + panel.attachPopupMenu(popupMenu) + } + + return panel + } + + private fun createPluginWidgetPanel(): WidgetPanel { + return WidgetPanel( + widgetWidth = ViewConstants.PLUGIN_LIST_ITEM_SIZE.width, + widgetHeight = ViewConstants.PLUGIN_LIST_ITEM_SIZE.height, + addDefaultPadding = false, + paddingTop = 10, + paddingLeft = 10, + paddingBottom = 10, + paddingRight = 10 + ) + } + + private fun buildInstallableTooltip(pluginStatus: PluginInfoWithStatus): String { + val lines = mutableListOf() + pluginStatus.remoteVersion?.takeIf { it.isNotBlank() }?.let { + lines += "Version: ${escapeHtml(it)}" + } + pluginStatus.author?.takeIf { it.isNotBlank() }?.let { + lines += "Author: ${escapeHtml(it)}" + } + pluginStatus.description?.takeIf { it.isNotBlank() }?.let { + lines += escapeHtml(it).replace("\n", "
") + } + + if (lines.isEmpty()) { + lines += "Click Download to install this plugin." + } + + return "${lines.joinToString("
")}" + } + + private fun escapeHtml(value: String): String { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + } + + + + private fun createToggleSwitch(plugin: Plugin, pluginInfo: PluginInfo): ToggleSwitch { + val toggleSwitch = ToggleSwitch() + + toggleSwitch.setActivated(reflectiveEditorPlugin.isPluginEnabled(plugin, pluginInfo)) + toggleSwitch.onToggleListener = { activated -> + reflectiveEditorPlugin.togglePlugin(plugin, pluginInfo, toggleSwitch, activated, mainPanel ?: JPanel()) + } + + return toggleSwitch + } + + private fun getPluginDirName(plugin: Plugin): String { + // The package name is typically like "GroundItems.plugin" so we take the first part + val packageName = plugin.javaClass.`package`.name + return packageName.substringBefore(".") + } + + private fun showPluginDetails(pluginInfo: PluginInfo, plugin: Plugin) { + currentPluginInfo = pluginInfo + currentPlugin = plugin + + searchFieldWrapper?.isVisible = false + + val existingDetailView = mainPanel.components.find { it.name == PLUGIN_DETAIL_VIEW } + if (existingDetailView != null) { + mainPanel.remove(existingDetailView) + } + + val detailView = BaseView(PLUGIN_DETAIL_VIEW) + detailView.layout = BorderLayout() + detailView.background = VIEW_BACKGROUND_COLOR + + val packageName = plugin.javaClass.`package`.name + + val headerPanel = ViewHeader("$packageName v${pluginInfo.version}", 40).apply { + border = BorderFactory.createEmptyBorder(5, 10, 5, 10) + } + + val backButton = ButtonPanel(FlowLayout.LEFT).addButton("Back") { + SwingUtilities.invokeLater { + // Find the ScrollablePanel in the plugin list view and reset its scroll position + val listView = mainPanel.components.find { it.name == PLUGIN_LIST_VIEW } + if (listView != null && listView is Container) { + findAndResetScrollablePanel(listView) + } + } + searchFieldWrapper?.isVisible = true + cardLayout.show(mainPanel, PLUGIN_LIST_VIEW) + } + + + headerPanel.add(backButton, BorderLayout.WEST) + + val contentPanel = JPanel() + contentPanel.layout = BoxLayout(contentPanel, BoxLayout.Y_AXIS) + contentPanel.background = VIEW_BACKGROUND_COLOR + contentPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + + val infoPanel = JPanel(BorderLayout()) + infoPanel.background = WIDGET_COLOR + infoPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + + + val infoText = """ + + Version: ${(pluginInfo.version)}
+ Author: ${(pluginInfo.author ?: "").trim('\'')}
+ Description: ${(pluginInfo.description ?: "").trim('\'')} + + """.trimIndent() + + + val infoFont = ViewConstants.FONT_RUNESCAPE_SMALL_14 + val infoLabel = LabelComponent(infoText, isHtml = true).apply { + font = infoFont + } + infoPanel.add(infoLabel, BorderLayout.CENTER) + + val fm = infoLabel.getFontMetrics(infoFont) + + val avgCharWidth = fm.stringWidth("x") + val availableWidth = 200 + val charsPerLine = availableWidth / avgCharWidth + + val textContent = infoText.replace("<[^>]*>".toRegex(), "") + val lines = textContent.split('\n').filter { it.isNotBlank() } + + var totalLines = 0 + for (line in lines) { + val lineLength = line.length + totalLines += kotlin.math.ceil(lineLength.toDouble() / charsPerLine).toInt() + } + + val lineHeight = fm.height + val estimatedHeight = (totalLines * lineHeight) + 20 + + val finalHeight = kotlin.math.max(60, kotlin.math.min(estimatedHeight, 150)) + infoPanel.maximumSize = Dimension(Int.MAX_VALUE, finalHeight) + infoPanel.preferredSize = Dimension(ViewConstants.DEFAULT_WIDGET_SIZE.width, finalHeight) + + + + contentPanel.add(infoPanel) + contentPanel.add(Box.createVerticalStrut(10)) + + addPluginSettings(contentPanel, plugin) + + val scrollablePanel = ScrollablePanel(contentPanel) + + SwingUtilities.invokeLater { + resetScrollablePanel(scrollablePanel) + } + + detailView.add(headerPanel, BorderLayout.NORTH) + detailView.add(scrollablePanel, BorderLayout.CENTER) + + mainPanel.add(detailView, PLUGIN_DETAIL_VIEW) + + mainPanel.revalidate() + mainPanel.repaint() + + cardLayout.show(mainPanel, PLUGIN_DETAIL_VIEW) + } + + private fun addPluginSettings(contentPanel: JPanel, plugin: Plugin) { + val settingsPanel = SettingsPanel(plugin) + settingsPanel.addSettingsFromPlugin() + contentPanel.add(settingsPanel) + } + + fun addPlugins(reflectiveEditorView: JPanel) { + // Ensure we run on the EDT; if not, reschedule and return + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater { addPlugins(reflectiveEditorView) } + return + } + + // Check if we need to reload plugins + if (reflectiveEditorPlugin.shouldReloadPlugins()) { + PluginRepository.reloadPlugins() + reflectiveEditorPlugin.setReloadPlugins(false) + } + + // Update plugin statuses to reflect current loaded plugins + reflectiveEditorPlugin.updatePluginStatuses() + + val contentPanel = pluginListContentPanel + if (contentPanel == null) { + // Fallback path: rebuild the list view if it was not initialized yet + val existingListView = mainPanel.components.find { it.name == PLUGIN_LIST_VIEW } + val pluginListView = createPluginListView() + pluginListView.name = PLUGIN_LIST_VIEW + if (existingListView != null) { + mainPanel.remove(existingListView) + } + mainPanel.add(pluginListView, PLUGIN_LIST_VIEW) + } else { + contentPanel.isVisible = false + try { + populatePluginListContent() + } finally { + contentPanel.isVisible = true + } + } + + searchFieldWrapper?.isVisible = true + + cardLayout.show(mainPanel, PLUGIN_LIST_VIEW) + + pluginListScrollablePanel?.let { scrollPanel -> + SwingUtilities.invokeLater { + resetScrollablePanel(scrollPanel) + } + } + + mainPanel.revalidate() + mainPanel.repaint() + } + + private fun findAndResetScrollablePanel(container: Component) { + if (container is ScrollablePanel) { + resetScrollablePanel(container) + } else if (container is Container) { + for (child in container.components) { + findAndResetScrollablePanel(child) + } + } + } + + private fun startPluginDownload(pluginStatus: PluginInfoWithStatus) { + reflectiveEditorPlugin.startPluginDownload(pluginStatus, mainPanel ?: JPanel()) + } + + private fun createPluginContextMenu(plugin: Plugin?, pluginInfo: PluginInfo?, pluginName: String, isDisabled: Boolean): JPopupMenu { + val popupMenu = PopupMenuComponent() + + popupMenu.addMenuItem("Delete Plugin") { + deletePlugin(pluginName, isDisabled) + } + + return popupMenu + } + + private fun deletePlugin(pluginName: String, isDisabled: Boolean) { + reflectiveEditorPlugin.deletePlugin(pluginName, isDisabled, mainPanel ?: JPanel()) + } + + private fun resetScrollablePanel(scrollablePanel: ScrollablePanel) { + try { + val contentField = ScrollablePanel::class.java.getDeclaredField("content") + contentField.isAccessible = true + val contentPanel = contentField.get(scrollablePanel) as JPanel + + // Reset the location to the top + contentPanel.setLocation(0, 0) + + // Force a repaint + scrollablePanel.revalidate() + scrollablePanel.repaint() + } catch (e: Exception) { + // If reflection fails, at least try to repaint + scrollablePanel.revalidate() + scrollablePanel.repaint() + } + } +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/views/ViewLayoutHelpers.kt b/plugin-playground/src/main/kotlin/KondoKit/views/ViewLayoutHelpers.kt new file mode 100644 index 0000000..0f2aec0 --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/views/ViewLayoutHelpers.kt @@ -0,0 +1,72 @@ +package KondoKit.views + +import KondoKit.ui.ViewConstants +import KondoKit.ui.components.SearchField +import KondoKit.ui.components.WidgetPanel +import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JPanel + +object ViewLayoutHelpers { + + data class SearchFieldSection( + val wrapper: JPanel, + val searchField: SearchField + ) + + /** + * Creates a standard search field row with consistent sizing and styling. + */ + fun createSearchFieldSection( + parent: JPanel, + placeholderText: String, + viewName: String, + initialText: String = "", + fieldWidth: Int = ViewConstants.SEARCH_FIELD_SIZE.width, + fieldHeight: Int = ViewConstants.SEARCH_FIELD_SIZE.height, + onSearch: (String) -> Unit + ): SearchFieldSection { + val searchField = SearchField( + parentPanel = parent, + onSearch = onSearch, + placeholderText = placeholderText, + fieldWidth = fieldWidth, + fieldHeight = fieldHeight, + viewName = viewName + ) + + if (initialText.isNotBlank()) { + searchField.setText(initialText) + } + + val preferredSize = Dimension(fieldWidth, fieldHeight) + val wrapper = WidgetPanel( + widgetWidth = preferredSize.width, + widgetHeight = preferredSize.height, + addDefaultPadding = false + ).apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + background = VIEW_BACKGROUND_COLOR + alignmentX = Component.CENTER_ALIGNMENT + add(searchField) + } + + return SearchFieldSection(wrapper = wrapper, searchField = searchField) + } + + fun createVerticalPanel(background: Color = VIEW_BACKGROUND_COLOR): JPanel { + return JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + this.background = background + } + } +} + +fun Container.addVerticalSpacing(pixels: Int) { + add(Box.createVerticalStrut(pixels)) +} diff --git a/plugin-playground/src/main/kotlin/KondoKit/views/XPTrackerView.kt b/plugin-playground/src/main/kotlin/KondoKit/views/XPTrackerView.kt new file mode 100644 index 0000000..eb4ff7c --- /dev/null +++ b/plugin-playground/src/main/kotlin/KondoKit/views/XPTrackerView.kt @@ -0,0 +1,459 @@ +package KondoKit.views + +import KondoKit.util.Helpers +import KondoKit.util.Helpers.formatHtmlLabelText +import KondoKit.util.Helpers.formatNumber +import KondoKit.util.Helpers.getProgressBarColor +import KondoKit.util.Helpers.getSpriteId +import KondoKit.util.SpriteToBufferedImage.getBufferedImageFromSprite +import KondoKit.ui.BaseView +import KondoKit.ui.OnUpdateCallback +import KondoKit.ui.OnXPUpdateCallback +import KondoKit.ui.ViewConstants +import KondoKit.ui.View +import KondoKit.util.setFixedSize +import KondoKit.util.attachPopupMenu +import KondoKit.util.XPTable +import KondoKit.ui.components.PopupMenuComponent +import KondoKit.ui.components.ProgressBar +import KondoKit.ui.components.LabelComponent +import KondoKit.ui.components.WidgetPanel +import KondoKit.ui.components.XPWidget +import KondoKit.plugin.Companion.IMAGE_SIZE +import KondoKit.plugin.Companion.LVL_ICON +import KondoKit.plugin.Companion.WIDGET_COLOR +import KondoKit.plugin +import KondoKit.plugin.Companion.playerXPMultiplier +import KondoKit.plugin.Companion.primaryColor +import KondoKit.plugin.Companion.secondaryColor +import KondoKit.plugin.StateManager.focusedView +import KondoKit.util.JsonParser +import plugin.api.API +import java.awt.* +import java.awt.image.BufferedImage +import javax.swing.* +import javax.swing.SwingConstants + +object XPTrackerView : View, OnUpdateCallback, OnXPUpdateCallback { + private val COMBAT_SKILLS = intArrayOf(0,1,2,3,4) + val xpWidgets: MutableMap = HashMap() + var totalXPWidget: XPWidget? = null + val initialXP: MutableMap = HashMap() + var xpTrackerView: JPanel? = null + const val VIEW_NAME = "XP_TRACKER_VIEW" + override val name: String = VIEW_NAME + override val iconSpriteId: Int = LVL_ICON + private val skillIconCache: MutableMap = HashMap() + + + val npcHitpointsMap: Map = try { + val json = Helpers.readResourceText("res/npc_hitpoints_map.json") ?: "{}" + val parsed: Map = JsonParser.fromJson(json) + parsed.mapNotNull { (key, value) -> + key.toIntOrNull()?.let { it to value } + }.toMap() + } catch (e: Exception) { + println("XPTracker Error parsing NPC HP: ${e.message}") + emptyMap() + } + + private val widgetFont = ViewConstants.FONT_RUNESCAPE_SMALL_16 + + private fun BufferedImage.ensureOpaque(): BufferedImage { + for (y in 0 until height) { + for (x in 0 until width) { + val color = getRGB(x, y) + if (color != 0) { + setRGB(x, y, color or (0xFF shl 24)) + } + } + } + return this + } + + private fun createIconContainer(image: BufferedImage): JPanel { + val processed = image.ensureOpaque() + val icon = ImageIcon(processed) + val label = JLabel(icon).apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + isOpaque = false + } + + return JPanel(BorderLayout()).apply { + background = WIDGET_COLOR + val wrapperSize = Dimension(IMAGE_SIZE) + preferredSize = wrapperSize + minimumSize = wrapperSize + maximumSize = wrapperSize + add(label, BorderLayout.CENTER) + } + } + + private fun createMetricLabel(title: String, initialValue: String = "0", topPadding: Int = 0): LabelComponent = + LabelComponent(alignment = JLabel.LEFT).apply { + updateHtmlText("$title ", primaryColor, initialValue, secondaryColor) + if (topPadding > 0) { + border = BorderFactory.createEmptyBorder(topPadding, 0, 0, 0) + } + } + + private fun getSkillIcon(skillId: Int): BufferedImage { + return skillIconCache[skillId] ?: getBufferedImageFromSprite(API.GetSprite(getSpriteId(skillId))).also { + skillIconCache[skillId] = it + } + } + + private fun createTotalWidgetContainer(popupMenu: JPopupMenu): Container { + totalXPWidget = createTotalXPWidget() + return wrappedWidget(totalXPWidget!!.container, padding = 0).also { + it.attachPopupMenu(popupMenu, includeChildren = true) + } + } + + override val panel: JPanel + get() = xpTrackerView ?: JPanel() + + override fun createView() { + createXPTrackerView() + } + + override fun registerFunctions() { + plugin.registerUpdateCallback(this) + plugin.registerXPUpdateCallback(this) + } + + override fun onUpdate() { + val widgets = xpWidgets.values + val totalXP = totalXPWidget + + widgets.forEach { xpWidget -> + val elapsedTime = (System.currentTimeMillis() - xpWidget.startTime) / 1000.0 / 60.0 / 60.0 + val xpPerHour = if (elapsedTime > 0) (xpWidget.totalXpGained / elapsedTime).toInt() else 0 + val formattedXpPerHour = formatNumber(xpPerHour) + xpWidget.xpPerHourLabel.text = + formatHtmlLabelText("XP /hr: ", primaryColor, formattedXpPerHour, secondaryColor) + xpWidget.container.repaint() + } + + totalXP?.let { widget -> + val elapsedTime = (System.currentTimeMillis() - widget.startTime) / 1000.0 / 60.0 / 60.0 + val totalXPPerHour = if (elapsedTime > 0) (widget.totalXpGained / elapsedTime).toInt() else 0 + val formattedTotalXpPerHour = formatNumber(totalXPPerHour) + widget.xpPerHourLabel.text = + formatHtmlLabelText("XP /hr: ", primaryColor, formattedTotalXpPerHour, secondaryColor) + widget.container.repaint() + } + } + + override fun onXPUpdate(skillId: Int, xp: Int) { + if (!initialXP.containsKey(skillId)) { + initialXP[skillId] = xp + return + } + val previousXpSnapshot = initialXP[skillId] ?: xp + if (xp == initialXP[skillId]) return + + val ensureOnEdt = Runnable { + var xpWidget = xpWidgets[skillId] + if (xpWidget != null) { + updateWidget(xpWidget, xp) + } else { + xpWidget = createXPWidget(skillId, previousXpSnapshot) + xpWidgets[skillId] = xpWidget + + val wrapped = wrappedWidget(xpWidget.container, padding = 0, innerPadding = 6) + val popupMenu = removeXPWidgetMenu(wrapped, skillId) + wrapped.attachPopupMenu(popupMenu, includeChildren = true) + + xpTrackerView?.add(wrapped) + xpTrackerView?.add(Box.createVerticalStrut(5)) + + if(focusedView == VIEW_NAME) { + xpTrackerView?.revalidate() + xpTrackerView?.repaint() + } + + updateWidget(xpWidget, xp) + } + } + + if (SwingUtilities.isEventDispatchThread()) { + ensureOnEdt.run() + } else { + SwingUtilities.invokeLater(ensureOnEdt) + } + } + + fun updateWidget(xpWidget: XPWidget, xp: Int) { + val (currentLevel, xpGainedSinceLastLevel) = XPTable.getLevelForXp(xp) + + var xpGainedSinceLastUpdate = xp - xpWidget.previousXp + xpWidget.totalXpGained += xpGainedSinceLastUpdate + updateTotalXPWidget(xpGainedSinceLastUpdate) + + val progress: Double + if (currentLevel >= 99) { + progress = 100.0 // Set progress to 100% if the level is 99 or above + xpWidget.xpLeftLabel.text = "" // Hide XP Left when level is 99 + xpWidget.actionsRemainingLabel.text = "" + } else { + val nextLevelXp = XPTable.getXpRequiredForLevel(currentLevel + 1) + val xpLeft = nextLevelXp - xp + progress = xpGainedSinceLastLevel.toDouble() / (nextLevelXp - XPTable.getXpRequiredForLevel(currentLevel)) * 100 + val xpLeftstr = formatNumber(xpLeft) + xpWidget.xpLeftLabel.text = formatHtmlLabelText("XP Left: ", primaryColor, xpLeftstr, secondaryColor) + if(COMBAT_SKILLS.contains(xpWidget.skillId)) { + if(LootTrackerView.lastConfirmedKillNpcId != -1 && npcHitpointsMap.isNotEmpty()) { + val npcHP = npcHitpointsMap[LootTrackerView.lastConfirmedKillNpcId] + val xpPerKill = when (xpWidget.skillId) { + 3 -> playerXPMultiplier * (npcHP ?: 1) // Hitpoints + else -> playerXPMultiplier * (npcHP ?: 1) * 4 // Combat XP for other skills + } + val remainingKills = xpLeft / xpPerKill + xpWidget.actionsRemainingLabel.text = formatHtmlLabelText("Kills: ", primaryColor, remainingKills.toString(), secondaryColor) + } + } else { + if(xpGainedSinceLastUpdate == 0) + xpGainedSinceLastUpdate = 1 // Avoid possible divide by 0 + val remainingActions = (xpLeft / xpGainedSinceLastUpdate).coerceAtLeast(1) + xpWidget.actionsRemainingLabel.text = formatHtmlLabelText("Actions: ", primaryColor, remainingActions.toString(), secondaryColor) + } + } + + val formattedXp = formatNumber(xpWidget.totalXpGained) + xpWidget.xpGainedLabel.text = formatHtmlLabelText("XP Gained: ", primaryColor, formattedXp, secondaryColor) + + xpWidget.progressBar.updateProgress(progress, currentLevel, if (currentLevel < 99) currentLevel + 1 else 99, focusedView == VIEW_NAME) + + xpWidget.previousXp = xp + if (focusedView == VIEW_NAME) + xpWidget.container.repaint() + } + + + private fun updateTotalXPWidget(xpGainedSinceLastUpdate: Int) { + val totalXPWidget = totalXPWidget ?: return + totalXPWidget.totalXpGained += xpGainedSinceLastUpdate + val formattedXp = formatNumber(totalXPWidget.totalXpGained) + totalXPWidget.xpGainedLabel.text = formatHtmlLabelText("Gained: ", primaryColor, formattedXp, secondaryColor) + if (focusedView == VIEW_NAME) + totalXPWidget.container.repaint() + } + + + fun resetXPTracker(xpTrackerView: JPanel) { + xpTrackerView.removeAll() + val popupMenu = createResetMenu() + + xpTrackerView.add(Box.createVerticalStrut(5)) + xpTrackerView.add(createTotalWidgetContainer(popupMenu)) + xpTrackerView.add(Box.createVerticalStrut(5)) + + initialXP.clear() + xpWidgets.clear() + + xpTrackerView.revalidate() + if (focusedView == VIEW_NAME) { + xpTrackerView.repaint() + } + } + + fun createTotalXPWidget(): XPWidget { + val widgetPanel = WidgetPanel( + widgetWidth = ViewConstants.DEFAULT_WIDGET_SIZE.width, + widgetHeight = ViewConstants.TOTAL_XP_WIDGET_SIZE.height, + addDefaultPadding = false, + paddingTop = 10, + paddingBottom = 10, + paddingRight = 10, + paddingLeft = 10 + ) + + val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(LVL_ICON)) + val (_, imageContainer) = Helpers.createImageCanvasComponents( + bufferedImageSprite, + borderInsets = Insets(0, 0, 0, 5) + ) + + + val textPanel = JPanel(GridLayout(2, 1, 5, 0)).apply { + background = WIDGET_COLOR + } + + val xpGainedLabel = createMetricLabel("Gained:") + val xpPerHourLabel = createMetricLabel("XP /hr:") + + textPanel.add(xpGainedLabel) + textPanel.add(xpPerHourLabel) + + widgetPanel.setFixedSize( + ViewConstants.DEFAULT_WIDGET_SIZE.width, + ViewConstants.TOTAL_XP_WIDGET_SIZE.height + ) + + widgetPanel.add(imageContainer, BorderLayout.WEST) + widgetPanel.add(textPanel, BorderLayout.CENTER) + + return XPWidget( + skillId = -1, + container = widgetPanel, + xpGainedLabel = xpGainedLabel, + xpLeftLabel = createMetricLabel("XP Left:"), + xpPerHourLabel = xpPerHourLabel, + progressBar = ProgressBar(0.0, Color.BLACK), // Unused + totalXpGained = 0, + startTime = System.currentTimeMillis(), + previousXp = 0, + actionsRemainingLabel = JLabel().apply { font = widgetFont }, + ) + } + + + fun createXPTrackerView() { + val widgetViewPanel = BaseView(VIEW_NAME, addDefaultSpacing = false).apply { + add(Box.createVerticalStrut(5)) + } + + val popupMenu = createResetMenu() + widgetViewPanel.add(createTotalWidgetContainer(popupMenu)) + widgetViewPanel.add(Box.createVerticalStrut(5)) + + xpTrackerView = widgetViewPanel + + // Preload skill icons to avoid first-drop lag + try { + for (i in 0 until 24) { + getSkillIcon(i) + } + } catch (_: Exception) { } + } + + + fun createResetMenu(): JPopupMenu { + val popupMenu = PopupMenuComponent() + popupMenu.addMenuItem("Reset Tracker") { + plugin.registerDrawAction { resetXPTracker(xpTrackerView!!) } + } + return popupMenu + } + + fun removeXPWidgetMenu(toRemove: Container, skillId: Int): JPopupMenu { + val popupMenu = PopupMenuComponent() + + popupMenu.addMenuItem("Reset") { + xpWidgets[skillId]?.let { widget -> + // Baseline at current XP and clear per-widget counters + initialXP[skillId] = widget.previousXp + widget.totalXpGained = 0 + widget.startTime = System.currentTimeMillis() + + // Recompute labels/progress for current XP without adding totals + updateWidget(widget, widget.previousXp) + } + } + + popupMenu.addMenuItem("Remove") { + // Reset the per-skill baseline to the current XP so next widget starts fresh + xpWidgets[skillId]?.let { widget -> + initialXP[skillId] = widget.previousXp + } + // Remove widget container and following spacer if present + xpTrackerView?.let { parent -> + val components = parent.components + val toRemoveIndex = components.indexOf(toRemove) + if (toRemoveIndex >= 0 && toRemoveIndex < components.size - 1) { + val nextComponent = components[toRemoveIndex + 1] + if (nextComponent is Box.Filler) { + parent.remove(nextComponent) + } + } + parent.remove(toRemove) + xpWidgets.remove(skillId) + parent.revalidate() + if (focusedView == VIEW_NAME) parent.repaint() + } + } + return popupMenu + } + + + fun createXPWidget(skillId: Int, previousXp: Int): XPWidget { + val widgetPanel = WidgetPanel( + widgetWidth = ViewConstants.DEFAULT_WIDGET_SIZE.width, + widgetHeight = 56, + addDefaultPadding = false + ) + + val iconContainer = createIconContainer(getSkillIcon(skillId)) + + val textPanel = JPanel(GridLayout(2, 2, 5, 0)).apply { + background = WIDGET_COLOR + } + + val xpGainedLabel = createMetricLabel("XP Gained:") + val xpLeftLabel = createMetricLabel("XP Left:", "0K") + val xpPerHourLabel = createMetricLabel("XP /hr:") + val actionsTitle = if (COMBAT_SKILLS.contains(skillId)) "Kills:" else "Actions:" + val actionsLabel = createMetricLabel(actionsTitle) + + val progressBar = ProgressBar(0.0, getProgressBarColor(skillId)).apply { + setFixedSize(160, 22) + } + + val progressPanel = JPanel(BorderLayout()).apply { + background = WIDGET_COLOR + add(progressBar, BorderLayout.CENTER) + } + + textPanel.add(xpGainedLabel) + textPanel.add(xpLeftLabel) + textPanel.add(xpPerHourLabel) + textPanel.add(actionsLabel) + + widgetPanel.add(iconContainer, BorderLayout.WEST) + widgetPanel.add(textPanel, BorderLayout.CENTER) + widgetPanel.add(progressPanel, BorderLayout.SOUTH) + + widgetPanel.revalidate() + if(focusedView == VIEW_NAME) + widgetPanel.repaint() + + return XPWidget( + skillId = skillId, + container = widgetPanel, + xpGainedLabel = xpGainedLabel, + xpLeftLabel = xpLeftLabel, + xpPerHourLabel = xpPerHourLabel, + progressBar = progressBar, + totalXpGained = 0, + actionsRemainingLabel = actionsLabel, + startTime = System.currentTimeMillis(), + previousXp = previousXp + ) + } + + fun wrappedWidget(component: Component, padding: Int = 7, innerPadding: Int = 0): Container { + val outerPanelSize = Dimension( + component.preferredSize.width + 2 * padding, + component.preferredSize.height + 2 * padding + ) + val outerPanel = JPanel(GridBagLayout()).apply { + background = WIDGET_COLOR + setFixedSize(outerPanelSize) + } + val innerPanel = JPanel(BorderLayout()).apply { + background = WIDGET_COLOR + setFixedSize(component.preferredSize) + if (innerPadding > 0) { + border = BorderFactory.createEmptyBorder(innerPadding, innerPadding, innerPadding, innerPadding) + } + add(component, BorderLayout.CENTER) + } + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.CENTER + } + outerPanel.add(innerPanel, gbc) + return outerPanel + } +}