diff --git a/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerConfig.java b/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerConfig.java index ea38412d39..134231e23d 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerConfig.java @@ -1,14 +1,31 @@ package net.runelite.client.plugins.microbot.HueycoatlPrayer; import net.runelite.client.config.*; +import net.runelite.client.plugins.microbot.util.prayer.Rs2PrayerEnum; @ConfigGroup("hueyprayer") public interface HueyPrayerConfig extends Config { + @ConfigSection( + name = "General", + description = "Core Huey prayer behaviour", + position = 0 + ) + String generalSection = "general"; + + @ConfigSection( + name = "Trio mode", + description = "After a Huey projectile hits, set protection for pillar charging (fixed or autobalance)", + position = 1 + ) + String trioSection = "trio"; + @ConfigItem( keyName = "enabled", name = "Enable", - description = "Enable auto prayer" + description = "Enable auto prayer", + position = 0, + section = generalSection ) default boolean enabled() { @@ -18,7 +35,9 @@ default boolean enabled() @ConfigItem( keyName = "disableAfterImpact", name = "Disable after impact", - description = "When enabled, turns off protection prayer after the projectile hits (saves prayer). When disabled, prayers stay on until the next attack switches them." + description = "When enabled, turns off protection prayer after the projectile hits (saves prayer). When disabled, prayers stay on until the next attack switches them.", + position = 1, + section = generalSection ) default boolean disableAfterImpact() { @@ -28,10 +47,85 @@ default boolean disableAfterImpact() @ConfigItem( keyName = "debug", name = "Debug Projectiles", - description = "Print projectile IDs" + description = "Print projectile IDs", + position = 2, + section = generalSection ) default boolean debug() { return false; } -} \ No newline at end of file + + @ConfigItem( + keyName = "trioMode", + name = "Trio mode", + description = "While incoming Huey projectiles still use the correct protect vs type, after impact (requires Disable after impact) switches to your pillar role: Fixed or Autobalance protection from teammates' overheads.", + position = 0, + section = trioSection + ) + default boolean trioMode() + { + return false; + } + + @ConfigItem( + keyName = "trioRoleStyle", + name = "Role style", + description = "Fixed: always use the protection prayer below. Autobalance: among other players in radius, count melee / missiles / magic overheads — you pray the least-covered protection.", + position = 1, + section = trioSection + ) + default TrioRoleStyle trioRoleStyle() + { + return TrioRoleStyle.FIXED; + } + + @ConfigItem( + keyName = "trioFixedProtection", + name = "Fixed protection", + description = "Used when Role style is Fixed.", + position = 2, + section = trioSection + ) + default TrioFixedProtection trioFixedProtection() + { + return TrioFixedProtection.PROTECT_RANGE; + } + + @ConfigItem( + keyName = "trioRadius", + name = "Autobalance radius", + description = "Tiles from your tile to include other players when autobalancing (they must still be loaded).", + position = 3, + section = trioSection + ) + default int trioRadius() + { + return 15; + } + + enum TrioRoleStyle + { + FIXED, + AUTOBALANCE + } + + enum TrioFixedProtection + { + PROTECT_MELEE(Rs2PrayerEnum.PROTECT_MELEE), + PROTECT_RANGE(Rs2PrayerEnum.PROTECT_RANGE), + PROTECT_MAGIC(Rs2PrayerEnum.PROTECT_MAGIC); + + private final Rs2PrayerEnum prayer; + + TrioFixedProtection(Rs2PrayerEnum prayer) + { + this.prayer = prayer; + } + + public Rs2PrayerEnum getPrayer() + { + return prayer; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerPlugin.java index 78a4ccfa89..b7ec7c9632 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/HueycoatlPrayer/HueyPrayerPlugin.java @@ -6,7 +6,11 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; +import net.runelite.api.HeadIcon; +import net.runelite.api.Player; import net.runelite.api.Projectile; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.ProjectileMoved; @@ -35,7 +39,7 @@ ) public class HueyPrayerPlugin extends Plugin { - static final String VERSION = "1.0.4"; + static final String VERSION = "1.0.7"; @Inject private Client client; @@ -129,8 +133,7 @@ public void onProjectileMoved(ProjectileMoved event) incomingHueyProjectiles.remove(projectile); if (incomingHueyProjectiles.isEmpty()) { - Rs2Prayer.disableAllPrayers(); - currentPrayer = null; + onHueyProjectilePhaseEnded(); } } return; @@ -173,9 +176,16 @@ private void switchPrayer(Rs2PrayerEnum prayer) { int tick = client.getTickCount(); - // prevent spam + duplicate toggles - if (currentPrayer == prayer) return; - if (tick == lastSwitchTick) return; + if (Rs2Prayer.isPrayerActive(prayer)) + { + currentPrayer = prayer; + return; + } + + if (tick == lastSwitchTick) + { + return; + } Rs2Prayer.disableAllPrayers(); Rs2Prayer.toggle(prayer, true); @@ -183,4 +193,141 @@ private void switchPrayer(Rs2PrayerEnum prayer) currentPrayer = prayer; lastSwitchTick = tick; } + + /** + * Last Huey projectile toward us has landed — either clear prayers or switch to trio pillar protection. + */ + private void onHueyProjectilePhaseEnded() + { + if (config.trioMode()) + { + Rs2PrayerEnum pillar = resolveTrioProtectionPrayer(); + if (pillar != null) + { + switchPrayer(pillar); + } + return; + } + Rs2Prayer.disableAllPrayers(); + currentPrayer = null; + } + + /** + * Fixed or autobalance protection for pillar charging (after projectiles, not during). + */ + private Rs2PrayerEnum resolveTrioProtectionPrayer() + { + if (config.trioRoleStyle() == HueyPrayerConfig.TrioRoleStyle.FIXED) + { + return config.trioFixedProtection().getPrayer(); + } + return pickAutobalanceProtectionPrayer(); + } + + private Rs2PrayerEnum pickAutobalanceProtectionPrayer() + { + Player local = client.getLocalPlayer(); + if (local == null) + { + return null; + } + WorldPoint localPoint = local.getWorldLocation(); + WorldView worldView = client.getTopLevelWorldView(); + if (worldView == null) + { + return null; + } + + int melee = 0; + int ranged = 0; + int magic = 0; + int radius = config.trioRadius(); + if (radius < 1) + { + radius = 1; + } + + for (Player p : worldView.players()) + { + if (p == null) + { + continue; + } + if (p == local) + { + continue; + } + WorldPoint wp = p.getWorldLocation(); + if (wp == null) + { + continue; + } + if (wp.distanceTo(localPoint) > radius) + { + continue; + } + HeadIcon overhead = p.getOverheadIcon(); + if (overhead == HeadIcon.MELEE) + { + melee++; + } + else if (overhead == HeadIcon.RANGED) + { + ranged++; + } + else if (overhead == HeadIcon.MAGIC) + { + magic++; + } + } + + if (melee == 0) + { + if (ranged == 0) + { + if (magic == 0) + { + return protectionMatchingLocalOverhead(local); + } + } + } + + int minCount = melee; + if (ranged < minCount) + { + minCount = ranged; + } + if (magic < minCount) + { + minCount = magic; + } + + if (melee == minCount) + { + return Rs2PrayerEnum.PROTECT_MELEE; + } + if (ranged == minCount) + { + return Rs2PrayerEnum.PROTECT_RANGE; + } + return Rs2PrayerEnum.PROTECT_MAGIC; + } + + private static Rs2PrayerEnum protectionMatchingLocalOverhead(Player local) + { + HeadIcon overhead = local.getOverheadIcon(); + if (overhead == HeadIcon.MELEE) + { + return Rs2PrayerEnum.PROTECT_MELEE; + } + if (overhead == HeadIcon.RANGED) + { + return Rs2PrayerEnum.PROTECT_RANGE; + } + if (overhead == HeadIcon.MAGIC) + { + return Rs2PrayerEnum.PROTECT_MAGIC; + } + return Rs2PrayerEnum.PROTECT_MELEE; + } } \ No newline at end of file diff --git a/src/main/java/net/runelite/client/plugins/microbot/qualityoflife/QoLPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/qualityoflife/QoLPlugin.java index d749d30ceb..135e489334 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/qualityoflife/QoLPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/qualityoflife/QoLPlugin.java @@ -32,7 +32,6 @@ import net.runelite.client.plugins.microbot.qualityoflife.scripts.wintertodt.WintertodtOverlay; import net.runelite.client.plugins.microbot.qualityoflife.scripts.wintertodt.WintertodtScript; import net.runelite.client.plugins.microbot.util.Global; -import net.runelite.client.plugins.microbot.util.antiban.FieldUtil; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; @@ -47,7 +46,6 @@ import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; -import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.SplashScreen; import net.runelite.client.ui.overlay.OverlayManager; import net.runelite.client.util.ImageUtil; @@ -55,16 +53,25 @@ import javax.inject.Inject; import javax.swing.*; +import javax.swing.plaf.ColorUIResource; import java.awt.*; import java.awt.datatransfer.DataFlavor; import java.awt.event.KeyEvent; import java.awt.image.BufferedImage; -import java.lang.reflect.Field; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import static net.runelite.client.plugins.microbot.qualityoflife.scripts.wintertodt.WintertodtScript.isInWintertodtRegion; @@ -88,13 +95,45 @@ public class QoLPlugin extends Plugin implements KeyListener { public static final List bankMenuEntries = new LinkedList<>(); public static final List furnaceMenuEntries = new LinkedList<>(); public static final List anvilMenuEntries = new LinkedList<>(); - private static final AtomicReference> pluginList = new AtomicReference<>(); + private static final AtomicBoolean uiUpdateQueued = new AtomicBoolean(false); + private static final AtomicBoolean uiRestoreQueued = new AtomicBoolean(false); + private static final AtomicBoolean uiQueuedDuringSplash = new AtomicBoolean(false); + private static final AtomicBoolean uiUpdatePendingAfterRestore = new AtomicBoolean(false); + + private static final AtomicBoolean loggedMissingSwitcherOn = new AtomicBoolean(false); + private static final AtomicBoolean loggedMissingSwitcherOff = new AtomicBoolean(false); + private static final AtomicBoolean loggedThemeException = new AtomicBoolean(false); + private static final AtomicBoolean loggedRestoreException = new AtomicBoolean(false); + private static final AtomicBoolean loggedToggleReflectionException = new AtomicBoolean(false); + private static final AtomicBoolean loggedMissingUiManagerKey = new AtomicBoolean(false); + private static final AtomicBoolean loggedMicrobotPluginCacheFailure = new AtomicBoolean(false); + private static final AtomicBoolean loggedMicrobotPluginFallbackScan = new AtomicBoolean(false); + + private static final AtomicReference selfRef = new AtomicReference<>(); + private static final AtomicReference microbotPluginRef = new AtomicReference<>(); + + // Use explicit synchronization everywhere (avoid mixing synchronizedMap + synchronized blocks). + private static final Map originalToggleIcons = new WeakHashMap<>(); + private static final Map originalLabelColors = new WeakHashMap<>(); + private static final Set touchedLabels = Collections.newSetFromMap(new WeakHashMap<>()); + private static final Map originalUiManagerValues = new HashMap<>(); + private static final String[] UI_KEYS_TO_PATCH = new String[]{ + "Component.accentColor", + "ProgressBar.selectionForeground", + "ProgressBar.selectionBackground", + "Button.default.focusColor" + }; private static final int HALF_ROTATION = 1024; private static final int FULL_ROTATION = 2048; private static final int PITCH_INDEX = 0; private static final int YAW_INDEX = 1; private static final BufferedImage SWITCHER_ON_IMG = getImageFromConfigResource("switcher_on"); + private static final BufferedImage SWITCHER_OFF_IMG = getImageFromConfigResource("switcher_off"); private static final BufferedImage STAR_ON_IMG = getImageFromConfigResource("star_on"); + private static volatile Color lastAccentApplied = null; + private static volatile Color lastToggleColorApplied = null; + private static volatile ImageIcon lastToggleOnIcon = null; + private static volatile Window lastWindowPatched = null; public static InventorySetup loadoutToLoad = null; private static GameState lastGameState = GameState.UNKNOWN; private final int[] deltaCamera = new int[3]; @@ -166,28 +205,59 @@ private static BufferedImage getImageFromConfigResource(String imgName) { Class clazz = Class.forName("net.runelite.client.plugins.config.ConfigPanel"); return ImageUtil.loadImageResource(clazz, imgName.concat(".png")); } catch (Exception e) { - e.printStackTrace(); + log.debug("QoL: failed to load ConfigPanel image {}", imgName, e); return null; } } private static ImageIcon remapImage(BufferedImage image, Color color) { - if (color != null) { - BufferedImage img = new BufferedImage(image.getWidth(), image.getHeight(), 2); - Graphics2D graphics = img.createGraphics(); - graphics.drawImage(image, 0, 0, null); - graphics.setColor(color); - graphics.setComposite(AlphaComposite.getInstance(10, 1)); - graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); - graphics.dispose(); - return new ImageIcon(img); - } else { + if (image == null || color == null) { return null; } + BufferedImage img = new BufferedImage(image.getWidth(), image.getHeight(), 2); + Graphics2D graphics = img.createGraphics(); + graphics.drawImage(image, 0, 0, null); + graphics.setColor(color); + graphics.setComposite(AlphaComposite.getInstance(10, 1)); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.dispose(); + return new ImageIcon(img); + } + + private static MicrobotPlugin findMicrobotPluginOnce() + { + MicrobotPlugin cached = microbotPluginRef.get(); + if (cached != null) + { + return cached; + } + + MicrobotPlugin found = (MicrobotPlugin) Microbot.getPluginManager().getPlugins().stream() + .filter(plugin -> plugin instanceof MicrobotPlugin) + .findAny().orElse(null); + if (found != null) + { + microbotPluginRef.compareAndSet(null, found); + } + return found; } @Override protected void startUp() throws AWTException { + selfRef.set(this); + // Cache MicrobotPlugin once (avoid scanning plugin list in steady-state UI updates). + try + { + findMicrobotPluginOnce(); + } + catch (Exception ex) + { + // Best-effort cache; updateUiElements() can still fall back if needed. + if (loggedMicrobotPluginCacheFailure.compareAndSet(false, true)) + { + log.debug("QoL: failed to cache MicrobotPlugin during startup", ex); + } + } if (overlayManager != null) { overlayManager.add(qoLOverlay); overlayManager.add(wintertodtOverlay); @@ -219,7 +289,10 @@ protected void startUp() throws AWTException { autoPrayer.run(config); keyManager.registerKeyListener(this); // pvpScript.run(config); - awaitExecutionUntil(() -> Microbot.getClientThread().invokeLater(this::updateUiElements), () -> !SplashScreen.isOpen(), 600); + uiQueuedDuringSplash.set(false); + awaitExecutionUntil(() -> queueUpdateUiElementsDuringSplash(), () -> !SplashScreen.isOpen(), 600); + // Splash closed; allow future splash-guarded queues. + uiQueuedDuringSplash.set(false); } @Override @@ -238,6 +311,56 @@ protected void shutDown() { potionManagerScript.shutdown(); autoPrayer.shutdown(); keyManager.unregisterKeyListener(this); + + // Best-effort restore of any UI theming we applied. + try + { + if (SwingUtilities.isEventDispatchThread()) + { + restoreUiElements(); + } + else + { + FutureTask restoreTask = new FutureTask<>(() -> + { + restoreUiElements(); + return null; + }); + SwingUtilities.invokeLater(restoreTask); + try + { + restoreTask.get(750, TimeUnit.MILLISECONDS); + } + catch (TimeoutException ignored) + { + log.warn("QoL: UI restore timed out during shutdown; continuing teardown."); + // Restore UIManager defaults immediately to reduce lingering accent. + if (SwingUtilities.isEventDispatchThread()) + { + restoreOriginalUiDefaultsOnly(); + } + else + { + SwingUtilities.invokeLater(QoLPlugin::restoreOriginalUiDefaultsOnly); + } + // Best-effort restore is already queued via `restoreTask`. + } + } + } + catch (Exception ex) + { + // shutdown path: avoid throwing; log once and continue teardown + if (loggedRestoreException.compareAndSet(false, true)) + { + log.warn("QoL: UI restore during shutdown failed", ex); + } + } + finally + { + // Always clear the ref so queued updates don't run post-shutdown. + selfRef.compareAndSet(this, null); + microbotPluginRef.set(null); + } } @Subscribe( @@ -247,7 +370,9 @@ public void onProfileChanged(ProfileChanged event) { log.info("Profile changed"); log.info("Updating UI elements"); // Wait for the splash screen to close before updating the UI elements - awaitExecutionUntil(() -> Microbot.getClientThread().invokeLater(this::updateUiElements), () -> !SplashScreen.isOpen(), 1000); + uiQueuedDuringSplash.set(false); + awaitExecutionUntil(() -> queueUpdateUiElementsDuringSplash(), () -> !SplashScreen.isOpen(), 1000); + uiQueuedDuringSplash.set(false); } @@ -283,7 +408,7 @@ public void onGameTick(GameTick event) { @Subscribe public void onGameStateChanged(GameStateChanged event) { if (event.getGameState() != GameState.UNKNOWN && lastGameState == GameState.UNKNOWN) { - updateUiElements(); + queueUpdateUiElements(); } if (event.getGameState() == GameState.LOGIN_SCREEN) { @@ -747,73 +872,506 @@ public void updateWintertodtInterupted(boolean interupted) { * @return true if the UI elements are successfully updated, false otherwise. */ private boolean updateUiElements() { - try { - // Get the Field object for the accent color (BRAND_ORANGE) in the ColorScheme class - Field accentColorField = ColorScheme.class.getDeclaredField("BRAND_ORANGE"); - // Update the accent color with the value from the config - FieldUtil.setFinalStatic(accentColorField, config.accentColor()); - - // Get the PluginToggleButton class to access its ON_SWITCHER field - Class pluginButton = Class.forName("net.runelite.client.plugins.microbot.ui.MicrobotPluginToggleButton"); - Field onSwitcherPluginPanel = pluginButton.getDeclaredField("ON_SWITCHER"); - onSwitcherPluginPanel.setAccessible(true); - // Update the ON_SWITCHER field with a remapped image based on the config toggle button color - FieldUtil.setFinalStatic(onSwitcherPluginPanel, remapImage(SWITCHER_ON_IMG, config.toggleButtonColor())); + // Swing/FlatLaf layout is not thread-safe; enforce EDT execution. + if (!SwingUtilities.isEventDispatchThread()) { + QoLPlugin self = selfRef.get(); + if (self == null) + { + return false; + } + // If a run is already queued and we still have a live plugin, consider it pending. + if (uiUpdateQueued.get()) + { + return true; + } + return queueUpdateUiElements(); + } + try { // Find the ConfigPlugin instance from the plugin manager - MicrobotPlugin microbotPlugin = (MicrobotPlugin) Microbot.getPluginManager().getPlugins().stream() - .filter(plugin -> plugin instanceof MicrobotPlugin) - .findAny().orElse(null); + MicrobotPlugin microbotPlugin = findMicrobotPluginOnce(); + if (microbotPlugin == null && loggedMicrobotPluginFallbackScan.compareAndSet(false, true)) + { + log.debug("QoL: MicrobotPlugin ref was empty; fell back to plugin manager scan."); + } // If ConfigPlugin is not found, log an error and return false if (microbotPlugin == null) { Microbot.log("Config Plugin not found"); return false; } + microbotPluginRef.set(microbotPlugin); // Get the plugin list panel from the ConfigPlugin instance JPanel pluginListPanel = getPluginListPanel(microbotPlugin); + Window pluginWindow = SwingUtilities.getWindowAncestor(pluginListPanel); + if (pluginWindow != lastWindowPatched) + { + // If the config window closed while we had an accent applied, restore defaults now. + if (pluginWindow == null && lastAccentApplied != null) + { + restoreOriginalUiDefaultsOnly(); + lastAccentApplied = null; + } + lastWindowPatched = pluginWindow; + synchronized (originalToggleIcons) + { + originalToggleIcons.clear(); + } + synchronized (originalLabelColors) + { + originalLabelColors.clear(); + } + synchronized (touchedLabels) + { + touchedLabels.clear(); + } + lastAccentApplied = null; + lastToggleColorApplied = null; + lastToggleOnIcon = null; + } + + // Best-effort accent color behavior (no static-final mutation / no Unsafe). + try + { + Color accent = config.accentColor(); + if (accent != null) + { + ColorUIResource accentRes = new ColorUIResource(accent); + rememberOriginalUiDefaults(); + // Only apply+refresh when accent actually changed. + if (!accent.equals(lastAccentApplied)) + { + UIManager.put("Component.accentColor", accentRes); + UIManager.put("ProgressBar.selectionForeground", accentRes); + UIManager.put("ProgressBar.selectionBackground", accentRes); + UIManager.put("Button.default.focusColor", accentRes); + lastAccentApplied = accent; + + // Refresh UI tree to apply defaults. + try + { + if (pluginWindow != null) + { + SwingUtilities.updateComponentTreeUI(pluginWindow); + pluginWindow.invalidate(); + pluginWindow.validate(); + pluginWindow.repaint(); + } + } + catch (Exception ex) + { + if (loggedThemeException.compareAndSet(false, true)) + { + log.warn("QoL: UI refresh after accent update failed", ex); + } + } + } + } + else if (lastAccentApplied != null) + { + // Accent cleared; restore original defaults (if we captured them) and refresh UI. + restoreOriginalUiDefaultsOnly(); + lastAccentApplied = null; + + try + { + if (pluginWindow != null) + { + SwingUtilities.updateComponentTreeUI(pluginWindow); + pluginWindow.invalidate(); + pluginWindow.validate(); + pluginWindow.repaint(); + } + } + catch (Exception ex) + { + if (loggedThemeException.compareAndSet(false, true)) + { + log.warn("QoL: UI refresh after accent restore failed", ex); + } + } + } + } + catch (Exception ex) + { + if (loggedThemeException.compareAndSet(false, true)) + { + log.warn("QoL: UI theme update failed", ex); + } + } + // Set the plugin list using the retrieved plugin list panel - pluginList.set(getPluginList(pluginListPanel)); + List currentPluginList = getPluginList(pluginListPanel); // If the plugin list is still null, log an error and return false - if (pluginList.get() == null) { + if (currentPluginList == null) { Microbot.log("Plugin list is null, waiting for it to be initialized"); return false; } - // Iterate through each plugin in the plugin list - for (Object plugin : pluginList.get()) { + // Pass 1: capture originals (before applying any changes). + for (Object plugin : currentPluginList) + { + try + { + if (plugin instanceof JPanel) + { + for (Component component : ((JPanel) plugin).getComponents()) + { + if (component instanceof JLabel) + { + JLabel label = (JLabel) component; + synchronized (originalLabelColors) + { + originalLabelColors.computeIfAbsent(label, l -> l.getForeground()); + } + } + } + } + + JToggleButton onOffToggle = (JToggleButton) FieldUtils.readDeclaredField(plugin, "onOffToggle", true); + if (onOffToggle == null) + { + continue; + } + synchronized (originalToggleIcons) + { + originalToggleIcons.computeIfAbsent(onOffToggle, t -> new IconState(t.getIcon(), t.getSelectedIcon())); + } + } + catch (Exception ex) + { + // Best-effort: one broken row shouldn't abort the whole update. + if (loggedToggleReflectionException.compareAndSet(false, true)) + { + log.debug("QoL: reflection lookup for plugin toggle failed; UI theming may be partial.", ex); + } + } + } + + // Pass 2: apply theming changes. + final Color labelColor = config.pluginLabelColor(); + final ImageIcon onIcon = getCachedToggleOnIcon(config.toggleButtonColor()); + for (Object plugin : currentPluginList) { // If the plugin is a JPanel, update the color of any JLabel components within it if (plugin instanceof JPanel) { for (Component component : ((JPanel) plugin).getComponents()) { if (component instanceof JLabel) { + JLabel label = (JLabel) component; // Set the label color based on the config - component.setForeground(config.pluginLabelColor()); + if (labelColor != null) + { + if (!labelColor.equals(label.getForeground())) + { + synchronized (touchedLabels) + { + touchedLabels.add(label); + } + label.setForeground(labelColor); + } + } } } } // Get the on/off toggle button for the plugin and update its selected icon - JToggleButton onOffToggle = (JToggleButton) FieldUtils.readDeclaredField(plugin, "onOffToggle", true); - onOffToggle.setSelectedIcon(remapImage(SWITCHER_ON_IMG, config.toggleButtonColor())); + JToggleButton onOffToggle; + try + { + onOffToggle = (JToggleButton) FieldUtils.readDeclaredField(plugin, "onOffToggle", true); + if (onOffToggle == null) + { + continue; + } + } + catch (Exception ex) + { + continue; + } + // Only recolor the "ON" (selected) icon. Do not overwrite the "OFF" icon, + // otherwise disabled plugins will also appear enabled. + Icon offIcon = onOffToggle.getIcon(); + if (onIcon == null) + { + if (loggedMissingSwitcherOn.compareAndSet(false, true)) + { + log.warn("QoL: missing ConfigPanel switcher_on.png; leaving plugin toggle icons unchanged."); + } + continue; + } + onOffToggle.setSelectedIcon(onIcon); + if (offIcon != null) { + onOffToggle.setIcon(offIcon); + } else if (SWITCHER_OFF_IMG != null) { + // Fallback: ensure OFF icon is distinct if missing. + onOffToggle.setIcon(new ImageIcon(SWITCHER_OFF_IMG)); + } else { + if (loggedMissingSwitcherOff.compareAndSet(false, true)) + { + log.warn("QoL: missing ConfigPanel switcher_off.png; OFF icon fallback unavailable."); + } + } } return true; } catch (Exception e) { // Log any exceptions that occur during the UI update process String errorMessage = "QoL Error updating UI elements: " + e.getMessage(); - log.error(errorMessage); + log.error(errorMessage, e); Microbot.log(errorMessage); return false; } } + private static ImageIcon getCachedToggleOnIcon(Color toggleColor) + { + if (SWITCHER_ON_IMG == null) + { + return null; + } + + if (toggleColor == null) + { + lastToggleColorApplied = null; + lastToggleOnIcon = null; + return null; + } + + if (!toggleColor.equals(lastToggleColorApplied) || lastToggleOnIcon == null) + { + lastToggleOnIcon = remapImage(SWITCHER_ON_IMG, toggleColor); + lastToggleColorApplied = toggleColor; + } + + return lastToggleOnIcon; + } + + private static final class IconState + { + private final Icon icon; + private final Icon selectedIcon; + + private IconState(Icon icon, Icon selectedIcon) + { + this.icon = icon; + this.selectedIcon = selectedIcon; + } + } + + private static void rememberOriginalUiDefaults() + { + synchronized (originalUiManagerValues) + { + if (!originalUiManagerValues.isEmpty()) + { + return; + } + + for (String k : UI_KEYS_TO_PATCH) + { + Object v = UIManager.get(k); + originalUiManagerValues.put(k, v); + if (v == null && !UIManager.getDefaults().containsKey(k) && loggedMissingUiManagerKey.compareAndSet(false, true)) + { + log.debug("QoL: UIManager key '{}' not present in defaults; accent patch may be FlatLaf-version dependent.", k); + } + } + } + } + + private static void restoreOriginalUiDefaultsOnly() + { + synchronized (originalUiManagerValues) + { + if (originalUiManagerValues.isEmpty()) + { + return; + } + + for (String k : UI_KEYS_TO_PATCH) + { + UIManager.put(k, originalUiManagerValues.get(k)); + } + } + } + + private static void queueRestoreUiElements() + { + if (!uiRestoreQueued.compareAndSet(false, true)) + { + return; + } + + SwingUtilities.invokeLater(() -> + { + try + { + restoreUiElements(); + } + finally + { + uiRestoreQueued.set(false); + } + }); + } + + private static void restoreUiElements() + { + if (!SwingUtilities.isEventDispatchThread()) + { + log.warn("QoL: restoreUiElements called off-EDT; skipping."); + return; + } + + // Restore UIManager defaults. + synchronized (originalUiManagerValues) + { + if (!originalUiManagerValues.isEmpty()) + { + for (String k : UI_KEYS_TO_PATCH) + { + UIManager.put(k, originalUiManagerValues.get(k)); + } + } + } + + // Restore per-component UI state (best-effort; components may be gone/rebuilt). + synchronized (originalToggleIcons) + { + for (Map.Entry e : originalToggleIcons.entrySet()) + { + JToggleButton t = e.getKey(); + IconState s = e.getValue(); + if (t != null && s != null) + { + t.setIcon(s.icon); + t.setSelectedIcon(s.selectedIcon); + } + } + } + + synchronized (originalLabelColors) + { + for (Map.Entry e : originalLabelColors.entrySet()) + { + JLabel l = e.getKey(); + boolean touched; + synchronized (touchedLabels) + { + touched = touchedLabels.contains(l); + } + if (l != null && touched) + { + l.setForeground(e.getValue()); + } + } + } + + // Refresh UI tree to apply restored defaults. + try + { + QoLPlugin self = selfRef.get(); + MicrobotPlugin microbotPlugin = microbotPluginRef.get(); + if (self != null && microbotPlugin != null) + { + JPanel pluginListPanel = self.getPluginListPanel(microbotPlugin); + Window w = SwingUtilities.getWindowAncestor(pluginListPanel); + if (w != null) + { + SwingUtilities.updateComponentTreeUI(w); + w.invalidate(); + w.validate(); + w.repaint(); + } + } + } + catch (Exception ex) + { + if (loggedRestoreException.compareAndSet(false, true)) + { + log.warn("QoL: UI restore refresh failed", ex); + } + } + + // Clear caches so re-enable re-captures fresh state. + synchronized (originalToggleIcons) + { + originalToggleIcons.clear(); + } + synchronized (originalLabelColors) + { + originalLabelColors.clear(); + } + synchronized (touchedLabels) + { + touchedLabels.clear(); + } + // Intentionally keep captured defaults for plugin lifetime so we can restore later. + lastAccentApplied = null; + lastToggleColorApplied = null; + lastToggleOnIcon = null; + lastWindowPatched = null; + + // If an update was requested while restore was in-progress, run it now. + if (uiUpdatePendingAfterRestore.compareAndSet(true, false)) + { + queueUpdateUiElements(); + } + } + + private static boolean queueUpdateUiElements() + { + // Don't interleave apply with restore. + if (uiRestoreQueued.get()) + { + uiUpdatePendingAfterRestore.set(true); + return false; + } + + // Prevent re-entrant scheduling during layout/validate cascades. + if (!uiUpdateQueued.compareAndSet(false, true)) + { + return selfRef.get() != null; + } + + SwingUtilities.invokeLater(() -> + { + try + { + QoLPlugin self = selfRef.get(); + if (self != null) + { + self.updateUiElements(); + } + } + finally + { + uiUpdateQueued.set(false); + } + }); + + return true; + } + + private static boolean queueUpdateUiElementsDuringSplash() + { + // Prevent repeated enqueue spam while waiting for splash to close. + if (!uiQueuedDuringSplash.compareAndSet(false, true)) + { + return false; + } + return queueUpdateUiElements(); + } + private JPanel getPluginListPanel(MicrobotPlugin microbotPlugin) throws ClassNotFoundException { Class pluginListPanelClass = Class.forName("net.runelite.client.plugins.microbot.ui.MicrobotPluginListPanel"); - assert microbotPlugin != null; + if (microbotPlugin == null) + { + throw new IllegalStateException("MicrobotPlugin instance is null"); + } return (JPanel) microbotPlugin.getInjector().getProvider(pluginListPanelClass).get(); }