Merge branch 'dev'

This commit is contained in:
Kam committed 2026-03-13 17:23:46 -04:00
commit 985e03c227
71 files changed
+3125 -989

No files matched your search

+98
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
id "dts.typescript-generator"
}
version = "1.11"
version = "1.11.1"
group= "kamkeel.customnpc-plus"
archivesBaseName = "CustomNPC-Plus"
@@ -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);
}
}
}
@@ -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
@@ -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());
@@ -67,6 +67,9 @@ public enum EnumDataPacket {
ABILITY_HOTBAR_SYNC,
ABILITY_COOLDOWN_SYNC,
// Profile System
PROFILE_SHARED_QUEST,
// Auction System
AUCTION_DATA,
@@ -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);
}
}
+1 -1
View File
@@ -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")
+16 -3
View File
@@ -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<Class<?>, 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());
}
@@ -124,6 +124,7 @@ public class ClientCacheHandler {
ClientCacheHandler.customOverlays.clear();
ClientCacheHandler.skinOverlays.clear();
ClientCacheHandler.playerAnimations.clear();
ProfileClientConfig.reset();
// Clear Texture Caches
GuiSoundSelection.cachedDomains.clear();
@@ -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);
@@ -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();
@@ -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<Integer, Long> sharedQuestTimestamps = new HashMap<>();
public static void readFromNBT(NBTTagCompound compound) {
if (compound == null) return;
Map<Integer, Long> 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);
}
}
@@ -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());
@@ -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();
@@ -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<Token> 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 "%=":
@@ -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<int[]> 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),
@@ -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
@@ -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<AutocompleteItem> {
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<AutocompleteItem> {
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<AutocompleteItem> {
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<AutocompleteItem> {
}
/**
* 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<AutocompleteItem> {
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<AutocompleteItem> {
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<AutocompleteItem> {
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<AutocompleteItem> {
}
}
/**
* 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
@@ -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;
}
@@ -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;
}
@@ -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()
));
}
}
@@ -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<AutocompleteItem> 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<String> 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<String> addedMethods = new HashSet<>();
Set<String> 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<MethodInfo> 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<String> 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<String> 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();
}
@@ -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<String> 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<String> 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<AutocompleteItem> 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<AutocompleteItem> items, boolean isStaticContext, boolean forMethodReference) {
// Add methods (getMethods returns Map<String, List<MethodInfo>>)
for (List<MethodInfo> 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<String, FieldInfo>)
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<String, EnumConstantInfo>)
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<String> 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<AutocompleteItem> items, String prefix,
protected WeigherChain getWeigherChain() {
return CompletionWeigherChains.javaChain();
}
protected void filterAndScore(List<AutocompleteItem> 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<AutocompleteItem> 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<AutocompleteItem> comparator = weigherChain.buildComparator(context);
items.sort(comparator);
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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<CompletionWeigher> weighers;
public WeigherChain(List<CompletionWeigher> 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<AutocompleteItem> buildComparator(ScoringContext context) {
final Map<AutocompleteItem, Comparable<?>[]> 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<Object>) 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<String, Comparable<?>> debugWeights(AutocompleteItem item, ScoringContext context) {
Map<String, Comparable<?>> result = new LinkedHashMap<>();
for (CompletionWeigher weigher : weighers) {
result.put(weigher.getId(), weigher.weigh(item, context));
}
return result;
}
public List<CompletionWeigher> getWeighers() {
return weighers;
}
}
@@ -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);
@@ -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;
@@ -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<Node>} 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<TypeInfo> args = type.getAppliedTypeArgs();
if (args != null && !args.isEmpty()) {
List<TypeInfo> 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<E>)
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) {
@@ -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<TextSegment> 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(),
@@ -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;
@@ -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<String, TypeInfo> 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<String>}) and its raw fields
* (declared with type variables like T) need to reflect the concrete type arguments.
*
* Example: for {@code class Box<T> { 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; }
@@ -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);
}
}
@@ -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) {
@@ -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<String> 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<TypeInfo> additionalBoundTypes = param.getAdditionalBoundTypes();
List<String> 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<TypeParamInfo> 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<TypeInfo> innerAdditional = tp.getAdditionalBoundTypes();
List<String> 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());
}
/**
@@ -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
@@ -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<String>), substitute class type variables (E -> String)
Map<String, TypeInfo> 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<FieldInfo> 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<String>}) and its raw methods
* (declared with type variables like T) need to reflect the concrete type arguments.
*
* Example: for {@code class Box<T> { 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<FieldInfo> 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.
*/
@@ -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}.
* <p>
* Priority is always determined internally by {@link TokenType#getPriority()} ()}
* and is never exposed to or loaded from user-facing JSON files.
* <p>
* 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<IResource> 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; }
}
}
@@ -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 + "]}";
@@ -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.
* <p>
* 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
@@ -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<String> 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&lt;String&gt; binds E &rarr; String) — priority
* 2. Declared bounds fallback (e.g., &lt;T extends Entity&gt; binds T &rarr; 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<TypeInfo> 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);
}
}
@@ -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 <T extends Number & Comparable<T>>} creates an intersection
* of Number and Comparable.
*
* <p>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.</p>
*
* <p>This is used by {@link GenericContext} when building bound fallbacks for type
* parameters with additional interface bounds.</p>
*/
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<TypeInfo> additionalBounds;
private IntersectionTypeInfo(TypeInfo primaryBound, List<TypeInfo> 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<TypeInfo> 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<MethodInfo> getAllMethodOverloads(String methodName) {
Set<MethodInfo> 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<TypeInfo> 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() + "}";
}
}
@@ -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<FieldInfo> 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<TypeInfo> typeArgs) {
if (typeArgs == null || typeArgs.isEmpty()) return this;
Set<ScriptTypeInfo> 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<String, List<MethodInfo>> entry : this.methods.entrySet()) {
List<MethodInfo> 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<String, FieldInfo> 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<String, EnumConstantInfo> 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<MethodInfo> 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<MethodInfo> 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<MethodInfo> getAllMethodOverloads(String methodName) {
return methods.getOrDefault(methodName, new ArrayList<>());
List<MethodInfo> 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<MethodInfo> getAllMethods() {
List<MethodInfo> result = getAllMethodsFlat();
result.addAll(getSyntheticMethods());
return result;
}
@Override
public List<FieldInfo> getAllFields() {
List<FieldInfo> result = new ArrayList<>(getFields().values());
result.addAll(getSyntheticFields());
return result;
}
@Override
public List<TypeInfo> getAllNestedTypes() {
return new ArrayList<>(innerClasses);
}
// ==================== CONSTRUCTOR MANAGEMENT ====================
@@ -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<MethodInfo> getAllMethods() {
List<MethodInfo> result = new ArrayList<>(super.getAllMethods());
for (TypeInfo bound : intersection.getAdditionalBounds())
result.addAll(bound.getAllMethods());
return result;
}
@Override
public List<FieldInfo> getAllFields() {
List<FieldInfo> 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.
*
* <p>If the TypeParamInfo has additional bounds (e.g., {@code T extends Number & Comparable<T>}),
* 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.</p>
*
* @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<TypeInfo> 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<MethodInfo> getAllMethods() {
if (javaClass != null) {
// Java reflection path: enumerate public methods + synthetics
List<MethodInfo> 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<MethodInfo> rawMethods = rawType.getAllMethods();
if (!rawMethods.isEmpty() && isParameterized()) {
GenericContext ctx = GenericContext.forReceiver(this);
List<MethodInfo> 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<MethodInfo> 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<FieldInfo> getAllFields() {
if (javaClass != null) {
// Java reflection path: enumerate public fields + synthetics
List<FieldInfo> 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<FieldInfo> rawFields = rawType.getAllFields();
if (!rawFields.isEmpty() && isParameterized()) {
GenericContext ctx = GenericContext.forReceiver(this);
List<FieldInfo> 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<FieldInfo> 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<TypeInfo> getAllNestedTypes() {
if (javaClass != null) {
List<TypeInfo> 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<String, JSTypeInfo> innerTypes = jsTypeInfo.getInnerTypes();
if (!innerTypes.isEmpty()) {
List<TypeInfo> 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<MethodInfo> getConstructors() {
List<MethodInfo> 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<String, EnumConstantInfo> getEnumConstants() {
if (javaClass == null || !javaClass.isEnum()) {
if (rawType != null && rawType != this) {
return rawType.getEnumConstants();
}
return Collections.emptyMap();
}
Map<String, EnumConstantInfo> 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<MethodInfo> 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;
@@ -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<String>.get(int) returns String (not T/Object)
* - Map<String, Integer>.get(Object) returns Integer (not V)
*
* Handles nested generics:
* - List<Map<String, Integer>>.get(0) returns Map<String, Integer>
*/
public class TypeSubstitutor {
/**
* Create a binding map from declared type parameters to applied type arguments.
*
* Example: For List<String> where List is declared as interface List<E>:
* - 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<String, TypeInfo> createBindings(List<TypeParamInfo> declaredParams, List<TypeInfo> appliedArgs) {
Map<String, TypeInfo> 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<String>)
* @return Bindings from the type's declared params to its applied args
*/
public static Map<String, TypeInfo> createBindingsFromReceiver(TypeInfo receiverType) {
if (receiverType == null) {
return new HashMap<>();
}
// Get the raw type to access declared type parameters
TypeInfo rawType = receiverType.getRawType();
List<TypeParamInfo> 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<TypeInfo> 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<String, TypeInfo> 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<TypeInfo> originalArgs = type.getAppliedTypeArgs();
List<TypeInfo> 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;
}
}
+218 -15
View File
@@ -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<Font> 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);
}
/**
+195 -45
View File
@@ -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<Glyph> {
/**
* 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.
*
* <p>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;
}
}
@@ -166,7 +166,6 @@ public class AnimationController implements IAnimationHandler {
File dir = getDir();
if (!dir.exists()) {
dir.mkdir();
return;
}
categoryManager.loadCategories(dir);
@@ -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<Integer> 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;
@@ -328,7 +328,6 @@ public class CustomEffectController implements ICustomEffectHandler {
File dir = getDir();
if (!dir.exists()) {
dir.mkdir();
return;
}
categoryManager.loadCategories(dir);
@@ -126,7 +126,6 @@ public class LinkedItemController implements ILinkedItemHandler {
File dir = getDir();
if (!dir.exists()) {
dir.mkdir();
return;
}
categoryManager.loadCategories(dir);
@@ -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;
@@ -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<Integer, Long> 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;
}
}
@@ -46,6 +46,7 @@ public class ScriptContainer implements IScriptUnit {
private static Method luaCall;
private CompiledScript compScript = null;
private final HashMap<String, ScriptObjectMirror> 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<String, Object> engineEntries = new HashMap<>(NpcAPI.engineObjects);
for (Map.Entry<String, Object> objectEntry : engineEntries.entrySet()) {
instanceBindings.put(objectEntry.getKey(), objectEntry.getValue());
if (globalsChanged) {
instanceBindings.put("API", NpcAPI.Instance());
for (Map.Entry<String, Object> objectEntry : NpcAPI.engineObjects.entrySet()) {
instanceBindings.put(objectEntry.getKey(), objectEntry.getValue());
}
lastGlobalsVersion = currentGlobalsVersion;
}
} else {
engine.put("API", NpcAPI.Instance());
HashMap<String, Object> engineEntries = new HashMap<>(NpcAPI.engineObjects);
for (Map.Entry<String, Object> objectEntry : engineEntries.entrySet()) {
engine.put(objectEntry.getKey(), objectEntry.getValue());
if (globalsChanged) {
engine.put("API", NpcAPI.Instance());
for (Map.Entry<String, Object> 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
@@ -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<String, Object> 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<String, Object> engineObjects : NpcAPI.engineObjects.entrySet()) {
engine.put(engineObjects.getKey(), engineObjects.getValue());
}
lastGlobalsVersion = currentGlobalsVersion;
}
}
@@ -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<String> cachedHooks;
private long lastForgeUpdate = -1L;
private final HashSet<String> 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);
}
}
@@ -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 ====================
/**
@@ -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);
@@ -160,6 +160,7 @@ public class NpcAPI extends AbstractNpcAPI {
private static final Map<Integer, ScriptWorld> worldCache = new LRUHashMap<>(10);
private static final CacheHashMap<ItemStack, CacheHashMap.CachedObject<ScriptItemStack>> scriptItemCache = new CacheHashMap<>(60 * 1000);
public static final HashMap<String, Object> 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) {
@@ -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
}
}
Binary file not shown.