package com.example.essentialsplus.ui; import com.example.essentialsplus.EssentialsPlusPlugin; import com.example.essentialsplus.skills.SkillGoals; import com.example.essentialsplus.skills.SkillHistory; import com.example.essentialsplus.skills.SkillStore; import com.example.essentialsplus.skills.XpSample; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Cursor; import java.awt.Dimension; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JSpinner; import javax.swing.SpinnerNumberModel; import javax.swing.SwingUtilities; import javax.swing.Timer; import net.runelite.api.Experience; import net.runelite.api.Skill; import net.runelite.client.ui.DynamicGridLayout; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.PluginPanel; import net.runelite.client.util.ImageUtil; /** Skills tab: xp gained per skill over the global time range, with charts and level-up history. */ public class SkillsPanel extends PluginPanel { private static final int LEVEL_UP_LIMIT = 15; private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH); private final EssentialsPlusPlugin plugin; private final SkillStore store; private final TimeFilter timeFilter; private final Runnable rangeListener = this::refreshLater; private final Timer coalesce; private final Timer ticker; private final Map icons = new EnumMap<>(Skill.class); private final SkillsCard card = new SkillsCard(null); private final JComboBox characterBox = new JComboBox<>(); private final JLabel chartTitle = new JLabel(); private final JLabel showAll = new JLabel("Show all"); private final XpChart cumulative = new XpChart(XpChart.Mode.CUMULATIVE, 150); private final XpChart perPeriod = new XpChart(XpChart.Mode.BARS, 100); private final StatsView stats = new StatsView(2); private final JPanel skillList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); private final JPanel levelList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); private final JLabel levelEmpty = new JLabel(); private final JCheckBox hudBox = new JCheckBox("Show xp HUD in-game"); private final SkillRow[] rows = new SkillRow[SkillHistory.SKILLS.length]; private final List rowOrder = new ArrayList<>(); private String selectedKey; /** character whose skills are shown (goals belong to it) */ private String shownKey; private int selectedSkill = -1; private boolean updatingCombo; public SkillsPanel(EssentialsPlusPlugin plugin, Runnable onBack) { super(true); this.plugin = plugin; this.store = plugin.getSkillStore(); this.timeFilter = plugin.getTimeFilter(); setBackground(Ui.BG); setLayout(new DynamicGridLayout(0, 1, 0, 6)); JPanel backRow = new JPanel(new BorderLayout(6, 0)); backRow.setOpaque(false); backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); JButton backBtn = button("← Overview", onBack); backBtn.setForeground(Ui.ACCENT); backRow.add(backBtn, BorderLayout.WEST); JLabel titleLabel = new JLabel("Skills"); titleLabel.setFont(FontManager.getRunescapeBoldFont()); titleLabel.setForeground(Ui.ACCENT); titleLabel.setHorizontalAlignment(JLabel.RIGHT); backRow.add(titleLabel, BorderLayout.CENTER); add(backRow); characterBox.setFont(FontManager.getRunescapeSmallFont()); characterBox.setBackground(Ui.CARD); characterBox.setFocusable(false); characterBox.addActionListener(e -> { Choice c = (Choice) characterBox.getSelectedItem(); if (!updatingCombo && c != null) { selectedKey = c.key; refresh(); } }); characterBox.setVisible(false); add(characterBox); card.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { maybePopup(e); } @Override public void mouseReleased(MouseEvent e) { maybePopup(e); } private void maybePopup(MouseEvent e) { if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) { JPopupMenu menu = new JPopupMenu(); JMenuItem reset = new JMenuItem("Reset Hourly"); reset.addActionListener(ev -> { plugin.resetSkillsHourly(shownKey); refreshLater(); }); menu.add(reset); menu.show(card, e.getX(), e.getY()); } } }); add(card); hudBox.setFont(FontManager.getRunescapeSmallFont()); hudBox.setForeground(Ui.TEXT); hudBox.setOpaque(false); hudBox.setFocusPainted(false); hudBox.setToolTipText("In-game overlay with the xp gained per skill in the selected time range"); hudBox.setSelected(plugin.isSkillsHudEnabled()); hudBox.addActionListener(e -> plugin.setSkillsHudEnabled(hudBox.isSelected())); JButton[] hudOptions = new JButton[1]; hudOptions[0] = button("Options", () -> showHudOptions(hudOptions[0])); hudOptions[0].setToolTipText("Choose what the in-game xp HUD shows"); JPanel hudRow = new JPanel(new BorderLayout(6, 0)); hudRow.setOpaque(false); hudRow.add(hudBox, BorderLayout.CENTER); hudRow.add(hudOptions[0], BorderLayout.EAST); add(hudRow); showAll.setFont(FontManager.getRunescapeSmallFont()); showAll.setForeground(Ui.ACCENT); showAll.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); showAll.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { selectSkill(-1); } }); JPanel chartHeader = new JPanel(new BorderLayout(6, 0)); chartHeader.setOpaque(false); chartHeader.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); JLabel xpLabel = new JLabel("XP GAINED"); xpLabel.setFont(FontManager.getRunescapeSmallFont()); xpLabel.setForeground(Ui.MUTED); chartHeader.add(xpLabel, BorderLayout.WEST); chartTitle.setFont(FontManager.getRunescapeSmallFont()); chartTitle.setForeground(Ui.TEXT); chartTitle.setHorizontalAlignment(JLabel.RIGHT); chartHeader.add(chartTitle, BorderLayout.CENTER); chartHeader.add(showAll, BorderLayout.EAST); add(chartHeader); add(cumulative); add(stats); add(WealthPanel.sectionHeader("XP PER PERIOD", null, null, null)); add(perPeriod); add(WealthPanel.sectionHeader("SKILLS (click to chart, cog for a goal)", null, null, null)); skillList.setOpaque(false); for (int i = 0; i < rows.length; i++) { rows[i] = new SkillRow(i); } add(skillList); add(WealthPanel.sectionHeader("LEVEL UPS", null, null, null)); levelList.setOpaque(false); levelEmpty.setFont(FontManager.getRunescapeSmallFont()); levelEmpty.setForeground(Ui.FAINT); levelEmpty.setHorizontalAlignment(JLabel.CENTER); add(levelList); coalesce = new Timer(1000, e -> refresh()); coalesce.setRepeats(false); ticker = new Timer(30_000, e -> refresh()); ticker.start(); timeFilter.addListener(rangeListener); refresh(); } public void dispose() { ticker.stop(); coalesce.stop(); timeFilter.removeListener(rangeListener); } /** Coalesces bursts of xp drops into one repaint per second. */ public void refreshLater() { if (!coalesce.isRunning()) { coalesce.start(); } } private void selectSkill(int skill) { selectedSkill = selectedSkill == skill ? -1 : skill; refresh(); } /** Resolves which character to show: explicit choice, else the logged-in one, else the latest. */ static SkillHistory resolve(SkillStore store, String preferred, String active) { SkillHistory h = store.get(preferred); if (h == null || h.latest() == null) { h = store.get(active); } if (h == null || h.latest() == null) { List all = store.all(); h = all.isEmpty() ? null : all.get(0); } return h; } /** Fills a summary card (used by the overview too). */ static void fillCard(SkillsCard card, SkillHistory h, TimeFilter filter, String title) { if (h == null) { card.update(title, "", false, 0, 0, filter.getLabel(), "", new long[0], new long[0]); return; } long start = h.start(filter); long rateStart = h.rateStart(filter); long[] gained = h.gained(filter); long total = 0; for (long g : gained) { total += g; } int levels = 0; for (SkillHistory.LevelUp lu : h.levelUps(start)) { levels += gained[lu.skill] > 0 ? lu.to - lu.from : 0; } long rateGained = h.gainedTotalSince(rateStart); long active = h.activeMillis(rateStart, -1); String xph = active >= Ui.MINUTE && rateGained > 0 ? Ui.gp(rateGained * Ui.HOUR / active) + " xp/h" : ""; List samples = h.range(start); long[] t = new long[samples.size() + 1]; long[] v = new long[samples.size() + 1]; long base = samples.isEmpty() ? 0 : samples.get(0).total(); for (int i = 0; i < samples.size(); i++) { t[i] = Math.max(samples.get(i).t, start); v[i] = samples.get(i).total() - base; } if (samples.isEmpty()) { t = new long[0]; v = new long[0]; } else { t[samples.size()] = System.currentTimeMillis(); v[samples.size()] = v[samples.size() - 1]; } card.update(title, h.name == null ? "" : h.name, true, total, levels, filter.getLabel(), xph, t, v); XpSample latest = h.latest(); int totalLevel = 0; if (latest != null) { for (int s = 0; s < SkillHistory.SKILLS.length; s++) { totalLevel += SkillHistory.level(latest.get(s)); } } card.setTotals(latest == null ? 0 : latest.total(), totalLevel); card.setSkillGains(gained); } private void refresh() { long now = System.currentTimeMillis(); String active = plugin.getSkillKey(); SkillHistory h = resolve(store, selectedKey, active); updateCombo(h); hudBox.setSelected(plugin.isSkillsHudEnabled()); shownKey = h == null ? null : h.key; fillCard(card, h, timeFilter, "Skills"); String label = timeFilter.getLabel(); if (h == null) { chartTitle.setText(""); showAll.setVisible(false); cumulative.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "Log in to start tracking xp"); perPeriod.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "No data yet"); stats.update(new ArrayList<>()); skillList.removeAll(); rowOrder.clear(); levelList.removeAll(); revalidate(); repaint(); return; } long start = h.start(timeFilter); long[] starts = h.starts(timeFilter); long rateStart = h.rateStart(timeFilter); long[] rateStarts = h.rateStarts(timeFilter); List samples = h.range(start); long chartStart = samples.isEmpty() ? now - Ui.HOUR : Math.max(start, samples.get(0).t); if (timeFilter.getRange() != null && !timeFilter.getRange().isAll()) { chartStart = start; } long[] gained = h.gained(timeFilter); List levelUps = h.levelUps(start); levelUps.removeIf(lu -> gained[lu.skill] <= 0); XpSample last = h.latest(); // ---- chart + stats for the selection int sel = selectedSkill; Color color = sel < 0 ? Ui.GOLD : skillColor(SkillHistory.SKILLS[sel]); chartTitle.setText(sel < 0 ? "All skills" : SkillHistory.SKILLS[sel].getName()); chartTitle.setForeground(sel < 0 ? Ui.TEXT : color); showAll.setVisible(sel >= 0); String empty = "No xp gained " + timeFilter.inLabel(); cumulative.setData(samples, levelUps, sel, chartStart, now, color, empty); perPeriod.setData(samples, levelUps, sel, chartStart, now, color, empty); long total = 0; int trained = 0; for (long g : gained) { total += g; trained += g > 0 ? 1 : 0; } long selGained = sel < 0 ? total : gained[sel]; long rateSelStart = sel < 0 ? rateStart : rateStarts[sel]; long selRateGained = sel < 0 ? h.gainedTotalSince(rateStart) : h.gainedSince(rateStarts[sel], sel); long activeMs = h.activeMillis(rateSelStart, sel); int levels = 0; for (SkillHistory.LevelUp lu : levelUps) { if (sel < 0 || lu.skill == sel) { levels += lu.to - lu.from; } } List cells = new ArrayList<>(); cells.add(new StatsView.Cell("XP gained", "+" + Ui.gp(selGained), label, selGained > 0 ? Ui.UP : Ui.FAINT, String.format(Locale.US, "%,d xp", selGained))); cells.add(new StatsView.Cell("XP / hour", activeMs >= Ui.MINUTE && selRateGained > 0 ? Ui.gp(selRateGained * Ui.HOUR / activeMs) : "-", "while training", Ui.TEXT, "Xp per hour of active training time")); cells.add(new StatsView.Cell("Levels", "+" + levels, null, levels > 0 ? Ui.UP : Ui.TEXT, "Levels gained in " + label)); cells.add(new StatsView.Cell("Active", duration(activeMs), null, Ui.TEXT, "Time spent gaining xp (gaps under 5 minutes)")); if (sel < 0) { int totalLevel = 0; for (int i = 0; i < SkillHistory.SKILLS.length; i++) { totalLevel += Math.min(99, SkillHistory.level(last.get(i))); } cells.add(new StatsView.Cell("Skills trained", String.valueOf(trained), null, Ui.TEXT, null)); cells.add(new StatsView.Cell("Total level", String.valueOf(totalLevel), null, Ui.GOLD, null)); } else { int xp = last.get(sel); int lvl = SkillHistory.level(xp); String toNext = lvl >= Experience.MAX_VIRT_LEVEL ? "-" : Ui.gp(Experience.getXpForLevel(lvl + 1) - xp); cells.add(new StatsView.Cell("Level", String.valueOf(lvl), Ui.gp(xp) + " xp", color, String.format(Locale.US, "%,d xp", xp))); cells.add(new StatsView.Cell("To level " + Math.min(lvl + 1, Experience.MAX_VIRT_LEVEL), toNext, null, Ui.TEXT, null)); SkillGoals.Goal goal = goal(sel); if (goal != null) { int left = goal.xpLeft(xp); long selXph = activeMs >= Ui.MINUTE && selGained > 0 ? selGained * Ui.HOUR / activeMs : 0; cells.add(new StatsView.Cell("Goal " + goal.level, percent(goal.progress(xp)), left == 0 ? "reached" : Ui.gp(left) + " xp left", left == 0 ? Ui.UP : Ui.GOLD, String.format(Locale.US, "%,d / %,d xp", xp, goal.targetXp()))); cells.add(new StatsView.Cell("Time to goal", left == 0 ? "-" : selXph > 0 ? duration(left * Ui.HOUR / selXph) : "-", selXph > 0 ? "at " + Ui.gp(selXph) + "/h" : "no xp/h yet", Ui.TEXT, null)); } } stats.update(cells); // ---- skill rows (trained skills by xp, then the rest in game order) List order = new ArrayList<>(); for (int i = 0; i < gained.length; i++) { order.add(i); } order.sort((a, b) -> Long.compare(gained[b], gained[a])); long maxGain = gained.length == 0 ? 0 : gained[order.get(0)]; for (int i = 0; i < rows.length; i++) { int xp = last.get(i); int from = gained[i] > 0 ? SkillHistory.level(h.at(starts[i]).get(i)) : SkillHistory.level(xp); long skillRateStart = rateStarts[i]; long skillRateGained = h.gainedSince(skillRateStart, i); long skillActive = skillRateGained > 0 ? h.activeMillis(skillRateStart, i) : 0; long xph = skillActive >= Ui.MINUTE ? skillRateGained * Ui.HOUR / skillActive : 0; rows[i].update(xp, SkillHistory.level(xp) - from, gained[i], xph, maxGain, i == sel, goal(i)); } if (!order.equals(rowOrder)) { rowOrder.clear(); rowOrder.addAll(order); skillList.removeAll(); for (int i : order) { skillList.add(rows[i]); } skillList.revalidate(); } // ---- level ups, newest first levelList.removeAll(); if (levelUps.isEmpty()) { levelEmpty.setText("No level ups " + timeFilter.inLabel()); levelList.add(levelEmpty); } for (int i = levelUps.size() - 1; i >= Math.max(0, levelUps.size() - LEVEL_UP_LIMIT); i--) { levelList.add(new LevelUpRow(levelUps.get(i))); } levelList.revalidate(); revalidate(); repaint(); } private SkillGoals.Goal goal(int skill) { return plugin.getSkillGoals().get(shownKey, SkillHistory.SKILLS[skill].getName()); } private static String percent(double ratio) { double p = ratio * 100; return (p >= 100 ? "100" : String.format(Locale.US, "%.1f", p)) + "%"; } /** Goal editor for one skill: target level with quick picks, set and clear. */ private void showGoalEditor(int skill, JComponent anchor, int x, int y) { if (shownKey == null) { return; } Skill s = SkillHistory.SKILLS[skill]; SkillHistory h = store.get(shownKey); XpSample last = h == null ? null : h.latest(); int xp = last == null ? 0 : last.get(skill); int level = SkillHistory.level(xp); SkillGoals.Goal current = goal(skill); int max = Experience.MAX_VIRT_LEVEL; int initial = current != null ? current.level : level < 99 ? nextMilestone(level) : level + 1; JPopupMenu menu = new JPopupMenu(); JPanel panel = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); panel.setBackground(Ui.CARD); panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); JLabel title = new JLabel(s.getName() + " goal"); title.setFont(FontManager.getRunescapeBoldFont()); title.setForeground(skillColor(s)); panel.add(title); JLabel now = new JLabel(String.format(Locale.US, "Now level %d (%,d xp)", level, xp)); now.setFont(FontManager.getRunescapeSmallFont()); now.setForeground(Ui.MUTED); panel.add(now); SpinnerNumberModel model = new SpinnerNumberModel(Math.max(2, Math.min(max, initial)), 2, max, 1); JSpinner spinner = new JSpinner(model); spinner.setFont(FontManager.getRunescapeFont()); JLabel preview = new JLabel(); preview.setFont(FontManager.getRunescapeSmallFont()); preview.setForeground(Ui.TEXT); Runnable updatePreview = () -> { int target = Experience.getXpForLevel((Integer) model.getValue()); int left = Math.max(0, target - xp); preview.setText(left == 0 ? "Already reached" : String.format(Locale.US, "%,d xp needed", left)); }; spinner.addChangeListener(e -> updatePreview.run()); updatePreview.run(); JPanel levelRow = new JPanel(new BorderLayout(6, 0)); levelRow.setOpaque(false); JLabel lvLabel = new JLabel("Target level"); lvLabel.setFont(FontManager.getRunescapeSmallFont()); lvLabel.setForeground(Ui.TEXT); levelRow.add(lvLabel, BorderLayout.WEST); levelRow.add(spinner, BorderLayout.CENTER); panel.add(levelRow); JPanel quick = new JPanel(new java.awt.GridLayout(1, 4, 3, 0)); quick.setOpaque(false); for (int[] pick : new int[][]{{1, 0}, {5, 0}, {0, 70}, {0, 99}}) { String label = pick[0] > 0 ? "+" + pick[0] : String.valueOf(pick[1]); quick.add(button(label, () -> model.setValue(Math.max(2, Math.min(max, pick[0] > 0 ? level + pick[0] : pick[1]))))); } panel.add(quick); panel.add(preview); JPanel actions = new JPanel(new java.awt.GridLayout(1, 2, 6, 0)); actions.setOpaque(false); JButton set = button("Set goal", () -> { plugin.getSkillGoals().set(shownKey, s.getName(), (Integer) model.getValue(), xp); menu.setVisible(false); refresh(); }); set.setForeground(Ui.ACCENT); actions.add(set); JButton clear = button("Clear", () -> { plugin.getSkillGoals().clear(shownKey, s.getName()); menu.setVisible(false); refresh(); }); clear.setEnabled(current != null); actions.add(clear); panel.add(actions); panel.setPreferredSize(new Dimension(190, panel.getPreferredSize().height)); menu.add(panel); menu.show(anchor, Math.max(0, Math.min(x, anchor.getWidth() - 190)), y); } /** Next round level worth aiming for: 10, 20 .. 80, 85, 90, 99. */ private static int nextMilestone(int level) { for (int m : new int[]{10, 20, 30, 40, 50, 60, 70, 80, 85, 90, 99}) { if (m > level) { return m; } } return level + 1; } private void updateCombo(SkillHistory shown) { List all = store.all(); List choices = new ArrayList<>(); for (SkillHistory h : all) { choices.add(new Choice(h.key, h.name == null ? h.key : h.name)); } List current = new ArrayList<>(); for (int i = 0; i < characterBox.getItemCount(); i++) { current.add(characterBox.getItemAt(i)); } updatingCombo = true; if (!choices.equals(current)) { characterBox.removeAllItems(); choices.forEach(characterBox::addItem); } for (Choice c : choices) { if (shown != null && c.key.equals(shown.key)) { characterBox.setSelectedItem(c); } } updatingCombo = false; boolean visible = choices.size() > 1; if (characterBox.isVisible() != visible) { characterBox.setVisible(visible); revalidate(); } } private BufferedImage icon(Skill skill) { return icons.computeIfAbsent(skill, s -> { BufferedImage img = plugin.getSkillIconManager().getSkillImage(s, true); return img == null ? null : ImageUtil.resizeImage(img, 16, 16); }); } private static String duration(long ms) { if (ms < Ui.MINUTE) { return "-"; } long h = ms / Ui.HOUR; long m = (ms % Ui.HOUR) / Ui.MINUTE; return h > 0 ? h + "h " + m + "m" : m + "m"; } static Color skillColor(Skill skill) { switch (skill.name()) { case "ATTACK": return new Color(220, 80, 60); case "DEFENCE": return new Color(120, 150, 230); case "STRENGTH": return new Color(60, 190, 120); case "HITPOINTS": return new Color(230, 90, 90); case "RANGED": return new Color(150, 200, 60); case "PRAYER": return new Color(235, 225, 170); case "MAGIC": return new Color(110, 140, 255); case "COOKING": return new Color(180, 100, 210); case "WOODCUTTING": return new Color(110, 180, 80); case "FLETCHING": return new Color(40, 190, 170); case "FISHING": return new Color(130, 170, 220); case "FIREMAKING": return new Color(250, 150, 40); case "CRAFTING": return new Color(200, 150, 100); case "SMITHING": return new Color(170, 170, 140); case "MINING": return new Color(120, 180, 210); case "HERBLORE": return new Color(60, 200, 70); case "AGILITY": return new Color(110, 115, 220); case "THIEVING": return new Color(190, 110, 170); case "SLAYER": return new Color(200, 70, 70); case "FARMING": return new Color(130, 200, 90); case "RUNECRAFT": return new Color(230, 200, 70); case "HUNTER": return new Color(170, 160, 110); case "CONSTRUCTION": return new Color(200, 180, 140); case "SAILING": return new Color(70, 170, 230); default: return Ui.GOLD; } } private static JButton button(String text, Runnable action) { JButton b = new JButton(text); b.setFont(FontManager.getRunescapeSmallFont()); b.setFocusPainted(false); b.setMargin(new java.awt.Insets(2, 2, 2, 2)); b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); b.addActionListener(e -> action.run()); return b; } private static final class Choice { final String key; final String name; Choice(String key, String name) { this.key = key; this.name = name; } @Override public boolean equals(Object o) { return o instanceof Choice && ((Choice) o).key.equals(key) && Objects.equals(((Choice) o).name, name); } @Override public int hashCode() { return key.hashCode(); } @Override public String toString() { return name; } } /** width of the goal cog hit area at the right of a skill row */ private static final int GEAR_HIT = 20; /** One skill: icon, level (+gained), xp gained, xp/h, progress to the next level and the goal, if set. */ private final class SkillRow extends JComponent { private final int skill; private int xp; private int levelsGained; private long gained; private long xph; private long maxGain; private boolean selected; private SkillGoals.Goal goal; private boolean hover; private boolean gearHover; SkillRow(int skill) { this.skill = skill; setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); MouseAdapter mouse = new MouseAdapter() { @Override public void mouseEntered(MouseEvent e) { hover = true; repaint(); } @Override public void mouseExited(MouseEvent e) { hover = false; gearHover = false; repaint(); } @Override public void mouseMoved(MouseEvent e) { boolean g = onGear(e.getX(), e.getY()); if (g != gearHover) { gearHover = g; setToolTipText(g ? "Set a level goal" : tooltip()); repaint(); } } @Override public void mousePressed(MouseEvent e) { if (SwingUtilities.isRightMouseButton(e) || onGear(e.getX(), e.getY())) { showGoalEditor(skill, SkillRow.this, e.getX(), e.getY()); } else { selectSkill(skill); } } }; addMouseListener(mouse); addMouseMotionListener(mouse); } private boolean onGear(int x, int y) { return x >= getWidth() - GEAR_HIT && y <= 20; } private String tooltip() { String t = String.format(Locale.US, "%s
%,d xp
+%,d xp gained", SkillHistory.SKILLS[skill].getName(), xp, gained); if (goal != null) { t += String.format(Locale.US, "
Goal %d: %,d xp left", goal.level, goal.xpLeft(xp)); } return t + "
Right-click or the cog to set a goal"; } void update(int xp, int levelsGained, long gained, long xph, long maxGain, boolean selected, SkillGoals.Goal goal) { boolean resize = (goal == null) != (this.goal == null); this.goal = goal; this.xp = xp; this.levelsGained = levelsGained; this.gained = gained; this.xph = xph; this.maxGain = maxGain; this.selected = selected; setToolTipText(tooltip()); if (resize) { revalidate(); } repaint(); } @Override public Dimension getPreferredSize() { if (goal == null) { return new Dimension(10, 34); } int asc = getFontMetrics(FontManager.getRunescapeSmallFont()).getAscent(); // goal text baseline at 20 + 3 * asc, then gap, 4px bar, bottom padding return new Dimension(10, 4 + 3 * asc + 16 + 14); } @Override protected void paintComponent(Graphics g0) { Graphics2D g = Ui.prepare(g0); int w = getWidth(); int h = getHeight(); Skill s = SkillHistory.SKILLS[skill]; Color color = skillColor(s); boolean trained = gained > 0; g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); g.fillRoundRect(0, 0, w, h, 6, 6); if (selected) { g.setColor(color); g.fillRoundRect(0, 0, 3, h, 3, 3); } // share of the top skill's gain as a faint background bar if (trained && maxGain > 0) { g.setColor(Ui.alpha(color, 26)); g.fillRoundRect(0, 0, (int) (w * (double) gained / maxGain), h, 6, 6); } BufferedImage img = icon(s); if (img != null) { g.drawImage(img, 6, (h - 16) / 2 - 2, null); } int level = SkillHistory.level(xp); g.setFont(FontManager.getRunescapeSmallFont()); FontMetrics fm = g.getFontMetrics(); int x = 28; int base1 = 4 + fm.getAscent(); int base2 = base1 + fm.getAscent() + 2; g.setColor(trained ? Ui.TEXT : Ui.MUTED); g.drawString(s.getName(), x, base1); String lv = "Lv " + level; g.setColor(Ui.MUTED); g.drawString(lv, x, base2); if (levelsGained > 0) { g.setColor(Ui.UP); g.drawString(" +" + levelsGained, x + fm.stringWidth(lv), base2); } paintGear(g, w - 11, 10, gearHover ? Ui.ACCENT : goal != null ? Ui.GOLD : hover ? Ui.MUTED : Ui.FAINT); int right = w - GEAR_HIT - 2; g.setColor(trained ? Ui.UP : Ui.FAINT); Ui.drawRight(g, trained ? "+" + Ui.gp(gained) + " xp" : "-", right, base1); if (xph > 0) { g.setColor(Ui.MUTED); Ui.drawRight(g, Ui.gp(xph) + "/h", right, base2); } // progress to next level int levelBarY = goal == null ? h - 4 : base2 + 6; if (level < Experience.MAX_VIRT_LEVEL) { int lo = Experience.getXpForLevel(level); int hi = Experience.getXpForLevel(level + 1); double p = hi > lo ? (double) (xp - lo) / (hi - lo) : 0; int bx = x; int bw = w - x - 7; g.setColor(Ui.alpha(Color.WHITE, 18)); g.fillRect(bx, levelBarY, bw, 2); g.setColor(Ui.alpha(color, trained ? 220 : 110)); g.fillRect(bx, levelBarY, (int) (bw * Math.max(0, Math.min(1, p))), 2); } if (goal != null) { int left = goal.xpLeft(xp); boolean reached = left == 0; int base3 = levelBarY + 2 + 6 + fm.getAscent(); String head = "Goal " + goal.level + " "; g.setColor(Ui.MUTED); g.drawString(head, x, base3); g.setColor(reached ? Ui.UP : Ui.GOLD); g.drawString(percent(goal.progress(xp)), x + fm.stringWidth(head), base3); String info = reached ? "reached!" : Ui.gp(left) + " xp left" + (xph > 0 ? " ~" + duration(left * Ui.HOUR / xph) : ""); g.setColor(reached ? Ui.UP : Ui.TEXT); Ui.drawRight(g, info, w - 7, base3); int bx = x; int bw = w - x - 7; int by = base3 + 5; g.setColor(Ui.alpha(Color.WHITE, 18)); g.fillRoundRect(bx, by, bw, 4, 4, 4); g.setColor(reached ? Ui.UP : Ui.GOLD); g.fillRoundRect(bx, by, Math.max(4, (int) (bw * goal.progress(xp))), 4, 4, 4); } g.dispose(); } /** Small cog: ring with eight teeth. */ private void paintGear(Graphics2D g, int cx, int cy, Color c) { Graphics2D g2 = (Graphics2D) g.create(); g2.translate(cx, cy); g2.setColor(c); for (int i = 0; i < 8; i++) { g2.fillRect(-1, -6, 3, 3); g2.rotate(Math.PI / 4); } g2.fillOval(-4, -4, 9, 9); g2.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); g2.fillOval(-1, -1, 3, 3); g2.dispose(); } } /** "Attack 70 → 71" with the time it happened. */ private final class LevelUpRow extends JComponent { private final SkillHistory.LevelUp levelUp; LevelUpRow(SkillHistory.LevelUp levelUp) { this.levelUp = levelUp; setToolTipText(Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN)); } @Override public Dimension getPreferredSize() { return new Dimension(10, 22); } @Override protected void paintComponent(Graphics g0) { Graphics2D g = Ui.prepare(g0); int w = getWidth(); int h = getHeight(); Skill s = SkillHistory.SKILLS[levelUp.skill]; g.setColor(Ui.CARD); g.fillRoundRect(0, 0, w, h, 6, 6); BufferedImage img = icon(s); if (img != null) { g.drawImage(img, 5, (h - 16) / 2, null); } g.setFont(FontManager.getRunescapeSmallFont()); FontMetrics fm = g.getFontMetrics(); int base = (h + fm.getAscent()) / 2 - 1; g.setColor(Ui.TEXT); String name = s.getName() + " "; g.drawString(name, 26, base); g.setColor(skillColor(s)); String lv = levelUp.to - levelUp.from > 1 ? levelUp.from + " > " + levelUp.to : String.valueOf(levelUp.to); g.drawString(lv, 26 + fm.stringWidth(name), base); g.setColor(Ui.MUTED); String when = Ui.ago(levelUp.t); if (System.currentTimeMillis() - levelUp.t >= Ui.DAY) { when = Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN); } Ui.drawRight(g, when, w - 7, base); g.dispose(); } } private static final String[][] HUD_OPTIONS = { {"skillsHudLevel", "Level"}, {"skillsHudPercent", "Progress %"}, {"skillsHudXpLeft", "Xp to level"}, {"skillsHudTimeToLevel", "Time to level"}, {"skillsHudXpHour", "Xp per hour"}, {"skillsHudProgressBar", "Progress bar"}, }; private void showHudOptions(JComponent anchor) { JPopupMenu menu = new JPopupMenu(); for (String[] option : HUD_OPTIONS) { JCheckBoxMenuItem item = new JCheckBoxMenuItem(option[1], plugin.isHudOption(option[0])); item.addActionListener(e -> plugin.setHudOption(option[0], item.isSelected())); menu.add(item); } menu.show(anchor, 0, anchor.getHeight()); } }