diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..873018f92 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# CustomNPC+ 1.11 Changelog + +## Ability System +- Phase-based abilities (Wind-up, Active, Dazed) with configurable cooldowns, targeting, range, interrupts, invulnerability, and magic damage +- **Melee:** Slam, Heavy Hit, Cutter +- **Energy Projectiles:** Orb, Disc, Beam, Laser Shot with variant system, homing, hit types (Single/Pierce/Multi), size interpolation, explosions +- **Barriers:** Dome, Wall, Shield with HP, reflection, damage absorption +- **Defensive:** Guard, Counter, Dodge +- **Movement:** Charge, Dash, Teleport +- **AOE:** Shockwave, Sweeper, Vortex +- **Hazards & Traps:** Persistent zones and proximity triggers with visual presets +- **Effect:** Healing, buffs, debuffs with target filtering (Allies/Enemies/All) +- **Custom Ability:** Blank-slate type with full script hooks and toggle modes +- Burst system, chained ability combos with concurrency, telegraph system (6 shapes) +- Per-phase animations and sound events +- Ability conditions: HP Threshold, Hit Count, Has Item, Quest Completed, Has Effect +- 12-slot player ability hotbar with HUD editor +- Full scripting API for ability and energy projectile lifecycle events +- Ability Extender system for custom hooks +- Live preview in editor +- Commands: `/kam ability list|types|prebuilts|info|delete|reload|give|remove|giveChain|removeChain|player` + +## Auction House +- Global marketplace with bidding, buyout, and snipe protection +- Built-in currency system (configurable, profile-persistent, Vault support) +- Listing fees, sales tax, minimum prices +- My Trades panel with claim management (20-day expiry) +- Search/sort, notifications (online + offline summary) +- Trade slots (default 8, expandable via permissions) +- Item blacklist (by item, wildcard, mod, NBT; soulbound always blocked) +- Auctioneer NPC role +- Admin GUI and logging +- Cancelable scripting events: Create, Bid, Buyout, Cancel, Claim + +## Script Editor +- Full code editor with syntax highlighting, autocomplete, error detection, and hover tooltips +- Go to Line, Search/Replace, Go to Definition, Fullscreen +- Undo/Redo, Duplicate Line, Comment Toggle, Rename, Move Lines +- Line numbers, smooth scrolling, indent guides, brace matching, smart brackets + +## Java Scripting +- Write scripts in Java via Janino compiler with automatic hook resolution +- Per-tab language selector (mix Java and JavaScript) +- External `.java` file support + +## Client-Side Scripting +- Server-controlled client script execution with auto-sync (off by default) + +## Addon API Support +- Ship `.d.ts` type definitions for automatic script editor autocomplete +- Patch support for extending CNPC+ types, hot reload + +## Traders +- Stock system: Global or Per-Player modes with configurable restock timers +- Currency trades (items + coins) +- Client-side balance preview + +## Cloner +- Folder and subfolder organization for all categories +- Full-width directory browser, improved tabs, tag-based filtering + +## Animations +- Data store for passing data across events/frames +- Consumer-based task system +- Expanded scripting API + +## Animated Textures +- Frame-based animation for items, effect icons, ability hotbar, scripted GUIs + +## NPC API +- `IHitboxData` (width/height scaling), `ITintData` (hurt tint, persistent tints) +- Scriptable behavior: sprint, swim, doors, fire reactions, tactical chance, walking range +- `ISound` (volume, pitch, repeat, position) +- Skin overlay `getColor()`/`setColor()`, knockback API +- Six overlays in singleplayer, script error reporting to console + +## NPC Combat & Behavior +- Attack speed enforcement, passive faction protection +- Improved track speed, invalid texture fallback + +## Other Changes +- Player Dialog Events match NPC Dialog Events +- Linked Item attack speed and scripting API +- Right-click cycling on multi-option buttons +- Legacy quest import (1.6.4), fullscreen/pannable GUIs, unified menu background +- Textfield CTRL shortcuts, `java.lang.reflect` banned in scripts +- Entity packet loss fix (invisible NPCs) +- Natural spawn GUI, runtime control, and despawn improvements + +## Bug Fixes +- Script config sync, Bukkit integration +- Quest cooldown for MC/RL Custom timers, quest/dialog saving and categories +- Bard music restart on dialog open +- Spawner yaw, biome spawn persistence, NPC invisibility +- Track speed, set speed edge cases, spawn data, block waypoints, job spawner +- Hitbox scaling and collision +- Animation offsets, lookup failures, syncing, resync on teleport, registration names, double loading +- Mount movement jitter, dismount edge cases diff --git a/build.gradle b/build.gradle index a76186d6d..3dfbb49ae 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id "dts.typescript-generator" } -version = "1.11" +version = "1.11.1" group= "kamkeel.customnpc-plus" archivesBaseName = "CustomNPC-Plus" diff --git a/src/main/java/kamkeel/npcs/controllers/ProfileController.java b/src/main/java/kamkeel/npcs/controllers/ProfileController.java index fcb3436af..c4be7322e 100644 --- a/src/main/java/kamkeel/npcs/controllers/ProfileController.java +++ b/src/main/java/kamkeel/npcs/controllers/ProfileController.java @@ -7,7 +7,9 @@ import kamkeel.npcs.controllers.data.profile.Profile; import kamkeel.npcs.controllers.data.profile.ProfileInfoEntry; import kamkeel.npcs.controllers.data.profile.ProfileOperation; import kamkeel.npcs.controllers.data.profile.Slot; +import kamkeel.npcs.network.packets.data.ProfileSharedQuestPacket; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.nbt.CompressedStreamTools; import net.minecraft.nbt.NBTTagCompound; import noppes.npcs.CustomNpcs; @@ -453,6 +455,9 @@ public class ProfileController implements IProfileHandler { PlayerDataScript handler = ScriptController.Instance.getPlayerScripts(profile.player); verifySlotQuests(profile.player); save(profile.player, profile); + if (profile.player instanceof EntityPlayerMP) { + ProfileSharedQuestPacket.sendToPlayer((EntityPlayerMP) profile.player); + } IPlayer scriptPlayer = (IPlayer) NpcAPI.Instance().getIEntity(profile.player); EventHooks.onProfileCreate(handler, scriptPlayer, profile, newSlotId, true); @@ -524,6 +529,9 @@ public class ProfileController implements IProfileHandler { saveSlotData(profile.player, profile, prevSlot); loadSlotData(profile.player, profile, newSlotId); syncSharedQuestsToPlayer(profile.player); + if (profile.player instanceof EntityPlayerMP) { + ProfileSharedQuestPacket.sendToPlayer((EntityPlayerMP) profile.player); + } EventHooks.onProfileChange(handler, scriptPlayer, profile, newSlotId, prevSlot, true); } else { @@ -901,5 +909,8 @@ public class ProfileController implements IProfileHandler { profile.sharedQuestTimestamps.put(questId, completeTime); } save(player, profile); + if (player instanceof EntityPlayerMP) { + ProfileSharedQuestPacket.sendToPlayer((EntityPlayerMP) player); + } } } diff --git a/src/main/java/kamkeel/npcs/controllers/SyncController.java b/src/main/java/kamkeel/npcs/controllers/SyncController.java index d0ee7b16c..57b111404 100644 --- a/src/main/java/kamkeel/npcs/controllers/SyncController.java +++ b/src/main/java/kamkeel/npcs/controllers/SyncController.java @@ -12,6 +12,7 @@ import kamkeel.npcs.network.PacketHandler; import kamkeel.npcs.network.enums.EnumSyncAction; import kamkeel.npcs.network.enums.EnumSyncType; import kamkeel.npcs.network.packets.data.LoginPacket; +import kamkeel.npcs.network.packets.data.ProfileSharedQuestPacket; import kamkeel.npcs.network.packets.data.ability.AbilityCooldownSyncPacket; import kamkeel.npcs.network.packets.data.ability.AbilityHotbarSyncPacket; import kamkeel.npcs.network.packets.data.ability.PlayerAbilitySyncPacket; @@ -184,6 +185,7 @@ public class SyncController { DBCAddon.instance.syncPlayer(player); syncPlayerData(player, false); PartyInfoPacket.sendPartyData(player); + ProfileSharedQuestPacket.sendToPlayer(player); // Sync skin overlays after full handshake to ensure the client is ready. // Overlays are not part of getSyncNBTFull(), and packets sent during diff --git a/src/main/java/kamkeel/npcs/network/PacketHandler.java b/src/main/java/kamkeel/npcs/network/PacketHandler.java index ef71ba967..06bffe6a7 100644 --- a/src/main/java/kamkeel/npcs/network/PacketHandler.java +++ b/src/main/java/kamkeel/npcs/network/PacketHandler.java @@ -586,6 +586,7 @@ public class PacketHandler { DATA_PACKET.registerPacket(new UpdateAnimationsPacket()); DATA_PACKET.registerPacket(new VillagerListPacket()); DATA_PACKET.registerPacket(new PlayerDataInfoPacket()); + DATA_PACKET.registerPacket(new ProfileSharedQuestPacket()); // Data | GUI Packets DATA_PACKET.registerPacket(new GuiClosePacket()); diff --git a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java index 1a24ac2ef..598126a2d 100644 --- a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java +++ b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java @@ -67,6 +67,9 @@ public enum EnumDataPacket { ABILITY_HOTBAR_SYNC, ABILITY_COOLDOWN_SYNC, + // Profile System + PROFILE_SHARED_QUEST, + // Auction System AUCTION_DATA, diff --git a/src/main/java/kamkeel/npcs/network/packets/data/ProfileSharedQuestPacket.java b/src/main/java/kamkeel/npcs/network/packets/data/ProfileSharedQuestPacket.java new file mode 100644 index 000000000..6fa6187c9 --- /dev/null +++ b/src/main/java/kamkeel/npcs/network/packets/data/ProfileSharedQuestPacket.java @@ -0,0 +1,57 @@ +package kamkeel.npcs.network.packets.data; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import io.netty.buffer.ByteBuf; +import kamkeel.npcs.network.AbstractPacket; +import kamkeel.npcs.network.PacketChannel; +import kamkeel.npcs.network.PacketHandler; +import kamkeel.npcs.network.enums.EnumDataPacket; +import kamkeel.npcs.util.ByteBufUtils; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.client.ProfileClientConfig; +import noppes.npcs.controllers.ProfileConfigSync; + +import java.io.IOException; + +public final class ProfileSharedQuestPacket extends AbstractPacket { + public static final String packetName = "Data|ProfileSharedQuest"; + + private NBTTagCompound compound; + + public ProfileSharedQuestPacket() { + } + + public ProfileSharedQuestPacket(NBTTagCompound compound) { + this.compound = compound; + } + + @Override + public Enum getType() { + return EnumDataPacket.PROFILE_SHARED_QUEST; + } + + @Override + public PacketChannel getChannel() { + return PacketHandler.DATA_PACKET; + } + + @Override + public void sendData(ByteBuf out) throws IOException { + ByteBufUtils.writeNBT(out, this.compound); + } + + @SideOnly(Side.CLIENT) + @Override + public void receiveData(ByteBuf in, EntityPlayer player) throws IOException { + NBTTagCompound nbt = ByteBufUtils.readNBT(in); + ProfileClientConfig.readFromNBT(nbt); + } + + public static void sendToPlayer(EntityPlayerMP player) { + NBTTagCompound compound = ProfileConfigSync.writeToNBT(player); + PacketHandler.Instance.sendToPlayer(new ProfileSharedQuestPacket(compound), player); + } +} diff --git a/src/main/java/noppes/npcs/CustomNpcs.java b/src/main/java/noppes/npcs/CustomNpcs.java index 810354152..d72bdf23f 100644 --- a/src/main/java/noppes/npcs/CustomNpcs.java +++ b/src/main/java/noppes/npcs/CustomNpcs.java @@ -125,7 +125,7 @@ import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; -@Mod(modid = "customnpcs", name = "CustomNPC+", version = "1.11") +@Mod(modid = "customnpcs", name = "CustomNPC+", version = "1.11.1") public class CustomNpcs { @SidedProxy(clientSide = "noppes.npcs.client.ClientProxy", serverSide = "noppes.npcs.CommonProxy") diff --git a/src/main/java/noppes/npcs/EventHooks.java b/src/main/java/noppes/npcs/EventHooks.java index 92475f8b3..e07facff0 100644 --- a/src/main/java/noppes/npcs/EventHooks.java +++ b/src/main/java/noppes/npcs/EventHooks.java @@ -92,6 +92,7 @@ import noppes.npcs.scripted.item.ScriptItemStack; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; +import java.util.HashMap; public class EventHooks { public EventHooks() { @@ -1053,12 +1054,24 @@ public class EventHooks { NpcAPI.EVENT_BUS.post(event); } + private static final HashMap, String> forgeEventNameCache = new HashMap<>(); + + private static String getForgeEventName(Event event) { + Class clazz = event.getClass(); + String cached = forgeEventNameCache.get(clazz); + if (cached != null) + return cached; + String eventName = clazz.getName(); + int i = eventName.lastIndexOf("."); + eventName = StringUtils.uncapitalize(eventName.substring(i + 1).replace("$", "")); + forgeEventNameCache.put(clazz, eventName); + return eventName; + } + public static void onForgeEvent(ForgeEvent ev, Event event) { ForgeDataScript handler = ScriptController.Instance.forgeScripts; if (handler.isEnabled()) { - String eventName = event.getClass().getName(); - int i = eventName.lastIndexOf("."); - eventName = StringUtils.uncapitalize(eventName.substring(i + 1).replace("$", "")); + String eventName = getForgeEventName(event); if (event.isCancelable()) { ev.setCanceled(event.isCanceled()); } diff --git a/src/main/java/noppes/npcs/client/ClientCacheHandler.java b/src/main/java/noppes/npcs/client/ClientCacheHandler.java index 30b14be83..3386ffb43 100644 --- a/src/main/java/noppes/npcs/client/ClientCacheHandler.java +++ b/src/main/java/noppes/npcs/client/ClientCacheHandler.java @@ -124,6 +124,7 @@ public class ClientCacheHandler { ClientCacheHandler.customOverlays.clear(); ClientCacheHandler.skinOverlays.clear(); ClientCacheHandler.playerAnimations.clear(); + ProfileClientConfig.reset(); // Clear Texture Caches GuiSoundSelection.cachedDomains.clear(); diff --git a/src/main/java/noppes/npcs/client/ClientProxy.java b/src/main/java/noppes/npcs/client/ClientProxy.java index 481e6ada8..ce49e6eee 100644 --- a/src/main/java/noppes/npcs/client/ClientProxy.java +++ b/src/main/java/noppes/npcs/client/ClientProxy.java @@ -930,6 +930,28 @@ public class ClientProxy extends CommonProxy { return Minecraft.getMinecraft().fontRenderer.getStringWidth(text); } + /** + * Return the width of a string when rendered with the specified font style (bold/italic). + * + * @param text the string to measure + * @param fontStyle combination of {@link java.awt.Font#BOLD} and {@link java.awt.Font#ITALIC}, + * or {@link java.awt.Font#PLAIN} for normal style + * @return the width in pixels + */ + public int width(String text, int fontStyle) { + if (useCustomFont) + return textFont.getStringWidth(text, fontStyle); + if (fontStyle == java.awt.Font.PLAIN) + return Minecraft.getMinecraft().fontRenderer.getStringWidth(text); + StringBuilder sb = new StringBuilder(); + if ((fontStyle & java.awt.Font.BOLD) != 0) + sb.append('\u00A7').append('l'); + if ((fontStyle & java.awt.Font.ITALIC) != 0) + sb.append('\u00A7').append('o'); + sb.append(text); + return Minecraft.getMinecraft().fontRenderer.getStringWidth(sb.toString()); + } + public FontContainer copy() { FontContainer font = new FontContainer(); font.textFont = textFont; @@ -939,7 +961,7 @@ public class ClientProxy extends CommonProxy { public void drawString(String text, int x, int y, int color) { if (useCustomFont) { - textFont.renderString(text, x, y, color, true); + // textFont.renderString(text, x, y, color, true); textFont.renderString(text, x, y, color, false); } else { Minecraft.getMinecraft().fontRenderer.drawStringWithShadow(text, x, y, color); diff --git a/src/main/java/noppes/npcs/client/CustomNpcResourceListener.java b/src/main/java/noppes/npcs/client/CustomNpcResourceListener.java index 14bb52e71..ed186d9cd 100644 --- a/src/main/java/noppes/npcs/client/CustomNpcResourceListener.java +++ b/src/main/java/noppes/npcs/client/CustomNpcResourceListener.java @@ -10,12 +10,12 @@ import net.minecraft.util.StatCollector; import noppes.npcs.CustomNpcs; import noppes.npcs.LogWriter; import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeRegistry; +import noppes.npcs.client.gui.util.script.interpreter.token.ScriptColorScheme; import javax.imageio.ImageIO; import java.awt.Graphics; import java.awt.image.BufferedImage; -import java.io.File; -import java.io.InputStream; +import java.io.*; public class CustomNpcResourceListener implements IResourceManagerReloadListener { @@ -30,8 +30,11 @@ public class CustomNpcResourceListener implements SimpleReloadableResourceManager simplemanager = (SimpleReloadableResourceManager) var1; FolderResourcePack pack = new FolderResourcePack(CustomNpcs.Dir); + LogWriter.info("[ResourceListener] Adding FolderResourcePack (as fallback): " + CustomNpcs.Dir.getAbsolutePath()); simplemanager.reloadResourcePack(pack); + ScriptColorScheme.reloadColorScheme(var1); + // Reload scripting .d.ts files JSTypeRegistry registry = JSTypeRegistry.getInstance(); registry.clear(); diff --git a/src/main/java/noppes/npcs/client/ProfileClientConfig.java b/src/main/java/noppes/npcs/client/ProfileClientConfig.java new file mode 100644 index 000000000..8a23cc5ca --- /dev/null +++ b/src/main/java/noppes/npcs/client/ProfileClientConfig.java @@ -0,0 +1,64 @@ +package noppes.npcs.client; + +import kamkeel.npcs.controllers.ProfileController; +import kamkeel.npcs.controllers.data.profile.Profile; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.config.ConfigMain; + +import java.util.HashMap; +import java.util.Map; + +public class ProfileClientConfig { + private static volatile Map sharedQuestTimestamps = new HashMap<>(); + + public static void readFromNBT(NBTTagCompound compound) { + if (compound == null) return; + + Map timestamps = new HashMap<>(); + if (compound.hasKey("SharedQuestTimestamps")) { + NBTTagList list = compound.getTagList("SharedQuestTimestamps", 10); + for (int i = 0; i < list.tagCount(); i++) { + NBTTagCompound entry = list.getCompoundTagAt(i); + timestamps.put(entry.getInteger("Quest"), entry.getLong("Date")); + } + } + sharedQuestTimestamps = timestamps; + } + + public static void reset() { + sharedQuestTimestamps = new HashMap<>(); + } + + /** + * Whether profiles are enabled, checking the correct source for each side. + */ + public static boolean isProfilesEnabled() { + if (ProfileController.Instance != null) + return ConfigMain.ProfilesEnabled; + return ClientCacheHandler.allowProfiles; + } + + /** + * Check if a quest has a shared timestamp, resolving from server or client cache. + */ + public static boolean hasSharedQuest(EntityPlayer player, int questId) { + if (ProfileController.Instance != null) { + Profile profile = ProfileController.Instance.getProfile(player); + return profile != null && profile.sharedQuestTimestamps.containsKey(questId); + } + return sharedQuestTimestamps.containsKey(questId); + } + + /** + * Get the shared quest timestamp, resolving from server or client cache. + */ + public static Long getSharedQuestTimestamp(EntityPlayer player, int questId) { + if (ProfileController.Instance != null) { + Profile profile = ProfileController.Instance.getProfile(player); + return profile != null ? profile.sharedQuestTimestamps.get(questId) : null; + } + return sharedQuestTimestamps.get(questId); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/select/GuiSoundSelection.java b/src/main/java/noppes/npcs/client/gui/select/GuiSoundSelection.java index aba735970..220833f2b 100644 --- a/src/main/java/noppes/npcs/client/gui/select/GuiSoundSelection.java +++ b/src/main/java/noppes/npcs/client/gui/select/GuiSoundSelection.java @@ -145,7 +145,8 @@ public class GuiSoundSelection extends SubGuiInterface implements ICustomScrollL scrollSounds.resetScroll(); getTextField(66).setText(""); soundSearch = ""; - initGui(); + scrollSounds.setList(domains.get(selectedDomain)); + getButton(1).enabled = false; } if (guiCustomScroll.id == 1) { selectedResource = new ResourceLocation(selectedDomain, guiCustomScroll.getSelected()); diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java index d98eae47d..647621117 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -19,6 +19,7 @@ import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo; import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; import noppes.npcs.client.gui.util.script.interpreter.token.Token; +import noppes.npcs.client.gui.util.script.interpreter.token.ScriptColorScheme; import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; import noppes.npcs.client.gui.util.script.interpreter.hover.GutterIconRenderer; import noppes.npcs.client.gui.util.script.interpreter.hover.HoverState; @@ -556,9 +557,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { int cursorCol = 0; if (container != null && container.lines != null && cursorLine < container.lines.size()) { LineData ld = container.lines.get(cursorLine); - String lineText = ld.text; int cursorOffset = selection.getCursorPosition() - ld.start; - cursorCol = ClientProxy.Font.width(lineText.substring(0, Math.min(cursorOffset, lineText.length()))); + ScriptLine sl = container.getDocument() != null ? container.getDocument().getLine(cursorLine) : null; + if (sl != null) { + cursorCol = sl.getRenderedWidth(0, Math.min(cursorOffset, ld.text.length())); + } else { + cursorCol = ClientProxy.Font.width(ld.text.substring(0, Math.min(cursorOffset, ld.text.length()))); + } } int screenX = GuiScriptTextArea.this.x + LINE_NUMBER_GUTTER_WIDTH + 1 + cursorCol; @@ -597,18 +602,18 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Draw outer border around entire area int offset = fullscreen() ? 2 : 1; drawRect(x - offset, y - offset - searchBar.getTotalHeight(), x + width + offset, y + height + offset, - 0xffa0a0a0); + ScriptColorScheme.getBackgroundStyle().getBorderColor()); int searchHeight = searchBar.getTotalHeight(); // Draw line number gutter background int viewportX = x + LINE_NUMBER_GUTTER_WIDTH; - drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff000000); + drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, ScriptColorScheme.getBackgroundStyle().getGutterColor()); // Draw text viewport background (starts after gutter) - drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, 0xff000000); + drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, ScriptColorScheme.getBackgroundStyle().getBackgroundColor()); // Draw separator line between gutter and text area - drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff3c3f41); + drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, ScriptColorScheme.getBackgroundStyle().getGutterSeparatorColor()); // Enable scissor test to clip drawing to the TEXT viewport rectangle (excludes gutter) GL11.glEnable(GL11.GL_SCISSOR_TEST); @@ -624,9 +629,15 @@ public class GuiScriptTextArea extends GuiNpcTextField { ((GuiNPCInterface) listener).mouseScroll = wheelDelta; // Intercept wheel scroll for tooltip (highest priority) - if (wheelDelta != 0 && hoverState.isTooltipVisible() && hoverState.isMouseOverTooltip(xMouse, yMouse)) { - hoverState.scrollTooltip(wheelDelta); - wheelDelta = 0; + // Only consume the wheel event when the tooltip actually has content to scroll; + // otherwise let it fall through to the editor so non-scrollable tooltips don't eat input. + if (wheelDelta != 0 && hoverState.isTooltipVisible()) { + if (hoverState.isMouseOverTooltip(xMouse, yMouse)) { + hoverState.scrollTooltip(wheelDelta); + wheelDelta = 0; + } else { + hoverState.clearHover(); + } } // Let autocomplete menu consume scroll first if visible @@ -833,13 +844,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5 - GutterIconRenderer.ICON_GUTTER_WIDTH; // right-align before icon space int lineNumY = posY + 1; // Highlight current line number - int lineNumColor = 0xFF606366; + int lineNumColor = ScriptColorScheme.getBackgroundStyle().getLineNumberColor(); if (active && isEnabled()) { for (int li = 0; li < list.size(); li++) { LineData ld = list.get(li); if (selection.getCursorPosition() >= ld.start && selection.getCursorPosition() < ld.end || (li == list.size() - 1 && selection.getCursorPosition() == text.length())) { if (li == i) { - lineNumColor = 0xFFb9c7d6; + lineNumColor = ScriptColorScheme.getBackgroundStyle().getLineNumberActiveColor(); break; } } @@ -860,13 +871,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { //Highlight braces the cursor position is on if (startBracket != endBracket) { if (startBracket >= data.start && startBracket < data.end) { - int s = ClientProxy.Font.width(line.substring(0, startBracket - data.start)); - int e = ClientProxy.Font.width(line.substring(0, startBracket - data.start + 1)) + 1; + int s = scriptLine.getRenderedWidth(0, startBracket - data.start); + int e = scriptLine.getRenderedWidth(0, startBracket - data.start + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); } if (endBracket >= data.start && endBracket < data.end) { - int s = ClientProxy.Font.width(line.substring(0, endBracket - data.start)); - int e = ClientProxy.Font.width(line.substring(0, endBracket - data.start + 1)) + 1; + int s = scriptLine.getRenderedWidth(0, endBracket - data.start); + int e = scriptLine.getRenderedWidth(0, endBracket - data.start + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); } } @@ -876,8 +887,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { for (int ubPos : unmatchedBraces) { if (ubPos >= data.start && ubPos < data.end) { int rel = ubPos - data.start; - int s = ClientProxy.Font.width(line.substring(0, rel)); - int e = ClientProxy.Font.width(line.substring(0, rel + 1)) + 1; + int s = scriptLine.getRenderedWidth(0, rel); + int e = scriptLine.getRenderedWidth(0, rel + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0xffcc0000); } } @@ -887,8 +898,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { Matcher m = container.regexWord.matcher(line); while (m.find()) { if (line.substring(m.start(), m.end()).equals(highlightedWord)) { - int s = ClientProxy.Font.width(line.substring(0, m.start())); - int e = ClientProxy.Font.width(line.substring(0, m.end())) + 1; + int s = scriptLine.getRenderedWidth(0, m.start()); + int e = scriptLine.getRenderedWidth(0, m.end()) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x99004c00); } } @@ -905,8 +916,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { int matchStart = Math.max(match[0] - data.start, 0); int matchEnd = Math.min(match[1] - data.start, line.length()); if (matchStart < matchEnd) { - int s = ClientProxy.Font.width(line.substring(0, matchStart)); - int e = ClientProxy.Font.width(line.substring(0, matchEnd)) + 1; + int s = scriptLine.getRenderedWidth(0, matchStart); + int e = scriptLine.getRenderedWidth(0, matchEnd) + 1; boolean isExcluded = searchBar.isMatchExcluded(mi); // Current match gets brighter highlight, others get dimmer int highlightColor = (mi == currentMatchIdx) ? 0xBB4488ff : 0x662266aa; @@ -940,9 +951,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { boolean isEmpty = (occ[0] == occ[1]); if (occStart <= occEnd) { // Changed from < to <= to handle empty case - int s = ClientProxy.Font.width(line.substring(0, occStart)); - int e = isEmpty ? s + 2 : ClientProxy.Font.width( - line.substring(0, occEnd)) + 1; // 2px wide for empty + int s = scriptLine.getRenderedWidth(0, occStart); + int e = isEmpty ? s + 2 : scriptLine.getRenderedWidth( + 0, occEnd) + 1; // 2px wide for empty int occX = x + LINE_NUMBER_GUTTER_WIDTH + s; int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e; boolean isPrimary = renameHandler.isPrimaryOccurrence(occ[0]); @@ -974,7 +985,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (currentWord != null && cursorInWord >= 0 && cursorInWord <= currentWord.length()) { String beforeCursor = currentWord.substring(0, Math.min(cursorInWord, currentWord.length())); - int cursorX = occX + ClientProxy.Font.width(beforeCursor); + int cursorX = occX + scriptLine.getRenderedWidth(occStart, occStart + beforeCursor.length()); // drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1, // 0xFFFFFFFF); } @@ -992,10 +1003,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Highlight selection if (selection.hasSelection() && selection.getEndSelection() > data.start && selection.getStartSelection() <= data.end) { if (selection.getStartSelection() < data.end) { - int s = ClientProxy.Font.width( - line.substring(0, Math.max(selection.getStartSelection() - data.start, 0))); - int e = ClientProxy.Font.width( - line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1; + int s = scriptLine.getRenderedWidth( + 0, Math.max(selection.getStartSelection() - data.start, 0)); + int e = scriptLine.getRenderedWidth( + 0, Math.min(selection.getEndSelection() - data.start, w)) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff); } } @@ -1050,9 +1061,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Draw cursor: pause blinking while user is active recently boolean recentInput = selection.hadRecentInput(); if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { - int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width( - line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); - drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, 0xffffffff); + int posX = x + LINE_NUMBER_GUTTER_WIDTH + scriptLine.getRenderedWidth( + 0, Math.min(selection.getCursorPosition() - data.start, line.length())); + drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, ScriptColorScheme.getBackgroundStyle().getCaretColor()); } } } @@ -1078,7 +1089,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { double linesCount = (double) effLines; int posY = (int) (y + 1f * scroll.getScrollPos() / linesCount * (height - 4)) + 1; - drawRect(posX, posY, posX + 5, posY + sbSize + 2, 0xFFe0e0e0); + drawRect(posX, posY, posX + 5, posY + sbSize + 2, ScriptColorScheme.getBackgroundStyle().getScrollbarColor()); } if (parent != null) @@ -1143,11 +1154,14 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines +1) { double yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight; if (yMouseD >= yPos && yMouseD < yPos + this.container.lineHeight) { + ScriptLine scriptLine = container.getDocument() != null ? container.getDocument().getLine(i) : null; int lineWidth = 0; char[] chars = data.text.toCharArray(); for (int j = 1; j <= chars.length; ++j) { - int w = ClientProxy.Font.width(data.text.substring(0, j)); + int w = scriptLine != null + ? scriptLine.getRenderedWidth(0, j) + : ClientProxy.Font.width(data.text.substring(0, j)); if (xMouse < lineWidth + (w - lineWidth) / 2) { return data.start + j - 1; } @@ -1225,9 +1239,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); tokenLocalEnd = Math.max(0, Math.min(tokenLocalEnd, lineText.length())); - int tokenScreenX = viewportX + ClientProxy.Font.width(lineText.substring(0, tokenLocalStart)); + int tokenScreenX = viewportX + lineData.getRenderedWidth(0, tokenLocalStart); int tokenScreenY = y + (lineIdx - scroll.getScrolledLine()) * container.lineHeight - (int)fracPixels; - int tokenWidth = ClientProxy.Font.width(lineText.substring(tokenLocalStart, tokenLocalEnd)); + int tokenWidth = lineData.getRenderedWidth(tokenLocalStart, tokenLocalEnd); return new Object[] { token, tokenScreenX, tokenScreenY, tokenWidth }; } @@ -2570,6 +2584,34 @@ public class GuiScriptTextArea extends GuiNpcTextField { String after = getSelectionAfterText(); int cursorPos = selection.getCursorPosition(); + // Diamond/generic angle bracket handling + if (c == '<') { + if (!container.getDocument().isExcludedInclusive(cursorPos) && isGenericContext(before)) { + if (hasUnmatchedCloserAhead(after, '<', '>')) { + // There's already an unmatched '>' ahead — just insert '<' + setText(before + "<" + after, true); + selection.reset(before.length() + 1); + } else { + setText(before + "<>" + after, true); + selection.reset(before.length() + 1); + } + scrollToCursor(); + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } + } + + if (c == '>') { + if (after.length() > 0 && after.charAt(0) == '>') { + if (shouldSkipAngleBracketClose(before)) { + selection.reset(before.length() + 1); + scrollToCursor(); + autocompleteManager.onCharTyped(c, text, cursorPos); + return true; + } + } + } + if ((c == ')' || c == ']' || c == '}') && after.length() > 0 && after.charAt(0) == c) { selection.reset(before.length() + 1); scrollToCursor(); @@ -2688,6 +2730,86 @@ public class GuiScriptTextArea extends GuiNpcTextField { return (backslashes % 2) == 1; } + private boolean isGenericContext(String before) { + if (before == null || before.isEmpty()) return false; + + int i = before.length() - 1; + while (i >= 0 && (before.charAt(i) == ' ' || before.charAt(i) == '\t')) { + i--; + } + if (i < 0) return false; + + char last = before.charAt(i); + + if (last == '>' || last == ')' || last == ']') return true; + if (last == ',') return true; + + if (Character.isJavaIdentifierPart(last)) { + int end = i + 1; + while (i >= 0 && Character.isJavaIdentifierPart(before.charAt(i))) { + i--; + } + String word = before.substring(i + 1, end); + + if (isComparisonKeyword(word)) return false; + if (isNumericLiteral(word)) return false; + + return true; + } + + return false; + } + + private boolean isComparisonKeyword(String word) { + return "if".equals(word) || "while".equals(word) || "for".equals(word) + || "return".equals(word) || "else".equals(word) || "case".equals(word) + || "assert".equals(word) || "throw".equals(word) || "true".equals(word) + || "false".equals(word) || "null".equals(word); + } + + private boolean isNumericLiteral(String word) { + if (word.isEmpty()) return false; + char first = word.charAt(0); + return first >= '0' && first <= '9'; + } + + private boolean shouldSkipAngleBracketClose(String before) { + if (before == null || before.isEmpty()) return false; + int depth = 0; + boolean inLineComment = false; + boolean inBlockComment = false; + boolean inString = false; + boolean escape = false; + char stringDelimiter = 0; + + for (int i = 0; i < before.length(); i++) { + char c = before.charAt(i); + char next = i + 1 < before.length() ? before.charAt(i + 1) : 0; + + if (inString) { + if (escape) { escape = false; } + else if (c == '\\') { escape = true; } + else if (c == stringDelimiter || c == '\n') { inString = false; } + continue; + } + if (inBlockComment) { + if (c == '*' && next == '/') { inBlockComment = false; i++; } + continue; + } + if (inLineComment) { + if (c == '\n') { inLineComment = false; } + continue; + } + if (c == '/' && next == '/') { inLineComment = true; i++; continue; } + if (c == '/' && next == '*') { inBlockComment = true; i++; continue; } + if (c == '"' || c == '\'') { inString = true; stringDelimiter = c; escape = false; continue; } + + if (c == '<') depth++; + else if (c == '>') depth--; + } + return depth > 0; + } + private boolean hasUnmatchedCloserAhead(String after, char opener, char closer) { int balance = 0; int len = after.length(); diff --git a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java index 989656ddf..5877c5a13 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java @@ -1181,13 +1181,15 @@ public class FormatHelper { return settings.spaceBeforeSemicolon ? " " : ""; } - // ---- COLON spacing (ternary : always gets space, label : uses settings) ---- + // ---- COLON spacing (ternary : always gets space, enhanced-for : always gets space, label : uses settings) ---- if (prev.type == TokenType.COLON) { if (isTernaryColon(allTokens, currIdx - 1)) return " "; + if (isEnhancedForLoopColon(allTokens, currIdx - 1)) return " "; return settings.spaceAfterColon ? " " : ""; } if (curr.type == TokenType.COLON) { if (isTernaryColon(allTokens, currIdx)) return " "; + if (isEnhancedForLoopColon(allTokens, currIdx)) return " "; return settings.spaceBeforeColon ? " " : ""; } @@ -1398,6 +1400,39 @@ public class FormatHelper { return false; } + /** + * Check if a colon at the given index is the separator in an enhanced for-loop + * ({@code for (Type var : collection)}) by scanning backwards from the colon + * for an opening paren preceded by the {@code for} keyword. + */ + private boolean isEnhancedForLoopColon(List tokens, int colonIdx) { + int depth = 0; + for (int i = colonIdx - 1; i >= 0; i--) { + Token t = tokens.get(i); + if (t.type == TokenType.PAREN_CLOSE) depth++; + else if (t.type == TokenType.PAREN_OPEN) { + if (depth > 0) { + depth--; + } else { + // Found the opening paren at our depth — check if preceded by 'for' + for (int j = i - 1; j >= 0; j--) { + if (tokens.get(j).type != TokenType.WHITESPACE) { + return tokens.get(j).type == TokenType.KEYWORD + && tokens.get(j).value.equals("for"); + } + } + return false; + } + } + // Stop at statement boundaries + if (t.type == TokenType.SEMICOLON || t.type == TokenType.BRACE_OPEN || + t.type == TokenType.BRACE_CLOSE) { + return false; + } + } + return false; + } + private boolean isAssignmentOperator(String op) { switch (op) { case "=": case "+=": case "-=": case "*=": case "/=": case "%=": diff --git a/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java b/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java index b54b1cf1d..87d330726 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java @@ -575,7 +575,7 @@ public class JavaTextContainer extends TextContainer { for (ImportEntry ie : importEntries) { // Highlight the 'import' keyword - marks.add(new Mark(ie.importKwStart, ie.importKwEnd, TokenType.IMPORT_KEYWORD)); + marks.add(new Mark(ie.importKwStart, ie.importKwEnd, TokenType.KEYWORD)); String fullPath = ie.fullPath; int pathStart = ie.pathStart; @@ -836,7 +836,7 @@ public class JavaTextContainer extends TextContainer { Matcher m = CLASS_DECL.matcher(text); List excluded = MethodBlock.getExcludedRanges(text); while (m.find()) { - marks.add(new Mark(m.start(1), m.end(1), TokenType.CLASS_KEYWORD)); + marks.add(new Mark(m.start(1), m.end(1), TokenType.KEYWORD)); int nameStart = m.start(2); int nameEnd = m.end(2); @@ -868,7 +868,7 @@ public class JavaTextContainer extends TextContainer { // Highlight general keywords collectPatternMatches(marks, KEYWORD, TokenType.KEYWORD); - collectPatternMatches(marks, MODIFIER, TokenType.MODIFIER); + collectPatternMatches(marks, MODIFIER, TokenType.KEYWORD); collectTypeDeclarations(marks); collectPatternMatches(marks, NEW_TYPE, TokenType.NEW_TYPE, 1); @@ -1211,10 +1211,7 @@ public class JavaTextContainer extends TextContainer { public enum TokenType { COMMENT('7', 130), STRING('5', 120), - CLASS_KEYWORD('c', 115), - IMPORT_KEYWORD('6', 110), KEYWORD('c', 100), - MODIFIER('6', 90), NEW_TYPE('d', 80), IMPORTED_CLASS('3', 75), //imported classes used in code INTERFACE_DECL('b', 85), diff --git a/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java index f8c92d64a..ecbe29c35 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java @@ -23,9 +23,9 @@ public class ScrollState { private boolean clickScrolling = false; // Animation parameters - private static final double TAU = 0.15; // Time constant (~55ms feels snappy) - private static final double SNAP_THRESHOLD = 0.1; // Snap to target when this close (increased to avoid microstutters) - private static final double MAX_DT = 0.1; // Max delta time for stability + private static final double TAU = 0.1; // Time constant (~55ms feels snappy) + private static final double SNAP_THRESHOLD = 0.01; // Snap to target when this close (increased to avoid microstutters) + private static final double MAX_DT = 0.05; // Max delta time for stability /** * Reset scroll state to initial values diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java index a8adf2031..6d2f5ff42 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteItem.java @@ -1,5 +1,7 @@ package noppes.npcs.client.gui.util.script.autocomplete; +import noppes.npcs.client.gui.util.script.autocomplete.weighter.CompletionWeigher; +import noppes.npcs.client.gui.util.script.autocomplete.weighter.WeigherChain; import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSFieldInfo; import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSMethodInfo; @@ -22,12 +24,13 @@ public class AutocompleteItem implements Comparable { public enum Kind { ENUM_CONSTANT(-1), // Enum constant value (highest priority for enums) METHOD(0), // Method or function - FIELD(1), // Field or property - VARIABLE(2), // Local variable or parameter - CLASS(3), // Class or interface type - ENUM(4), // Enum type - KEYWORD(6), // Language keyword - SNIPPET(7); // Code snippet + FIELD(10), // Field or property + PARAMETER(19), // Local variable or parameter + VARIABLE(20), // Local variable or parameter + CLASS(30), // Class or interface type + ENUM(40), // Enum type + KEYWORD(60), // Language keyword + SNIPPET(70); // Code snippet private final int priority; @@ -141,7 +144,7 @@ public class AutocompleteItem implements Comparable { signature, method.getDocumentation(), method, - false, // TODO: Check for @Deprecated annotation + method.getJavaMethod() != null && method.getJavaMethod().isAnnotationPresent(Deprecated.class), false, // Methods don't require imports null, -1 // Java methods don't use inheritance depth sorting @@ -158,7 +161,7 @@ public class AutocompleteItem implements Comparable { Kind kind; switch (field.getScope()) { case PARAMETER: - kind = Kind.VARIABLE; + kind = Kind.PARAMETER; break; case LOCAL: kind = Kind.VARIABLE; @@ -577,8 +580,12 @@ public class AutocompleteItem implements Comparable { } /** - * Add a boost to the match score (e.g., from usage tracking). + * @deprecated Use {@link WeigherChain} instead. This method is no longer + * called by the autocomplete system. Score boosts are now handled by + * individual {@link CompletionWeigher} implementations that return + * independent comparable values per scoring dimension. */ + @Deprecated public void addScoreBoost(int boost) { this.matchScore += boost; } @@ -599,6 +606,7 @@ public class AutocompleteItem implements Comparable { public String getImportPath() { return importPath; } public int getMatchScore() { return matchScore; } public int[] getMatchIndices() { return matchIndices; } + public int getInheritanceDepth() { return inheritanceDepth; } public int getColor() { return color; }; public AutocompleteItem setColor(int col){ this.color = col;return this; } /** @@ -694,6 +702,7 @@ public class AutocompleteItem implements Comparable { case METHOD: return "m"; case FIELD: return "f"; case VARIABLE: return "v"; + case PARAMETER: return "p"; case CLASS: return "C"; case ENUM: return "E"; case ENUM_CONSTANT: return "e"; @@ -711,6 +720,7 @@ public class AutocompleteItem implements Comparable { case METHOD: return 0xFFB877DB; // Purple for methods case FIELD: return 0xFF79C0FF; // Blue for fields case VARIABLE: return 0xFF7EE787; // Green for variables + case PARAMETER: return 0XFFFF00FF; // Green for variables case CLASS: return 0xFFFFA657; // Orange for classes case ENUM: return 0xFFFFD866; // Yellow for enums case ENUM_CONSTANT: return 0xFFFFD866; @@ -720,6 +730,10 @@ public class AutocompleteItem implements Comparable { } } + /** + * Note: No longer used for autocomplete sorting (weigher chain used instead). + * Kept for backward compatibility and collections sorting. + */ @Override public int compareTo(AutocompleteItem other) { // First by match score (descending) - this includes all penalties and boosts diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java index 1ef9d3034..729a8dd74 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteManager.java @@ -165,7 +165,7 @@ public class AutocompleteManager { */ public void onCharTyped(char c, String text, int cursorPosition) { // Check if we should close autocomplete - if (CLOSE_CHARS.indexOf(c) >= 0) { + if (CLOSE_CHARS.indexOf(c) >= 0 || document.isExcludedInclusive(cursorPosition)) { dismiss(); return; } @@ -389,6 +389,17 @@ public class AutocompleteManager { // Walk left across a dotted receiver chain while (pos >= 0) { + // Skip excluded regions (comments/strings) to avoid reading dots from inside them + if (document != null && document.isExcluded(pos)) { + int jumped = jumpBeforeExcluded(pos); + if (jumped == pos) { + pos--; + } else { + pos = jumped; + } + continue; + } + char c = text.charAt(pos); if (Character.isWhitespace(c)) { @@ -414,10 +425,24 @@ public class AutocompleteManager { // Consume identifier while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; - // If preceded by a dot, keep going + // If preceded by a dot, keep going (skip excluded ranges when looking for the dot) int checkPos = pos; - while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) { - checkPos--; + while (checkPos >= 0) { + char ch = text.charAt(checkPos); + if (Character.isWhitespace(ch)) { + checkPos--; + continue; + } + if (document != null && document.isExcluded(checkPos)) { + int jumped = jumpBeforeExcluded(checkPos); + if (jumped == checkPos) { + checkPos--; + } else { + checkPos = jumped; + } + continue; + } + break; } if (checkPos >= 0 && text.charAt(checkPos) == '.') { pos = checkPos - 1; @@ -1044,13 +1069,16 @@ public class AutocompleteManager { // Check if we found a dot if (pos >= 0 && text.charAt(pos) == '.') { + if (document != null && document.isExcluded(pos)) return -1; return pos; } else { while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; - if (pos >= 0 && text.charAt(pos) == '.') + if (pos >= 0 && text.charAt(pos) == '.') { + if (document != null && document.isExcluded(pos)) return -1; return pos; + } } return -1; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java index d62fbe48d..4fc78de21 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/AutocompleteMenu.java @@ -419,7 +419,7 @@ public class AutocompleteMenu extends Gui { GL11.glPushMatrix(); float scale = 0.5f; GL11.glScalef(scale, scale, scale); - int col = TokenType.MODIFIER.getHexColor(); + int col = TokenType.KEYWORD.getHexColor(); if (isStatic) font.drawString("s", (int) (textX / scale), (int) (textY / scale), col); @@ -482,6 +482,8 @@ public class AutocompleteMenu extends Gui { return TokenType.getColor(item.getTypeInfo()); case VARIABLE: return TokenType.LOCAL_FIELD.getHexColor(); + case PARAMETER: + return TokenType.PARAMETER.getHexColor(); case KEYWORD: return TokenType.KEYWORD.getHexColor(); default: @@ -637,6 +639,9 @@ public class AutocompleteMenu extends Gui { private void drawTypeLabel(AutocompleteItem item, String typeLabel, int x, int y, int typeColor, TypeInfo typeInfo) { if (typeInfo == null || item.getKind() == AutocompleteItem.Kind.CLASS) { //draw package on right + // draw default package color instead of interface/enum colors + if (typeInfo != null && !typeInfo.isClass()) + typeColor = TokenType.getPackageColor(); drawSimpleTypeWithArraySuffix(typeLabel, x, y, typeColor); return; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/CompletionWeigherChains.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/CompletionWeigherChains.java new file mode 100644 index 000000000..010e2dea1 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/CompletionWeigherChains.java @@ -0,0 +1,33 @@ +package noppes.npcs.client.gui.util.script.autocomplete; + +import noppes.npcs.client.gui.util.script.autocomplete.weighter.*; + +import java.util.Arrays; + +public class CompletionWeigherChains { + + public static WeigherChain javaChain() { + return new WeigherChain(Arrays.asList( + new MatchQualityWeigher(), + new MemberKindWeigher(), + new ContextFitWeigher(), + new ImportStatusWeigher(), + new UsageFrequencyWeigher(), + new DeprecatedWeigher(), + new AlphabeticalWeigher() + )); + } + + public static WeigherChain jsChain() { + return new WeigherChain(Arrays.asList( + new MatchQualityWeigher(), + new MemberKindWeigher(), + new ContextFitWeigher(), + new ImportStatusWeigher(), + new UsageFrequencyWeigher(), + new InheritanceDepthWeigher(), + new DeprecatedWeigher(), + new AlphabeticalWeigher() + )); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java index b342a5384..d6a49846e 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JSAutocompleteProvider.java @@ -1,15 +1,16 @@ package noppes.npcs.client.gui.util.script.autocomplete; +import noppes.npcs.client.gui.util.script.autocomplete.weighter.WeigherChain; import noppes.npcs.client.gui.util.script.interpreter.js_parser.*; +import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; import noppes.npcs.client.gui.util.script.interpreter.type.synthetic.*; +import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo; import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; import noppes.npcs.config.ConfigScript; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.*; @@ -37,6 +38,15 @@ public class JSAutocompleteProvider extends JavaAutocompleteProvider { /** * Add suggestions for member access (after dot). * Uses ScriptDocument's unified type resolution system. + * + * Fixes applied vs original implementation: + * - Single getMemberAccessResolvePosition() call (was duplicated) + * - Single getMergeRegistryType() call (was duplicated) + * - Shared addedMethods/addedFields sets across ALL sources for cross-source deduplication + * - Unified nested type lookup via getAllNestedTypes() instead of raw reflection + * - Single Object method injection path (registry-based via fromJSMethod) + * - Name-based Object.class guard instead of fragile Class identity comparison + * - Logged SecurityException instead of silently swallowed */ protected void addMemberSuggestions(Context context, List items) { String receiverExpr = context.receiverExpression; @@ -44,113 +54,105 @@ public class JSAutocompleteProvider extends JavaAutocompleteProvider { return; } + // Resolve position once — used for both type resolution and static context detection + int resolvePos = getMemberAccessResolvePosition(context); + // Use ScriptDocument's resolveExpressionType - handles both Java and JS - TypeInfo receiverType = document.resolveExpressionType(receiverExpr, getMemberAccessResolvePosition(context)); + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, resolvePos); if (receiverType == null || !receiverType.isResolved()) { return; } - // Check for synthetic types first (e.g., Nashorn Java object, custom types) - SyntheticType syntheticType = - document.getTypeResolver().getSyntheticType(receiverType.getSimpleName()); + // --- JS-SPECIFIC: synthetic type early return --- + SyntheticType syntheticType = document.getTypeResolver().getSyntheticType(receiverType.getSimpleName()); if (syntheticType != null) { addSyntheticTypeSuggestions(syntheticType, items, context.methodsOnly); return; } - boolean isStaticContext = isStaticAccess(receiverExpr, getMemberAccessResolvePosition(context)); + boolean isStaticContext = isStaticAccess(receiverExpr, resolvePos); + // Resolve merge-registry type once — used for both method and field fallback Class javaClass = receiverType.getJavaClass(); - if (javaClass != null) { - Set addedMethods = new HashSet<>(); - for (Method method : javaClass.getMethods()) { - if (!Modifier.isPublic(method.getModifiers())) continue; - if (isStaticContext && !Modifier.isStatic(method.getModifiers())) continue; - String sig = method.getName() + "(" + method.getParameterCount() + ")"; - if (addedMethods.add(sig)) { - items.add(AutocompleteItem.fromMethod(MethodInfo.fromReflection(method, receiverType), context.methodsOnly)); - } + JSTypeInfo mergeRegistryType = (javaClass != null + && receiverType.getSyntheticMethods().isEmpty() + && receiverType.getSyntheticFields().isEmpty()) + ? getMergeRegistryType(javaClass) : null; + + // Shared deduplication sets — prevent the same method/field appearing from multiple sources + Set addedMethods = new HashSet<>(); + Set addedFields = new HashSet<>(); + + // ---- SHARED: unified method enumeration (TypeInfo.getAllMethods covers reflection + synthetics) ---- + for (MethodInfo method : receiverType.getAllMethods()) { + if (isStaticContext && !Modifier.isStatic(method.getModifiers())) + continue; + String sig = method.getName() + "(" + method.getParameterCount() + ")"; + if (addedMethods.add(sig)) + items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); + } + + // --- JS-SPECIFIC: merge registry type fallback for methods --- + if (mergeRegistryType != null) { + addMethodsFromType(mergeRegistryType, receiverType, items, addedMethods, context.methodsOnly, + isStaticContext); + } + + if (!context.methodsOnly) { + // ---- SHARED: unified field enumeration with deduplication ---- + for (FieldInfo field : receiverType.getAllFields()) { + if (isStaticContext && !Modifier.isStatic(field.getModifiers())) + continue; + if (addedFields.add(field.getName())) + items.add(AutocompleteItem.fromField(field)); } - List effectiveSyntheticMethods = receiverType.getSyntheticMethods(); - if (!effectiveSyntheticMethods.isEmpty()) { - for (MethodInfo method : effectiveSyntheticMethods) { - if (isStaticContext && !method.isStatic()) continue; - items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); - } - } else { - JSTypeInfo jsType = getMergeRegistryType(javaClass); - if (jsType != null) { - addMethodsFromType(jsType, receiverType, items, new HashSet<>(), context.methodsOnly, isStaticContext); - } + + // --- JS-SPECIFIC: merge registry type fallback for fields --- + if (mergeRegistryType != null) { + addFieldsFromType(mergeRegistryType, receiverType, items, addedFields, isStaticContext); } - if (!context.methodsOnly) { - for (Field field : javaClass.getFields()) { - if (!Modifier.isPublic(field.getModifiers())) continue; - if (isStaticContext && !Modifier.isStatic(field.getModifiers())) continue; - items.add(AutocompleteItem.fromField(FieldInfo.fromReflection(field, receiverType))); - } - if (!receiverType.getSyntheticFields().isEmpty()) { - for (FieldInfo field : receiverType.getSyntheticFields()) { - if (isStaticContext && !field.isStatic()) continue; - items.add(AutocompleteItem.fromField(field)); - } - } else { - JSTypeInfo jsType = getMergeRegistryType(javaClass); - if (jsType != null) { - addFieldsFromType(jsType, receiverType, items, new HashSet<>(), isStaticContext); - } - } + } + + // ---- SHARED: nested types (inner classes) in static context via getAllNestedTypes() ---- + if (isStaticContext && !context.methodsOnly) { + // ScriptTypeInfo.getAllNestedTypes() returns script inner classes directly; + // TypeInfo.getAllNestedTypes() uses reflection. No duplicate logic needed. + for (TypeInfo nestedType : receiverType.getAllNestedTypes()) { + items.add(AutocompleteItem.fromType(nestedType)); } - if (!isStaticContext && javaClass != Object.class) { - JSTypeInfo objJSType = registry.getType("Object"); - if (objJSType != null) { - for (JSMethodInfo m : objJSType.getMethods().values()) { - if (!m.isStatic()) { + } + + // Enum constants — polymorphic via TypeInfo.getEnumConstants() + if (!context.methodsOnly) { + for (EnumConstantInfo enumConstant : receiverType.getEnumConstants().values()) { + items.add(AutocompleteItem.fromField(enumConstant.getFieldInfo())); + } + } + + // --- JS-SPECIFIC: Object methods from JS registry (single unified path) --- + // Add Object.class JS methods for instance access, unless receiver IS Object itself. + // Uses name comparison instead of Class identity for robustness across classloaders. + if (!isStaticContext && (javaClass == null || !"java.lang.Object".equals(javaClass.getName()))) { + JSTypeInfo objJSType = registry.getType("Object"); + if (objJSType != null) { + for (JSMethodInfo m : objJSType.getMethods().values()) { + if (!m.isStatic()) { + String sig = m.getName() + "(" + m.getParameterCount() + ")"; + if (addedMethods.add(sig)) { items.add(AutocompleteItem.fromJSMethod(m, receiverType, 1, context.methodsOnly)); } } } } - return; } - Set syntheticMethodNames = new HashSet<>(); - for (MethodInfo method : receiverType.getSyntheticMethods()) { - syntheticMethodNames.add(method.getName()); - if (isStaticContext && !method.isStatic()) continue; - items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); - } - for (FieldInfo field : receiverType.getSyntheticFields()) { - if (!context.methodsOnly) { - if (isStaticContext && !field.isStatic()) continue; - if (syntheticMethodNames.contains(field.getName())) continue; - items.add(AutocompleteItem.fromField(field)); - } - } - - JSTypeInfo jsTypeInfo = receiverType.getJSTypeInfo(); - if (jsTypeInfo != null) { - addMethodsFromType(jsTypeInfo, receiverType, items, new HashSet<>(), context.methodsOnly, isStaticContext); - if (!context.methodsOnly && ConfigScript.ShowImplementationFieldsInAutocomplete) { - addFieldsFromType(jsTypeInfo, receiverType, items, new HashSet<>(), isStaticContext); - } - } - - if (!isStaticContext) { - Set addedObjMethods = new HashSet<>(); - for (Method m : Object.class.getMethods()) { - if (!Modifier.isPublic(m.getModifiers()) || Modifier.isStatic(m.getModifiers())) continue; - String sig = m.getName() + "(" + m.getParameterCount() + ")"; - if (addedObjMethods.add(sig)) { - items.add(AutocompleteItem.fromMethod(MethodInfo.fromReflection(m, receiverType), context.methodsOnly)); - } - } - JSTypeInfo objJSType = registry.getType("Object"); - if (objJSType != null) { - for (JSMethodInfo m : objJSType.getMethods().values()) { - if (!m.isStatic()) { - items.add(AutocompleteItem.fromJSMethod(m, receiverType, 1, context.methodsOnly)); - } + // --- JS-SPECIFIC: pure JS type fallback (javaClass == null, not ScriptTypeInfo) --- + if (javaClass == null && !(receiverType instanceof ScriptTypeInfo)) { + JSTypeInfo jsTypeInfo = receiverType.getJSTypeInfo(); + if (jsTypeInfo != null) { + addMethodsFromType(jsTypeInfo, receiverType, items, addedMethods, context.methodsOnly, isStaticContext); + if (!context.methodsOnly && ConfigScript.ShowImplementationFieldsInAutocomplete) { + addFieldsFromType(jsTypeInfo, receiverType, items, addedFields, isStaticContext); } } } @@ -348,6 +350,11 @@ public class JSAutocompleteProvider extends JavaAutocompleteProvider { return UsageTracker.getJSInstance(); } + @Override + protected WeigherChain getWeigherChain() { + return CompletionWeigherChains.jsChain(); + } + public String[] getKeywords() { return TypeChecker.getJavaScriptKeywords(); } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java index c268baf27..acb9cee3f 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/JavaAutocompleteProvider.java @@ -1,5 +1,7 @@ package noppes.npcs.client.gui.util.script.autocomplete; +import noppes.npcs.client.gui.util.script.autocomplete.weighter.ScoringContext; +import noppes.npcs.client.gui.util.script.autocomplete.weighter.WeigherChain; import noppes.npcs.client.gui.util.script.interpreter.ScriptDocument; import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; @@ -10,8 +12,6 @@ import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; import noppes.npcs.client.gui.util.script.interpreter.type.TypeResolver; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.*; @@ -83,12 +83,9 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { } } - // Filter and score by prefix, then apply usage boosts and static penalties + // Filter by prefix, then sort using weigher chain filterAndScore(items, context.prefix, context.isMemberAccess, isStaticContext, ownerFullName); - // Sort by score - Collections.sort(items); - return items; } @@ -100,92 +97,52 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { if (receiverExpr == null || receiverExpr.isEmpty()) { return; } + + // Resolve position once — used for both type resolution and static context detection + int resolvePos = getMemberAccessResolvePosition(context); - // Resolve the type of the receiver expression - TypeInfo receiverType = document.resolveExpressionType(receiverExpr, getMemberAccessResolvePosition(context)); + TypeInfo receiverType = document.resolveExpressionType(receiverExpr, resolvePos); if (receiverType == null || !receiverType.isResolved()) { return; } - // Determine if this is a static context (accessing a class type) - boolean isStaticContext = isStaticAccess(receiverExpr, getMemberAccessResolvePosition(context)); + boolean isStaticContext = isStaticAccess(receiverExpr, resolvePos); - Class clazz = receiverType.getJavaClass(); - if (clazz == null) { - // Try ScriptTypeInfo - if (receiverType instanceof ScriptTypeInfo) { - addScriptTypeMembers((ScriptTypeInfo) receiverType, items, isStaticContext, context.methodsOnly); - } - return; - } - - // Add methods + // Shared deduplication sets across all member sources Set addedMethods = new HashSet<>(); - for (Method method : clazz.getMethods()) { - if (Modifier.isPublic(method.getModifiers())) { - // Filter by static context - if (isStaticContext && !Modifier.isStatic(method.getModifiers())) { - continue; - } - - String sig = method.getName() + "(" + method.getParameterCount() + ")"; - if (!addedMethods.contains(sig)) { - addedMethods.add(sig); - MethodInfo methodInfo = MethodInfo.fromReflection(method, receiverType); - items.add(AutocompleteItem.fromMethod(methodInfo, context.methodsOnly)); - } - } - } + Set addedFields = new HashSet<>(); - for (MethodInfo method : receiverType.getSyntheticMethods()) { - if (isStaticContext && !Modifier.isStatic(method.getModifiers())) - continue; - - items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); + // Unified method enumeration — works for Java types, ScriptTypeInfo, and intersection bounds + for (MethodInfo method : receiverType.getAllMethods()) { + if (isStaticContext && !Modifier.isStatic(method.getModifiers())) continue; + String sig = method.getName() + "(" + method.getParameterCount() + ")"; + if (addedMethods.add(sig)) + items.add(AutocompleteItem.fromMethod(method, context.methodsOnly)); } - // Add fields (skip if methodsOnly is true - but the filtering will be done in getSuggestions) if (!context.methodsOnly) { - for (Field field : clazz.getFields()) { - if (Modifier.isPublic(field.getModifiers())) { - // Filter by static context - if (isStaticContext && !Modifier.isStatic(field.getModifiers())) { - continue; - } - - FieldInfo fieldInfo = FieldInfo.fromReflection(field, receiverType); - items.add(AutocompleteItem.fromField(fieldInfo)); - } - } - - for (FieldInfo field : receiverType.getSyntheticFields()) { - if (isStaticContext && !Modifier.isStatic(field.getModifiers())) - continue; - - items.add(AutocompleteItem.fromField(field)); + // Unified field enumeration with deduplication + for (FieldInfo field : receiverType.getAllFields()) { + if (isStaticContext && !Modifier.isStatic(field.getModifiers())) continue; + if (addedFields.add(field.getName())) + items.add(AutocompleteItem.fromField(field)); } } - // Add nested types (inner classes/interfaces) in static context + // Nested types (inner classes) in static context via getAllNestedTypes() if (isStaticContext && !context.methodsOnly) { - try { - for (Class nested : clazz.getDeclaredClasses()) { - if (!Modifier.isPublic(nested.getModifiers())) { - continue; - } - // Convert $ notation to dot notation for resolution - String nestedFullName = nested.getName().replace('$', '.'); - TypeInfo nestedType = TypeResolver.getInstance().resolveFullName(nestedFullName); - if (nestedType != null && nestedType.isResolved()) { - items.add(AutocompleteItem.fromType(nestedType)); - } - } - } catch (SecurityException ignored) { - // Some runtimes may restrict getDeclaredClasses() + for (TypeInfo nestedType : receiverType.getAllNestedTypes()) { + items.add(AutocompleteItem.fromType(nestedType)); + } + } + + // Enum constants — polymorphic via TypeInfo.getEnumConstants() + if (!context.methodsOnly) { + for (EnumConstantInfo enumConstant : receiverType.getEnumConstants().values()) { + items.add(AutocompleteItem.fromField(enumConstant.getFieldInfo())); } } - } /** @@ -218,76 +175,6 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { return Math.max(0, Math.min(context.prefixStart, context.text.length())); } - /** - * Add members from a script-defined type. - */ - protected void addScriptTypeMembers(ScriptTypeInfo scriptType, List items, boolean isStaticContext) { - addScriptTypeMembers(scriptType, items, isStaticContext, false); - } - - /** - * Add members from a script-defined type. - * @param scriptType The script type to get members from - * @param items The list to add items to - * @param isStaticContext Whether we're in a static context - * @param forMethodReference If true, only add methods with no parentheses in insert text - */ - protected void addScriptTypeMembers(ScriptTypeInfo scriptType, List items, boolean isStaticContext, boolean forMethodReference) { - // Add methods (getMethods returns Map>) - for (List overloads : scriptType.getMethods().values()) { - for (MethodInfo method : overloads) { - // Filter by static context - if (isStaticContext && !method.isStatic()) { - continue; - } - items.add(AutocompleteItem.fromMethod(method, forMethodReference)); - } - } - - // Add fields (skip if methodsOnly/forMethodReference is true) - if (!forMethodReference) { - // Add fields (getFields returns Map) - for (FieldInfo field : scriptType.getFields().values()) { - // Filter by static context - if (isStaticContext && !field.isStatic()) { - continue; - } - items.add(AutocompleteItem.fromField(field)); - } - - // Add enum constants (getEnumConstants returns Map) - for (EnumConstantInfo enumConstant : scriptType.getEnumConstants().values()) { - items.add(AutocompleteItem.fromField(enumConstant.getFieldInfo())); - } - - // Add nested types (inner classes/interfaces) in static context - if (isStaticContext) { - for (ScriptTypeInfo inner : scriptType.getInnerClasses()) { - items.add(AutocompleteItem.fromType(inner)); - } - } - } - - // Add parent class members - if (scriptType.hasSuperClass()) { - TypeInfo superType = scriptType.getSuperClass(); - if (superType != null && superType.getJavaClass() != null) { - Class superClazz = superType.getJavaClass(); - Set addedMethods = new HashSet<>(); - for (Method method : superClazz.getMethods()) { - if (Modifier.isPublic(method.getModifiers())) { - String sig = method.getName() + "(" + method.getParameterCount() + ")"; - if (!addedMethods.contains(sig)) { - addedMethods.add(sig); - MethodInfo methodInfo = MethodInfo.fromReflection(method, superType); - items.add(AutocompleteItem.fromMethod(methodInfo, forMethodReference)); - } - } - } - } - } - } - /** * Add suggestions based on current scope (not after a dot). */ @@ -427,8 +314,6 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { if (existingSimpleNames.contains(type.getSimpleName())) { continue; } - - // Create an item that requires import AutocompleteItem item = new AutocompleteItem.Builder() .name(type.getSimpleName()) .insertText(type.getSimpleName()) @@ -483,126 +368,31 @@ public class JavaAutocompleteProvider implements AutocompleteProvider { return UsageTracker.getJavaInstance(); } - /** - * Filter items by prefix, calculate match scores, apply usage boosts, and penalize static members in instance contexts. - */ - protected void filterAndScore(List items, String prefix, + protected WeigherChain getWeigherChain() { + return CompletionWeigherChains.javaChain(); + } + + protected void filterAndScore(List items, String prefix, boolean isMemberAccess, boolean isStaticContext, String ownerFullName) { - UsageTracker tracker = getUsageTracker(); - - if (prefix == null || prefix.isEmpty()) { - // No filtering needed, all items get a base score + usage boost - for (AutocompleteItem item : items) { - item.calculateMatchScore("", false); - applyUsageBoost(item, tracker, ownerFullName); - applyStaticPenalty(item, isMemberAccess, isStaticContext); - applyObjectMethodPenalty(item); - applyKeywordPenalty(item, prefix); - } - return; - } - - // For non-member access (first word), require strict prefix matching - // For member access (after dot), allow fuzzy/contains matching boolean requirePrefix = !isMemberAccess; - - // Filter, score, apply usage boosts, and apply penalties + Iterator iter = items.iterator(); while (iter.hasNext()) { AutocompleteItem item = iter.next(); - int score = item.calculateMatchScore(prefix, requirePrefix); + int score = item.calculateMatchScore( + prefix != null ? prefix : "", requirePrefix); if (score < 0) { iter.remove(); - } else { - applyUsageBoost(item, tracker, ownerFullName); - applyStaticPenalty(item, isMemberAccess, isStaticContext); - applyObjectMethodPenalty(item); - applyKeywordPenalty(item, prefix); } } - } - - /** - * Apply usage-based score boost to an item. - */ - protected void applyUsageBoost(AutocompleteItem item, UsageTracker tracker, String ownerFullName) { - int usageCount = tracker.getUsageCount(item, ownerFullName); - int boost = UsageTracker.calculateUsageBoost(usageCount); - item.addScoreBoost(boost); - } - - /** - * Apply penalty to static members when accessed in a non-static (instance) context. - * This matches IntelliJ's behavior where static members are deprioritized when - * accessing through an instance (e.g., Minecraft.getMinecraft().getMinecraft()). - * However, if the item is a very strong match (exact prefix), don't penalize as much. - */ - protected void applyStaticPenalty(AutocompleteItem item, boolean isMemberAccess, boolean isStaticContext) { - // Only apply penalty in member access contexts (after dot) - if (!isMemberAccess) { - return; - } - - // Only penalize when we're in an instance context (not static) - if (isStaticContext) { - return; - } - - boolean isStatic = item.isStatic(); - - // Apply penalty to static members in instance context - if (isStatic) { - int matchScore = item.getMatchScore(); - // Strong prefix matches (score >= 800) get a lighter penalty - // Weaker matches get pushed down more aggressively - if (matchScore >= 800) { - // Light penalty for exact prefix matches - just deprioritize slightly - item.addScoreBoost(-200); - } else { - // Heavy penalty for fuzzy/substring matches - push to bottom - item.addScoreBoost(-matchScore); - } - } - } - - /** - * Apply penalty to inherited Object methods to push them to bottom. - * Strong matches get lighter penalty, weak matches get pushed all the way down. - */ - protected void applyObjectMethodPenalty(AutocompleteItem item) { - if (!item.isInheritedObjectMethod()) { - return; - } - - int matchScore = item.getMatchScore(); - // Strong prefix matches (score >= 900) get a moderate penalty - // Everything else gets pushed to the very bottom - if (matchScore >= 900) { - // Strong match - moderate penalty to keep it visible but below normal methods - item.addScoreBoost(-500); - } else { - // Weak match - heavy penalty to push to bottom - item.addScoreBoost(-10000); - } - } - /** - * Push keywords below non-keywords unless user typed a longer prefix. - */ - protected void applyKeywordPenalty(AutocompleteItem item, String prefix) { - if (item.getKind() != AutocompleteItem.Kind.KEYWORD) { - return; - } + ScoringContext context = new ScoringContext( + prefix, isMemberAccess, isStaticContext, + ownerFullName, getUsageTracker(), requirePrefix + ); - if (prefix == null || prefix.length() < 2) { - item.addScoreBoost(-10000); - } - } - - /** - * Builder class for AutocompleteItem to handle cases without source data. - */ - private static class AutocompleteItemBuilder { - // This would be needed if AutocompleteItem had a builder pattern + WeigherChain weigherChain = getWeigherChain(); + Comparator comparator = weigherChain.buildComparator(context); + items.sort(comparator); } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java index 6d23e0c2f..ee95f2fdf 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/UsageTracker.java @@ -122,7 +122,7 @@ public class UsageTracker { // ==================== INSTANCE METHODS ==================== - private UsageTracker(String filename) { + UsageTracker(String filename) { File dir = getDir(); this.file = new File(dir, filename); } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/AlphabeticalWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/AlphabeticalWeigher.java new file mode 100644 index 000000000..93252dc69 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/AlphabeticalWeigher.java @@ -0,0 +1,15 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +public class AlphabeticalWeigher extends CompletionWeigher { + + public AlphabeticalWeigher() { + super("alphabetical"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + return item.getName().toLowerCase(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/CompletionWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/CompletionWeigher.java new file mode 100644 index 000000000..0505da0d1 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/CompletionWeigher.java @@ -0,0 +1,40 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +/** + * A single scoring dimension for autocomplete ranking. + * + * Weighers are evaluated in chain order. For each pair of items being compared, + * the first weigher to return non-equal values determines the ordering. + * + * Contract: + * - weigh() MUST be a pure function (no side effects, no mutations) + * - weigh() MUST return consistent results for the same item+context + * - weigh() MAY return null to abstain (treated as equal for this weigher) + * - Lower Comparable values = higher priority (shown first) unless negated + * + * Modeled after IntelliJ's LookupElementWeigher (Apache 2.0). + */ +public abstract class CompletionWeigher { + + private final String id; + private final boolean negated; + + protected CompletionWeigher(String id, boolean negated) { + this.id = id; + this.negated = negated; + } + + protected CompletionWeigher(String id) { + this(id, false); + } + + public abstract Comparable weigh(AutocompleteItem item, ScoringContext context); + + public String getId() { return id; } + public boolean isNegated() { return negated; } + + @Override + public String toString() { return id; } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ContextFitWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ContextFitWeigher.java new file mode 100644 index 000000000..f2e1db62d --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ContextFitWeigher.java @@ -0,0 +1,38 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +/** + * How well does this item fit the current access context? + * + * Combines what were previously applyStaticPenalty() and applyObjectMethodPenalty(). + * Uses ordered enum categories, eliminating all magic threshold numbers. + */ +public class ContextFitWeigher extends CompletionWeigher { + + public enum ContextFit { + /** Item naturally belongs in this context. */ + NATURAL, + /** Static member accessed through an instance — legal but bad style. */ + WRONG_STATIC_CONTEXT, + /** Inherited Object method (wait, notify, hashCode, getClass, etc.) */ + OBJECT_METHOD + } + + public ContextFitWeigher() { + super("contextFit"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + if (item.isInheritedObjectMethod()) { + return ContextFit.OBJECT_METHOD; + } + + if (context.isMemberAccess && !context.isStaticContext && item.isStatic()) { + return ContextFit.WRONG_STATIC_CONTEXT; + } + + return ContextFit.NATURAL; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/DeprecatedWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/DeprecatedWeigher.java new file mode 100644 index 000000000..c8044226f --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/DeprecatedWeigher.java @@ -0,0 +1,15 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +public class DeprecatedWeigher extends CompletionWeigher { + + public DeprecatedWeigher() { + super("deprecated"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + return item.isDeprecated(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ImportStatusWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ImportStatusWeigher.java new file mode 100644 index 000000000..7008e6e4b --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ImportStatusWeigher.java @@ -0,0 +1,27 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +/** + * Demote unimported classes below imported ones. + * + * Fixes the bug where the unimported class penalty (-500) was applied at + * creation time and overwritten by calculateMatchScore(). In the chain model, + * import status is a separate dimension that can never be overwritten. + */ +public class ImportStatusWeigher extends CompletionWeigher { + + public enum ImportStatus { + IMPORTED, + UNIMPORTED + } + + public ImportStatusWeigher() { + super("importStatus"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + return item.requiresImport() ? ImportStatus.UNIMPORTED : ImportStatus.IMPORTED; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/InheritanceDepthWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/InheritanceDepthWeigher.java new file mode 100644 index 000000000..4c8ba8e58 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/InheritanceDepthWeigher.java @@ -0,0 +1,26 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +/** + * Prefer members defined closer in the inheritance tree. + * Child class members rank above parent class members. + * + * Only meaningful for JS/.d.ts types where inheritance depth is tracked. + * For items without inheritance tracking (depth < 0), this weigher abstains. + */ +public class InheritanceDepthWeigher extends CompletionWeigher { + + public InheritanceDepthWeigher() { + super("inheritanceDepth"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + int depth = item.getInheritanceDepth(); + if (depth < 0) { + return null; + } + return depth; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MatchQualityWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MatchQualityWeigher.java new file mode 100644 index 000000000..a8f638729 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MatchQualityWeigher.java @@ -0,0 +1,22 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +/** + * Text match quality against the user's prefix. + * PRIMARY sort dimension — how well the item name matches what was typed. + * + * Returns the existing calculateMatchScore() result, negated so that + * higher scores = higher priority. + */ +public class MatchQualityWeigher extends CompletionWeigher { + + public MatchQualityWeigher() { + super("matchQuality", true); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + return item.getMatchScore(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MemberKindWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MemberKindWeigher.java new file mode 100644 index 000000000..af2693751 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/MemberKindWeigher.java @@ -0,0 +1,56 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +public class MemberKindWeigher extends CompletionWeigher { + + public enum MemberKind { + ENUM_CONSTANT, + LOCAL_VARIABLE, + INSTANCE_FIELD, + INSTANCE_METHOD, + TYPE_CLASS, + TYPE_ENUM, + TYPE_INTERFACE, + STATIC_FIELD, + STATIC_METHOD, + KEYWORD, + SNIPPET + } + + public MemberKindWeigher() { + super("memberKind"); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + switch (item.getKind()) { + case ENUM_CONSTANT: return MemberKind.ENUM_CONSTANT; + case PARAMETER: return MemberKind.LOCAL_VARIABLE; + case VARIABLE: return MemberKind.LOCAL_VARIABLE; + case METHOD: + return item.isStatic() ? MemberKind.STATIC_METHOD : MemberKind.INSTANCE_METHOD; + case FIELD: + return item.isStatic() ? MemberKind.STATIC_FIELD : MemberKind.INSTANCE_FIELD; + case CLASS: return resolveTypeTier(item); + case ENUM: return MemberKind.TYPE_ENUM; + case KEYWORD: return MemberKind.KEYWORD; + case SNIPPET: return MemberKind.SNIPPET; + default: return MemberKind.INSTANCE_FIELD; + } + } + + private MemberKind resolveTypeTier(AutocompleteItem item) { + Object source = item.getSourceData(); + if (source instanceof TypeInfo) { + TypeInfo typeInfo = (TypeInfo) source; + switch (typeInfo.getKind()) { + case INTERFACE: return MemberKind.TYPE_INTERFACE; + case ENUM: return MemberKind.TYPE_ENUM; + default: return MemberKind.TYPE_CLASS; + } + } + return MemberKind.TYPE_CLASS; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ScoringContext.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ScoringContext.java new file mode 100644 index 000000000..6ee5501f5 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/ScoringContext.java @@ -0,0 +1,27 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.UsageTracker; + +/** + * Immutable context for a single scoring pass. + * Created once when suggestions are requested, shared by all weighers. + */ +public class ScoringContext { + + public final String prefix; + public final boolean isMemberAccess; + public final boolean isStaticContext; + public final String ownerFullName; + public final UsageTracker usageTracker; + public final boolean requirePrefix; + + public ScoringContext(String prefix, boolean isMemberAccess, boolean isStaticContext, + String ownerFullName, UsageTracker usageTracker, boolean requirePrefix) { + this.prefix = prefix; + this.isMemberAccess = isMemberAccess; + this.isStaticContext = isStaticContext; + this.ownerFullName = ownerFullName; + this.usageTracker = usageTracker; + this.requirePrefix = requirePrefix; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/UsageFrequencyWeigher.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/UsageFrequencyWeigher.java new file mode 100644 index 000000000..fbb0a2308 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/UsageFrequencyWeigher.java @@ -0,0 +1,25 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; +import noppes.npcs.client.gui.util.script.autocomplete.UsageTracker; + +/** + * Boost items the user has selected before. + * Uses logarithmic scaling with a cap via UsageTracker.calculateUsageBoost(). + * Negated: higher usage count = higher priority. + */ +public class UsageFrequencyWeigher extends CompletionWeigher { + + public UsageFrequencyWeigher() { + super("usageFrequency", true); + } + + @Override + public Comparable weigh(AutocompleteItem item, ScoringContext context) { + if (context.usageTracker == null) { + return 0; + } + int usageCount = context.usageTracker.getUsageCount(item, context.ownerFullName); + return UsageTracker.calculateUsageBoost(usageCount); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/WeigherChain.java b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/WeigherChain.java new file mode 100644 index 000000000..3d2a6e878 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/autocomplete/weighter/WeigherChain.java @@ -0,0 +1,81 @@ +package noppes.npcs.client.gui.util.script.autocomplete.weighter; + +import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteItem; + +import java.util.*; + +/** + * An ordered list of weighers that produces a Comparator for sorting autocomplete items. + * + * Items are compared dimension-by-dimension through the chain. + * The first weigher to produce a non-equal comparison determines the ordering. + * If all weighers return equal, items are considered equivalent (stable sort preserves + * insertion order). + * + * Simplified from IntelliJ's CompletionSorterImpl — uses Comparator instead of a + * classifier tree since we sort all items in a single batch. + */ +public class WeigherChain { + + private final List weighers; + + public WeigherChain(List weighers) { + this.weighers = Collections.unmodifiableList(new ArrayList<>(weighers)); + } + + /** + * Build a Comparator that evaluates items through the weigher chain. + * + * Weigher results are cached per item via IdentityHashMap to avoid + * recomputing weights during O(N log N) comparisons. + */ + public Comparator buildComparator(ScoringContext context) { + final Map[]> cache = new IdentityHashMap<>(); + final int chainLength = weighers.size(); + + return (a, b) -> { + Comparable[] weightsA = cache.computeIfAbsent(a, k -> computeWeights(k, context)); + Comparable[] weightsB = cache.computeIfAbsent(b, k -> computeWeights(k, context)); + + for (int i = 0; i < chainLength; i++) { + Comparable wA = weightsA[i]; + Comparable wB = weightsB[i]; + + if (wA == null && wB == null) continue; + if (wA == null) return 1; + if (wB == null) return -1; + + @SuppressWarnings("unchecked") + int cmp = ((Comparable) wA).compareTo(wB); + + if (cmp != 0) { + return weighers.get(i).isNegated() ? -cmp : cmp; + } + } + return 0; + }; + } + + private Comparable[] computeWeights(AutocompleteItem item, ScoringContext context) { + Comparable[] weights = new Comparable[weighers.size()]; + for (int i = 0; i < weighers.size(); i++) { + weights[i] = weighers.get(i).weigh(item, context); + } + return weights; + } + + /** + * Get all weigher results for an item (for debugging/testing). + */ + public Map> debugWeights(AutocompleteItem item, ScoringContext context) { + Map> result = new LinkedHashMap<>(); + for (CompletionWeigher weigher : weighers) { + result.put(weigher.getId(), weigher.weigh(item, context)); + } + return result; + } + + public List getWeighers() { + return weighers; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java index e56e08c7f..bec08df8b 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java @@ -44,19 +44,32 @@ public class ErrorUnderlineRenderer { ScriptDocument doc, int lineStartX, int baselineY, String lineText, int lineStart, int lineEnd) { + drawErrorUnderlines(doc, null, lineStartX, baselineY, lineText, lineStart, lineEnd); + } + + /** + * Draw error underlines for all validation errors in the document that intersect the given line. + * When a ScriptLine is provided, uses style-aware width calculation so underlines align + * correctly under bold/italic tokens. + */ + public static void drawErrorUnderlines( + ScriptDocument doc, ScriptLine line, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd) { if (doc == null) return; for (DocumentError error : doc.getErrors()) { drawUnderlineForSpan(error.getStartPos(), error.getEndPos(), - lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR, line); } } /** * Calculate underline position for a span of text within a line. * Handles clipping to line boundaries and pixel width calculation. + * When a ScriptLine is provided, uses style-aware width calculation. * * @param spanStart Global offset where the span starts * @param spanEnd Global offset where the span ends @@ -64,11 +77,13 @@ public class ErrorUnderlineRenderer { * @param lineText The text content of the line * @param lineStart Global offset where this line starts * @param lineEnd Global offset where this line ends + * @param line Optional ScriptLine for style-aware width; null falls back to plain width * @return UnderlinePosition with x and width, or null if span doesn't intersect line */ private static UnderlinePosition calculateUnderlinePosition( int spanStart, int spanEnd, - int lineStartX, String lineText, int lineStart, int lineEnd) { + int lineStartX, String lineText, int lineStart, int lineEnd, + ScriptLine line) { // Skip if span doesn't intersect this line if (spanEnd < lineStart || spanStart > lineEnd) @@ -89,18 +104,24 @@ public class ErrorUnderlineRenderer { if (lineLocalStart < 0 || lineLocalStart >= lineText.length()) return null; - // Compute pixel position - String beforeSpan = lineText.substring(0, lineLocalStart); - int beforeWidth = ClientProxy.Font.width(beforeSpan); - + // Compute pixel position using style-aware width when available + int beforeWidth; int spanWidth; - if (lineLocalEnd > lineText.length()) { - // Span extends past line end - spanWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + + if (line != null) { + beforeWidth = line.getRenderedWidth(0, lineLocalStart); + int clampedEnd = Math.min(lineLocalEnd, lineText.length()); + spanWidth = line.getRenderedWidth(lineLocalStart, clampedEnd); } else { - // Span is fully on this line (or clipped) - String spanTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); - spanWidth = ClientProxy.Font.width(spanTextOnLine); + String beforeSpan = lineText.substring(0, lineLocalStart); + beforeWidth = ClientProxy.Font.width(beforeSpan); + + if (lineLocalEnd > lineText.length()) { + spanWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + } else { + String spanTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); + spanWidth = ClientProxy.Font.width(spanTextOnLine); + } } return new UnderlinePosition(lineStartX + beforeWidth, spanWidth); @@ -123,9 +144,22 @@ public class ErrorUnderlineRenderer { int lineStartX, int baselineY, String lineText, int lineStart, int lineEnd, int color) { + drawUnderlineForSpan(spanStart, spanEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, color, null); + } + + /** + * Draw an underline for a simple span if it intersects the line. + * When a ScriptLine is provided, uses style-aware width calculation. + */ + public static void drawUnderlineForSpan( + int spanStart, int spanEnd, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd, + int color, ScriptLine line) { UnderlinePosition pos = calculateUnderlinePosition( - spanStart, spanEnd, lineStartX, lineText, lineStart, lineEnd); + spanStart, spanEnd, lineStartX, lineText, lineStart, lineEnd, line); if (pos != null && pos.isValid()) { drawCurlyUnderline(pos.x, baselineY, pos.width, color); diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java index 2eeff85c0..5659c7f56 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/FieldChainMarker.java @@ -151,11 +151,11 @@ public class FieldChainMarker { int pos = m.end(2); while (pos < text.length()) { pos = document.skipWhitespace(pos); - if (pos >= text.length() || text.charAt(pos) != '.') break; + if (pos >= text.length() || document.isExcluded(pos) || text.charAt(pos) != '.') break; pos++; // Skip dot pos = document.skipWhitespace(pos); - if (pos >= text.length() || !Character.isJavaIdentifierStart(text.charAt(pos))) break; + if (pos >= text.length() || document.isExcluded(pos) || !Character.isJavaIdentifierStart(text.charAt(pos))) break; // Read identifier int identStart = pos; @@ -452,11 +452,11 @@ public class FieldChainMarker { int pos = identEnd; while (pos < text.length()) { pos = document.skipWhitespace(pos); - if (pos >= text.length() || text.charAt(pos) != '.') break; + if (pos >= text.length() || document.isExcluded(pos) || text.charAt(pos) != '.') break; pos++; // Skip dot pos = document.skipWhitespace(pos); - if (pos >= text.length() || !Character.isJavaIdentifierStart(text.charAt(pos))) break; + if (pos >= text.length() || document.isExcluded(pos) || !Character.isJavaIdentifierStart(text.charAt(pos))) break; // Read identifier int nStart = pos; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java index 69ddd4547..e9d6d7cf2 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java @@ -4308,6 +4308,54 @@ public class ScriptDocument { return resolveTypeAndTrackUsage(typeName); } + /** + * Recursively resolve any unresolved type names inside a TypeInfo using position-aware resolution. + * + * This handles a different concern than {@link GenericContext#substitute(TypeInfo)} — + * it does not replace type variables with concrete bindings. Instead, it re-resolves + * unresolved simple names (e.g., inner class names like "Node" or script-declared type + * parameters like "T") that appear as type arguments in parameterized types. + * + * For example, if a type resolves to {@code List} but "Node" is still unresolved, + * this will attempt to resolve "Node" using the position-aware + * {@link #resolveType(String, int)} which can find inner classes and type parameters + * visible at the given position. + * + * @param type the type whose unresolved components should be re-resolved + * @param position the script text offset for scope-aware resolution + * @return the type with all resolvable components resolved, or the original if nothing changed + */ + private TypeInfo substituteTypeParams(TypeInfo type, int position) { + if (type == null) + return null; + + if (!type.isResolved() && type.getSimpleName() != null + && !type.getSimpleName().contains(".")) { + TypeInfo sub = resolveType(type.getSimpleName(), position); + return (sub != null && sub.isResolved()) ? sub : type; + } + + if (type.isParameterized()) { + List args = type.getAppliedTypeArgs(); + if (args != null && !args.isEmpty()) { + List substituted = new ArrayList<>(args.size()); + boolean anyChanged = false; + for (TypeInfo arg : args) { + TypeInfo sub = substituteTypeParams(arg, position); + if (sub != arg) + anyChanged = true; + substituted.add(sub); + } + if (anyChanged) { + TypeInfo raw = type.getRawType(); + return raw != null ? raw.parameterize(substituted) : type.parameterize(substituted); + } + } + } + + return type; + } + /** * Position-aware type resolution overload. * Resolves a type name with positional context so that an unqualified inner class name @@ -4330,7 +4378,7 @@ public class ScriptDocument { // If already resolved, no need for positional fallback if (resolved != null && resolved.isResolved()) { - return resolved; + return substituteTypeParams(resolved, position); } if (typeName != null && !typeName.contains(".")) { @@ -4343,14 +4391,7 @@ public class ScriptDocument { // Check if the name is a declared type parameter on this class (e.g., E in class Box) TypeParamInfo typeParam = enclosing.getDeclaredTypeParam(baseTypeName); if (typeParam != null) { - TypeInfo boundType = typeParam.getBoundTypeInfo(); - if (boundType == null) { - typeParam.resolveBoundType(); - boundType = typeParam.getBoundTypeInfo(); - } - TypeInfo result = (boundType != null && boundType.isResolved()) - ? TypeInfo.typeParameter(baseTypeName, boundType) - : TypeInfo.typeParameter(baseTypeName); + TypeInfo result = TypeInfo.typeParameter(baseTypeName, typeParam); for (int i = 0; i < arrayDims; i++) result = TypeInfo.arrayOf(result); return result; } @@ -4586,7 +4627,7 @@ public class ScriptDocument { // Modifiers - Java only if (!isJavaScript()) { - addPatternMarks(marks, MODIFIER_PATTERN, TokenType.MODIFIER); + addPatternMarks(marks, MODIFIER_PATTERN, TokenType.KEYWORD); } // Type declarations and usages - Java only (JS doesn't have explicit types) @@ -5297,7 +5338,7 @@ public class ScriptDocument { continue; // Mark 'import' keyword - marks.add(new ScriptLine.Mark(imp.getStartOffset(), imp.getStartOffset() + 6, TokenType.IMPORT_KEYWORD, imp)); + marks.add(new ScriptLine.Mark(imp.getStartOffset(), imp.getStartOffset() + 6, TokenType.KEYWORD, imp)); // Parse path tokens int pathStart = imp.getPathStartOffset(); @@ -5477,7 +5518,7 @@ public class ScriptDocument { if (isExcluded(m.start())) continue; - marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.CLASS_KEYWORD)); + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.KEYWORD)); String kind = m.group(1); TokenType nameType; @@ -5541,7 +5582,7 @@ public class ScriptDocument { int extIdx = indexOfAtDepthZero(betweenParamsAndBrace, "extends"); if (extIdx >= 0) { int extendsAbsStart = scanPos + extIdx; - marks.add(new ScriptLine.Mark(extendsAbsStart, extendsAbsStart + 7, TokenType.IMPORT_KEYWORD)); + marks.add(new ScriptLine.Mark(extendsAbsStart, extendsAbsStart + 7, TokenType.KEYWORD)); // Extract parent name after 'extends ' String afterExtends = betweenParamsAndBrace.substring(extIdx + 7).trim(); @@ -5574,7 +5615,7 @@ public class ScriptDocument { int implIdx = indexOfAtDepthZero(betweenParamsAndBrace, "implements"); if (implIdx >= 0) { int implAbsStart = scanPos + implIdx; - marks.add(new ScriptLine.Mark(implAbsStart, implAbsStart + 10, TokenType.IMPORT_KEYWORD)); + marks.add(new ScriptLine.Mark(implAbsStart, implAbsStart + 10, TokenType.KEYWORD)); String afterImpl = betweenParamsAndBrace.substring(implIdx + 10).trim(); int implListStart = implAbsStart + 10; @@ -6426,7 +6467,7 @@ public class ScriptDocument { while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--; - if (pos < 0 || text.charAt(pos) != '.') + if (pos < 0 || isExcluded(pos) || text.charAt(pos) != '.') return false; // Skip the dot and any whitespace @@ -6434,7 +6475,7 @@ public class ScriptDocument { while (pos >= 0 && Character.isWhitespace(text.charAt(pos))) pos--; - if (pos < 0) + if (pos < 0 || isExcluded(pos)) return false; // Check what's before the dot - could be: @@ -6465,7 +6506,7 @@ public class ScriptDocument { pos--; // If preceded by a dot, this is part of a chain - treat as instance for now - if (pos >= 0 && text.charAt(pos) == '.') { + if (pos >= 0 && !isExcluded(pos) && text.charAt(pos) == '.') { return false; } @@ -7158,7 +7199,8 @@ public class ScriptDocument { while (scanPos >= 0 && Character.isWhitespace(text.charAt(scanPos))) scanPos--; - if (scanPos < 0 || text.charAt(scanPos) != '.') { + // Guard: dot inside a comment/string must not be treated as a receiver chain separator + if (scanPos < 0 || isExcluded(scanPos) || text.charAt(scanPos) != '.') { return null; // No receiver } @@ -8089,11 +8131,24 @@ public class ScriptDocument { if (Character.isJavaIdentifierPart(c)) { while (pos >= 0 && Character.isJavaIdentifierPart(text.charAt(pos))) pos--; - // Skip whitespace to check for chained identifier (preceded by a dot) + // Skip whitespace AND excluded ranges to check for chained identifier (preceded by a dot). + // Without skipping excluded ranges, a dot inside a comment (e.g. "//event.\n player") + // would be mistaken for a chain continuation dot. int checkPos = pos; - while (checkPos >= 0 && Character.isWhitespace(text.charAt(checkPos))) checkPos--; - // If it's part of a chained identifier (preceded by a dot), continue - if (checkPos >= 0 && text.charAt(checkPos) == '.') { pos = checkPos - 1; continue; } + while (checkPos >= 0) { + if (isExcluded(checkPos)) { + for (int[] range : excludedRanges) { + if (checkPos >= range[0] && checkPos < range[1]) { + checkPos = range[0] - 1; + break; + } + } + continue; + } + if (Character.isWhitespace(text.charAt(checkPos))) { checkPos--; continue; } + break; + } + if (checkPos >= 0 && !isExcluded(checkPos) && text.charAt(checkPos) == '.') { pos = checkPos - 1; continue; } break; } @@ -8201,7 +8256,7 @@ for (ScriptTypeInfo type:scriptTypes.values()) { superClass, parentConstructor, false).setConstructor(true); callInfo.validate(); methodCalls.add(callInfo); - marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.IMPORT_KEYWORD, + marks.add(new ScriptLine.Mark(nameStart, nameEnd, TokenType.KEYWORD, errorMsg != null ? errorMsg : callInfo)); } @@ -9974,7 +10029,7 @@ for (ScriptTypeInfo type:scriptTypes.values()) { TokenType type = isClassTypeInfo ? varInfo.isGlobal() ? TokenType.GLOBAL_FIELD - : TokenType.LOCAL_FIELD : info.getTokenType(); + : TokenType.LOCAL_FIELD : TokenType.METHOD_CALL; marks.add(new ScriptLine.Mark(start, end, type, isClassTypeInfo ? varInfo : ctorCall)); continue; } @@ -10218,7 +10273,7 @@ for (ScriptTypeInfo type:scriptTypes.values()) { int i = position - 1; while (i >= 0 && Character.isWhitespace(text.charAt(i))) i--; - return i >= 0 && text.charAt(i) == '.'; + return i >= 0 && text.charAt(i) == '.' && !isExcluded(i); } boolean isInImportOrPackage(int position) { diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java index 28dea5e08..1265aa582 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java @@ -132,15 +132,22 @@ public class ScriptLine { clearTokens(); int cursor = globalStart; + /* + * globalEnd includes the newline character position (lineEnd = lineStart + text.length() + 1), + * but the line's text field does NOT include the newline. We must clamp all token boundaries + * to the displayable text end so that '\n' never leaks into rendered token text. + */ + int displayEnd = Math.min(globalStart + text.length(), fullText.length()); + for (Mark mark : marks) { - // Skip marks that don't overlap this line - if (mark.end <= globalStart || mark.start >= globalEnd) { + // Skip marks that don't overlap the displayable portion of this line + if (mark.end <= globalStart || mark.start >= displayEnd) { continue; } - // Clamp mark to line boundaries + // Clamp mark to displayable line boundaries (excludes trailing newline) int tokenStart = Math.max(mark.start, globalStart); - int tokenEnd = Math.min(mark.end, globalEnd); + int tokenEnd = Math.min(mark.end, displayEnd); // Validate bounds tokenStart = Math.max(0, Math.min(tokenStart, fullText.length())); @@ -168,13 +175,10 @@ public class ScriptLine { cursor = tokenEnd; } - // Add trailing default token if needed - if (cursor < globalEnd) { - int end = Math.min(globalEnd, fullText.length()); - if (cursor < end) { - String trailingText = fullText.substring(cursor, end); - addToken(Token.defaultToken(trailingText, cursor, end)); - } + // Add trailing default token if needed (clamped to displayable text, excluding newline) + if (cursor < displayEnd) { + String trailingText = fullText.substring(cursor, displayEnd); + addToken(Token.defaultToken(trailingText, cursor, displayEnd)); } } @@ -183,12 +187,7 @@ public class ScriptLine { token.setTypeInfo((TypeInfo) metadata); } else if (metadata instanceof TypeParamInfo) { TypeParamInfo typeParam = (TypeParamInfo) metadata; - TypeInfo boundType = typeParam.getBoundTypeInfo(); - if (boundType != null) { - token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName(), boundType)); - } else { - token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName())); - } + token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName(), typeParam)); } else if (metadata instanceof MethodCallInfo) { MethodCallInfo callInfo = (MethodCallInfo) metadata; if (callInfo.isConstructor()) { @@ -234,6 +233,59 @@ public class ScriptLine { // ==================== RENDERING ==================== + /** + * Compute the rendered pixel width of a substring of this line, accounting for + * bold/italic token styles. Characters covered by bold or italic tokens will be + * measured with the appropriate font style, producing the same widths as the + * actual rendered output. + * + * @param localStart start index within the line text (inclusive) + * @param localEnd end index within the line text (exclusive) + * @return pixel width matching the rendered output + */ + public int getRenderedWidth(int localStart, int localEnd) { + localStart = Math.max(0, Math.min(localStart, text.length())); + localEnd = Math.max(localStart, Math.min(localEnd, text.length())); + if (localStart >= localEnd) return 0; + + int width = 0; + int cursor = localStart; + + for (Token t : tokens) { + if (cursor >= localEnd) break; + + int tokenLocalStart = t.getGlobalStart() - globalStart; + int tokenLocalEnd = tokenLocalStart + t.getText().length(); + + // Gap before this token (rendered as plain text) + if (cursor < tokenLocalStart) { + int gapEnd = Math.min(tokenLocalStart, localEnd); + if (gapEnd > cursor) { + width += ClientProxy.Font.width(text.substring(cursor, gapEnd)); + cursor = gapEnd; + } + } + + if (cursor >= localEnd) break; + + // Token overlap with [localStart, localEnd) + int overlapStart = Math.max(cursor, tokenLocalStart); + int overlapEnd = Math.min(tokenLocalEnd, localEnd); + if (overlapEnd > overlapStart) { + String substr = text.substring(overlapStart, overlapEnd); + width += ClientProxy.Font.width(substr, t.getFontStyle()); + cursor = overlapEnd; + } + } + + // Trailing text after last token (plain) + if (cursor < localEnd) { + width += ClientProxy.Font.width(text.substring(cursor, localEnd)); + } + + return width; + } + /** * Draw this line with syntax highlighting using Minecraft color codes. * Compatible with the existing rendering system. @@ -249,7 +301,7 @@ public class ScriptLine { for (Token t : tokens) { int tokenStart = t.getGlobalStart() - globalStart; // relative position in line - int tokenWidth = ClientProxy.Font.width(t.getText()); + int tokenWidth = ClientProxy.Font.width(t.getText(), t.getFontStyle()); // Calculate gap width before this token if (tokenStart > lastIndex && tokenStart <= text.length()) { @@ -293,12 +345,9 @@ public class ScriptLine { * Also draws wavy underlines for tokens with errors. */ public void drawStringHex(int x, int y) { - // Build the complete text with all tokens and gaps, draw it as ONE string - // to match the spacing behavior of drawString which draws everything at once - StringBuilder fullText = new StringBuilder(); int lastIndex = 0; - - // Build segments with token position info + + // Build segments with token position info and compute positions using style-aware widths java.util.List segments = new java.util.ArrayList<>(); for (Token t : tokens) { @@ -307,37 +356,29 @@ public class ScriptLine { // Add any gap before this token if (tokenStart > lastIndex && tokenStart <= text.length()) { String gap = text.substring(lastIndex, tokenStart); - segments.add(new TextSegment(fullText.length(), gap, 0xFFFFFFFF, false)); - fullText.append(gap); + segments.add(new TextSegment(lastIndex, gap, 0xFFFFFFFF, false)); } - // Add the colored token + // Add the colored token (with style prefix for rendering) String styledText = t.getStylePrefix() + t.getText(); - segments.add(new TextSegment(fullText.length(), styledText, t.getHexColor(), true)); - fullText.append(styledText); - - lastIndex = tokenStart + styledText.length(); + segments.add(new TextSegment(tokenStart, styledText, t.getHexColor(), true)); + + lastIndex = tokenStart + t.getText().length(); } // Add any remaining text after the last token if (lastIndex < text.length()) { String remaining = text.substring(lastIndex); - segments.add(new TextSegment(fullText.length(), remaining, 0xFFFFFFFF, false)); - fullText.append(remaining); + segments.add(new TextSegment(lastIndex, remaining, 0xFFFFFFFF, false)); } - // Draw each segment at the correct position - // Calculate positions based on the full string to match drawString's spacing + // Draw each segment at the correct position using style-aware width for (TextSegment seg : segments) { if (!seg.text.isEmpty()) { - // Get the width of everything before this segment - String prefix = fullText.substring(0, seg.startPos); - int prefixWidth = ClientProxy.Font.width(prefix); - int color = (seg.color & 0xFF000000) == 0 ? (0xFF000000 | seg.color) : seg.color; + int prefixWidth = getRenderedWidth(0, seg.startPos); + int color = seg.color; - // Draw this segment at the correct position ClientProxy.Font.drawString(seg.text, x + prefixWidth, y, color); - // Minecraft.getMinecraft().fontRenderer.drawString(seg.text, x + prefixWidth, y, color); } } @@ -354,6 +395,7 @@ public class ScriptLine { ErrorUnderlineRenderer.drawErrorUnderlines( parent, + this, lineStartX, baselineY, getText(), diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java index 05da45227..7a712ae6c 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptTextContainer.java @@ -210,10 +210,7 @@ public class ScriptTextContainer extends JavaTextContainer { noppes.npcs.client.gui.util.script.interpreter.token.TokenType type) { if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.COMMENT) return JavaTextContainer.TokenType.COMMENT; if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.STRING) return JavaTextContainer.TokenType.STRING; - if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.CLASS_KEYWORD) return JavaTextContainer.TokenType.CLASS_KEYWORD; - if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.IMPORT_KEYWORD) return JavaTextContainer.TokenType.IMPORT_KEYWORD; if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.KEYWORD) return JavaTextContainer.TokenType.KEYWORD; - if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.MODIFIER) return JavaTextContainer.TokenType.MODIFIER; if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.INTERFACE_DECL) return JavaTextContainer.TokenType.INTERFACE_DECL; if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.ENUM_DECL) return JavaTextContainer.TokenType.ENUM_DECL; if (type == noppes.npcs.client.gui.util.script.interpreter.token.TokenType.CLASS_DECL) return JavaTextContainer.TokenType.CLASS_DECL; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java index 466f1d8c4..78f342db5 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/field/FieldInfo.java @@ -9,7 +9,7 @@ import noppes.npcs.client.gui.util.script.interpreter.jsdoc.JSDocInfo; import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; -import noppes.npcs.client.gui.util.script.interpreter.type.TypeSubstitutor; + import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo; import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; @@ -18,7 +18,6 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; /** * Metadata for a field (variable) declaration or reference. @@ -165,12 +164,8 @@ public final class FieldInfo { } // If the receiver is parameterized, substitute class type variables in the field type - if (GenericContext.hasGenerics(containingType)) { - Map receiverBindings = TypeSubstitutor.createBindingsFromReceiver(containingType); - if (!receiverBindings.isEmpty()) { - type = TypeSubstitutor.substitute(type, receiverBindings); - } - } + if (GenericContext.hasGenerics(containingType)) + type = GenericContext.forReceiver(containingType).substitute(type); // Check if this is an enum constant if (field.isEnumConstant()) { @@ -531,6 +526,38 @@ public final class FieldInfo { public String getDocumentation() { return documentation; } public JSDocInfo getJSDocInfo() { return jsDocInfo; } public void setJSDocInfo(JSDocInfo jsDocInfo) { this.jsDocInfo = jsDocInfo; } + + /** + * Create a copy of this field with its declared type substituted using the given context. + * Returns {@code this} unchanged if the context is null, the declared type is null, + * or substitution produces the same type (identity check). + * + * Used when a generic type is parameterized (e.g., {@code Box}) and its raw fields + * (declared with type variables like T) need to reflect the concrete type arguments. + * + * Example: for {@code class Box { T value; }}, calling this on the raw field {@code value: T} + * with a context binding {@code T → String} produces {@code value: String}. + * + * @param context the generic context containing type variable bindings; if null, returns {@code this} unchanged + * @return a new FieldInfo with the substituted declared type, or {@code this} if no substitution occurred + */ + public FieldInfo substituteTypeParams(GenericContext context) { + if (context == null || declaredType == null) { + return this; + } + TypeInfo subType = context.substitute(declaredType); + if (subType == declaredType) { + return this; + } + FieldInfo result = new FieldInfo(name, scope, subType, declarationOffset, resolved, + containingMethod, documentation, initStart, initEnd, modifiers, reflectionField); + result.jsDocInfo = this.jsDocInfo; + result.scopeInfo = this.scopeInfo; + result.isVarArg = this.isVarArg; + result.inferredType = this.inferredType; + return result; + } + public int getInitStart() { return initStart; } public int getInitEnd() { return initEnd; } public boolean hasInitializer() { return initStart >= 0 && initEnd > initStart; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java index 3c5087533..69014acc9 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/GutterIconRenderer.java @@ -282,7 +282,7 @@ public class GutterIconRenderer { int currentY = y + PADDING; for (TextSegment segment : segments) { - ClientProxy.Font.drawString(segment.text, currentX, currentY, 0xFF000000 | segment.color); + ClientProxy.Font.drawString(segment.text, currentX, currentY, segment.color); currentX += ClientProxy.Font.width(segment.text); } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java index 7618561c5..af018e5bb 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java @@ -19,7 +19,7 @@ import noppes.npcs.client.gui.util.script.interpreter.token.Token; public class HoverState { /** Minimum hover time (ms) before showing tooltip */ - private static final long HOVER_DELAY_MS = 100; + private static final long HOVER_DELAY_MS = 250; // ==================== STATE ==================== @@ -89,6 +89,11 @@ public class HoverState { /** Whether click-to-pin behaviour is enabled for this hover state. */ private boolean clickToPinEnabled = true; + /** Locked mouse position for tooltip placement (captured once when tooltip becomes visible) */ + private int lockedTooltipMouseX; + private int lockedTooltipMouseY; + private boolean isTooltipPositionLocked; + // ==================== UPDATE ==================== /** @@ -172,6 +177,9 @@ public class HoverState { if (elapsed >= HOVER_DELAY_MS && !tooltipVisible) { tooltipVisible = true; hoverInfo = TokenHoverInfo.fromToken(token); + lockedTooltipMouseX = lastMouseX; + lockedTooltipMouseY = lastMouseY; + isTooltipPositionLocked = true; } } } @@ -223,6 +231,9 @@ public class HoverState { if (elapsed >= HOVER_DELAY_MS && !tooltipVisible) { tooltipVisible = true; hoverInfo = TokenHoverInfo.fromToken(token); + lockedTooltipMouseX = lastMouseX; + lockedTooltipMouseY = lastMouseY; + isTooltipPositionLocked = true; } } } @@ -249,6 +260,7 @@ public class HoverState { targetScrollOffset = 0; tooltipMaxScroll = 0; isDraggingScrollbar = false; + isTooltipPositionLocked = false; } } } @@ -284,6 +296,11 @@ public class HoverState { this.tokenWidth = tokenW; // ensure hoveredToken reflects pinned token this.hoveredToken = token; + if (this.tooltipVisible && !this.isTooltipPositionLocked) { + this.lockedTooltipMouseX = lastMouseX; + this.lockedTooltipMouseY = lastMouseY; + this.isTooltipPositionLocked = true; + } } /** @@ -294,14 +311,20 @@ public class HoverState { this.pinnedHoverInfo = null; this.tooltipVisible = false; this.hoverInfo = null; + this.isTooltipPositionLocked = false; } public boolean isPinned() { return pinnedToken != null; } + public int getLockedMouseX() { return lockedTooltipMouseX; } + public int getLockedMouseY() { return lockedTooltipMouseY; } + public boolean isPositionLocked() { return isTooltipPositionLocked; } + // ==================== POSITION CALCULATION ==================== /** * Get the character index within a line at the given X pixel position. + * Uses style-aware width calculation to account for bold/italic tokens. */ private int getCharacterIndexAtX(ScriptLine line, int x) { String text = line.getText(); @@ -309,7 +332,7 @@ public class HoverState { int accumWidth = 0; for (int i = 0; i < text.length(); i++) { - int charWidth = noppes.npcs.client.ClientProxy.Font.width(String.valueOf(text.charAt(i))); + int charWidth = line.getRenderedWidth(i, i + 1); if (accumWidth + charWidth / 2 > x) { return i; } @@ -324,20 +347,19 @@ public class HoverState { private void calculateTokenPosition(ScriptLine line, Token token, int viewportX, int viewportY, float scrollOffset, int lineHeight) { - // X position: calculate width of text before the token - String lineText = line.getText(); + // X position: calculate width of text before the token (style-aware) int tokenLocalStart = token.getGlobalStart() - line.getGlobalStart(); - tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); + tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, line.getText().length())); - String textBefore = lineText.substring(0, tokenLocalStart); - tokenScreenX = viewportX + noppes.npcs.client.ClientProxy.Font.width(textBefore); + tokenScreenX = viewportX + line.getRenderedWidth(0, tokenLocalStart); // Y position: line position minus scroll int lineY = line.getLineIndex(); tokenScreenY = viewportY + (int) ((lineY - scrollOffset) * lineHeight); - // Token width - tokenWidth = noppes.npcs.client.ClientProxy.Font.width(token.getText()); + // Token width (style-aware) + int tokenLocalEnd = tokenLocalStart + token.getText().length(); + tokenWidth = line.getRenderedWidth(tokenLocalStart, tokenLocalEnd); } public void setTooltipBounds(int x, int y, int width, int height) { diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java index b45688807..9bfb9ff3d 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java @@ -185,7 +185,6 @@ public class TokenHoverInfo { break; case LITERAL: case KEYWORD: - case MODIFIER: case STRING: case COMMENT: if (info.hasErrors()) @@ -266,17 +265,17 @@ public class TokenHoverInfo { int mods = clazz.getModifiers(); // Modifiers - if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isAbstract(mods) && !clazz.isInterface()) addSegment("abstract ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isFinal(mods) && !isEnum) addSegment("final ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isAbstract(mods) && !clazz.isInterface()) addSegment("abstract ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isFinal(mods) && !isEnum) addSegment("final ", TokenType.KEYWORD.getHexColor()); // Class type keyword if (clazz.isInterface()) { - addSegment("interface ", TokenType.MODIFIER.getHexColor()); + addSegment("interface ", TokenType.KEYWORD.getHexColor()); } else if (isEnum) { - addSegment("enum ", TokenType.MODIFIER.getHexColor()); + addSegment("enum ", TokenType.KEYWORD.getHexColor()); } else { - addSegment("class ", TokenType.MODIFIER.getHexColor()); + addSegment("class ", TokenType.KEYWORD.getHexColor()); } // Class name - use proper color based on type @@ -290,7 +289,7 @@ public class TokenHoverInfo { // Extends Class superclass = clazz.getSuperclass(); if (superclass != null && superclass != Object.class && !isEnum) { - addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); addSegment(superclass.getSimpleName(), getColorForClass(superclass)); } @@ -299,7 +298,7 @@ public class TokenHoverInfo { // Implements Class[] interfaces = clazz.getInterfaces(); if (interfaces.length > 0) { - addSegment(clazz.isInterface() ? " extends " : " implements ", TokenType.MODIFIER.getHexColor()); + addSegment(clazz.isInterface() ? " extends " : " implements ", TokenType.KEYWORD.getHexColor()); for (int i = 0; i < Math.min(interfaces.length, 3); i++) { if (i > 0) addSegment(", ", TokenType.DEFAULT.getHexColor()); addSegment(interfaces[i].getSimpleName(), TokenType.INTERFACE_DECL.getHexColor()); @@ -325,19 +324,19 @@ public class TokenHoverInfo { int mods = scriptType.getModifiers(); // Modifiers - if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.KEYWORD.getHexColor()); if (Modifier.isAbstract(mods) && scriptType.getKind() != TypeInfo.Kind.INTERFACE) - addSegment("abstract ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isFinal(mods)) addSegment("final ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isStatic(mods)) addSegment("static ", TokenType.MODIFIER.getHexColor()); + addSegment("abstract ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isFinal(mods)) addSegment("final ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isStatic(mods)) addSegment("static ", TokenType.KEYWORD.getHexColor()); // Class type keyword if (scriptType.getKind() == TypeInfo.Kind.INTERFACE) { - addSegment("interface ", TokenType.MODIFIER.getHexColor()); + addSegment("interface ", TokenType.KEYWORD.getHexColor()); } else if (scriptType.getKind() == TypeInfo.Kind.ENUM) { - addSegment("enum ", TokenType.MODIFIER.getHexColor()); + addSegment("enum ", TokenType.KEYWORD.getHexColor()); } else { - addSegment("class ", TokenType.MODIFIER.getHexColor()); + addSegment("class ", TokenType.KEYWORD.getHexColor()); } // Class name @@ -347,7 +346,7 @@ public class TokenHoverInfo { addTypeSegments(typeInfo); addDeclaredTypeParamSegments(typeInfo); if (scriptType.hasSuperClass()) { - addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); TypeInfo superClass = scriptType.getSuperClass(); if (superClass != null && superClass.isResolved()) { // Color based on resolved type @@ -370,7 +369,7 @@ public class TokenHoverInfo { // Already showed extends, so use comma addSegment(", ", TokenType.DEFAULT.getHexColor()); } else { - addSegment(keyword, TokenType.MODIFIER.getHexColor()); + addSegment(keyword, TokenType.KEYWORD.getHexColor()); } List interfaceNames = scriptType.getImplementedInterfaceNames(); @@ -414,12 +413,12 @@ public class TokenHoverInfo { JSTypeInfo jsType = typeInfo.getJSTypeInfo(); iconIndicator = "I"; - addSegment("interface ", TokenType.MODIFIER.getHexColor()); + addSegment("interface ", TokenType.KEYWORD.getHexColor()); addTypeSegments(typeInfo); addDeclaredTypeParamSegments(typeInfo); if (jsType.getExtendsType() != null) { - addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); addSegment(jsType.getExtendsType(), TokenType.INTERFACE_DECL.getHexColor()); } @@ -442,7 +441,7 @@ public class TokenHoverInfo { if (token.getMethodInfo() != null) { MethodInfo constructor = token.getMethodInfo(); declaration.add(new TextSegment("\n", TokenType.DEFAULT.getHexColor())); - additionalInfo.add("Constructor"); + // additionalInfo.add("Constructor"); buildConstructorDeclaration(constructor, typeInfo); } } @@ -451,13 +450,13 @@ public class TokenHoverInfo { iconIndicator = "T"; String paramName = typeInfo.getTypeParameterName(); - addSegment("type parameter ", TokenType.MODIFIER.getHexColor()); + addSegment("type parameter ", TokenType.KEYWORD.getHexColor()); addSegment("<", TokenType.DEFAULT.getHexColor()); addSegment(paramName, TokenType.GENERIC_TYPE_PARAM.getHexColor()); TypeInfo bound = typeInfo.getBoundType(); if (bound != null && bound.getJavaClass() != null && bound.getJavaClass() != Object.class) { - addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); addSegment(bound.getSimpleName(), getColorForTypeInfo(bound)); } addSegment(">", TokenType.DEFAULT.getHexColor()); @@ -583,7 +582,7 @@ public class TokenHoverInfo { // // Show modifiers from source if we have a declaration position // String modifiers = extractModifiersAtPosition(fieldInfo.getDeclarationOffset()); // if (modifiers != null && !modifiers.isEmpty()) { -// addSegment(modifiers + " ", TokenType.MODIFIER.getHexColor()); +// addSegment(modifiers + " ", TokenType.KEYWORD.getHexColor()); // } // } @@ -673,7 +672,7 @@ public class TokenHoverInfo { addInitializationTokens(token, fieldInfo); // Show it's a local variable - additionalInfo.add("Local variable"); + // additionalInfo.add("Local variable"); } public String getPackageName(TypeInfo type) { @@ -722,7 +721,7 @@ public class TokenHoverInfo { addSegment(fieldInfo.getName(), TokenType.PARAMETER.getHexColor()); - additionalInfo.add("Parameter"); + // additionalInfo.add("Parameter"); } private void extractUndefinedInfo(Token token) { @@ -796,13 +795,72 @@ public class TokenHoverInfo { TypeParamInfo param = params.get(i); addSegment(param.getName(), TokenType.GENERIC_TYPE_PARAM.getHexColor()); if (param.getBoundTypeName() != null && !"Object".equals(param.getBoundTypeName())) { - addSegment(" extends ", TokenType.MODIFIER.getHexColor()); + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); TypeInfo boundType = param.getBoundTypeInfo(); if (boundType != null && boundType.isResolved()) { addTypeSegments(boundType); } else { addSegment(param.getBoundTypeName(), TokenType.IMPORTED_CLASS.getHexColor()); } + + List additionalBoundTypes = param.getAdditionalBoundTypes(); + List additionalBoundNames = param.getAdditionalBoundNames(); + for (int j = 0; j < additionalBoundNames.size(); j++) { + addSegment(" & ", TokenType.DEFAULT.getHexColor()); + TypeInfo additionalBoundType = j < additionalBoundTypes.size() ? additionalBoundTypes.get(j) : null; + if (additionalBoundType != null && additionalBoundType.isResolved()) { + addTypeSegments(additionalBoundType); + if (!additionalBoundType.isParameterized()) { + addBoundTypeParamSegments(additionalBoundType); + } + } else { + addSegment(additionalBoundNames.get(j), TokenType.IMPORTED_CLASS.getHexColor()); + } + } + } + } + + addSegment(">", TokenType.DEFAULT.getHexColor()); + } + + private void addBoundTypeParamSegments(TypeInfo boundType) { + List params = boundType.getTypeParams(); + if (params == null || params.isEmpty()) return; + + addSegment("<", TokenType.DEFAULT.getHexColor()); + for (int i = 0; i < params.size(); i++) { + if (i > 0) + addSegment(", ", TokenType.DEFAULT.getHexColor()); + TypeParamInfo tp = params.get(i); + addSegment(tp.getName(), TokenType.GENERIC_TYPE_PARAM.getHexColor()); + + if (tp.getBoundTypeName() != null && !"Object".equals(tp.getBoundTypeName())) { + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); + TypeInfo innerBound = tp.getBoundTypeInfo(); + if (innerBound != null && innerBound.isResolved()) { + addTypeSegments(innerBound); + if (!innerBound.isParameterized()) { + addBoundTypeParamSegments(innerBound); + } + } else { + addSegment(tp.getBoundTypeName(), TokenType.IMPORTED_CLASS.getHexColor()); + } + + List innerAdditional = tp.getAdditionalBoundTypes(); + List innerAdditionalNames = tp.getAdditionalBoundNames(); + for (int j = 0; j < innerAdditionalNames.size(); j++) { + addSegment(" & ", TokenType.DEFAULT.getHexColor()); + TypeInfo innerAddBound = j < innerAdditional.size() ? innerAdditional.get(j) : null; + if (innerAddBound != null && innerAddBound.isResolved()) { + addTypeSegments(innerAddBound); + if (!innerAddBound.isParameterized()) { + addBoundTypeParamSegments(innerAddBound); + } + } else { + addSegment(innerAdditionalNames.get(j), TokenType.IMPORTED_CLASS.getHexColor()); + } + } + addSegment(" extends ", TokenType.KEYWORD.getHexColor()); } } addSegment(">", TokenType.DEFAULT.getHexColor()); @@ -844,9 +902,9 @@ public class TokenHoverInfo { // Modifiers (public, private, etc.) declaration.clear(); int mods = constructor.getModifiers(); - if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); - else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.MODIFIER.getHexColor()); - else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.KEYWORD.getHexColor()); + else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.KEYWORD.getHexColor()); + else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.KEYWORD.getHexColor()); // Constructor name (same as class name) addSegment(constructor.getName(), containingType.getTokenType().getHexColor()); @@ -890,14 +948,14 @@ public class TokenHoverInfo { // Skip for now - could add later // Modifiers - if (Modifier.isPublic(mods)) addSegment("public ", TokenType.MODIFIER.getHexColor()); - else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.MODIFIER.getHexColor()); - else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isPublic(mods)) addSegment("public ", TokenType.KEYWORD.getHexColor()); + else if (Modifier.isProtected(mods)) addSegment("protected ", TokenType.KEYWORD.getHexColor()); + else if (Modifier.isPrivate(mods)) addSegment("private ", TokenType.KEYWORD.getHexColor()); - if (Modifier.isStatic(mods)) addSegment("static ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isFinal(mods)) addSegment("final ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isAbstract(mods)) addSegment("abstract ", TokenType.MODIFIER.getHexColor()); - if (Modifier.isSynchronized(mods)) addSegment("synchronized ", TokenType.MODIFIER.getHexColor()); + if (Modifier.isStatic(mods)) addSegment("static ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isFinal(mods)) addSegment("final ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isAbstract(mods)) addSegment("abstract ", TokenType.KEYWORD.getHexColor()); + if (Modifier.isSynchronized(mods)) addSegment("synchronized ", TokenType.KEYWORD.getHexColor()); // Return type - check for actual type color (handle array suffix coloring) TypeInfo returnType = methodInfo.getReturnType(); @@ -932,12 +990,12 @@ public class TokenHoverInfo { if (methodInfo.isDeclaration() && methodInfo.getDeclarationOffset() >= 0) { String modifiers = extractModifiersAtPosition(methodInfo.getDeclarationOffset()); if (modifiers != null && !modifiers.isEmpty()) { - addSegment(modifiers + " ", TokenType.MODIFIER.getHexColor()); + addSegment(modifiers + " ", TokenType.KEYWORD.getHexColor()); } } else { // Fallback: show static if we know it if (methodInfo.isStatic()) { - addSegment("static ", TokenType.MODIFIER.getHexColor()); + addSegment("static ", TokenType.KEYWORD.getHexColor()); } } @@ -1395,20 +1453,20 @@ public class TokenHoverInfo { */ private void addFieldModifiers(int mods) { if (Modifier.isPublic(mods)) - addSegment("public ", TokenType.MODIFIER.getHexColor()); + addSegment("public ", TokenType.KEYWORD.getHexColor()); else if (Modifier.isProtected(mods)) - addSegment("protected ", TokenType.MODIFIER.getHexColor()); + addSegment("protected ", TokenType.KEYWORD.getHexColor()); else if (Modifier.isPrivate(mods)) - addSegment("private ", TokenType.MODIFIER.getHexColor()); + addSegment("private ", TokenType.KEYWORD.getHexColor()); if (Modifier.isStatic(mods)) - addSegment("static ", TokenType.MODIFIER.getHexColor()); + addSegment("static ", TokenType.KEYWORD.getHexColor()); if (Modifier.isFinal(mods)) - addSegment("final ", TokenType.MODIFIER.getHexColor()); + addSegment("final ", TokenType.KEYWORD.getHexColor()); if (Modifier.isVolatile(mods)) - addSegment("volatile ", TokenType.MODIFIER.getHexColor()); + addSegment("volatile ", TokenType.KEYWORD.getHexColor()); if (Modifier.isTransient(mods)) - addSegment("transient ", TokenType.MODIFIER.getHexColor()); + addSegment("transient ", TokenType.KEYWORD.getHexColor()); } /** diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java index f3215b1a9..ec9fae79a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java @@ -39,7 +39,7 @@ public class TokenHoverRenderer { private static final int SEPARATOR_SPACING = 5; /** Vertical offset from token */ - private static final int VERTICAL_OFFSET = 4; + private static final int VERTICAL_OFFSET = 1; /** Maximum tooltip width as percentage of viewport */ private static final float MAX_WIDTH_RATIO = 0.9f; @@ -137,9 +137,20 @@ public class TokenHoverRenderer { } } - // Position the tooltip — use dragged position if user has panned the panel - int tooltipX = positionOverridden ? hoverState.getOverriddenTooltipX() : tokenX; - int tooltipY = positionOverridden ? hoverState.getOverriddenTooltipY() : tokenY + lineHeight + VERTICAL_OFFSET; + // Position the tooltip — use dragged position if user has panned the panel, + // otherwise use locked mouse position (captured when tooltip first became visible) + int tooltipX; + int tooltipY; + if (positionOverridden) { + tooltipX = hoverState.getOverriddenTooltipX(); + tooltipY = hoverState.getOverriddenTooltipY(); + } else if (hoverState.isPositionLocked()) { + tooltipX = hoverState.getLockedMouseX() + 2; + tooltipY = tokenY + lineHeight + VERTICAL_OFFSET; + } else { + tooltipX = tokenX; + tooltipY = tokenY + lineHeight + VERTICAL_OFFSET; + } // Clamp position — use screen bounds when user has dragged the panel outside the editor viewport int leftBound = useScreenBounds ? 0 : viewportX; @@ -164,7 +175,7 @@ public class TokenHoverRenderer { if (useScreenBounds) { tooltipY = bottomBound - boxHeight; } else { - tooltipY = tokenY - boxHeight - VERTICAL_OFFSET; // try above token + tooltipY = tokenY - boxHeight; } } if (tooltipY < topBound) { @@ -176,13 +187,20 @@ public class TokenHoverRenderer { // Mouse-over detection bounds: // When dragged, use panel rect only — no gap extension (token is unrelated to panel position) - // When default, union the token bottom and the tooltip rect so both directions are covered + // When default and tooltip is BELOW the token, union the token bottom and the tooltip rect + // so the small gap between token and tooltip doesn't break hover detection. + // When tooltip is ABOVE the token, use only the tooltip rect — bridging would cover + // intervening tokens and prevent hover-switching between them. if (hoverState.hasOverriddenPosition()) { hoverState.setTooltipBounds(tooltipX, tooltipY, boxWidth, boxHeight); - } else { - int boundsTopY = Math.min(tokenY + lineHeight, tooltipY); - int boundsBottomY = Math.max(tooltipY + boxHeight, tokenY + lineHeight); + } else if (tooltipY >= tokenY + lineHeight) { + // Tooltip is below the token — bridge the gap + int boundsTopY = tokenY + lineHeight; + int boundsBottomY = tooltipY + boxHeight; hoverState.setTooltipBounds(tooltipX, boundsTopY, boxWidth, boundsBottomY - boundsTopY); + } else { + // Tooltip is above the token — use tooltip rect only + hoverState.setTooltipBounds(tooltipX, tooltipY, boxWidth, boxHeight); } // Render the tooltip @@ -218,7 +236,7 @@ public class TokenHoverRenderer { int boxHeight = visibleContentHeight + PADDING * 2; // Store scroll metadata into HoverState - hoverState.setTooltipMaxScroll(Math.max(0, totalContentHeight - visibleContentHeight + BOTTOM_GAP)); + hoverState.setTooltipMaxScroll(Math.max(0, totalContentHeight - visibleContentHeight + (hasScrollbar? BOTTOM_GAP : 0))); // Draw background Gui.drawRect(x, y, x + boxWidth, y + boxHeight, BG_COLOR); @@ -437,7 +455,7 @@ public class TokenHoverRenderer { for (TokenHoverInfo.TextSegment segment : segments) { String text = segment.text; - int color = 0xFF000000 | segment.color; + int color = segment.color; // Split segment into words for wrapping String[] words = text.split("(?<=\\s)|(?=\\s)"); // Keep whitespace as separate tokens diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java index ccfc347c5..12ba570d3 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/method/MethodInfo.java @@ -11,7 +11,7 @@ import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; import noppes.npcs.client.gui.util.script.interpreter.type.GenericContext; import noppes.npcs.client.gui.util.script.interpreter.type.TypeChecker; import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; -import noppes.npcs.client.gui.util.script.interpreter.type.TypeSubstitutor; + import noppes.npcs.client.gui.util.script.interpreter.type.GenericParameterAdapter; import java.lang.reflect.Constructor; @@ -173,13 +173,12 @@ public final class MethodInfo { } // If the receiver is parameterized (e.g., List), substitute class type variables (E -> String) - Map receiverBindings = null; + GenericContext receiverCtx = null; if (GenericContext.hasGenerics(containingType)) { - receiverBindings = TypeSubstitutor.createBindingsFromReceiver(containingType); - if (!receiverBindings.isEmpty()) { - returnType = TypeSubstitutor.substitute(returnType, receiverBindings); - } + receiverCtx = GenericContext.forReceiver(containingType); + returnType = receiverCtx.substitute(returnType); } + List params = new ArrayList<>(); // Use getGenericParameterTypes() to preserve generic information java.lang.reflect.Type[] genericParamTypes = method.getGenericParameterTypes(); @@ -190,9 +189,8 @@ public final class MethodInfo { paramType = TypeInfo.fromClass(method.getParameterTypes()[i]); } - if (receiverBindings != null && !receiverBindings.isEmpty()) { - paramType = TypeSubstitutor.substitute(paramType, receiverBindings); - } + if (receiverCtx != null) + paramType = receiverCtx.substitute(paramType); // Adapt Object params to receiver generic type args (e.g., Map.get(Object) -> Map.get(K)) TypeInfo adapted = GenericParameterAdapter.adaptParameterType(method, containingType, i, paramType); @@ -377,6 +375,37 @@ public final class MethodInfo { */ public boolean hasInheritanceMarker() { return isOverride() || isImplements(); } + /** + * Create a copy of this method with all type variables substituted using the given context. + * Applies substitution to both the return type and every parameter type recursively. + * + * Used when a generic type is parameterized (e.g., {@code Box}) and its raw methods + * (declared with type variables like T) need to reflect the concrete type arguments. + * + * Example: for {@code class Box { T get(); }}, calling this on the raw {@code get() → T} + * with a context binding {@code T → String} produces {@code get() → String}. + * + * @param context the generic context containing type variable bindings; if null, returns {@code this} unchanged + * @return a new MethodInfo with substituted types, or {@code this} if context is null + */ + public MethodInfo substituteTypeParams(GenericContext context) { + if (context == null) { + return this; + } + TypeInfo subReturnType = context.substitute(returnType); + List subParams = new ArrayList<>(parameters.size()); + for (FieldInfo param : parameters) { + subParams.add(param.substituteTypeParams(context)); + } + MethodInfo result = new MethodInfo(name, subReturnType, containingType, subParams, + fullDeclarationOffset, typeOffset, nameOffset, bodyStart, bodyEnd, + resolved, isDeclaration, modifiers, documentation, javaMethod); + result.jsDocInfo = this.jsDocInfo; + result.overridesFrom = this.overridesFrom; + result.implementsFrom = this.implementsFrom; + return result; + } + /** * Check if a position is inside this method's body. */ diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/ScriptColorScheme.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/ScriptColorScheme.java new file mode 100644 index 000000000..f5d6a9cb9 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/ScriptColorScheme.java @@ -0,0 +1,270 @@ +package noppes.npcs.client.gui.util.script.interpreter.token; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.resources.IResource; +import net.minecraft.client.resources.IResourceManager; +import net.minecraft.util.ResourceLocation; +import noppes.npcs.LogWriter; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Manages reloadable style overrides (color, bold, italic) for {@link TokenType}, + * plus editor background/gutter/scrollbar colors via {@link BackgroundStyleEntry}. + *

+ * Priority is always determined internally by {@link TokenType#getPriority()} ()} + * and is never exposed to or loaded from user-facing JSON files. + *

+ * Loaded from {@code assets/customnpcs/colorscheme/script_editor_scheme.json} on resource reload. + * Styles are cached in a flat array indexed by {@link TokenType#ordinal()} for zero-cost lookups. + */ +public final class ScriptColorScheme { + + // Volatile array reference swapped atomically — all tokens update at once + static volatile StyleEntry[] styles = buildDefaults(); + + // Background/gutter/scrollbar colors — volatile for same atomic-swap pattern + private static volatile BackgroundStyleEntry background = BackgroundStyleEntry.DEFAULT; + + private ScriptColorScheme() {} + + /** Returns the current background style (never null). */ + public static BackgroundStyleEntry getBackgroundStyle() { + return background; + } + + /** + * Reload the color scheme from the resource pack's JSON file. + * Uses {@code getAllResources()} to find the highest-priority version + * (the last entry in the list, which is the latest resource pack override). + * Falls back to defaults if the file is missing or malformed. + */ + public static void reloadColorScheme(IResourceManager resourceManager) { + ResourceLocation loc = new ResourceLocation("customnpcs", "colorscheme/script_editor_scheme.json"); + try { + List resources = resourceManager.getAllResources(loc); + IResource top = resources.get(resources.size() - 1); + + try (InputStream is = top.getInputStream(); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + loadFromJson(reader); + LogWriter.info("[ColorScheme] Loaded script_editor_scheme.json successfully"); + logLoadedSample(); + } + } catch (FileNotFoundException e) { + LogWriter.info("[ColorScheme] No script_editor_scheme.json found, using defaults"); + resetToDefaults(); + } catch (Exception e) { + LogWriter.error("[ColorScheme] Failed to load script_editor_scheme.json: " + e.getMessage()); + resetToDefaults(); + } + } + + private static void logLoadedSample() { + StyleEntry[] current = styles; + TokenType[] samples = { TokenType.COMMENT, TokenType.KEYWORD, TokenType.STRING, TokenType.METHOD_CALL, TokenType.DEFAULT }; + for (TokenType tt : samples) { + StyleEntry e = current[tt.ordinal()]; + LogWriter.info("[ColorScheme] " + tt.name() + " -> color=#" + String.format("%08X", e.hexColor) + + ", bold=" + e.bold + ", italic=" + e.italic); + } + + BackgroundStyleEntry bg = background; + LogWriter.info("[ColorScheme] background -> bg=#" + String.format("%08X", bg.getBackgroundColor()) + + ", gutter=#" + String.format("%08X", bg.getGutterColor())); + } + + /** + * Parse a color scheme JSON and install it as the active scheme. + * Expected format: {@code { "TOKEN_NAME": { "color": "#RRGGBB", "bold": bool, "italic": bool }, ... }} + * Missing properties per token fall back to the enum default. Parse errors are logged per-token. + */ + public static void loadFromJson(Reader reader) { + try { + JsonParser parser = new JsonParser(); + JsonElement root = parser.parse(reader); + if (!root.isJsonObject()) { + LogWriter.error("[ColorScheme] script_editor_scheme.json root is not a JSON object, using defaults"); + return; + } + + JsonObject obj = root.getAsJsonObject(); + + background = parseBackground(obj); + + TokenType[] all = TokenType.values(); + StyleEntry[] next = new StyleEntry[all.length]; + for (TokenType tt : all) { + next[tt.ordinal()] = parseEntry(obj, tt); + } + styles = next; + } catch (Exception e) { + LogWriter.error("[ColorScheme] Failed to parse script_editor_scheme.json, using defaults: " + e.getMessage()); + } + } + + public static void resetToDefaults() { + styles = buildDefaults(); + background = BackgroundStyleEntry.DEFAULT; + } + + private static StyleEntry parseEntry(JsonObject root, TokenType tt) { + if (!root.has(tt.name())) { + return defaultEntry(tt); + } + try { + JsonElement elem = root.get(tt.name()); + if (!elem.isJsonObject()) { + LogWriter.error("[ColorScheme] Entry for " + tt.name() + " is not an object, skipping"); + return defaultEntry(tt); + } + JsonObject entry = elem.getAsJsonObject(); + + int defaultColor = tt.getDefaultHexColor(); + if ((defaultColor & 0xFF000000) == 0) { + defaultColor |= 0xFF000000; + } + + int color = entry.has("color") + ? parseColor(entry.get("color"), defaultColor, tt.name()) + : defaultColor; + boolean bold = entry.has("bold") ? entry.get("bold").getAsBoolean() : tt.getDefaultBold(); + boolean italic = entry.has("italic") ? entry.get("italic").getAsBoolean() : tt.getDefaultItalic(); + + return new StyleEntry(color, bold, italic); + } catch (Exception e) { + LogWriter.error("[ColorScheme] Error parsing token " + tt.name() + ", using defaults: " + e.getMessage()); + return defaultEntry(tt); + } + } + + private static StyleEntry defaultEntry(TokenType tt) { + int color = tt.getDefaultHexColor(); + if ((color & 0xFF000000) == 0) { + color |= 0xFF000000; + } + return new StyleEntry(color, tt.getDefaultBold(), tt.getDefaultItalic()); + } + + private static BackgroundStyleEntry parseBackground(JsonObject root) { + if (!root.has("background")) { + return BackgroundStyleEntry.DEFAULT; + } + try { + JsonElement elem = root.get("background"); + if (!elem.isJsonObject()) { + LogWriter.error("[ColorScheme] 'background' is not an object, using defaults"); + return BackgroundStyleEntry.DEFAULT; + } + JsonObject bg = elem.getAsJsonObject(); + BackgroundStyleEntry d = BackgroundStyleEntry.DEFAULT; + + int backgroundColor = bg.has("backgroundColor") ? parseColor(bg.get("backgroundColor"), d.getBackgroundColor(), "background.backgroundColor") : d.getBackgroundColor(); + int gutterColor = bg.has("gutterColor") ? parseColor(bg.get("gutterColor"), d.getGutterColor(), "background.gutterColor") : d.getGutterColor(); + int gutterSeparatorColor = bg.has("gutterSeparatorColor") ? parseColor(bg.get("gutterSeparatorColor"), d.getGutterSeparatorColor(), "background.gutterSeparatorColor") : d.getGutterSeparatorColor(); + int lineNumberColor = bg.has("lineNumberColor") ? parseColor(bg.get("lineNumberColor"), d.getLineNumberColor(), "background.lineNumberColor") : d.getLineNumberColor(); + int lineNumberActiveColor = bg.has("lineNumberActiveColor") ? parseColor(bg.get("lineNumberActiveColor"), d.getLineNumberActiveColor(), "background.lineNumberActiveColor") : d.getLineNumberActiveColor(); + int scrollbarColor = bg.has("scrollbarColor") ? parseColor(bg.get("scrollbarColor"), d.getScrollbarColor(), "background.scrollbarColor") : d.getScrollbarColor(); + int borderColor = bg.has("borderColor") ? parseColor(bg.get("borderColor"), d.getBorderColor(), "background.borderColor") : d.getBorderColor(); + int caretColor = bg.has("caretColor") ? parseColor(bg.get("caretColor"), d.getCaretColor(), "background.caretColor") : d.getCaretColor(); + + return new BackgroundStyleEntry(backgroundColor, gutterColor, gutterSeparatorColor, + lineNumberColor, lineNumberActiveColor, scrollbarColor, borderColor, caretColor); + } catch (Exception e) { + LogWriter.error("[ColorScheme] Error parsing 'background', using defaults: " + e.getMessage()); + return BackgroundStyleEntry.DEFAULT; + } + } + + private static StyleEntry[] buildDefaults() { + TokenType[] all = TokenType.values(); + StyleEntry[] defaults = new StyleEntry[all.length]; + for (TokenType tt : all) { + defaults[tt.ordinal()] = defaultEntry(tt); + } + return defaults; + } + + private static int parseColor(JsonElement element, int fallback, String tokenName) { + try { + if (element.isJsonPrimitive()) { + if (element.getAsJsonPrimitive().isString()) { + String s = element.getAsString().trim(); + if (s.startsWith("#")) { + s = s.substring(1); + } + int color = (int) Long.parseLong(s, 16); + if (s.length() <= 6) { + color |= 0xFF000000; + } + return color; + } else if (element.getAsJsonPrimitive().isNumber()) { + return element.getAsInt(); + } + } + } catch (NumberFormatException e) { + LogWriter.error("[ColorScheme] Invalid color for " + tokenName + ": " + element + ", using default"); + } + return fallback; + } + + static final class StyleEntry { + final int hexColor; + final boolean bold; + final boolean italic; + + StyleEntry(int hexColor, boolean bold, boolean italic) { + this.hexColor = hexColor; + this.bold = bold; + this.italic = italic; + } + } + + public static final class BackgroundStyleEntry { + static final BackgroundStyleEntry DEFAULT = new BackgroundStyleEntry( + 0xff000000, // backgroundColor (text viewport) + 0xff000000, // gutterColor (line-number gutter) + 0xff3c3f41, // gutterSeparatorColor + 0xFF606366, // lineNumberColor (inactive) + 0xFFb9c7d6, // lineNumberActiveColor (current line) + 0xFFe0e0e0, // scrollbarColor + 0xffa0a0a0, // borderColor (outer border) + 0xffffffff // caretColor (cursor) + ); + + private final int backgroundColor; + private final int gutterColor; + private final int gutterSeparatorColor; + private final int lineNumberColor; + private final int lineNumberActiveColor; + private final int scrollbarColor; + private final int borderColor; + private final int caretColor; + + BackgroundStyleEntry(int backgroundColor, int gutterColor, int gutterSeparatorColor, + int lineNumberColor, int lineNumberActiveColor, + int scrollbarColor, int borderColor, int caretColor) { + this.backgroundColor = backgroundColor; + this.gutterColor = gutterColor; + this.gutterSeparatorColor = gutterSeparatorColor; + this.lineNumberColor = lineNumberColor; + this.lineNumberActiveColor = lineNumberActiveColor; + this.scrollbarColor = scrollbarColor; + this.borderColor = borderColor; + this.caretColor = caretColor; + } + + public int getBackgroundColor() { return backgroundColor; } + public int getGutterColor() { return gutterColor; } + public int getGutterSeparatorColor() { return gutterSeparatorColor; } + public int getLineNumberColor() { return lineNumberColor; } + public int getLineNumberActiveColor() { return lineNumberActiveColor; } + public int getScrollbarColor() { return scrollbarColor; } + public int getBorderColor() { return borderColor; } + public int getCaretColor() { return caretColor; } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java index b7b81af9e..7e12ee0c4 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/Token.java @@ -58,10 +58,6 @@ public class Token { return new Token(text, start, end, TokenType.KEYWORD); } - public static Token modifier(String text, int start, int end) { - return new Token(text, start, end, TokenType.MODIFIER); - } - public static Token comment(String text, int start, int end) { return new Token(text, start, end, TokenType.COMMENT); } @@ -196,8 +192,7 @@ public class Token { // ==================== TYPE CHECKS ==================== public boolean isKeyword() { - return type == TokenType.KEYWORD || type == TokenType.MODIFIER || - type == TokenType.CLASS_KEYWORD || type == TokenType.IMPORT_KEYWORD; + return type == TokenType.KEYWORD; } public boolean isTypeReference() { @@ -264,6 +259,18 @@ public class Token { return sb.toString(); } + /** + * Get the java.awt.Font style bitmask for this token. + * Returns a combination of {@link java.awt.Font#PLAIN}, {@link java.awt.Font#BOLD}, + * and {@link java.awt.Font#ITALIC}. + */ + public int getFontStyle() { + int style = java.awt.Font.PLAIN; + if (type.isBold()) style |= java.awt.Font.BOLD; + if (type.isItalic()) style |= java.awt.Font.ITALIC; + return style; + } + @Override public String toString() { return "Token{'" + text + "', " + type + ", [" + globalStart + "-" + globalEnd + "]}"; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java index 9306a3c67..e5bd1f244 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/token/TokenType.java @@ -1,74 +1,76 @@ package noppes.npcs.client.gui.util.script.interpreter.token; +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; /** * Defines all token types for syntax highlighting with hex colors and priorities. * Priority determines which token type wins when marks overlap. * Higher priority = wins conflicts. + *

+ * Priority is defined exclusively by the enum and never stored elsewhere. + * Visual style properties (color, bold, italic) can be overridden at runtime + * via {@link ScriptColorScheme}. The enum constructor values serve as compile-time defaults. */ public enum TokenType { // Comments and strings have highest priority - they override everything inside them - COMMENT(0x777777, 140), - STRING(0xCC8855, 130), + COMMENT(0xFF777777, 140), + STRING(0xFF17C6A3, 130), // JSDoc elements - lower priority but will fill gaps left by fragmented comment marking - JSDOC_TAG(0xCC9933, 125), // @param, @type, @return etc. (gold/orange) - JSDOC_TYPE(0x00AAAA, 124), // {TypeName} in JSDoc (aqua like types) + JSDOC_TAG(0xFF8E8070, 125,true, true), // @param, @type, @return etc. (gold/orange) + JSDOC_TYPE(0xFF9A8C7C, 124), // {TypeName} in JSDoc (aqua like types) - UNUSED_IMPORT(0x666666, 119), // unused import statements (gray) + UNUSED_IMPORT(0xFF666666, 119), // unused import statements (gray) // Keywords and modifiers - CLASS_KEYWORD(0xFF5555, 115), // 'class', 'interface', 'enum' keywords - IMPORT_KEYWORD(0xFFAA00, 110), // 'import' keyword - KEYWORD(0xFF5555, 100), // control flow: if, else, for, while, etc. - MODIFIER(0xFFAA00, 90), // public, private, static, final, etc. + KEYWORD(0xFFFF5555, 100,true, false), // control flow: if, else, for, while, etc. // Type declarations and references - INTERFACE_DECL(0x55FFFF, 85), // interface names (aqua) - ENUM_DECL(0xFF55FF, 85), // enum names (magenta) - ENUM_CONSTANT(0x55FFFF, 84, true, false), // enum constant values (blue, bold+italic) - like IntelliJ - CLASS_DECL(0x00AAAA, 85), // class names in declarations - IMPORTED_CLASS(0x00AAAA, 75), // imported class usages - GENERIC_TYPE_PARAM(0x00FA9A, 76), // generic type parameters like T, E, K, V (light green) - TYPE_DECL(0x00AAAA, 70), // package paths, type references + INTERFACE_DECL(0xFF80F2F6, 85), // interface names (aqua) + ENUM_DECL(0xFFFF55FF, 85), // enum names (magenta) + ENUM_CONSTANT(0xFF55FFFF, 84, true, true), // enum constant values (blue, bold+italic) - like IntelliJ + CLASS_DECL(0xFF00AAAA, 85), // class names in declarations + IMPORTED_CLASS(0xFF00AAAA, 75), // imported class usages + GENERIC_TYPE_PARAM(0xFF00FA9A, 76), // generic type parameters like T, E, K, V (light green) + TYPE_DECL(0xFF00AAAA, 70), // package paths, type references // Methods - METHOD_DECL(0x00AA00, 60), // method declarations (green) - METHOD_CALL(0x55FF55, 50), // method calls (bright green) + METHOD_DECL(0xFF00AA00, 60), // method declarations (green) + METHOD_CALL(0xFF55FF55, 50), // method calls (bright green) // Variables and fields - UNDEFINED_VAR(0xAA0000, 20), // unresolved variables (dark red) - high priority - PARAMETER(0x5555FF, 36), // method parameters (blue) - GLOBAL_FIELD(0x55FFFF, 35), // class-level fields (aqua) - LOCAL_FIELD(0xFFFF55, 25), // local variables (yellow) - STATIC_FINAL_FIELD(0xFF55FF, 36, false, true), // static final fields (magenta, italic) + UNDEFINED_VAR(0xFFAA0000, 20), // unresolved variables (dark red) - high priority + PARAMETER(0xFF5555FF, 36), // method parameters (blue) + GLOBAL_FIELD(0xFF55FFFF, 35), // class-level fields (aqua) + LOCAL_FIELD(0xFFFFFF55, 25), // local variables (yellow) + STATIC_FINAL_FIELD(0xFFFF55FF, 36, true, true), // static final fields (magenta, italic) // Literals - LITERAL(0x777777, 40), // numeric and boolean literals + LITERAL(0xFF79C0FF, 40), // numeric and boolean literals // Default - VARIABLE(0xFFFFFF, 30), // generic variables - DEFAULT(0xFFFFFF, 0); // default text color (white) + VARIABLE(0xFFFFFFFF, 30), // generic variables + DEFAULT(0xFFFFFFFF, 0); // default text color (white) - private final int hexColor; + private final int defaultHexColor; private final int priority; - private final boolean bold; - private final boolean italic; + private final boolean defaultBold; + private final boolean defaultItalic; TokenType(int hexColor, int priority) { this(hexColor, priority, false, false); } TokenType(int hexColor, int priority, boolean bold, boolean italic) { - this.hexColor = hexColor; + this.defaultHexColor = hexColor; this.priority = priority; - this.bold = bold; - this.italic = italic; + this.defaultBold = bold; + this.defaultItalic = italic; } public int getHexColor() { - return hexColor; + return ScriptColorScheme.styles[ordinal()].hexColor; } public int getPriority() { @@ -76,11 +78,23 @@ public enum TokenType { } public boolean isBold() { - return bold; + return ScriptColorScheme.styles[ordinal()].bold; } public boolean isItalic() { - return italic; + return ScriptColorScheme.styles[ordinal()].italic; + } + + int getDefaultHexColor() { + return defaultHexColor; + } + + boolean getDefaultBold() { + return defaultBold; + } + + boolean getDefaultItalic() { + return defaultItalic; } public static TokenType getByType(TypeInfo typeInfo) { @@ -97,7 +111,10 @@ public enum TokenType { public static int getColor(TypeInfo typeInfo) { return getByType(typeInfo).getHexColor(); } - + + public static int getPackageColor(){ + return IMPORTED_CLASS.getHexColor(); + } /** * Convert this token type to a Minecraft color code character. * Used for backward compatibility with the existing rendering system. @@ -114,12 +131,8 @@ public enum TokenType { return '6'; // gold case JSDOC_TYPE: return '3'; // dark aqua (like types) - case CLASS_KEYWORD: case KEYWORD: return 'c'; // red - case IMPORT_KEYWORD: - case MODIFIER: - return '6'; // gold case ENUM_DECL: case STATIC_FINAL_FIELD: return 'd'; // magenta diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java index e657e4c07..455e8c6b3 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/GenericContext.java @@ -8,12 +8,16 @@ import java.util.List; import java.util.Map; /** - * A context for resolving and substituting declared type parameters (T, E, K, V...) - * based on a receiver type. + * Single source of truth for generic type variable substitution. * - * Policy: - * - Prefer applied type arguments when present (e.g. List binds E -> String). - * - Fall back to declared bounds when no applied argument exists (e.g. T extends Entity -> Entity). + * Resolves type parameters (T, E, K, V...) to concrete types using: + * 1. Applied type arguments (e.g., List<String> binds E → String) — priority + * 2. Declared bounds fallback (e.g., <T extends Entity> binds T → Entity) — fallback + * + * Usage patterns: + * - {@link #forReceiver(TypeInfo)} — build context from a parameterized receiver type + * - {@link #fromBindings(List, List)} — build context from explicit params/args (anti-recursion safe) + * - {@link #substitute(TypeInfo)} — instance method with two-tier resolution */ public final class GenericContext { @@ -73,8 +77,9 @@ public final class GenericContext { if (declared == null) continue; String name = declared.getName(); if (name == null || name.isEmpty()) continue; - TypeInfo bound = declared.getBoundTypeInfo(); - bounds.put(name, (bound != null && bound.isResolved()) ? bound : TypeInfo.OBJECT); + TypeInfo tp = TypeInfo.typeParameter(name, declared); + TypeInfo effectiveBound = tp.getBoundType(); + bounds.put(name, effectiveBound != null ? effectiveBound : TypeInfo.OBJECT); } return new GenericContext(applied, bounds); @@ -125,15 +130,9 @@ public final class GenericContext { if (declared == null) continue; String name = declared.getName(); if (name == null || name.isEmpty()) continue; - TypeInfo bound = declared.getBoundTypeInfo(); - TypeInfo effectiveBound = (bound != null && bound.isResolved()) ? bound : TypeInfo.OBJECT; - - // TODO: Goatee not sure what this was for -// List additionalBounds = declared.getAdditionalBoundTypes(); -// if (additionalBounds != null && !additionalBounds.isEmpty()) { -// effectiveBound = IntersectionTypeInfo.of(effectiveBound, additionalBounds); -// } - bounds.put(name, effectiveBound); + TypeInfo tp = TypeInfo.typeParameter(name, declared); + TypeInfo effectiveBound = tp.getBoundType(); + bounds.put(name, effectiveBound != null ? effectiveBound : TypeInfo.OBJECT); } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/IntersectionTypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/IntersectionTypeInfo.java new file mode 100644 index 000000000..74e039559 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/IntersectionTypeInfo.java @@ -0,0 +1,160 @@ +package noppes.npcs.client.gui.util.script.interpreter.type; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Represents an intersection type from multiple bounds on a type parameter. + * For example, {@code >} creates an intersection + * of Number and Comparable. + * + *

The primary bound determines the display name and main type identity. + * Member lookup (hasMethod, getMethodInfo, hasField, getFieldInfo, getAllMethodOverloads) + * delegates to ALL constituent types, so methods from any bound are accessible.

+ * + *

This is used by {@link GenericContext} when building bound fallbacks for type + * parameters with additional interface bounds.

+ */ +public class IntersectionTypeInfo extends TypeInfo { + + /** The primary (first) bound type — used for display and as the "main" type. */ + private final TypeInfo primaryBound; + + /** Additional interface bounds (everything after {@code &}). */ + private final List additionalBounds; + + private IntersectionTypeInfo(TypeInfo primaryBound, List additionalBounds) { + super(primaryBound.getSimpleName(), primaryBound.getFullName(), "", + primaryBound.getKind(), primaryBound.getJavaClass(), true, null, true); + this.primaryBound = primaryBound; + this.additionalBounds = additionalBounds; + } + + /** + * Create an intersection type from a primary bound and additional interface bounds. + * If there are no additional bounds, returns the primary bound directly (no wrapper). + * + * @param primary the primary (first) bound type + * @param additional the additional interface bounds (may be empty) + * @return an IntersectionTypeInfo if there are additional bounds, otherwise the primary itself + */ + public static TypeInfo of(TypeInfo primary, List additional) { + if (additional == null || additional.isEmpty()) { + return primary; + } + return new IntersectionTypeInfo(primary, new ArrayList<>(additional)); + } + + @Override + public boolean hasMethod(String methodName) { + if (primaryBound.hasMethod(methodName)) return true; + for (TypeInfo bound : additionalBounds) { + if (bound.hasMethod(methodName)) return true; + } + return false; + } + + @Override + public boolean hasMethod(String methodName, int paramCount) { + if (primaryBound.hasMethod(methodName, paramCount)) return true; + for (TypeInfo bound : additionalBounds) { + if (bound.hasMethod(methodName, paramCount)) return true; + } + return false; + } + + @Override + public MethodInfo getMethodInfo(String methodName) { + MethodInfo m = primaryBound.getMethodInfo(methodName); + if (m != null) return m; + for (TypeInfo bound : additionalBounds) { + m = bound.getMethodInfo(methodName); + if (m != null) return m; + } + return null; + } + + @Override + public List getAllMethodOverloads(String methodName) { + Set seen = new LinkedHashSet<>(); + seen.addAll(primaryBound.getAllMethodOverloads(methodName)); + for (TypeInfo bound : additionalBounds) { + seen.addAll(bound.getAllMethodOverloads(methodName)); + } + return new ArrayList<>(seen); + } + + @Override + public MethodInfo getBestMethodOverload(String methodName, TypeInfo[] argTypes) { + MethodInfo m = primaryBound.getBestMethodOverload(methodName, argTypes); + if (m != null) return m; + for (TypeInfo bound : additionalBounds) { + m = bound.getBestMethodOverload(methodName, argTypes); + if (m != null) return m; + } + return null; + } + + @Override + public MethodInfo getBestMethodOverload(String methodName, TypeInfo expectedReturnType) { + MethodInfo m = primaryBound.getBestMethodOverload(methodName, expectedReturnType); + if (m != null) return m; + for (TypeInfo bound : additionalBounds) { + m = bound.getBestMethodOverload(methodName, expectedReturnType); + if (m != null) return m; + } + return null; + } + + @Override + public boolean hasField(String fieldName) { + if (primaryBound.hasField(fieldName)) return true; + for (TypeInfo bound : additionalBounds) { + if (bound.hasField(fieldName)) return true; + } + return false; + } + + @Override + public FieldInfo getFieldInfo(String fieldName) { + FieldInfo f = primaryBound.getFieldInfo(fieldName); + if (f != null) return f; + for (TypeInfo bound : additionalBounds) { + f = bound.getFieldInfo(fieldName); + if (f != null) return f; + } + return null; + } + + public TypeInfo getPrimaryBound() { + return primaryBound; + } + + public List getAdditionalBounds() { + return new ArrayList<>(additionalBounds); + } + + @Override + public String getDisplayName() { + StringBuilder sb = new StringBuilder(primaryBound.getDisplayName()); + for (TypeInfo bound : additionalBounds) { + sb.append(" & ").append(bound.getDisplayName()); + } + return sb.toString(); + } + + @Override + public boolean isResolved() { + return true; + } + + @Override + public String toString() { + return "IntersectionTypeInfo{" + getDisplayName() + "}"; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java index 2c49b036a..b40c17bf9 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/ScriptTypeInfo.java @@ -10,6 +10,7 @@ import noppes.npcs.client.gui.util.script.interpreter.token.TokenType; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -152,32 +153,12 @@ public class ScriptTypeInfo extends TypeInfo { return super.getTypeParam(name); } - private static MethodInfo substituteMethod(MethodInfo m, GenericContext ctx) { - TypeInfo retType = m.getReturnType(); - TypeInfo subRet = ctx.substitute(retType); - List params = new ArrayList<>(m.getParameters()); - boolean changed = subRet != retType; - for (int i = 0; i < params.size(); i++) { - FieldInfo p = params.get(i); - TypeInfo subP = ctx.substitute(p.getTypeInfo()); - if (subP != p.getTypeInfo()) { - params.set(i, FieldInfo.reflectionParam(p.getName(), subP)); - changed = true; - } - } - return changed ? MethodInfo.external(m.getName(), subRet, m.getContainingType(), - params, m.getModifiers(), m.getDocumentation()) : m; - } - - @Override public TypeInfo parameterize(List typeArgs) { if (typeArgs == null || typeArgs.isEmpty()) return this; Set active = PARAMETERIZING.get(); if (!active.add(this)) { - // Re-entrant call on the same raw type — fall back to shallow parameterization - // (no method/field substitution) to break the infinite recursion. return super.parameterize(typeArgs); } @@ -199,17 +180,14 @@ public class ScriptTypeInfo extends TypeInfo { // Parameterize methods, constructors, and fields with the generic context of this type. for (Map.Entry> entry : this.methods.entrySet()) { List substituted = new ArrayList<>(); - for (MethodInfo m : entry.getValue()) substituted.add(substituteMethod(m, ctx)); + for (MethodInfo m : entry.getValue()) substituted.add(m.substituteTypeParams(ctx)); result.methods.put(entry.getKey(), substituted); } for (MethodInfo ctor : this.constructors) { - result.constructors.add(substituteMethod(ctor, ctx)); + result.constructors.add(ctor.substituteTypeParams(ctx)); } for (Map.Entry entry : this.fields.entrySet()) { - FieldInfo f = entry.getValue(); - TypeInfo subType = ctx.substitute(f.getTypeInfo()); - result.fields.put(entry.getKey(), subType != f.getTypeInfo() - ? FieldInfo.external(f.getName(), subType, null, f.getModifiers()) : f); + result.fields.put(entry.getKey(), entry.getValue().substituteTypeParams(ctx)); } return result; } finally { @@ -310,7 +288,11 @@ public class ScriptTypeInfo extends TypeInfo { if (enumConstants.containsKey(fieldName)) return true; - + + for (FieldInfo f : getSyntheticFields()) + if (f.getName().equals(fieldName)) + return true; + return false; } @@ -325,6 +307,10 @@ public class ScriptTypeInfo extends TypeInfo { return enumConst.getFieldInfo(); } + for (FieldInfo f : getSyntheticFields()) + if (f.getName().equals(fieldName)) + return f; + return null; } @@ -361,6 +347,7 @@ public class ScriptTypeInfo extends TypeInfo { /** * Get all enum constants. */ + @Override public Map getEnumConstants() { return new HashMap<>(enumConstants); } @@ -380,32 +367,51 @@ public class ScriptTypeInfo extends TypeInfo { @Override public boolean hasMethod(String methodName) { - return methods.containsKey(methodName); + if (methods.containsKey(methodName)) + return true; + for (MethodInfo m : getSyntheticMethods()) + if (m.getName().equals(methodName)) + return true; + return false; } @Override public boolean hasMethod(String methodName, int paramCount) { List overloads = methods.get(methodName); - if (overloads == null) return false; - for (MethodInfo m : overloads) { - if (m.getParameterCount() == paramCount) { - return true; + if (overloads != null) { + for (MethodInfo m : overloads) { + if (m.getParameterCount() == paramCount) { + return true; + } } } + for (MethodInfo m : getSyntheticMethods()) + if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) + return true; return false; } @Override public MethodInfo getMethodInfo(String methodName) { List overloads = methods.get(methodName); - return (overloads != null && !overloads.isEmpty()) ? overloads.get(0) : null; + if (overloads != null && !overloads.isEmpty()) + return overloads.get(0); + for (MethodInfo m : getSyntheticMethods()) + if (m.getName().equals(methodName)) + return m; + return null; } /** * Get all method overloads with the given name. */ + @Override public List getAllMethodOverloads(String methodName) { - return methods.getOrDefault(methodName, new ArrayList<>()); + List result = new ArrayList<>(methods.getOrDefault(methodName, Collections.emptyList())); + for (MethodInfo m : getSyntheticMethods()) + if (m.getName().equals(methodName)) + result.add(m); + return result; } /** @@ -432,7 +438,26 @@ public class ScriptTypeInfo extends TypeInfo { allMethods.addAll(overloads); } return allMethods; - } + } + + @Override + public List getAllMethods() { + List result = getAllMethodsFlat(); + result.addAll(getSyntheticMethods()); + return result; + } + + @Override + public List getAllFields() { + List result = new ArrayList<>(getFields().values()); + result.addAll(getSyntheticFields()); + return result; + } + + @Override + public List getAllNestedTypes() { + return new ArrayList<>(innerClasses); + } // ==================== CONSTRUCTOR MANAGEMENT ==================== diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java index 2e1fa9aa5..dcac25c99 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java @@ -194,12 +194,74 @@ public class TypeInfo { public static TypeInfo typeParameter(String paramName, TypeInfo boundType) { Class javaClass = (boundType != null && boundType.javaClass != null) ? boundType.javaClass : Object.class; Kind kind = (boundType != null) ? boundType.kind : Kind.CLASS; + + if (boundType instanceof IntersectionTypeInfo) { + final IntersectionTypeInfo intersection = (IntersectionTypeInfo) boundType; + TypeInfo ti = new TypeInfo(paramName, paramName, "", kind, javaClass, true, null) { + @Override + public List getAllMethods() { + List result = new ArrayList<>(super.getAllMethods()); + for (TypeInfo bound : intersection.getAdditionalBounds()) + result.addAll(bound.getAllMethods()); + + return result; + } + @Override + public List getAllFields() { + List result = new ArrayList<>(super.getAllFields()); + for (TypeInfo bound : intersection.getAdditionalBounds()) + result.addAll(bound.getAllFields()); + + return result; + } + }; + ti.typeParameterName = paramName; + ti.boundType = boundType; + return ti; + } + TypeInfo ti = new TypeInfo(paramName, paramName, "", kind, javaClass, true, null); ti.typeParameterName = paramName; ti.boundType = boundType; return ti; } + /** + * Create a TypeInfo representing a bounded type parameter from a {@link TypeParamInfo}. + * This is the single point of control for creating type parameter TypeInfos with bound handling. + * + *

If the TypeParamInfo has additional bounds (e.g., {@code T extends Number & Comparable}), + * an {@link IntersectionTypeInfo} is created internally to represent the combined bound. + * This centralizes intersection creation so no caller needs to call {@code IntersectionTypeInfo.of()} directly.

+ * + * @param paramName the type parameter name (e.g., "T") + * @param typeParam the TypeParamInfo containing bound information + * @return a TypeInfo tagged as a type parameter with the effective bound + */ + public static TypeInfo typeParameter(String paramName, TypeParamInfo typeParam) { + if (typeParam == null) { + return typeParameter(paramName); + } + TypeInfo primaryBound = typeParam.getBoundTypeInfo(); + if (primaryBound == null) { + typeParam.resolveBoundType(); + primaryBound = typeParam.getBoundTypeInfo(); + } + + TypeInfo effectiveBound; + if (primaryBound != null && primaryBound.isResolved()) { + List additionalBounds = typeParam.getAdditionalBoundTypes(); + if (additionalBounds != null && !additionalBounds.isEmpty()) { + effectiveBound = IntersectionTypeInfo.of(primaryBound, additionalBounds); + } else { + effectiveBound = primaryBound; + } + } else { + effectiveBound = null; + } + return typeParameter(paramName, effectiveBound); + } + public boolean isTypeParameter() { return typeParameterName != null; } @@ -542,6 +604,7 @@ public class TypeInfo { public boolean isInnerClass() { return enclosingType != null; } public boolean isInterface() {return kind == Kind.INTERFACE;} public boolean isEnum() {return kind == Kind.ENUM;} + public boolean isClass() {return kind == Kind.CLASS;} public boolean isPrimitive() {return this.isPrimitive || javaClass != null && javaClass.isPrimitive();} private TypeInfo setPrimitive(boolean value) { this.isPrimitive = value; return this;} @@ -709,6 +772,145 @@ public class TypeInfo { } } + /** + * Returns all public instance members of this type for autocomplete/resolution. + * Override in subclasses to extend or replace the default Java reflection path. + */ + public List getAllMethods() { + if (javaClass != null) { + // Java reflection path: enumerate public methods + synthetics + List result = new ArrayList<>(); + try { + for (java.lang.reflect.Method m : javaClass.getMethods()) { + try { + result.add(MethodInfo.fromReflection(m, this)); + } catch (Exception e) { + // Skip individual methods that fail (e.g., linkage errors on obscure return types) + } + } + } catch (Exception ignored) {} + result.addAll(getSyntheticMethods()); + return result; + } + + // javaClass == null: check rawType, jsTypeInfo, synthetics in priority order + if (rawType != null && rawType != this) { + List rawMethods = rawType.getAllMethods(); + if (!rawMethods.isEmpty() && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + List substituted = new ArrayList<>(rawMethods.size()); + for (MethodInfo m : rawMethods) { + substituted.add(m.substituteTypeParams(ctx)); + } + return substituted; + } + return rawMethods; + } + + // No javaClass, no rawType — collect from jsTypeInfo and synthetics + List result = new ArrayList<>(); + if (jsTypeInfo != null) { + for (JSMethodInfo m : jsTypeInfo.getMethods().values()) { + result.add(MethodInfo.fromJSMethod(m, this)); + } + } + result.addAll(getSyntheticMethods()); + return result; + } + + public List getAllFields() { + if (javaClass != null) { + // Java reflection path: enumerate public fields + synthetics + List result = new ArrayList<>(); + try { + for (java.lang.reflect.Field f : javaClass.getFields()) { + try { + result.add(FieldInfo.fromReflection(f, this)); + } catch (Exception e) { + // Skip individual fields that fail rather than losing ALL fields. + } + } + } catch (Exception ignored) {} + result.addAll(getSyntheticFields()); + return result; + } + + // javaClass == null: check rawType, jsTypeInfo, synthetics in priority order + if (rawType != null && rawType != this) { + List rawFields = rawType.getAllFields(); + if (!rawFields.isEmpty() && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + List substituted = new ArrayList<>(rawFields.size()); + for (FieldInfo f : rawFields) { + substituted.add(f.substituteTypeParams(ctx)); + } + return substituted; + } + return rawFields; + } + + // No javaClass, no rawType — collect from jsTypeInfo and synthetics + List result = new ArrayList<>(); + if (jsTypeInfo != null) { + for (JSFieldInfo f : jsTypeInfo.getFields().values()) { + result.add(FieldInfo.fromJSField(f, this)); + } + } + result.addAll(getSyntheticFields()); + return result; + } + + /** + * Returns all public nested types (inner classes/interfaces/enums) of this type. + * Uses reflection to discover nested types from the Java class, filters to public only, + * converts internal '$' separators to '.' for name resolution, and resolves each via TypeResolver. + * + * Override in subclasses (e.g., ScriptTypeInfo) to return script-defined inner classes. + * + * @return List of resolved nested TypeInfo instances (may be empty, never null) + */ + public List getAllNestedTypes() { + if (javaClass != null) { + List result = new ArrayList<>(); + try { + for (Class nested : javaClass.getDeclaredClasses()) { + if (!Modifier.isPublic(nested.getModifiers())) { + continue; + } + // Convert JVM internal '$' separator to '.' for TypeResolver lookup + String nestedFullName = nested.getName().replace('$', '.'); + TypeInfo nestedType = TypeResolver.getInstance().resolveFullName(nestedFullName); + if (nestedType != null && nestedType.isResolved()) { + result.add(nestedType); + } + } + } catch (SecurityException ignored) { + // Some runtimes may restrict getDeclaredClasses() + } + return result; + } + + if (rawType != null && rawType != this) { + return rawType.getAllNestedTypes(); + } + + // No javaClass, no rawType — check jsTypeInfo for inner types + if (jsTypeInfo != null) { + Map innerTypes = jsTypeInfo.getInnerTypes(); + if (!innerTypes.isEmpty()) { + List result = new ArrayList<>(innerTypes.size()); + for (JSTypeInfo inner : innerTypes.values()) { + TypeInfo ti = TypeInfo.fromJSTypeInfo(inner); + if (ti != null) { + result.add(ti); + } + } + return result; + } + } + return Collections.emptyList(); + } + /** * Check if this type has a method with the given name. */ @@ -727,6 +929,8 @@ public class TypeInfo { } catch (Exception e) { // Security or linkage error } + } else if (rawType != null && rawType != this) { + return rawType.hasMethod(methodName); } TypeInfo obj = TypeInfo.object(); @@ -755,6 +959,8 @@ public class TypeInfo { } catch (Exception e) { // Security or linkage error } + } else if (rawType != null && rawType != this) { + return rawType.hasMethod(methodName, paramCount); } TypeInfo obj = TypeInfo.object(); if (obj != this) return obj.hasMethod(methodName); @@ -766,13 +972,15 @@ public class TypeInfo { * Override in ScriptTypeInfo for script-defined types. */ public boolean hasConstructors() { - if (javaClass == null) return false; - try { - return javaClass.getConstructors().length > 0; - } catch (Exception e) { - // Security or linkage error - return false; + if (javaClass != null) { + try { + return javaClass.getConstructors().length > 0; + } catch (Exception e) { + return false; + } } + if (rawType != null && rawType != this) return rawType.hasConstructors(); + return false; } /** @@ -781,16 +989,18 @@ public class TypeInfo { */ public List getConstructors() { List result = new ArrayList<>(); - if (javaClass == null) return result; - - try { - java.lang.reflect.Constructor[] constructors = javaClass.getConstructors(); - for (java.lang.reflect.Constructor ctor : constructors) { - result.add(MethodInfo.fromReflectionConstructor(ctor, this)); + if (javaClass != null) { + try { + java.lang.reflect.Constructor[] constructors = javaClass.getConstructors(); + for (java.lang.reflect.Constructor ctor : constructors) { + result.add(MethodInfo.fromReflectionConstructor(ctor, this)); + } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return result; } + if (rawType != null && rawType != this) return rawType.getConstructors(); return result; } @@ -799,45 +1009,49 @@ public class TypeInfo { * Override in ScriptTypeInfo for script-defined types. */ public MethodInfo findConstructor(int argCount) { - if (javaClass == null) return null; - - try { - Constructor[] constructors = javaClass.getConstructors(); - for (Constructor ctor : constructors) { - if (ctor.getParameterCount() == argCount) { - return MethodInfo.fromReflectionConstructor(ctor, this); + if (javaClass != null) { + try { + Constructor[] constructors = javaClass.getConstructors(); + for (Constructor ctor : constructors) { + if (ctor.getParameterCount() == argCount) { + return MethodInfo.fromReflectionConstructor(ctor, this); + } } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return null; } + if (rawType != null && rawType != this) return rawType.findConstructor(argCount); return null; } public MethodInfo findConstructor(TypeInfo[] argTypes) { - if (javaClass == null) return null; - - try { - Constructor[] constructors = javaClass.getConstructors(); - for (Constructor ctor : constructors) { - if (ctor.getParameterCount() == argTypes.length) { - Class[] paramTypes = ctor.getParameterTypes(); - boolean match = true; - for (int i = 0; i < argTypes.length; i++) { - TypeInfo paramTypeInfo = TypeInfo.fromClass(paramTypes[i]); - if (!TypeChecker.isTypeCompatible(paramTypeInfo, argTypes[i])) { - match = false; - break; + if (javaClass != null) { + try { + Constructor[] constructors = javaClass.getConstructors(); + for (Constructor ctor : constructors) { + if (ctor.getParameterCount() == argTypes.length) { + Class[] paramTypes = ctor.getParameterTypes(); + boolean match = true; + for (int i = 0; i < argTypes.length; i++) { + TypeInfo paramTypeInfo = TypeInfo.fromClass(paramTypes[i]); + if (!TypeChecker.isTypeCompatible(paramTypeInfo, argTypes[i])) { + match = false; + break; + } + } + if (match) { + return MethodInfo.fromReflectionConstructor(ctor, this); } } - if (match) { - return MethodInfo.fromReflectionConstructor(ctor, this); - } } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return null; } + if (rawType != null && rawType != this) return rawType.findConstructor(argTypes); return null; } @@ -851,30 +1065,63 @@ public class TypeInfo { return jsTypeInfo.hasField(fieldName); } - if (javaClass == null) return false; - try { - for (java.lang.reflect.Field f : javaClass.getFields()) { - if (f.getName().equals(fieldName)) { - return true; + if (javaClass != null) { + try { + for (java.lang.reflect.Field f : javaClass.getFields()) { + if (f.getName().equals(fieldName)) { + return true; + } } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return false; } + if (rawType != null && rawType != this) return rawType.hasField(fieldName); return false; } - public boolean hasEnumConstant(String constantName) { - if (javaClass == null || !javaClass.isEnum()) return false; + /** + * Returns all enum constants for this type as a name-to-EnumConstantInfo map. + * For Java enum types, builds the map via reflection. For script-defined enums, + * ScriptTypeInfo overrides this to return its stored constants directly. + * + * @return Map of constant name to EnumConstantInfo (empty if not an enum or no constants) + */ + public Map getEnumConstants() { + if (javaClass == null || !javaClass.isEnum()) { + if (rawType != null && rawType != this) { + return rawType.getEnumConstants(); + } + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); try { Object[] constants = javaClass.getEnumConstants(); for (Object constant : constants) { - if (constant.toString().equals(constantName)) - return true; + String name = constant.toString(); + result.put(name, EnumConstantInfo.fromReflection(name, this, null)); } } catch (Exception e) { - // Security or linkage error + // Security or linkage error — return whatever we have so far } + return result; + } + + public boolean hasEnumConstant(String constantName) { + if (javaClass != null && javaClass.isEnum()) { + try { + Object[] constants = javaClass.getEnumConstants(); + for (Object constant : constants) { + if (constant.toString().equals(constantName)) + return true; + } + } catch (Exception e) { + // Security or linkage error + } + return false; + } + if (rawType != null && rawType != this) return rawType.hasEnumConstant(constantName); return false; } @@ -882,16 +1129,19 @@ public class TypeInfo { * Get an enum constant by name. */ public EnumConstantInfo getEnumConstant(String constantName) { - if (javaClass == null || !javaClass.isEnum()) return null; - try { - Object[] constants = javaClass.getEnumConstants(); - for (Object constant : constants) { - if (constant.toString().equals(constantName)) - return EnumConstantInfo.fromReflection(constantName, this, null); + if (javaClass != null && javaClass.isEnum()) { + try { + Object[] constants = javaClass.getEnumConstants(); + for (Object constant : constants) { + if (constant.toString().equals(constantName)) + return EnumConstantInfo.fromReflection(constantName, this, null); + } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return null; } + if (rawType != null && rawType != this) return rawType.getEnumConstant(constantName); return null; } @@ -914,6 +1164,13 @@ public class TypeInfo { } catch (Exception e) { // Security or linkage error } + } else if (rawType != null && rawType != this) { + MethodInfo rawMethod = rawType.getMethodInfo(methodName); + if (rawMethod != null && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + return rawMethod.substituteTypeParams(ctx); + } + return rawMethod; } TypeInfo obj = TypeInfo.object(); if (obj != this) return obj.getMethodInfo(methodName); @@ -950,6 +1207,16 @@ public class TypeInfo { // Security or linkage error } if (!overloads.isEmpty()) return overloads; + } else if (rawType != null && rawType != this) { + java.util.List rawOverloads = rawType.getAllMethodOverloads(methodName); + if (!rawOverloads.isEmpty() && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + for (MethodInfo m : rawOverloads) { + overloads.add(m.substituteTypeParams(ctx)); + } + return overloads; + } + return rawOverloads; } TypeInfo obj = TypeInfo.object(); if (obj != this) return obj.getAllMethodOverloads(methodName); @@ -1016,16 +1283,25 @@ public class TypeInfo { return null; } - if (javaClass == null) return null; - try { - for (java.lang.reflect.Field f : javaClass.getFields()) { - if (f.getName().equals(fieldName)) { - // Create a synthetic FieldInfo from reflection - return FieldInfo.fromReflection(f, this); + if (javaClass != null) { + try { + for (java.lang.reflect.Field f : javaClass.getFields()) { + if (f.getName().equals(fieldName)) { + return FieldInfo.fromReflection(f, this); + } } + } catch (Exception e) { + // Security or linkage error } - } catch (Exception e) { - // Security or linkage error + return null; + } + if (rawType != null && rawType != this) { + FieldInfo rawField = rawType.getFieldInfo(fieldName); + if (rawField != null && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + return rawField.substituteTypeParams(ctx); + } + return rawField; } return null; } @@ -1144,7 +1420,17 @@ public class TypeInfo { return null; } - // Not a functional interface + // Not a functional interface — try rawType for parameterized script types + if (rawType != null && rawType != this) { + MethodInfo rawSAM = rawType.getSingleAbstractMethod(); + if (rawSAM != null && isParameterized()) { + GenericContext ctx = GenericContext.forReceiver(this); + cachedSAM = rawSAM.substituteTypeParams(ctx); + return cachedSAM; + } + cachedSAM = rawSAM; + return cachedSAM; + } cachedSAM = null; return null; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java deleted file mode 100644 index 2554867d0..000000000 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeSubstitutor.java +++ /dev/null @@ -1,146 +0,0 @@ -package noppes.npcs.client.gui.util.script.interpreter.type; - -import noppes.npcs.client.gui.util.script.interpreter.js_parser.TypeParamInfo; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Utility for substituting type variables (like T, E, K, V) with concrete types. - * - * Used when resolving members on parameterized types: - * - List.get(int) returns String (not T/Object) - * - Map.get(Object) returns Integer (not V) - * - * Handles nested generics: - * - List>.get(0) returns Map - */ -public class TypeSubstitutor { - - /** - * Create a binding map from declared type parameters to applied type arguments. - * - * Example: For List where List is declared as interface List: - * - declaredParams = [TypeParamInfo("E", ...)] - * - appliedArgs = [TypeInfo(String)] - * - Result: {"E" -> TypeInfo(String)} - * - * @param declaredParams The declared type parameters (from the generic type definition) - * @param appliedArgs The applied type arguments (from the parameterized usage) - * @return A map from type parameter names to their concrete TypeInfo values - */ - public static Map createBindings(List declaredParams, List appliedArgs) { - Map bindings = new HashMap<>(); - - if (declaredParams == null || appliedArgs == null) { - return bindings; - } - - int count = Math.min(declaredParams.size(), appliedArgs.size()); - for (int i = 0; i < count; i++) { - String paramName = declaredParams.get(i).getName(); - TypeInfo argType = appliedArgs.get(i); - if (paramName != null && argType != null) { - bindings.put(paramName, argType); - } - } - - return bindings; - } - - /** - * Create bindings from a parameterized receiver type. - * - * @param receiverType The parameterized type (e.g., List) - * @return Bindings from the type's declared params to its applied args - */ - public static Map createBindingsFromReceiver(TypeInfo receiverType) { - if (receiverType == null) { - return new HashMap<>(); - } - - // Get the raw type to access declared type parameters - TypeInfo rawType = receiverType.getRawType(); - List declaredParams = null; - - // Get declared params from JSTypeInfo or reflection - if (rawType.isJSType() && rawType.getJSTypeInfo() != null) { - declaredParams = rawType.getJSTypeInfo().getTypeParams(); - } else { - declaredParams = rawType.getTypeParams(); - } - - // Get applied args from the parameterized type - List appliedArgs = receiverType.getAppliedTypeArgs(); - - return createBindings(declaredParams, appliedArgs); - } - - /** - * Substitute type variables in a type using the given bindings. - * - * @param type The type to substitute (may contain type variables like T, E) - * @param bindings The map from type variable names to concrete types - * @return The substituted type - */ - public static TypeInfo substitute(TypeInfo type, Map bindings) { - if (type == null || bindings == null || bindings.isEmpty()) { - return type; - } - - String typeName = type.getSimpleName(); - - // Check if this type is itself a type variable. - // Two cases: - // 1. Unresolved TypeInfo (Java reflection path: TypeInfo.fromGenericType creates these for T, E, K, V) - // 2. TypeInfo.typeParameter() (script-defined path: resolved but tagged as a type parameter) - // For tagged type parameters, use getTypeParameterName() as the lookup key. - if (!type.isResolved() || type.isTypeParameter()) { - String lookupKey = type.isTypeParameter() ? type.getTypeParameterName() : typeName; - TypeInfo substitution = bindings.get(lookupKey); - if (substitution != null) { - return substitution; - } - } - - // If the type has applied type arguments, substitute within them recursively - if (type.isParameterized()) { - List originalArgs = type.getAppliedTypeArgs(); - List substitutedArgs = new ArrayList<>(); - boolean changed = false; - - for (TypeInfo arg : originalArgs) { - TypeInfo substitutedArg = substitute(arg, bindings); - substitutedArgs.add(substitutedArg); - if (substitutedArg != arg) { - changed = true; - } - } - - if (changed) { - // Create a new parameterized type with substituted args - return type.getRawType().parameterize(substitutedArgs); - } - } - - // Handle array types - substitute the element type - if (type.getSimpleName().endsWith("[]")) { - TypeStringNormalizer.ArraySplit split = TypeStringNormalizer.splitArraySuffixes(typeName); - String elementTypeName = split.base; - int dims = split.dimensions; - TypeInfo elementSubstitution = elementTypeName != null ? bindings.get(elementTypeName) : null; - if (elementSubstitution != null) { - TypeInfo result = elementSubstitution; - for (int i = 0; i < dims; i++) { - result = TypeInfo.arrayOf(result); - } - return result; - } - } - - return type; - } - -} diff --git a/src/main/java/noppes/npcs/config/GlyphCache.java b/src/main/java/noppes/npcs/config/GlyphCache.java index 86f34d433..8cc890869 100644 --- a/src/main/java/noppes/npcs/config/GlyphCache.java +++ b/src/main/java/noppes/npcs/config/GlyphCache.java @@ -1,9 +1,11 @@ package noppes.npcs.config; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ScaledResolution; import net.minecraft.client.renderer.GLAllocation; import net.minecraft.util.ResourceLocation; import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; import java.awt.AlphaComposite; import java.awt.Color; @@ -43,13 +45,13 @@ public class GlyphCache { * The width in pixels of every texture used for caching pre-rendered glyph images. Used by GlyphCache when calculating * floating point 0.0-1.0 texture coordinates. Must be a power of two for mip-mapping to work. */ - private static final int TEXTURE_WIDTH = 256; + private static final int TEXTURE_WIDTH = 512; /** * The height in pixels of every texture used for caching pre-rendered glyph images. Used by GlyphCache when calculating * floating point 0.0-1.0 texture coordinates. Must be a power of two for mip-mapping to work. */ - private static final int TEXTURE_HEIGHT = 256; + private static final int TEXTURE_HEIGHT = 512; /** * Initial width in pixels of the stringImage buffer used to extract individual glyph images. @@ -66,7 +68,23 @@ public class GlyphCache { * glyphs from "bleeding through" when the scaled GUI resolution is not pixel aligned and sometimes results in off-by-one * sampling of the glyph cache textures. */ - private static final int GLYPH_BORDER = 1; + private static final int GLYPH_BORDER = 2; + + /** + * Extra padding (in pixels) added around each glyph's reported pixel bounds before blitting into the atlas. + * Font hinting (especially GASP-aware rendering) can cause rendered pixels to extend beyond the bounds + * reported by GlyphVector.getGlyphPixelBounds(). This padding ensures those overflow pixels are captured. + */ + private static final int GLYPH_PADDING = 2; + + /** + * Minimum spacing (in pixels) inserted between adjacent glyphs in the temporary string image during + * rasterization. Each glyph is shifted right by glyphSpacing * glyphIndex to prevent kerning from + * merging adjacent glyphs. This base value is used for plain/bold styles. For italic fonts, the + * spacing is increased dynamically by {@link #computeGlyphSpacing(Font)} to account for the + * horizontal overhang caused by the italic slant. + */ + private static final int GLYPH_SPACING_BASE = 2 * GLYPH_PADDING + 1; /** * Transparent (alpha zero) white background color for use with BufferedImage.clearRect(). @@ -83,6 +101,21 @@ public class GlyphCache { */ private boolean antiAliasEnabled = false; + /** + * The current Minecraft GUI scale factor (1-4). Glyphs are rasterized at fontSize * guiScaleFactor + * so the atlas has enough texel density for the actual display resolution. This prevents + * GL_LINEAR texture filtering from diluting alpha values at high resolutions. + */ + private int guiScaleFactor = 1; + + /** + * Return the GUI scale factor used for glyph rasterization. StringCache uses this + * to compute an effective render scale that compensates for the larger atlas images. + */ + int getGuiScaleFactor() { + return guiScaleFactor; + } + /** * Temporary image for rendering a string to and then extracting the glyph images from. @@ -95,6 +128,7 @@ public class GlyphCache { private Graphics2D stringGraphics; + /** * All font glyphs are packed inside this image and are then loaded from here into an OpenGL texture. */ @@ -107,9 +141,53 @@ public class GlyphCache { /** * Needed for all text layout operations that create GlyphVectors (maps point size to pixel size). + * Rebuilt after setRenderingHints() so layout FRC matches rendering FRC (especially for GASP hinting). */ private FontRenderContext fontRenderContext = glyphCacheGraphics.getFontRenderContext(); + /** + * Return the current FontRenderContext used for glyph layout and rendering. + * StringCache must use this same FRC when computing glyph pixel bounds for positioning, + * so that layout positions match the GASP-aware hinting used during rasterization. + */ + FontRenderContext getFontRenderContext() { + return fontRenderContext; + } + + int getGlyphPadding() { + return GLYPH_PADDING; + } + + /** + * Compute the inter-glyph spacing needed for a given font when rasterizing into the temporary + * string image. For plain and bold fonts, the base spacing (2*GLYPH_PADDING+1) is sufficient. + * For italic fonts, the slant causes the top of each glyph to lean horizontally, and at large + * raster sizes (e.g. 54pt at scale 3) this overhang can be 8+ pixels — far exceeding the base + * spacing's 1-pixel effective gap. Without adequate spacing, adjacent italic glyphs' rendered + * pixels overlap in the string image, and narrow glyphs like 'I'/'i' capture neighboring + * fragments as visible dot artifacts in their cached texture. + * + * The italic overhang is computed as: lineHeight * |italicAngle|. Java2D's standard italic + * angle is ~0.2 (approx 12°). A safety factor of 1.5× is applied since antialiasing can + * produce faint alpha slightly beyond the geometric slant boundary. + */ + private int computeGlyphSpacing(Font font) { + float italicAngle = font.getItalicAngle(); + if (italicAngle == 0 && font.isItalic()) { + italicAngle = -0.2f; // Java2D synthetic italic default shear + } + + if (italicAngle == 0) { + return GLYPH_SPACING_BASE; + } + + java.awt.font.LineMetrics lm = font.getLineMetrics("I", fontRenderContext); + float lineHeight = lm.getAscent() + lm.getDescent(); + int italicOverhang = (int) Math.ceil(lineHeight * Math.abs(italicAngle) * 1.5f); + + return Math.max(GLYPH_SPACING_BASE, 2 * GLYPH_PADDING + italicOverhang + 1); + } + /** * Intermediate data array for use with textureImage.getRgb(). @@ -258,6 +336,7 @@ public class GlyphCache { fontSize = size; antiAliasEnabled = antiAlias; + guiScaleFactor = queryGuiScaleFactor(); setRenderingHints(); } @@ -273,15 +352,60 @@ public class GlyphCache { fontSize = size; antiAliasEnabled = antiAlias; + guiScaleFactor = queryGuiScaleFactor(); setRenderingHints(); } } + private static int queryGuiScaleFactor() { + try { + Minecraft mc = Minecraft.getMinecraft(); + if (mc != null && mc.displayWidth > 0 && mc.displayHeight > 0) { + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + return Math.max(1, sr.getScaleFactor()); + } + } catch (Exception ignored) { + } + return 1; + } + + /** + * Check if the GUI scale factor has changed (e.g. due to window resize) and invalidate the glyph + * cache if so. Glyphs must be re-rasterized at the new density to prevent GL_LINEAR filtering + * from diluting alpha at high display resolutions. + * + * @return true if the scale factor changed and glyphs were invalidated + */ + boolean checkAndUpdateScaleFactor() { + int newScale = queryGuiScaleFactor(); + if (newScale != guiScaleFactor) { + guiScaleFactor = newScale; + invalidateGlyphCache(); + return true; + } + return false; + } + + private void invalidateGlyphCache() { + glyphCache.clear(); + fontCache.clear(); + cachePosX = GLYPH_BORDER; + cachePosY = GLYPH_BORDER; + cacheLineHeight = 0; + allocateGlyphCacheTexture(); + allocateStringImage(STRING_WIDTH, STRING_HEIGHT); + } + int fontHeight(String s) { Font font = lookupFont(s.toCharArray(), 0, s.length(), 0); return (int) (font.getLineMetrics(s, this.fontRenderContext).getHeight()); } + java.awt.font.LineMetrics getLineMetrics(String s) { + Font font = lookupFont(s.toCharArray(), 0, s.length(), 0); + return font.getLineMetrics(s, this.fontRenderContext); + } + /** * Given a single OpenType font, perform full text layout and create a new GlyphVector for a string. * @@ -311,6 +435,9 @@ public class GlyphCache { * @return an OpenType font capable of displaying at least the first character at the start position in text */ Font lookupFont(char text[], int start, int limit, int style) { + /* Rasterize at scaled size so atlas texel density matches display resolution */ + int renderSize = fontSize * guiScaleFactor; + /* Try using an already known base font; the first font in usedFonts list is the one set with setDefaultFont() */ Iterator iterator = usedFonts.iterator(); while (iterator.hasNext()) { @@ -318,7 +445,7 @@ public class GlyphCache { Font font = iterator.next(); if (font.canDisplayUpTo(text, start, limit) != start) { /* Return a font instance of the proper point size and style; usedFonts has only 1pt sized plain style fonts */ - return font.deriveFont(style, fontSize); + return font.deriveFont(style, renderSize); } } @@ -333,7 +460,7 @@ public class GlyphCache { usedFonts.add(font); /* Return a font instance of the proper point size and style; allFonts has only 1pt sized plain style fonts */ - return font.deriveFont(style, fontSize); + return font.deriveFont(style, renderSize); } } @@ -341,7 +468,7 @@ public class GlyphCache { Font font = usedFonts.get(0); /* Return a font instance of the proper point size and style; usedFonts only 1pt sized plain style fonts */ - return font.deriveFont(style, fontSize); + return font.deriveFont(style, renderSize); } /** @@ -384,14 +511,37 @@ public class GlyphCache { int numGlyphs = vector.getNumGlyphs(); /* Length of the GlyphVector */ Rectangle dirty = null; /* Total area within texture that needs to be updated with glTexSubImage2D() */ boolean vectorRendered = false; /* True if entire GlyphVector was rendered into stringImage */ + int glyphSpacing = computeGlyphSpacing(font); for (int index = 0; index < numGlyphs; index++) { + int charIndex = start + vector.getGlyphCharIndex(index); + if (charIndex >= start && charIndex < limit) { + char c = text[charIndex]; + if (c == '\n' || c == '\r') { + continue; + } + } + /* If this glyph code is already in glyphCache, then there is no reason to pre-render it again */ int glyphCode = vector.getGlyphCode(index); if (glyphCache.containsKey(fontKey | glyphCode)) { continue; } + /* + * Skip whitespace glyphs that have no visible ink (spaces, tabs, etc.). These characters + * contribute only advance width, not visible pixels. Without this check, GLYPH_PADDING + * expansion would create a small non-zero rect for the empty glyph, and the blit from + * stringImage could capture stray antialiased fringe pixels from neighboring glyphs, + * causing whitespace to render as visible dots. + */ + { + Rectangle glyphBounds = vector.getGlyphPixelBounds(index, fontRenderContext, 0, 0); + if (glyphBounds.width == 0 || glyphBounds.height == 0) { + continue; + } + } + /* * The only way to get glyph shapes with font hinting is to draw the entire glyph vector into a * temporary BufferedImage, and then bit blit the individual glyphs based on their bounding boxes @@ -410,7 +560,7 @@ public class GlyphCache { */ for (int i = 0; i < numGlyphs; i++) { Point2D pos = vector.getGlyphPosition(i); - pos.setLocation(pos.getX() + 2 * i, pos.getY()); + pos.setLocation(pos.getX() + glyphSpacing * i, pos.getY()); vector.setGlyphPosition(i, pos); } @@ -422,15 +572,26 @@ public class GlyphCache { */ vectorBounds = vector.getPixelBounds(fontRenderContext, 0, 0); - /* Enlage the stringImage if it is too small to store the entire rendered string */ + /* Expand bounds to account for hinting overflow */ + vectorBounds.grow(GLYPH_PADDING, GLYPH_PADDING); + + /* Enlarge the stringImage if it is too small to store the entire rendered string */ if (stringImage == null || vectorBounds.width > stringImage.getWidth() || vectorBounds.height > stringImage.getHeight()) { int width = Math.max(vectorBounds.width, stringImage.getWidth()); int height = Math.max(vectorBounds.height, stringImage.getHeight()); allocateStringImage(width, height); } - /* Erase the upper-left corner where the string will get drawn*/ - stringGraphics.clearRect(0, 0, vectorBounds.width, vectorBounds.height); + /* + * Clear the ENTIRE stringImage, not just the vectorBounds area. The stringImage is reused + * across calls and may be larger than the current vectorBounds. Individual glyph pixel bounds + * (from getGlyphPixelBounds()) can differ slightly from the aggregate vector bounds + * (from getPixelBounds()) due to integer rounding. After GLYPH_PADDING expansion, a glyph's + * padded rect could extend 1-2 pixels beyond the vectorBounds area, capturing stale pixels + * from a previous render. This caused tiny dot artifacts above capital letters whose ink + * extends to the top of the line bounds. Clearing the full image eliminates this class of bugs. + */ + stringGraphics.clearRect(0, 0, stringImage.getWidth(), stringImage.getHeight()); /* Draw string with opaque white color and baseline adjustment so the upper-left corner of the image is at (0,0) */ stringGraphics.drawGlyphVector(vector, -vectorBounds.x, -vectorBounds.y); @@ -441,8 +602,35 @@ public class GlyphCache { * by this method is positioned around the origin of each individual glyph." However, the actual * bounds are all relative to the start of the entire GlyphVector, which is actually more useful * for extracting the glyph's image from the rendered string. + * + * Passing fontRenderContext ensures bounds match the GASP-aware rendering hints. */ - Rectangle rect = vector.getGlyphPixelBounds(index, null, -vectorBounds.x, -vectorBounds.y); + Rectangle rect = vector.getGlyphPixelBounds(index, fontRenderContext, -vectorBounds.x, -vectorBounds.y); + rect.grow(GLYPH_PADDING, GLYPH_PADDING); + + /* + * Clamp the padded rect to the stringImage bounds. Due to rounding differences between + * getPixelBounds() and getGlyphPixelBounds(), a padded glyph rect can occasionally extend + * beyond the image edge. Reading pixels outside the image would cause an exception or + * capture garbage data. + */ + if (rect.x < 0) { + rect.width += rect.x; // shrink width by the overshoot + rect.x = 0; + } + if (rect.y < 0) { + rect.height += rect.y; // shrink height by the overshoot + rect.y = 0; + } + if (rect.x + rect.width > stringImage.getWidth()) { + rect.width = stringImage.getWidth() - rect.x; + } + if (rect.y + rect.height > stringImage.getHeight()) { + rect.height = stringImage.getHeight() - rect.y; + } + if (rect.width <= 0 || rect.height <= 0) { + continue; + } /* If the current line in cache image is full, then advance to the next line */ if (cachePosX + rect.width + GLYPH_BORDER > TEXTURE_WIDTH) { @@ -573,10 +761,23 @@ public class GlyphCache { private void setRenderingHints() { stringGraphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antiAliasEnabled ? RenderingHints.VALUE_ANTIALIAS_ON : RenderingHints.VALUE_ANTIALIAS_OFF); - stringGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, - antiAliasEnabled ? RenderingHints.VALUE_TEXT_ANTIALIAS_ON : RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); +// if (antiAliasEnabled) { +// stringGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, +// RenderingHints.VALUE_TEXT_ANTIALIAS_ON); +// stringGraphics.setRenderingHint(RenderingHints.KEY_RENDERING, +// RenderingHints.VALUE_RENDER_QUALITY); +// } else { +// stringGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, +// RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); +// } + + stringGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + stringGraphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); stringGraphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_OFF); + + fontRenderContext = stringGraphics.getFontRenderContext(); } /** @@ -608,8 +809,10 @@ public class GlyphCache { GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, imageBuffer); /* Explicitely disable mipmap support becuase updateTexture() will only update the base level 0 */ - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE); } /** diff --git a/src/main/java/noppes/npcs/config/StringCache.java b/src/main/java/noppes/npcs/config/StringCache.java index c40d59f1c..63c0b6a91 100644 --- a/src/main/java/noppes/npcs/config/StringCache.java +++ b/src/main/java/noppes/npcs/config/StringCache.java @@ -3,11 +3,13 @@ package noppes.npcs.config; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.Tessellator; import net.minecraft.util.ResourceLocation; +import noppes.npcs.LogWriter; import org.lwjgl.opengl.GL11; import java.awt.Font; -import java.awt.Point; +import java.awt.Rectangle; import java.awt.font.GlyphVector; +import java.awt.geom.Point2D; import java.lang.ref.WeakReference; import java.text.Bidi; import java.util.ArrayList; @@ -71,6 +73,38 @@ public class StringCache { */ private static final int STRIKETHROUGH_THICKNESS = 2; + // BetterFonts base scale: maps internal 2x glyph coords to GUI coords. + // With scale-aware rasterization, the effective scale becomes BASE_RENDER_SCALE / guiScaleFactor. + private static final float BASE_RENDER_SCALE = 0.5F; + private static final boolean FONT_DEBUG_LOG = Boolean.getBoolean("cnpc.font.debug"); + private static final boolean FONT_DEBUG_DISABLE_INK_OFFSET = Boolean.getBoolean("cnpc.font.debug.disableInkOffset"); + + private static boolean isLineBreak(char c) { + return c == '\n' || c == '\r'; + } + + private static boolean isSpacingDebugGlyph(char c) { + return c == '(' || c == '"' || c == 't' || c == '.'; + } + + private static String debugGlyphLabel(char c) { + return c == '"' ? "\\\"" : String.valueOf(c); + } + + private float renderScale() { + return BASE_RENDER_SCALE / glyphCache.getGuiScaleFactor(); + } + + private float pixelSnap(float coord) { + float scale = renderScale(); + float invScale = 1.0F / scale; + return (float) Math.floor(coord * invScale + 0.5F) * scale; + } + + private int baselineOffset = BASELINE_OFFSET; + private int underlineOffset = UNDERLINE_OFFSET; + private int strikethroughOffset = STRIKETHROUGH_OFFSET; + /** * Reference to the unicode.FontRenderer class. Needed for creating GlyphVectors and retrieving glyph texture coordinates. */ @@ -319,31 +353,21 @@ public class StringCache { * the string to which this Glyph object belongs. */ static private class Glyph implements Comparable { - /** - * The index into the original string (i.e. with color codes) for the character that generated this glyph. - */ public int stringIndex; - - /** - * Texture ID and position/size of the glyph's pre-rendered image within the cache texture. - */ public GlyphCache.Entry texture; - /** - * Glyph's horizontal position (in pixels) relative to the entire string's baseline - */ + /** Logical horizontal position from getGlyphPosition() — used for spacing/advance */ public int x; - /** - * Glyph's vertical position (in pixels) relative to the entire string's baseline - */ + /** Ink vertical position from getGlyphPixelBounds() — used for texture quad placement */ public int y; - /** - * Glyph's horizontal advance (in pixels) used for strikethrough and underline effects - */ + /** Horizontal advance (logical) used for strikethrough and underline effects */ public int advance; + /** Ink X offset from logical position (inkBoundsX - logicalX) for texture quad placement */ + public int inkOffsetX; + /** * Allows arrays of Glyph objects to be sorted. Performs numeric comparison on stringIndex. * @@ -451,7 +475,44 @@ public class StringCache { if (g.y > maxY) maxY = g.y; } - fontHeight = (int) ((maxY + height) / 2.0F - minY / 2.0F); + int scaleFactor = glyphCache.getGuiScaleFactor(); + fontHeight = (int) (((maxY + height) - minY) / 2.0F / scaleFactor); + updateFontMetrics(); + } + + private void updateFontMetrics() { + java.awt.font.LineMetrics lm = glyphCache.getLineMetrics("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"); + float ascent = lm.getAscent(); + float descent = lm.getDescent(); + + /* + * baselineOffset is added directly to startY (GUI pixels) at render time, so it must be a + * scale-invariant GUI-pixel value. The constant BASELINE_OFFSET (7) is correct for the default + * 18pt font and remains stable across all GUI scale factors because it operates in GUI coordinate + * space — independent of the glyph rasterization scale. + * + * A dynamic formula like (ascent - padding) * renderScale is fragile: ascent comes from the + * scaled font (fontSize * scaleFactor) and rounding/font-metric variations across scales can + * produce values 1-2 pixels too large, visibly shifting text down within its highlight box. + */ + baselineOffset = BASELINE_OFFSET; + + /* + * underlineOffset and strikethroughOffset are multiplied by renderScale() at draw time + * (same as glyph.x/glyph.y), so they must be in glyph-space pixels. These scale correctly + * because ascent/descent are proportional to the rasterization size. + */ + underlineOffset = (int) (descent * 0.25f + 0.5f); + strikethroughOffset = -(int) (ascent * 0.35f + 0.5f); + } + + private void checkForScaleChange() { + if (glyphCache.checkAndUpdateScaleFactor()) { + weakRefCache.clear(); + stringCache.clear(); + cacheDightGlyphs(); + updateHeight(); + } } /** @@ -490,12 +551,14 @@ public class StringCache { return 0; } + /* Detect window resize: if GUI scale factor changed, invalidate all caches and re-rasterize */ + checkForScaleChange(); /* Make sure the entire string is cached before rendering and return its glyph representation */ Entry entry = cacheString(str); /* Adjust the baseline of the string because the startY coordinate in Minecraft is for the top of the string */ - startY += BASELINE_OFFSET; + startY += baselineOffset; /* Color currently selected by color code; reapplied to Tessellator instance after glBindTexture() */ @@ -548,7 +611,13 @@ public class StringCache { /* Select the current glyph's texture information and horizontal layout position within this string */ Glyph glyph = entry.glyphs[glyphIndex]; GlyphCache.Entry texture = glyph.texture; - int glyphX = glyph.x; + + /* Skip glyphs with no cached texture (whitespace characters like spaces and tabs) */ + if (texture == null) { + continue; + } + + int glyphX = glyph.x + glyph.inkOffsetX; /* * Replace ASCII digits in the string with their respective glyphs; strings differing by digits are only cached once. @@ -557,6 +626,10 @@ public class StringCache { * of the width mismatch. */ char c = str.charAt(glyph.stringIndex); + if (isLineBreak(c)) { + continue; + } + if (c >= '0' && c <= '9') { int oldWidth = texture.width; texture = digitGlyphs[fontStyle][c - '0'].texture; @@ -580,10 +653,21 @@ public class StringCache { } /* The divide by 2.0F is needed to align with the scaled GUI coordinate system; startX/startY are already scaled */ - float x1 = startX + (glyphX) / 2.0F; - float x2 = startX + (glyphX + texture.width) / 2.0F; - float y1 = startY + (glyph.y) / 2.0F; - float y2 = startY + (glyph.y + texture.height) / 2.0F; + if (FONT_DEBUG_DISABLE_INK_OFFSET) { + glyphX = glyph.x; + } + float rs = renderScale(); + float x1 = pixelSnap(startX + (glyphX) * rs); + float x2 = pixelSnap(startX + (glyphX + texture.width) * rs); + float y1 = pixelSnap(startY + (glyph.y) * rs); + float y2 = pixelSnap(startY + (glyph.y + texture.height) * rs); + + if (FONT_DEBUG_LOG && isSpacingDebugGlyph(c)) { + LogWriter.info("[StringCache glyph-debug] char='" + debugGlyphLabel(c) + "' idx=" + glyph.stringIndex + + " x=" + glyph.x + " advance=" + glyph.advance + " texW=" + texture.width + + " inkOffsetX=" + glyph.inkOffsetX + " quadX1=" + x1 + " quadX2=" + x2 + + " y=" + glyph.y + " tex=" + texture.textureName); + } tessellator.addVertexWithUV(x1, y1, 0, texture.u1, texture.v1); tessellator.addVertexWithUV(x1, y2, 0, texture.u1, texture.v2); @@ -620,15 +704,16 @@ public class StringCache { Glyph glyph = entry.glyphs[glyphIndex]; /* The strike/underlines are drawn beyond the glyph's width to include the extra space between glyphs */ - int glyphSpace = glyph.advance - glyph.texture.width; + int glyphSpace = glyph.texture != null ? glyph.advance - glyph.texture.width : 0; /* Draw underline under glyph if the style is enabled */ if ((renderStyle & ColorCode.UNDERLINE) != 0) { /* The divide by 2.0F is needed to align with the scaled GUI coordinate system; startX/startY are already scaled */ - float x1 = startX + (glyph.x - glyphSpace) / 2.0F; - float x2 = startX + (glyph.x + glyph.advance) / 2.0F; - float y1 = startY + (UNDERLINE_OFFSET) / 2.0F; - float y2 = startY + (UNDERLINE_OFFSET + UNDERLINE_THICKNESS) / 2.0F; + float urs = renderScale(); + float x1 = pixelSnap(startX + (glyph.x - glyphSpace) * urs); + float x2 = pixelSnap(startX + (glyph.x + glyph.advance) * urs); + float y1 = pixelSnap(startY + (underlineOffset) * urs); + float y2 = pixelSnap(startY + (underlineOffset + UNDERLINE_THICKNESS) * urs); tessellator.addVertex(x1, y1, 0); tessellator.addVertex(x1, y2, 0); @@ -639,10 +724,11 @@ public class StringCache { /* Draw strikethrough in the middle of glyph if the style is enabled */ if ((renderStyle & ColorCode.STRIKETHROUGH) != 0) { /* The divide by 2.0F is needed to align with the scaled GUI coordinate system; startX/startY are already scaled */ - float x1 = startX + (glyph.x - glyphSpace) / 2.0F; - float x2 = startX + (glyph.x + glyph.advance) / 2.0F; - float y1 = startY + (STRIKETHROUGH_OFFSET) / 2.0F; - float y2 = startY + (STRIKETHROUGH_OFFSET + STRIKETHROUGH_THICKNESS) / 2.0F; + float srs = renderScale(); + float x1 = pixelSnap(startX + (glyph.x - glyphSpace) * srs); + float x2 = pixelSnap(startX + (glyph.x + glyph.advance) * srs); + float y1 = pixelSnap(startY + (strikethroughOffset) * srs); + float y2 = pixelSnap(startY + (strikethroughOffset + STRIKETHROUGH_THICKNESS) * srs); tessellator.addVertex(x1, y1, 0); tessellator.addVertex(x1, y2, 0); @@ -658,7 +744,7 @@ public class StringCache { /* Return total horizontal advance (slightly wider than the bounding box, but close enough for centering strings) */ - return entry.advance / 2; + return (int) (entry.advance * renderScale()); } /** @@ -673,11 +759,50 @@ public class StringCache { return 0; } + checkForScaleChange(); + /* Make sure the entire string is cached and rendered since it will probably be used again in a renderString() call */ Entry entry = cacheString(str); /* Return total horizontal advance (slightly wider than the bounding box, but close enough for centering strings) */ - return entry.advance / 2; + return (int) (entry.advance * renderScale()); + } + + /** + * Return the width of a string in pixels when rendered with the specified font style. + * This is needed when measuring text that will be rendered with bold/italic formatting, + * since bold glyphs are wider than plain glyphs. + * + *

The font style is applied by prepending the appropriate Minecraft formatting codes + * (§l for bold, §o for italic) to the string before measuring. This causes cacheString() + * to lay out the entire string using the specified font style. + * + * @param str compute the width of this string + * @param fontStyle combination of {@link java.awt.Font#BOLD} and {@link java.awt.Font#ITALIC} + * @return the width in pixels (in the scaled GUI coordinate system) + */ + public int getStringWidth(String str, int fontStyle) { + /* Check for invalid arguments */ + if (str == null || str.isEmpty()) { + return 0; + } + + /* If plain style, delegate to the normal method (no prefix needed) */ + if (fontStyle == Font.PLAIN) { + return getStringWidth(str); + } + + /* Build the style prefix: §l for bold, §o for italic */ + StringBuilder sb = new StringBuilder(); + if ((fontStyle & Font.BOLD) != 0) { + sb.append('\u00A7').append('l'); + } + if ((fontStyle & Font.ITALIC) != 0) { + sb.append('\u00A7').append('o'); + } + sb.append(str); + + return getStringWidth(sb.toString()); } /** @@ -696,8 +821,8 @@ public class StringCache { return 0; } - /* Convert the width from GUI coordinate system to pixels */ - width += width; + /* Convert the width from GUI coordinate system to internal glyph pixels */ + width = (int) (width / renderScale()); /* The glyph array for a string is sorted by the string's logical character position */ Glyph glyphs[] = cacheString(str).glyphs; @@ -1125,6 +1250,11 @@ public class StringCache { /* Break the string up into segments, where each segment can be displayed using a single font */ while (start < limit) { + if (isLineBreak(text[start])) { + start++; + continue; + } + Font font = glyphCache.lookupFont(text, start, limit, style); int next = font.canDisplayUpTo(text, start, limit); @@ -1177,20 +1307,41 @@ public class StringCache { /* Creating a GlyphVector takes care of all language specific OpenType glyph substitutions and positionings */ GlyphVector vector = glyphCache.layoutGlyphVector(font, text, start, limit, layoutFlags); + /* + * Use the same GASP-aware FontRenderContext that was used to rasterize glyphs into the atlas. + * Passing null here would use a default FRC that can produce different pixel bounds for hinted + * glyphs, causing position mismatches and visible spacing gaps (especially on punctuation). + */ + java.awt.font.FontRenderContext frc = glyphCache.getFontRenderContext(); + /* * Extract all needed information for each glyph from the GlyphVector so it won't be needed for actual rendering. * Note that initially, glyph.start holds the character index into the stripped text array. But after the entire * string is layed out, this field will be adjusted on every Glyph object to correctly index the original unstripped * string. + * + * Use getGlyphPosition() (logical font metrics) for horizontal placement and advance calculation. + * Use getGlyphPixelBounds() only for the Y position and to compute the ink X offset from the logical X. + * This separation ensures proper inter-glyph spacing (from logical metrics) while keeping correct + * texture quad alignment (from ink bounds). Under GASP hinting, ink bounds can be tighter than + * logical positions, causing glyphs to visually touch or overlap if used for spacing. */ Glyph glyph = null; int numGlyphs = vector.getNumGlyphs(); + int glyphPadding = glyphCache.getGlyphPadding(); for (int index = 0; index < numGlyphs; index++) { - Point position = vector.getGlyphPixelBounds(index, null, advance, 0).getLocation(); + int charIndex = start + vector.getGlyphCharIndex(index); + if (charIndex >= start && charIndex < limit && isLineBreak(text[charIndex])) { + continue; + } - /* Compute horizontal advance for the previous glyph based on this glyph's position */ + Point2D logicalPos = vector.getGlyphPosition(index); + int logicalX = advance + (int) logicalPos.getX(); + Rectangle inkBounds = vector.getGlyphPixelBounds(index, frc, advance, 0); + + /* Compute horizontal advance for the previous glyph based on logical positions */ if (glyph != null) { - glyph.advance = position.x - glyph.x; + glyph.advance = logicalX - glyph.x; } /* @@ -1198,10 +1349,11 @@ public class StringCache { * it will be corrected later to account for the color codes that have been stripped out. */ glyph = new Glyph(); - glyph.stringIndex = start + vector.getGlyphCharIndex(index); + glyph.stringIndex = charIndex; glyph.texture = glyphCache.lookupGlyph(font, vector.getGlyphCode(index)); - glyph.x = position.x; - glyph.y = position.y; + glyph.x = logicalX; + glyph.y = inkBounds.y - glyphPadding; + glyph.inkOffsetX = (inkBounds.x - logicalX) - glyphPadding; glyphList.add(glyph); } @@ -1215,5 +1367,3 @@ public class StringCache { return advance; } } - - diff --git a/src/main/java/noppes/npcs/controllers/AnimationController.java b/src/main/java/noppes/npcs/controllers/AnimationController.java index ae9012b75..cf1bd2c95 100644 --- a/src/main/java/noppes/npcs/controllers/AnimationController.java +++ b/src/main/java/noppes/npcs/controllers/AnimationController.java @@ -166,7 +166,6 @@ public class AnimationController implements IAnimationHandler { File dir = getDir(); if (!dir.exists()) { dir.mkdir(); - return; } categoryManager.loadCategories(dir); diff --git a/src/main/java/noppes/npcs/controllers/CategoryManager.java b/src/main/java/noppes/npcs/controllers/CategoryManager.java index a4d14def4..80da9768d 100644 --- a/src/main/java/noppes/npcs/controllers/CategoryManager.java +++ b/src/main/java/noppes/npcs/controllers/CategoryManager.java @@ -67,6 +67,10 @@ public class CategoryManager { itemCategoryMap.remove(itemId); } + public boolean isLoaded() { + return baseDir != null; + } + public File getItemDir(int itemId) { int catId = itemCategoryMap.getOrDefault(itemId, UNCATEGORIZED_ID); return getCategoryDir(catId); @@ -86,6 +90,7 @@ public class CategoryManager { // ========== Category CRUD ========== public Category createCategory(String name) { + if (baseDir == null) return null; while (hasCategoryName(name)) name += "_"; lastUsedCatID++; Category cat = new Category(lastUsedCatID, name); @@ -96,7 +101,7 @@ public class CategoryManager { } public void saveCategory(Category cat) { - if (cat.id <= 0) return; + if (cat.id <= 0 || baseDir == null) return; Category existing = categories.get(cat.id); if (existing != null && !existing.title.equals(cat.title)) { String newTitle = cat.title; @@ -116,6 +121,7 @@ public class CategoryManager { } public boolean removeCategory(int catId, Set allItemIds) { + if (baseDir == null) return false; if (catId <= UNCATEGORIZED_ID) return false; Category cat = categories.get(catId); if (cat == null) return false; @@ -149,6 +155,7 @@ public class CategoryManager { } public void moveItem(int itemId, String fileName, int destCatId) { + if (baseDir == null) return; int oldCatId = itemCategoryMap.getOrDefault(itemId, UNCATEGORIZED_ID); if (oldCatId == destCatId) return; diff --git a/src/main/java/noppes/npcs/controllers/CustomEffectController.java b/src/main/java/noppes/npcs/controllers/CustomEffectController.java index f6338ab53..640f544c4 100644 --- a/src/main/java/noppes/npcs/controllers/CustomEffectController.java +++ b/src/main/java/noppes/npcs/controllers/CustomEffectController.java @@ -328,7 +328,6 @@ public class CustomEffectController implements ICustomEffectHandler { File dir = getDir(); if (!dir.exists()) { dir.mkdir(); - return; } categoryManager.loadCategories(dir); diff --git a/src/main/java/noppes/npcs/controllers/LinkedItemController.java b/src/main/java/noppes/npcs/controllers/LinkedItemController.java index fb461f272..f4e994c2c 100644 --- a/src/main/java/noppes/npcs/controllers/LinkedItemController.java +++ b/src/main/java/noppes/npcs/controllers/LinkedItemController.java @@ -126,7 +126,6 @@ public class LinkedItemController implements ILinkedItemHandler { File dir = getDir(); if (!dir.exists()) { dir.mkdir(); - return; } categoryManager.loadCategories(dir); diff --git a/src/main/java/noppes/npcs/controllers/PlayerQuestController.java b/src/main/java/noppes/npcs/controllers/PlayerQuestController.java index 288c24f79..cb3fce39f 100644 --- a/src/main/java/noppes/npcs/controllers/PlayerQuestController.java +++ b/src/main/java/noppes/npcs/controllers/PlayerQuestController.java @@ -1,7 +1,7 @@ package noppes.npcs.controllers; import kamkeel.npcs.controllers.ProfileController; -import kamkeel.npcs.controllers.data.profile.Profile; +import noppes.npcs.client.ProfileClientConfig; import kamkeel.npcs.network.packets.data.AchievementPacket; import kamkeel.npcs.network.packets.data.ChatAlertPacket; import net.minecraft.entity.player.EntityPlayer; @@ -131,14 +131,12 @@ public class PlayerQuestController { boolean finishedLocally = data.finishedQuests.containsKey(quest.id); boolean completedShared = false; boolean hasCooldownShared = false; - if (ConfigMain.ProfilesEnabled && quest.profileOptions.enableOptions) { - Profile profile = ProfileController.Instance.getProfile(player); - if (profile != null && profile.sharedQuestTimestamps.containsKey(quest.id)) { - if (quest.profileOptions.completeControl == EnumProfileSync.Shared) - completedShared = true; - if (quest.profileOptions.cooldownControl == EnumProfileSync.Shared) - hasCooldownShared = true; - } + if (ProfileClientConfig.isProfilesEnabled() && quest.profileOptions.enableOptions + && ProfileClientConfig.hasSharedQuest(player, quest.id)) { + if (quest.profileOptions.completeControl == EnumProfileSync.Shared) + completedShared = true; + if (quest.profileOptions.cooldownControl == EnumProfileSync.Shared) + hasCooldownShared = true; } boolean isFinished = finishedLocally || completedShared; diff --git a/src/main/java/noppes/npcs/controllers/ProfileConfigSync.java b/src/main/java/noppes/npcs/controllers/ProfileConfigSync.java new file mode 100644 index 000000000..5291ff1fd --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/ProfileConfigSync.java @@ -0,0 +1,34 @@ +package noppes.npcs.controllers; + +import kamkeel.npcs.controllers.ProfileController; +import kamkeel.npcs.controllers.data.profile.Profile; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.config.ConfigMain; + +import java.util.Map; + +public class ProfileConfigSync { + public static NBTTagCompound writeToNBT(EntityPlayer player) { + NBTTagCompound compound = new NBTTagCompound(); + if (!ConfigMain.ProfilesEnabled || ProfileController.Instance == null) { + return compound; + } + + Profile profile = ProfileController.Instance.getProfile(player); + if (profile == null) { + return compound; + } + + NBTTagList questList = new NBTTagList(); + for (Map.Entry entry : profile.sharedQuestTimestamps.entrySet()) { + NBTTagCompound questEntry = new NBTTagCompound(); + questEntry.setInteger("Quest", entry.getKey()); + questEntry.setLong("Date", entry.getValue()); + questList.appendTag(questEntry); + } + compound.setTag("SharedQuestTimestamps", questList); + return compound; + } +} diff --git a/src/main/java/noppes/npcs/controllers/ScriptContainer.java b/src/main/java/noppes/npcs/controllers/ScriptContainer.java index 57c0a0ca1..77f25efd0 100644 --- a/src/main/java/noppes/npcs/controllers/ScriptContainer.java +++ b/src/main/java/noppes/npcs/controllers/ScriptContainer.java @@ -46,6 +46,7 @@ public class ScriptContainer implements IScriptUnit { private static Method luaCall; private CompiledScript compScript = null; private final HashMap cachedFunctions = new HashMap<>(); + private long lastGlobalsVersion = -1; /** * Persisted per-container language. Null means inherit from handler default. @@ -224,6 +225,7 @@ public class ScriptContainer implements IScriptUnit { if (ScriptController.Instance.lastLoaded > this.lastCreated) { this.lastCreated = ScriptController.Instance.lastLoaded; evaluated = false; + lastGlobalsVersion = -1; } synchronized (lock) { @@ -236,18 +238,24 @@ public class ScriptContainer implements IScriptUnit { engine.getContext().setWriter(pw); engine.getContext().setErrorWriter(pw); - // Populate bindings with API objects + // Populate bindings with API objects (skip if globals unchanged) + long currentGlobalsVersion = NpcAPI.engineObjectsVersion; + boolean globalsChanged = lastGlobalsVersion != currentGlobalsVersion; if (instanceBindings != null) { - instanceBindings.put("API", NpcAPI.Instance()); - HashMap engineEntries = new HashMap<>(NpcAPI.engineObjects); - for (Map.Entry objectEntry : engineEntries.entrySet()) { - instanceBindings.put(objectEntry.getKey(), objectEntry.getValue()); + if (globalsChanged) { + instanceBindings.put("API", NpcAPI.Instance()); + for (Map.Entry objectEntry : NpcAPI.engineObjects.entrySet()) { + instanceBindings.put(objectEntry.getKey(), objectEntry.getValue()); + } + lastGlobalsVersion = currentGlobalsVersion; } } else { - engine.put("API", NpcAPI.Instance()); - HashMap engineEntries = new HashMap<>(NpcAPI.engineObjects); - for (Map.Entry objectEntry : engineEntries.entrySet()) { - engine.put(objectEntry.getKey(), objectEntry.getValue()); + if (globalsChanged) { + engine.put("API", NpcAPI.Instance()); + for (Map.Entry objectEntry : NpcAPI.engineObjects.entrySet()) { + engine.put(objectEntry.getKey(), objectEntry.getValue()); + } + lastGlobalsVersion = currentGlobalsVersion; } } @@ -328,6 +336,11 @@ public class ScriptContainer implements IScriptUnit { return !this.getFullCode().isEmpty(); } + @Override + public boolean isUnknownFunction(String type) { + return unknownFunctions.contains(type); + } + public void setEngine(String scriptLanguage) { if (!Objects.equals(scriptLanguage, this.currentScriptLanguage)) { // Instance scopes share the template's engine — never create a new one diff --git a/src/main/java/noppes/npcs/controllers/data/DataScript.java b/src/main/java/noppes/npcs/controllers/data/DataScript.java index 7d9fb5c3a..1d98933ef 100644 --- a/src/main/java/noppes/npcs/controllers/data/DataScript.java +++ b/src/main/java/noppes/npcs/controllers/data/DataScript.java @@ -45,6 +45,7 @@ public class DataScript implements IScriptHandlerPacket { public String scriptLanguage = "ECMAScript"; private EntityNPCInterface npc; public boolean enabled = false; + private long lastGlobalsVersion = -1; public ICustomNpc dummyNpc; public IWorld dummyWorld; @@ -271,9 +272,13 @@ public class DataScript implements IScriptHandlerPacket { engine.put(entry.getKey(), entry.getValue().value); } - engine.put("API", NpcAPI.Instance()); - for (Map.Entry engineObjects : NpcAPI.engineObjects.entrySet()) { - engine.put(engineObjects.getKey(), engineObjects.getValue()); + long currentGlobalsVersion = NpcAPI.engineObjectsVersion; + if (lastGlobalsVersion != currentGlobalsVersion) { + engine.put("API", NpcAPI.Instance()); + for (Map.Entry engineObjects : NpcAPI.engineObjects.entrySet()) { + engine.put(engineObjects.getKey(), engineObjects.getValue()); + } + lastGlobalsVersion = currentGlobalsVersion; } } diff --git a/src/main/java/noppes/npcs/controllers/data/ForgeDataScript.java b/src/main/java/noppes/npcs/controllers/data/ForgeDataScript.java index 26685aee8..067474a0f 100644 --- a/src/main/java/noppes/npcs/controllers/data/ForgeDataScript.java +++ b/src/main/java/noppes/npcs/controllers/data/ForgeDataScript.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; public class ForgeDataScript extends MultiScriptHandler { @@ -32,6 +33,7 @@ public class ForgeDataScript extends MultiScriptHandler { private static List cachedHooks; private long lastForgeUpdate = -1L; + private final HashSet globalUnknownEvents = new HashSet<>(); public ForgeDataScript() { } @@ -59,6 +61,7 @@ public class ForgeDataScript extends MultiScriptHandler { protected void reInitScripts() { lastInited = ScriptController.Instance.lastLoaded; lastForgeUpdate = ScriptController.Instance.lastForgeUpdate; + globalUnknownEvents.clear(); for (IScriptUnit script : this.scripts) { if (script instanceof ScriptContainer) @@ -79,10 +82,25 @@ public class ForgeDataScript extends MultiScriptHandler { } } + // Skip events that no script tab handles + if (globalUnknownEvents.contains(type)) { + return; + } + + boolean anyHandled = false; for (IScriptUnit script : this.scripts) { if (script == null || script.hasErrored() || !script.hasCode()) continue; + boolean wasUnknown = script.isUnknownFunction(type); script.run(type, event); + if (!wasUnknown || !script.isUnknownFunction(type)) { + anyHandled = true; + } + } + + // If no script tab handled this event type, cache it globally + if (!anyHandled) { + globalUnknownEvents.add(type); } } diff --git a/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java b/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java index 2e4b25bad..5cdd58e04 100644 --- a/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java +++ b/src/main/java/noppes/npcs/controllers/data/IScriptUnit.java @@ -168,6 +168,13 @@ public interface IScriptUnit { */ IScriptUnit createInstanceScope(IScriptHandler instanceHandler); + /** + * Check if the given function name is known to be absent from this script unit. + */ + default boolean isUnknownFunction(String type) { + return false; + } + // ==================== EXECUTION ==================== /** diff --git a/src/main/java/noppes/npcs/controllers/data/Quest.java b/src/main/java/noppes/npcs/controllers/data/Quest.java index c2b9ac397..c7e63ec1f 100644 --- a/src/main/java/noppes/npcs/controllers/data/Quest.java +++ b/src/main/java/noppes/npcs/controllers/data/Quest.java @@ -1,7 +1,6 @@ package noppes.npcs.controllers.data; -import kamkeel.npcs.controllers.ProfileController; -import kamkeel.npcs.controllers.data.profile.Profile; +import noppes.npcs.client.ProfileClientConfig; import kamkeel.npcs.network.packets.data.QuestCompletionPacket; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.entity.player.EntityPlayerMP; @@ -22,7 +21,6 @@ import noppes.npcs.api.handler.data.IQuestCategory; import noppes.npcs.api.handler.data.IQuestInterface; import noppes.npcs.api.handler.data.IQuestObjective; import noppes.npcs.api.handler.data.ISlot; -import noppes.npcs.config.ConfigMain; import noppes.npcs.constants.EnumProfileSync; import noppes.npcs.constants.EnumQuestCompletion; import noppes.npcs.constants.EnumQuestRepeat; @@ -176,13 +174,13 @@ public class Quest implements ICompatibilty, IQuest { } public long getTimeUntilRepeat(EntityPlayer player) { - if (ConfigMain.ProfilesEnabled && profileOptions.enableOptions && profileOptions.cooldownControl == EnumProfileSync.Shared) { - Profile profile = ProfileController.Instance.getProfile(player); - if (profile != null) { - Long questTime = profile.sharedQuestTimestamps.get(this.id); - if (questTime == null) return 0L; + if (ProfileClientConfig.isProfilesEnabled() && profileOptions.enableOptions + && profileOptions.cooldownControl == EnumProfileSync.Shared) { + Long questTime = ProfileClientConfig.getSharedQuestTimestamp(player, this.id); + if (questTime != null) { return calculateRemainingCooldown(player, questTime); } + return 0L; } IPlayerQuestData questData = PlayerData.get(player).getQuestData(); return getTimeUntilRepeatQuestData(player, questData); diff --git a/src/main/java/noppes/npcs/scripted/NpcAPI.java b/src/main/java/noppes/npcs/scripted/NpcAPI.java index 1b0d392b2..1a0b7edf1 100644 --- a/src/main/java/noppes/npcs/scripted/NpcAPI.java +++ b/src/main/java/noppes/npcs/scripted/NpcAPI.java @@ -160,6 +160,7 @@ public class NpcAPI extends AbstractNpcAPI { private static final Map worldCache = new LRUHashMap<>(10); private static final CacheHashMap> scriptItemCache = new CacheHashMap<>(60 * 1000); public static final HashMap engineObjects = new HashMap<>(); + public static volatile long engineObjectsVersion = 0; public static final EventBus EVENT_BUS = new EventBus(); private static AbstractNpcAPI instance = null; @@ -255,10 +256,12 @@ public class NpcAPI extends AbstractNpcAPI { public void addGlobalObject(String key, Object obj) { NpcAPI.engineObjects.put(key, obj); + NpcAPI.engineObjectsVersion++; } public void removeGlobalObject(String key) { NpcAPI.engineObjects.remove(key); + NpcAPI.engineObjectsVersion++; } public boolean hasGlobalObject(String key) { diff --git a/src/main/resources/assets/customnpcs/colorscheme/script_editor_scheme.json b/src/main/resources/assets/customnpcs/colorscheme/script_editor_scheme.json new file mode 100644 index 000000000..6a0da80b8 --- /dev/null +++ b/src/main/resources/assets/customnpcs/colorscheme/script_editor_scheme.json @@ -0,0 +1,128 @@ +{ + "background": { + "backgroundColor": "#FF000000", + "gutterColor": "#FF000000", + "gutterSeparatorColor": "#FF3C3F41", + "lineNumberColor": "#FF606366", + "lineNumberActiveColor": "#FFB9C7D6", + "scrollbarColor": "#FFE0E0E0", + "borderColor": "#FFA0A0A0", + "caretColor": "#FFFFFFFF" + + }, + "COMMENT": { + "color": "#FF777777", + "bold": false, + "italic": false + }, + "STRING": { + "color": "#FF16c57d", + "bold": false, + "italic": false + }, + "JSDOC_TAG": { + "color": "#FF8E8070", + "bold": true, + "italic": false + }, + "JSDOC_TYPE": { + "color": "#FF9A8C7C", + "bold": false, + "italic": false + }, + "UNUSED_IMPORT": { + "color": "#FF666666", + "bold": false, + "italic": false + }, + "KEYWORD": { + "color": "#FFFF5555", + "bold": false, + "italic": false + }, + "INTERFACE_DECL": { + "color": "#FF80F2F6", + "bold": false, + "italic": false + }, + "ENUM_DECL": { + "color": "#FFFF55FF", + "bold": false, + "italic": false + }, + "ENUM_CONSTANT": { + "color": "#FF55FFFF", + "bold": true, + "italic": true + }, + "CLASS_DECL": { + "color": "#FF00AAAA", + "bold": false, + "italic": false + }, + "IMPORTED_CLASS": { + "color": "#FF00AAAA", + "bold": false, + "italic": false + }, + "GENERIC_TYPE_PARAM": { + "color": "#FF00FA9A", + "bold": false, + "italic": false + }, + "TYPE_DECL": { + "color": "#FF00AAAA", + "bold": false, + "italic": false + }, + "METHOD_DECL": { + "color": "#FF00AA00", + "bold": false, + "italic": false + }, + "METHOD_CALL": { + "color": "#FF55FF55", + "bold": false, + "italic": false + }, + "UNDEFINED_VAR": { + "color": "#FFAA0000", + "bold": false, + "italic": false + }, + "PARAMETER": { + "color": "#FF5555FF", + "bold": false, + "italic": false + }, + "GLOBAL_FIELD": { + "color": "#FF55FFFF", + "bold": false, + "italic": false + }, + "LOCAL_FIELD": { + "color": "#FFFFFF55", + "bold": false, + "italic": false + }, + "STATIC_FINAL_FIELD": { + "color": "#FF8DDAF8", + "bold": true, + "italic": true + }, + "LITERAL": { + "color": "#FF79C0FF", + "bold": false, + "italic": false + }, + "VARIABLE": { + "color": "#FFFFFFFF", + "bold": false, + "italic": false + }, + "DEFAULT": { + "color": "#FFFFFFFF", + "bold": false, + "italic": false + } +} diff --git a/tools/standalone-JaninoLoader-1.0.2.jar b/tools/standalone-JaninoLoader-1.0.2.jar index a41ae9768..a03157587 100644 Binary files a/tools/standalone-JaninoLoader-1.0.2.jar and b/tools/standalone-JaninoLoader-1.0.2.jar differ