Merge branch 'KondoImprovements' into 'master'

Draft: KondoKit V2.1 + ChatboxHelms plugin

See merge request 2009scape/rt4-client!30
This commit is contained in:
downthecrop 2025-10-04 06:40:40 +00:00
commit 59cefc5f44
33 changed files with 4474 additions and 1078 deletions

View file

@ -0,0 +1,390 @@
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 = "<img=3>"
private val gson = Gson()
val TYPE_ICONS: Map<Int, String> = hashMapOf(
0 to "",
1 to "<img=4>", // IM
2 to "<img=5>", // HCIM
3 to "<img=6>", // UIM
)
@Exposed(description = "Username to account type mappings (0=Normal, 1=IM, 2=HCIM, 3=UIM)")
private var usernameMatches = HashMap<String, Int>()
private var ACCOUNT_TYPE = 0
private var component: Component? = null
// See JagString.java parse() in the client, There is special encoding
// and this correctly resolves specials.
val SPECIAL_ESCAPES: Map<Char, String> = 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"
)
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<String, Int>
} catch (e: Exception) {
println("Failed to deserialize username matches: ${e.message}")
HashMap()
}
}
else -> HashMap()
}
}
override fun OnPluginsReloaded(): Boolean {
grabConfig()
return false
}
override fun Draw(timeDelta: Long) {
if (component != null && component?.id == TARGET_COMPONENT_ID) {
val modifiedStr = replaceUsernameInBytes(component!!.text.chars, Player.usernameInput.toString())
component?.text = JagString.parse(modifiedStr)
}
}
override fun ComponentDraw(componentIndex: Int, component: Component?, screenX: Int, screenY: Int) {
if (component?.id == TARGET_COMPONENT_ID && componentIndex == TARGET_COMPONENT_INDEX) {
this.component = component
}
}
override fun OnLogin() {
grabConfig()
}
// Allow setting from the chatbox
override fun ProcessCommand(commandStr: String?, args: Array<out String>?) {
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()] = ACCOUNT_TYPE
storeData()
}
}
}
}
fun OnKondoValueUpdated() {
storeData()
OnPluginsReloaded() //refresh the ui
}
private fun getCleanUserName(): String {
return Player.usernameInput.toString().replace(" ", "_")
}
private fun grabConfig() {
// Check if we already have the account type for this user
val cleanUsername = getCleanUserName()
if (usernameMatches.containsKey(cleanUsername.toLowerCase())) {
ACCOUNT_TYPE = usernameMatches[cleanUsername]!!
return
}
// No match found, check the hiscores (live server)
fetchAccountTypeFromAPI()
}
private fun fetchAccountTypeFromAPI(){
val cleanUsername = getCleanUserName()
val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${cleanUsername.toLowerCase()}"
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, cleanUsername)
}
} catch (_: Exception) { }
}.start()
}
private fun updatePlayerData(jsonResponse: String, username: String) {
val hiscoresResponse = gson.fromJson(jsonResponse, HiscoresResponse::class.java)
ACCOUNT_TYPE = hiscoresResponse.info.iron_mode.toInt()
// Store the result in our local cache
usernameMatches[username] = 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()
}
// Lifted from KondoHiscores view
data class HiscoresResponse(
val info: PlayerInfo,
val skills: List<Skill>
)
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
)
}

View file

@ -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

Binary file not shown.

View file

@ -3,6 +3,8 @@ package KondoKit
import rt4.GameShell
import java.awt.*
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 +13,16 @@ import java.util.Timer
import javax.swing.*
object Helpers {
// Convenience helper for loading resources relative to the KondoKit plugin class
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() }
}
}
fun convertValue(type: Class<*>, genericType: Type?, value: String): Any {
return when {
@ -233,4 +245,4 @@ object Helpers {
else -> Color(128, 128, 128) // Default grey for unhandled skill IDs
}
}
}
}

View file

@ -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<String> = 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<Enum<*>>)).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("<html><div style='color: $textColor; background-color: $bgColor; padding: 3px; word-break: break-all;'>$text</div></html>").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 = "<html><div style='color: $textColor; background-color: $bgColor; padding: 3px; word-break: break-all;'>$text</div></html>"
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
}
}

View file

@ -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<Int, XPWidget> = HashMap()
var totalXPWidget: XPWidget? = null
val initialXP: MutableMap<Int, Int> = HashMap()
var xpTrackerView: JPanel? = null
const val VIEW_NAME = "XP_TRACKER_VIEW"
val npcHitpointsMap: Map<Int, Int> = 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<Int, Int>()
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
)

View file

@ -0,0 +1,45 @@
package KondoKit.components
import KondoKit.plugin.Companion.TITLE_BAR_COLOR
import KondoKit.plugin.Companion.WIDGET_COLOR
import KondoKit.plugin.Companion.secondaryColor
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 {
val button = JButton(text).apply {
background = TITLE_BAR_COLOR
foreground = secondaryColor
font = Font("RuneScape Small", Font.PLAIN, 14)
addActionListener {
action()
}
}
add(button)
return button
}
fun addIcon(icon: Icon, action: () -> Unit): JButton {
val button = JButton(icon).apply {
background = TITLE_BAR_COLOR
foreground = secondaryColor
font = Font("RuneScape Small", Font.PLAIN, 14)
addActionListener {
action()
}
}
add(button)
return button
}
}

View file

@ -0,0 +1,60 @@
package KondoKit.components
import KondoKit.ImageCanvas
import KondoKit.SpriteToBufferedImage
import KondoKit.plugin.Companion.WIDGET_COLOR
import KondoKit.plugin.Companion.primaryColor
import KondoKit.plugin.Companion.secondaryColor
import plugin.api.API
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
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 {
val size = Dimension(iconWidth, iconHeight)
preferredSize = size
maximumSize = size
minimumSize = size
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)
}
}

View file

@ -0,0 +1,35 @@
package KondoKit.components
import KondoKit.Helpers.formatHtmlLabelText
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 = Font("RuneScape Small", Font.TRUETYPE_FONT, 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 = Font("RuneScape Small", Font.BOLD, 16)
}
}

View file

@ -0,0 +1,27 @@
package KondoKit.components
import KondoKit.plugin.Companion.POPUP_BACKGROUND
import KondoKit.plugin.Companion.POPUP_FOREGROUND
import java.awt.Font
import javax.swing.JMenuItem
import javax.swing.JPopupMenu
class PopupMenuComponent : JPopupMenu() {
init {
background = POPUP_BACKGROUND
}
fun addMenuItem(text: String, action: () -> Unit): JMenuItem {
val menuItem = JMenuItem(text).apply {
font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16)
background = POPUP_BACKGROUND
foreground = POPUP_FOREGROUND
addActionListener {
action()
}
}
add(menuItem)
return menuItem
}
}

View file

@ -1,4 +1,4 @@
package KondoKit
package KondoKit.components
import KondoKit.plugin.Companion.PROGRESS_BAR_FILL
import KondoKit.plugin.Companion.secondaryColor

View file

@ -0,0 +1,169 @@
package KondoKit.components.ReflectiveEditorComponents
import KondoKit.ImageCanvas
import KondoKit.plugin
import KondoKit.plugin.Companion.WIDGET_COLOR
import KondoKit.plugin.Companion.secondaryColor
import plugin.api.API
import java.awt.*
import java.awt.datatransfer.DataFlavor
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import javax.imageio.ImageIO
import javax.swing.*
class CustomSearchField(private val parentPanel: JPanel, private val onSearch: (String) -> Unit) : Canvas() {
private var cursorVisible: Boolean = true
private var text: String = ""
private val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(1423)) // MAG_SPRITE
private val imageCanvas = bufferedImageSprite.let {
ImageCanvas(it).apply {
preferredSize = Dimension(12, 12) // ICON_DIMENSION_SMALL
size = preferredSize
minimumSize = preferredSize
maximumSize = preferredSize
fillColor = WIDGET_COLOR
}
}
// Method to set the text programmatically
fun setText(newText: String) {
text = newText
SwingUtilities.invokeLater {
repaint()
}
}
// Method to get the current text
fun getText(): String {
return text
}
init {
preferredSize = Dimension(230, 30) // SEARCH_FIELD_DIMENSION
background = WIDGET_COLOR // COLOR_BACKGROUND_DARK
foreground = secondaryColor // COLOR_FOREGROUND_LIGHT
font = Font("Arial", Font.PLAIN, 14) // 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') {
onSearch(text)
} else {
text += e.keyChar
}
SwingUtilities.invokeLater {
repaint()
}
}
override fun keyPressed(e: KeyEvent) {
if (e.isControlDown) {
when (e.keyCode) {
KeyEvent.VK_A -> {
text = ""
SwingUtilities.invokeLater {
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 = ""
onSearch(text)
SwingUtilities.invokeLater {
repaint()
}
}
}
})
javax.swing.Timer(500, ActionListener { _ ->
cursorVisible = !cursorVisible
if (plugin.StateManager.focusedView == "REFLECTIVE_EDITOR_VIEW")
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
// 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 == "") {
g.color = Color.GRAY // Use a lighter color for placeholder text
g.drawString("Search plugins...", 30, 20)
} else {
g.color = foreground // Use normal color for actual 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 != "") {
g.color = Color.RED
g.drawString("x", width - 20, 20)
}
}
private fun getBufferedImageFromSprite(sprite: Any?): BufferedImage {
// This is a simplified version - you might need to adjust based on your actual implementation
// For now, let's just return a placeholder image
val image = BufferedImage(12, 12, BufferedImage.TYPE_INT_ARGB)
val g2d = image.createGraphics()
g2d.color = Color.GRAY
g2d.fillOval(2, 2, 8, 8)
g2d.drawLine(8, 8, 11, 11)
g2d.dispose()
return image
}
}

View file

@ -0,0 +1,14 @@
package KondoKit.components.ReflectiveEditorComponents
data class GitLabPlugin(
val id: String,
val path: String,
val pluginProperties: PluginProperties?,
val pluginError: String?
)
data class PluginProperties(
val author: String,
val version: String,
val description: String
)

View file

@ -0,0 +1,185 @@
package KondoKit.components.ReflectiveEditorComponents
import KondoKit.views.ReflectiveEditorView
import com.google.gson.Gson
import com.google.gson.JsonObject
import java.awt.EventQueue
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.*
import javax.swing.SwingUtilities
object GitLabPluginFetcher {
private const val GITLAB_PROJECT_ID = "38297322"
private const val GITLAB_BRANCH = "master"
private const val CHROME_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 const val DEBUG = true // Set to false to disable debug logging
// Thread pool for concurrent plugin property fetching
private val executorService = Executors.newFixedThreadPool(5)
// Debug logging function
private fun debugLog(message: String) {
if (DEBUG) {
println("[GitLabPluginFetcher] $message")
}
}
// Function to fetch plugins from GitLab
fun fetchGitLabPlugins(onComplete: (List<GitLabPlugin>) -> Unit) {
Thread {
try {
debugLog("Starting to fetch GitLab plugins...")
val plugins = mutableListOf<GitLabPlugin>()
val apiUrl = "https://gitlab.com/api/v4/projects/${GITLAB_PROJECT_ID}/repository/tree?ref=${GITLAB_BRANCH}&per_page=100"
debugLog("API URL: $apiUrl")
// Create URL connection
val url = URL(apiUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", CHROME_USER_AGENT)
debugLog("Set User-Agent header to: $CHROME_USER_AGENT")
// Read response
val responseCode = connection.responseCode
debugLog("Response code: $responseCode")
if (responseCode == HttpURLConnection.HTTP_OK) {
val reader = BufferedReader(InputStreamReader(connection.inputStream))
val response = reader.use { it.readText() }
debugLog("Response length: ${response.length} characters")
debugLog("Response preview: ${response.take(200)}...")
// Parse JSON response
val gson = Gson()
val treeItems = gson.fromJson(response, Array<JsonObject>::class.java)
debugLog("Parsed ${treeItems.size} items from JSON")
// Filter for directories (trees) with mode "040000"
val pluginDirectories = mutableListOf<Pair<String, String>>()
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")
// Fetch plugin properties in parallel
val pluginFutures = mutableListOf<Future<GitLabPlugin>>()
for ((folderId, folderPath) in pluginDirectories) {
val future = executorService.submit(Callable<GitLabPlugin> {
try {
val pluginProperties = fetchPluginProperties(folderPath)
debugLog("Successfully fetched properties for: $folderPath")
GitLabPlugin(folderId, folderPath, pluginProperties, null)
} catch (e: Exception) {
debugLog("Error fetching plugin.properties for $folderPath: ${e.message}")
GitLabPlugin(folderId, folderPath, null, "Error fetching plugin.properties")
}
})
pluginFutures.add(future)
}
// Collect results
for (future in pluginFutures) {
try {
plugins.add(future.get())
} catch (e: Exception) {
debugLog("Error getting plugin result: ${e.message}")
}
}
} else {
debugLog("HTTP error: $responseCode")
val errorReader = BufferedReader(InputStreamReader(connection.errorStream))
val errorResponse = errorReader.use { it.readText() }
debugLog("Error response: $errorResponse")
}
debugLog("Completed fetching plugins. Total plugins: ${plugins.size}")
// Update on UI thread
SwingUtilities.invokeLater {
onComplete(plugins)
}
} catch (e: Exception) {
debugLog("Exception occurred: ${e.message}")
e.printStackTrace()
SwingUtilities.invokeLater {
onComplete(emptyList())
}
}
}.start()
}
// Function to fetch plugin.properties from a specific folder
private fun fetchPluginProperties(folderPath: String): PluginProperties {
val pluginFilePath = "$folderPath/plugin.properties"
val pluginUrl = "https://gitlab.com/api/v4/projects/${GITLAB_PROJECT_ID}/repository/files/${pluginFilePath.replace("/", "%2F")}/raw?ref=${GITLAB_BRANCH}"
debugLog("Fetching plugin.properties from: $pluginUrl")
// Create URL connection
val url = URL(pluginUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", CHROME_USER_AGENT)
// Read response
val responseCode = connection.responseCode
debugLog("plugin.properties response code: $responseCode")
if (responseCode == HttpURLConnection.HTTP_OK) {
val reader = BufferedReader(InputStreamReader(connection.inputStream))
val rawContent = reader.use { it.readText() }
debugLog("plugin.properties content length: ${rawContent.length} characters")
debugLog("plugin.properties content preview: ${rawContent.take(200)}...")
return parseProperties(rawContent)
} else {
debugLog("Failed to fetch plugin.properties. Response code: $responseCode")
val errorReader = BufferedReader(InputStreamReader(connection.errorStream))
val errorResponse = errorReader.use { it.readText() }
debugLog("Error response for plugin.properties: $errorResponse")
throw Exception("Plugin file not found for folder: $folderPath")
}
}
// Function to parse plugin.properties content
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)
}
}

View file

@ -0,0 +1,35 @@
package KondoKit.components.ReflectiveEditorComponents
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit
/**
* Simple test to verify GitLabPluginFetcher debug logging works
*/
fun main() {
println("Testing GitLabPluginFetcher with debug logging...")
GitLabPluginFetcher.fetchGitLabPlugins { plugins ->
println("Fetched ${plugins.size} plugins")
plugins.forEach { plugin ->
println("- ${plugin.path}: ${plugin.pluginProperties?.description ?: plugin.pluginError}")
}
// Shutdown the executor service
try {
val field = GitLabPluginFetcher::class.java.getDeclaredField("executorService")
field.isAccessible = true
val executorService = field.get(GitLabPluginFetcher) as ExecutorService
executorService.shutdown()
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Give the async fetch some time to complete
Thread.sleep(10000)
println("Test completed")
}

View file

@ -0,0 +1,413 @@
package KondoKit.components.ReflectiveEditorComponents
import KondoKit.views.ReflectiveEditorView
import plugin.PluginRepository
import java.awt.EventQueue
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 GITLAB_PROJECT_PATH = "2009scape/tools/client-plugins"
private const val GITLAB_BRANCH = "master"
private const val CHROME_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 const val MAX_CONCURRENT_DOWNLOADS = 3
private const val DEBUG = true // Set to false to disable debug logging
// Thread pool for concurrent downloads
private val downloadExecutor = Executors.newFixedThreadPool(MAX_CONCURRENT_DOWNLOADS)
// Callback for download progress updates
interface DownloadProgressCallback {
fun onProgress(pluginName: String, progress: Int)
fun onComplete(pluginName: String, success: Boolean, errorMessage: String? = null)
}
// Debug logging function
private fun debugLog(message: String) {
if (DEBUG) {
println("[PluginDownloadManager] $message")
}
}
// Test URL method for debugging
fun testDownloadUrl(plugin: GitLabPlugin): String {
return "https://gitlab.com/$GITLAB_PROJECT_PATH/-/archive/$GITLAB_BRANCH/${GITLAB_PROJECT_PATH.replace("/", "-")}-$GITLAB_BRANCH.zip?path=${plugin.path}"
}
/**
* Download a single plugin
*/
fun downloadPlugin(plugin: GitLabPlugin, callback: (String, Boolean, String?) -> Unit) {
downloadExecutor.submit {
try {
debugLog("Starting download for plugin: ${plugin.path}")
// Download the plugin as a ZIP archive
val success = downloadAndExtractPlugin(plugin, object : DownloadProgressCallback {
override fun onProgress(pluginName: String, progress: Int) {
// We don't need to do anything here for the simple callback
}
override fun onComplete(pluginName: String, success: Boolean, errorMessage: String?) {
callback(pluginName, success, errorMessage)
}
})
if (success) {
debugLog("Successfully downloaded and extracted plugin: ${plugin.path}")
SwingUtilities.invokeLater {
callback(plugin.path, true, null)
}
} else {
debugLog("Failed to download plugin: ${plugin.path}")
SwingUtilities.invokeLater {
callback(plugin.path, false, "Failed to download plugin")
}
}
} catch (e: Exception) {
debugLog("Exception during download of ${plugin.path}: ${e.message}")
e.printStackTrace()
SwingUtilities.invokeLater {
callback(plugin.path, false, e.message)
}
}
}
}
/**
* 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)
// Download the plugin as a ZIP archive
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<GitLabPlugin>, callback: (String, Boolean, String?) -> Unit) {
debugLog("Starting concurrent download of ${plugins.size} plugins")
// Submit all downloads to the executor
for (plugin in plugins) {
downloadPlugin(plugin, object : DownloadProgressCallback {
override fun onProgress(pluginName: String, progress: Int) {
// We don't need to do anything here for the simple callback
}
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 {
// Validate plugin path
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
"https://gitlab.com/$GITLAB_PROJECT_PATH/-/archive/$GITLAB_BRANCH/${GITLAB_PROJECT_PATH.replace("/", "-")}-$GITLAB_BRANCH.zip?path=${plugin.path}&ref_type=heads",
// Format 2: Using ref parameter
"https://gitlab.com/$GITLAB_PROJECT_PATH/-/archive/$GITLAB_BRANCH/${GITLAB_PROJECT_PATH.replace("/", "-")}-$GITLAB_BRANCH.zip?path=${plugin.path}",
// Format 3: Direct archive URL without path parameter (we'll filter later)
"https://gitlab.com/$GITLAB_PROJECT_PATH/-/archive/$GITLAB_BRANCH/${GITLAB_PROJECT_PATH.replace("/", "-")}-$GITLAB_BRANCH.zip"
)
for (downloadUrl in downloadUrls) {
debugLog("Trying download URL: $downloadUrl")
// Validate URL
val url = try {
URL(downloadUrl)
} catch (e: Exception) {
debugLog("Invalid URL: $downloadUrl for plugin: ${plugin.path}. Error: ${e.message}")
continue
}
// Create URL connection
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", CHROME_USER_AGENT)
// Add Accept header to avoid 406 errors
connection.setRequestProperty("Accept", "*/*")
debugLog("Connection opened for plugin: ${plugin.path}")
debugLog("Request headers - User-Agent: ${connection.getRequestProperty("User-Agent")}")
debugLog("Request headers - Accept: ${connection.getRequestProperty("Accept")}")
// Get content length for progress tracking
val contentLength = connection.contentLength
debugLog("Content length: $contentLength bytes for plugin: ${plugin.path}")
// Read response
val responseCode = connection.responseCode
debugLog("Response code: $responseCode for plugin: ${plugin.path}")
// Log response message
val responseMessage = connection.responseMessage
debugLog("Response message: $responseMessage for plugin: ${plugin.path}")
if (responseCode == HttpURLConnection.HTTP_OK) {
// Check if input stream is available
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}")
// Create output directory
val pluginsDir = ReflectiveEditorView.pluginsDirectory
debugLog("Plugins directory: ${pluginsDir.absolutePath}")
// Validate plugins directory
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}")
// Create directory if it doesn't exist
if (!pluginDir.exists()) {
pluginDir.mkdirs()
debugLog("Created plugin directory: ${pluginDir.absolutePath}")
}
// Download the ZIP file
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}")
// Extract the ZIP file
if (extractZipFile(tempZipFile, pluginDir, plugin.path)) {
debugLog("Successfully extracted plugin to ${pluginDir.absolutePath}")
// Clean up temp file
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}")
// Validate inputs
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")
// Skip the top-level directory in the ZIP (GitLab adds a project-branch-hash directory)
// We want to extract the contents of the plugin directory directly
val relativePath = if (entryName.contains("/")) {
entryName.substring(entryName.indexOf("/") + 1)
} else {
entryName
}
debugLog("Relative path for entry: $relativePath")
// Only extract files that are part of this specific plugin
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}")
// Create parent directories if needed
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
}
}
// Extract file or directory
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 {
// Create file
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
}
}
}

View file

@ -0,0 +1,17 @@
package KondoKit.components.ReflectiveEditorComponents
/**
* Data class representing a plugin with its installation status and update information
*/
data class PluginStatus(
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
)

View file

@ -1,4 +1,4 @@
package KondoKit
package KondoKit.components
import KondoKit.plugin.Companion.SCROLL_BAR_COLOR
import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR

View file

@ -0,0 +1,168 @@
package KondoKit.components
import KondoKit.ImageCanvas
import KondoKit.SpriteToBufferedImage
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.*
open class SearchField(
protected val onSearch: (String) -> Unit,
private val placeholderText: String = "Search...",
private val fieldWidth: Int = 230,
private val fieldHeight: Int = 30,
private val viewName: String? = null
) : 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 {
preferredSize = Dimension(12, 12)
size = preferredSize
minimumSize = preferredSize
maximumSize = preferredSize
fillColor = WIDGET_COLOR
}
}
init {
val dimension = Dimension(fieldWidth, fieldHeight)
preferredSize = dimension
background = WIDGET_COLOR
foreground = secondaryColor
font = Font("Arial", Font.PLAIN, 14)
minimumSize = dimension
maximumSize = dimension
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') {
triggerSearch()
} else {
text += e.keyChar
}
SwingUtilities.invokeLater {
repaint()
}
}
override fun keyPressed(e: KeyEvent) {
if (e.isControlDown) {
when (e.keyCode) {
KeyEvent.VK_A -> {
text = ""
SwingUtilities.invokeLater {
repaint()
}
}
KeyEvent.VK_V -> {
try {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
val pasteText = clipboard.getData(DataFlavor.stringFlavor) as String
text += pasteText
SwingUtilities.invokeLater {
repaint()
}
} catch (ex: Exception) {
// Ignore clipboard errors
}
}
}
}
}
})
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.x > width - 20 && e.y < 20) {
text = ""
SwingUtilities.invokeLater {
repaint()
}
}
}
})
Timer(500) { _ ->
cursorVisible = !cursorVisible
// Only repaint if the view is active or if viewName is not specified
if (viewName == null || focusedView == viewName) {
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
// 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 // Use a lighter color for placeholder text
g.drawString(placeholderText, 30, 20)
} else {
g.color = foreground // Use normal color for actual 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
repaint()
}
fun getText(): String = text
private fun triggerSearch() {
val query = text.trim()
if (query.isNotEmpty()) {
text = query
repaint()
onSearch(query)
}
}
}

View file

@ -0,0 +1,503 @@
package KondoKit.components
import KondoKit.Helpers
import KondoKit.Helpers.FieldNotifier
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.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 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().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 = Font("RuneScape Small", Font.TRUETYPE_FONT, 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<out Enum<*>>).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.type == HashMap::class.java || field.type.simpleName == "HashMap" -> {
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 = 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 -> 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
)
}
}
fieldPanel.add(applyButton, gbc)
add(fieldPanel)
}
// Track field changes in real-time and update UI (skip HashMap fields)
if (field.type != HashMap::class.java && field.type.simpleName != "HashMap") {
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: java.lang.reflect.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)
// 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
}
} ?: ""
// Create title label
val titleLabel = JLabel("${field.name} (Key-Value Pairs)").apply {
foreground = secondaryColor
font = Font("RuneScape Small", Font.TRUETYPE_FONT, 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<Any, Any>()
// Create a table model for the HashMap
val tableModel = object : DefaultTableModel() {
init {
val columnVector = java.util.Vector<String>()
columnVector.add("Key")
columnVector.add("Value")
columnIdentifiers = columnVector
hashMap.forEach { (key, value) ->
val vector = java.util.Vector<Any>()
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 {
preferredSize = Dimension(Int.MAX_VALUE, 150)
maximumSize = preferredSize
minimumSize = preferredSize
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 = JButton("+").apply {
maximumSize = Dimension(40, 30)
addActionListener {
val vector = java.util.Vector<Any>()
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()
}
}
// Remove button
val removeButton = JButton("-").apply {
maximumSize = Dimension(40, 30)
addActionListener {
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 button
val applyButton = JButton("Apply Changes").apply {
maximumSize = Dimension(120, 30)
addActionListener {
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<Any, Any> ?: HashMap<Any, Any>()
// 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()
// Only add non-empty keys
if (key.isNotBlank()) {
// Skip empty values
if (value.isBlank()) {
Helpers.showToast(
this@SettingsPanel,
"Skipping entry with empty value for key '$key'",
JOptionPane.WARNING_MESSAGE
)
continue
}
// Try to convert the value to the appropriate type based on the field's generic type
val convertedValue = try {
// For HashMap<String, Int> - treat all HashMap<String, Int> fields the same way regardless of plugin
val fieldTypeName = field.genericType.toString()
if (fieldTypeName.contains("java.util.HashMap<java.lang.String, java.lang.Integer>") ||
fieldTypeName.contains("HashMap<String, Int>") ||
fieldTypeName.contains("HashMap<String, Integer>")) {
try {
val intValue = value.toInt()
intValue
} catch (e: NumberFormatException) {
Helpers.showToast(
this@SettingsPanel,
"Invalid number format for key '$key': '$value'. Using 0 as default.",
JOptionPane.WARNING_MESSAGE
)
0
}
}
// For other numeric types
else if (fieldTypeName.contains("java.lang.Integer") ||
fieldTypeName.contains("int")) {
value.toInt()
}
else if (fieldTypeName.contains("java.lang.Double") ||
fieldTypeName.contains("double")) {
value.toDouble()
}
else if (fieldTypeName.contains("java.lang.Float") ||
fieldTypeName.contains("float")) {
value.toFloat()
}
else if (fieldTypeName.contains("java.lang.Boolean") ||
fieldTypeName.contains("boolean")) {
value.toBoolean()
}
// Default to string for other types
else {
value
}
} catch (e: Exception) {
// If conversion fails, keep as string
value
}
currentHashMap[key] = convertedValue
}
}
// Update the field to trigger notifications (even though the reference is the same)
// This ensures OnKondoValueUpdated() gets called if it exists
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
}
companion object {
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 = Math.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(KondoKit.plugin.Companion.TOOLTIP_BACKGROUND)
val textColor = Helpers.colorToHex(KondoKit.plugin.Companion.secondaryColor)
contentPane = JLabel("<html><div style='color: $textColor; background-color: $bgColor; padding: 3px; word-break: break-all;'>$text</div></html>").apply {
border = BorderFactory.createLineBorder(Color.BLACK)
isOpaque = true
background = KondoKit.plugin.Companion.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(KondoKit.plugin.Companion.TOOLTIP_BACKGROUND)
val textColor = Helpers.colorToHex(KondoKit.plugin.Companion.secondaryColor)
label.text = "<html><div style='color: $textColor; background-color: $bgColor; padding: 3px; word-break: break-all;'>$text</div></html>"
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
}
}
}

View file

@ -0,0 +1,127 @@
package KondoKit.components
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 = Color(200, 200, 200)
private var buttonColor = Color(255, 255, 255)
private var borderColor = Color(50, 50, 50)
private var activeSwitch = Color(0, 125, 255)
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)
preferredSize = Dimension(32, 20)
maximumSize = Dimension(32, 20)
minimumSize = Dimension(32, 20)
isOpaque = false // Make the panel background transparent
}
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)
}
// Clear the buffer with transparent background
g?.color = Color(0, 0, 0, 0)
g?.fillRect(0, 0, width, height)
// Draw the track with circular ends (rounded rectangle)
val trackHeight = height - 2
val trackWidth = width - 2
val trackArc = trackHeight // Makes it fully rounded at the ends
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)
// Draw the thumb (circular button)
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
}
}

View file

@ -0,0 +1,31 @@
package KondoKit.components
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
preferredSize = Dimension(Int.MAX_VALUE, headerHeight)
border = BorderFactory.createEmptyBorder(5, 10, 5, 10)
layout = BorderLayout()
titleLabel.foreground = secondaryColor
titleLabel.font = Font("RuneScape Small", Font.PLAIN, 16)
titleLabel.horizontalAlignment = SwingConstants.CENTER
add(titleLabel, BorderLayout.CENTER)
}
fun setTitle(title: String) {
titleLabel.text = title
}
}

View file

@ -0,0 +1,35 @@
package KondoKit.components
import KondoKit.plugin.Companion.WIDGET_COLOR
import java.awt.BorderLayout
import java.awt.Dimension
import javax.swing.BorderFactory
import javax.swing.JPanel
class WidgetPanel(
private val widgetWidth: Int = 220,
private val widgetHeight: Int = 50,
private val addDefaultPadding: Boolean = true
) : JPanel() {
init {
layout = BorderLayout(5, 5)
background = WIDGET_COLOR
val size = Dimension(widgetWidth, widgetHeight)
preferredSize = size
maximumSize = size
minimumSize = size
if (addDefaultPadding) {
border = BorderFactory.createEmptyBorder(5, 5, 5, 5)
}
}
fun setFixedSize(width: Int, height: Int) {
val size = Dimension(width, height)
preferredSize = size
maximumSize = size
minimumSize = size
}
}

View file

@ -1,35 +1,18 @@
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.views.*
import KondoKit.views.OnUpdateCallback
import KondoKit.views.OnDrawCallback
import KondoKit.views.OnXPUpdateCallback
import KondoKit.views.OnKillingBlowNPCCallback
import KondoKit.views.OnPostClientTickCallback
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.components.ScrollablePanel
import plugin.Plugin
import plugin.api.*
import plugin.api.API.*
@ -98,9 +81,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,12 +101,38 @@ class plugin : Plugin() {
private const val HIDDEN_VIEW = "HIDDEN"
private var altCanvas: AltCanvas? = null
private val drawActions = mutableListOf<() -> Unit>()
private val views = mutableListOf<View>()
private val updateCallbacks = mutableListOf<OnUpdateCallback>()
private val drawCallbacks = mutableListOf<OnDrawCallback>()
private val xpUpdateCallbacks = mutableListOf<OnXPUpdateCallback>()
private val killingBlowNPCCallbacks = mutableListOf<OnKillingBlowNPCCallback>()
private val postClientTickCallbacks = mutableListOf<OnPostClientTickCallback>()
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() {
@ -137,7 +146,7 @@ class plugin : Plugin() {
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()
}
@ -164,41 +173,28 @@ 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()
frame.remove(rightPanelWrapper)
frame.layout = BorderLayout()
rightPanelWrapper?.let { frame.add(it, BorderLayout.EAST) }
frame.revalidate()
frame.repaint()
// Rebuild the reflective editor UI on the EDT and in one batch
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)
}
// Call registered XP update callbacks
xpUpdateCallbacks.forEach { callback ->
callback.onXPUpdate(skillId, xp)
}
}
override fun Draw(timeDelta: Long) {
@ -208,7 +204,10 @@ class plugin : Plugin() {
}
if (pluginsReloaded) {
reflectiveEditorView?.let { addPlugins(it) }
// Rebuild the reflective editor UI on the EDT and in one batch
SwingUtilities.invokeLater {
ReflectiveEditorView.addPlugins(ReflectiveEditorView.panel)
}
pluginsReloaded = false
}
@ -219,10 +218,18 @@ class plugin : Plugin() {
accumulatedTime += timeDelta
if (accumulatedTime >= TICK_INTERVAL) {
lootTrackerView?.let { onPostClientTick(it) }
// Call registered post client tick callbacks
postClientTickCallbacks.forEach { callback ->
callback.onPostClientTick()
}
accumulatedTime = 0L
}
// Call registered draw callbacks
drawCallbacks.forEach { callback ->
callback.onDraw(timeDelta)
}
// Draw synced actions (that require to be done between glBegin and glEnd)
if (drawActions.isNotEmpty()) {
synchronized(drawActions) {
@ -260,32 +267,17 @@ class plugin : Plugin() {
}
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()
// Call registered update callbacks
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)
// Call registered killing blow NPC callbacks
killingBlowNPCCallbacks.forEach { callback ->
callback.onKillingBlowNPC(npcID, x, z)
}
}
private fun allSpritesLoaded() : Boolean {
@ -296,7 +288,7 @@ class plugin : Plugin() {
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, Constants.COMBAT_LVL_SPRITE, LootTrackerView.BAG_ICON)
for (icon in otherIcons) {
if(!js5Archive8.isFileReady(icon)){
return false
@ -317,6 +309,11 @@ class plugin : Plugin() {
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) {
@ -409,7 +406,7 @@ class plugin : Plugin() {
private fun searchHiscore(username: String): Runnable {
return Runnable {
setActiveView(HiscoresView.VIEW_NAME)
val customSearchField = hiScoreView?.let { HiscoresView.CustomSearchField(it) }
val customSearchField = HiscoresView.hiScoreView?.let { HiscoresView.CustomSearchField(it) }
customSearchField?.searchPlayer(username) ?: run {
println("searchView is null or CustomSearchField creation failed.")
@ -447,15 +444,33 @@ class plugin : Plugin() {
}
// Register Views
createXPTrackerView()
createHiscoreSearchView()
createLootTrackerView()
createReflectiveEditorView()
val xpTrackerView = XPTrackerView
val hiscoresView = HiscoresView
val lootTrackerView = LootTrackerView
val reflectiveEditorView = ReflectiveEditorView
// Create views
xpTrackerView.createView()
hiscoresView.createView()
lootTrackerView.createView()
reflectiveEditorView.createView()
// Register views
views.add(xpTrackerView)
views.add(hiscoresView)
views.add(lootTrackerView)
views.add(reflectiveEditorView)
// Register view functions
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 +478,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 +496,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,7 +585,7 @@ class plugin : Plugin() {
}
lastClickTime = currentTime
if (focusedView == viewName) {
if (StateManager.focusedView == viewName) {
setActiveView("HIDDEN")
} else {
setActiveView(viewName)
@ -666,7 +716,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)

View file

@ -1,3 +1,3 @@
AUTHOR='downthecrop'
DESCRIPTION='A plugin that adds a right-side panel with custom widgets and navigation.'
VERSION=2.0
VERSION=2.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

View file

@ -0,0 +1,33 @@
package KondoKit.views
import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR
import java.awt.Dimension
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 = 242,
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) {
val dimension = Dimension(preferredWidth, height)
preferredSize = dimension
maximumSize = dimension
minimumSize = dimension
}
}

View file

@ -1,28 +1,21 @@
package KondoKit
package KondoKit.views
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.ImageCanvas
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.components.LabelComponent
import KondoKit.components.SearchField
import KondoKit.plugin.Companion.WIDGET_COLOR
import KondoKit.plugin.Companion.primaryColor
import KondoKit.plugin.Companion.VIEW_BACKGROUND_COLOR
import KondoKit.plugin.Companion.POPUP_FOREGROUND
import KondoKit.plugin.Companion.secondaryColor
import KondoKit.plugin.StateManager.focusedView
import KondoKit.plugin.Companion.primaryColor
import KondoKit.plugin.Companion.TOOLTIP_BACKGROUND
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
@ -64,125 +57,41 @@ object Constants {
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 {
object HiscoresView : View {
const val VIEW_NAME = "HISCORE_SEARCH_VIEW"
var hiScoreView: JPanel? = null
class CustomSearchField(private val hiscoresPanel: JPanel) : Canvas() {
override val name: String = VIEW_NAME
override val iconSpriteId: Int = Constants.MAG_SPRITE
override val panel: JPanel
get() = hiScoreView ?: JPanel()
private var cursorVisible: Boolean = true
override fun createView() {
createHiscoreSearchView()
}
override fun registerFunctions() {
// Hiscores functions are handled within the view itself
}
class CustomSearchField(private val hiscoresPanel: JPanel) : SearchField(
onSearch = { _ -> }, // Placeholder, will be replaced
viewName = VIEW_NAME
) {
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)
}
// This is a workaround to set the onSearch callback after the class is fully initialized
val onSearchField = javaClass.superclass.getDeclaredField("onSearch")
onSearchField.isAccessible = true
onSearchField.set(this, { username: String -> searchPlayer(username) })
}
fun searchPlayer(username: String) {
text = username.replace(" ", "_")
val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${text.toLowerCase()}"
val cleanUsername = username.replace(" ", "_")
setText(cleanUsername)
val apiUrl = "http://api.2009scape.org:3000/hiscores/playerSkills/1/${cleanUsername.toLowerCase()}"
updateHiscoresView(null, "Searching...")
@ -223,7 +132,6 @@ object HiscoresView {
}.start()
}
private fun updatePlayerData(jsonResponse: String, username: String) {
val hiscoresResponse = gson.fromJson(jsonResponse, HiscoresResponse::class.java)
updateHiscoresView(hiscoresResponse, username)
@ -232,10 +140,12 @@ object HiscoresView {
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 {
var nameLabel = LabelComponent().apply {
updateHtmlText(username, secondaryColor, "", primaryColor)
font = Constants.FONT_ARIAL_BOLD_12
foreground = Constants.COLOR_FOREGROUND_LIGHT
border = BorderFactory.createEmptyBorder(0, 6, 0, 0) // Top, Left, Bottom, Right padding
horizontalAlignment = JLabel.CENTER
}
playerNameLabel?.add(nameLabel)
playerNameLabel?.revalidate()
@ -260,10 +170,12 @@ object HiscoresView {
}
val exp_multiplier = data.info.exp_multiplier
nameLabel = JLabel(formatHtmlLabelText(username, secondaryColor, " (${exp_multiplier}x)", primaryColor), JLabel.CENTER).apply {
nameLabel = LabelComponent().apply {
updateHtmlText(username, secondaryColor, " (${exp_multiplier}x)", primaryColor)
font = Constants.FONT_ARIAL_BOLD_12
foreground = Constants.COLOR_FOREGROUND_LIGHT
border = BorderFactory.createEmptyBorder(0, 6, 0, 0) // Top, Left, Bottom, Right padding
horizontalAlignment = JLabel.CENTER
}
@ -342,13 +254,9 @@ object HiscoresView {
}
fun createHiscoreSearchView() {
val hiscorePanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
name = VIEW_NAME
val hiscorePanel = BaseView(VIEW_NAME, Constants.HISCORE_PANEL_DIMENSION.width).apply {
background = Constants.COLOR_BACKGROUND_MEDIUM
preferredSize = Constants.HISCORE_PANEL_DIMENSION
maximumSize = preferredSize
minimumSize = preferredSize
setViewSize(Constants.HISCORE_PANEL_DIMENSION.height)
}
val customSearchField = CustomSearchField(hiscorePanel)
@ -392,23 +300,23 @@ object HiscoresView {
minimumSize = preferredSize
}
for (i in SKILL_DISPLAY_ORDER) {
for (i in Constants.SKILL_DISPLAY_ORDER) {
val skillPanel = JPanel().apply {
layout = BorderLayout()
background = COLOR_BACKGROUND_DARK
background = Constants.COLOR_BACKGROUND_DARK
preferredSize = Constants.SKILL_PANEL_DIMENSION
maximumSize = preferredSize
minimumSize = preferredSize
border = MatteBorder(5, 0, 0, 0, COLOR_BACKGROUND_DARK)
border = MatteBorder(5, 0, 0, 0, Constants.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
preferredSize = Constants.SKILL_SPRITE_DIMENSION
size = Constants.SKILL_SPRITE_DIMENSION
fillColor = Constants.COLOR_BACKGROUND_DARK
}
}
@ -421,7 +329,7 @@ object HiscoresView {
}
val imageContainer = JPanel(FlowLayout(FlowLayout.CENTER, 5, 0)).apply {
background = COLOR_BACKGROUND_DARK
background = Constants.COLOR_BACKGROUND_DARK
add(imageCanvas)
add(numberLabel)
}
@ -433,7 +341,7 @@ object HiscoresView {
hiscorePanel.add(skillsPanel)
val totalCombatPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply {
background = COLOR_BACKGROUND_DARK
background = Constants.COLOR_BACKGROUND_DARK
preferredSize = Constants.TOTAL_COMBAT_PANEL_DIMENSION
maximumSize = preferredSize
minimumSize = preferredSize
@ -442,7 +350,7 @@ object HiscoresView {
val bufferedImageSprite = getBufferedImageFromSprite(API.GetSprite(Constants.LVL_BAR_SPRITE))
val totalLevelIcon = ImageCanvas(bufferedImageSprite).apply {
fillColor = COLOR_BACKGROUND_DARK
fillColor = Constants.COLOR_BACKGROUND_DARK
preferredSize = Constants.ICON_DIMENSION_LARGE
size = Constants.ICON_DIMENSION_LARGE
}
@ -464,7 +372,7 @@ object HiscoresView {
val bufferedImageSprite2 = getBufferedImageFromSprite(API.GetSprite(Constants.COMBAT_LVL_SPRITE))
val combatLevelIcon = ImageCanvas(bufferedImageSprite2).apply {
fillColor = COLOR_BACKGROUND_DARK
fillColor = Constants.COLOR_BACKGROUND_DARK
preferredSize = Constants.ICON_DIMENSION_LARGE
size = Constants.ICON_DIMENSION_LARGE
}
@ -478,7 +386,7 @@ object HiscoresView {
}
val combatLevelPanel = JPanel(FlowLayout(FlowLayout.LEFT)).apply {
background = COLOR_BACKGROUND_DARK
background = Constants.COLOR_BACKGROUND_DARK
add(combatLevelIcon)
add(combatLevelLabel)
}

View file

@ -1,18 +1,23 @@
package KondoKit
package KondoKit.views
import KondoKit.Helpers
import KondoKit.Helpers.addMouseListenerToAll
import KondoKit.Helpers.formatHtmlLabelText
import KondoKit.ImageCanvas
import KondoKit.SpriteToBufferedImage.getBufferedImageFromSprite
import KondoKit.XPTrackerView.wrappedWidget
import KondoKit.views.XPTrackerView.wrappedWidget
import KondoKit.components.PopupMenuComponent
import KondoKit.components.ProgressBar
import KondoKit.plugin.Companion.POPUP_BACKGROUND
import KondoKit.plugin.Companion.POPUP_FOREGROUND
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.*
@ -22,17 +27,16 @@ 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 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<Int, GroundSnapshot>()
var gePriceMap = loadGEPrices()
const val VIEW_NAME = "LOOT_TRACKER_VIEW"
@ -42,9 +46,33 @@ 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
override val panel: JPanel
get() = lootTrackerView ?: JPanel()
override fun createView() {
createLootTrackerView()
}
override fun registerFunctions() {
// Register callbacks with the plugin
KondoKit.plugin.registerPostClientTickCallback(this)
KondoKit.plugin.registerKillingBlowNPCCallback(this)
}
override fun onPostClientTick() {
lootTrackerView?.let { onPostClientTick(it) }
}
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<String, String> {
return if (plugin.useLiveGEPrices) {
return if (useLiveGEPrices) {
try {
println("LootTracker: Loading Remote GE Prices")
val url = URL("https://cdn.2009scape.org/gedata/latest.json")
@ -79,23 +107,21 @@ object LootTrackerView {
} 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<String, String>()
Helpers.readResourceText("res/item_configs.json")?.let { json ->
val items = json.trim().removeSurrounding("[", "]").split("},").map { it.trim() + "}" }
val gePrices = mutableMapOf<String, String>()
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
}
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
}
gePrices
} ?: emptyMap()
} catch (e: Exception) {
emptyMap()
}
@ -105,9 +131,7 @@ object LootTrackerView {
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()
@ -376,7 +400,8 @@ object LootTrackerView {
fun onPostClientTick(lootTrackerView: JPanel) {
val toRemove = mutableListOf<Int>()
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 +417,9 @@ object LootTrackerView {
}
}
toRemove.forEach { npcDeathSnapshots.remove(it) }
toRemove.forEach { npcId ->
npcDeathSnapshots.remove(npcId)
}
}
@ -427,7 +454,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)
}
}
@ -503,7 +530,7 @@ object LootTrackerView {
private fun removeLootFrameMenu(toRemove: JPanel, npcName: String): JPopupMenu {
// Create a popup menu
val popupMenu = JPopupMenu()
val popupMenu = PopupMenuComponent()
val rFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16)
popupMenu.background = POPUP_BACKGROUND
@ -540,20 +567,17 @@ object LootTrackerView {
private fun resetLootTrackerMenu(): JPopupMenu {
// Create a popup menu
val popupMenu = JPopupMenu()
val rFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16)
popupMenu.background = POPUP_BACKGROUND
val popupMenu = PopupMenuComponent()
// 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
font = Font("RuneScape Small", Font.TRUETYPE_FONT, 16)
background = POPUP_BACKGROUND
foreground = POPUP_FOREGROUND
}
popupMenu.add(menuItem1)
menuItem1.addActionListener {
plugin.registerDrawAction {
registerDrawAction {
resetLootTracker()
}
}
@ -608,4 +632,18 @@ object LootTrackerView {
data class GroundSnapshot(val items: Set<Item>, val location: Pair<Int, Int>, var age: Int)
data class Item(val id: Int, val quantity: Int)
}
// XPWidget data class for loot tracking
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
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
package KondoKit.views
import javax.swing.JPanel
interface View {
val name: String
val iconSpriteId: Int
val panel: JPanel
fun createView()
fun registerFunctions()
}

View file

@ -0,0 +1,21 @@
package KondoKit.views
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()
}

View file

@ -0,0 +1,497 @@
package KondoKit.views
import KondoKit.Helpers
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.XPTable
import KondoKit.components.PopupMenuComponent
import KondoKit.components.ProgressBar
import KondoKit.components.WidgetPanel
import KondoKit.plugin.Companion.IMAGE_SIZE
import KondoKit.plugin.Companion.LVL_ICON
import KondoKit.plugin.Companion.TOTAL_XP_WIDGET_SIZE
import KondoKit.plugin.Companion.WIDGET_COLOR
import KondoKit.plugin.Companion.WIDGET_SIZE
import KondoKit.plugin
import KondoKit.plugin.Companion.POPUP_BACKGROUND
import KondoKit.plugin.Companion.POPUP_FOREGROUND
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.image.BufferedImage
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
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<Int, XPWidget> = HashMap()
var totalXPWidget: XPWidget? = null
val initialXP: MutableMap<Int, Int> = 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<Int, BufferedImage> = HashMap()
val npcHitpointsMap: Map<Int, Int> = try {
val json = Helpers.readResourceText("res/npc_hitpoints_map.json") ?: "{}"
val pairs = json.trim().removeSurrounding("{", "}").split(",")
val map = mutableMapOf<Int, Int>()
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()
}
private val widgetFont = Font("RuneScape Small", Font.TRUETYPE_FONT, 16)
private fun createPopupListener(popupMenu: JPopupMenu) = 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)
}
}
private fun attachPopup(component: Container, popupMenu: JPopupMenu) {
val listener = createPopupListener(popupMenu)
addMouseListenerToAll(component, listener)
component.addMouseListener(listener)
}
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): JLabel {
return JLabel(formatHtmlLabelText("$title ", primaryColor, initialValue, secondaryColor)).apply {
horizontalAlignment = JLabel.LEFT
font = widgetFont
if (topPadding > 0) {
border = BorderFactory.createEmptyBorder(topPadding, 0, 0, 0)
} else {
border = BorderFactory.createEmptyBorder(0, 0, 0, 0)
}
}
}
private fun createMenuItem(text: String): JMenuItem = JMenuItem(text).apply {
font = widgetFont
background = POPUP_BACKGROUND
foreground = POPUP_FOREGROUND
}
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).also { attachPopup(it, popupMenu) }
}
override val panel: JPanel
get() = xpTrackerView ?: JPanel()
override fun createView() {
createXPTrackerView()
}
override fun registerFunctions() {
// Register callbacks with the plugin
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)
val popupMenu = removeXPWidgetMenu(wrapped, skillId)
attachPopup(wrapped, popupMenu)
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)
// 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) {
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(TOTAL_XP_WIDGET_SIZE.width, TOTAL_XP_WIDGET_SIZE.height, addDefaultPadding = false)
val iconContainer = createIconContainer(getBufferedImageFromSprite(API.GetSprite(LVL_ICON)))
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.add(iconContainer, 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) {
// Ignore preload errors; fallback at use time
}
}
fun createResetMenu(): JPopupMenu {
val popupMenu = PopupMenuComponent()
val resetItem = createMenuItem("Reset Tracker")
popupMenu.add(resetItem)
resetItem.addActionListener { plugin.registerDrawAction { resetXPTracker(xpTrackerView!!) } }
return popupMenu
}
fun removeXPWidgetMenu(toRemove: Container, skillId: Int): JPopupMenu {
val popupMenu = PopupMenuComponent()
val resetItem = createMenuItem("Reset")
popupMenu.add(resetItem)
val removeItem = createMenuItem("Remove")
popupMenu.add(removeItem)
resetItem.addActionListener {
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)
}
}
removeItem.addActionListener {
// 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(WIDGET_SIZE.width, WIDGET_SIZE.height, 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 {
preferredSize = Dimension(160, 22)
minimumSize = preferredSize
maximumSize = preferredSize
}
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): 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
)