diff --git a/forge-game/src/main/java/forge/game/keyword/Craft.java b/forge-game/src/main/java/forge/game/keyword/Craft.java index bef4cd169e5..0728b5afc89 100644 --- a/forge-game/src/main/java/forge/game/keyword/Craft.java +++ b/forge-game/src/main/java/forge/game/keyword/Craft.java @@ -1,15 +1,17 @@ package forge.game.keyword; +import forge.card.CardType; import forge.game.cost.Cost; import forge.game.cost.CostExile; import forge.game.cost.CostPart; import forge.game.cost.CostPartMana; +import forge.util.Lang; public class Craft extends KeywordWithCost { String manaString = "Mana?"; String exileString = "Exile?"; - + private String withDescription = ""; @Override protected void parse(String details) { @@ -24,21 +26,56 @@ protected void parse(String details) { } } - final StringBuilder sb = new StringBuilder(); - if (k.length > 2) { - sb.append("Exile ").append(k[2]).append(" from among permanents you control and/or cards in your graveyard"); - } else for (CostPart part : kCost.getCostParts()) { + // Build "with" description from all CostExile parts + final java.util.List withParts = new java.util.ArrayList<>(); + int exilePartCount = 0; + for (CostPart part : kCost.getCostParts()) { if (part instanceof CostExile) { - int xMin = 0; - if (k[0].contains("XMin")) { - String cutString = k[0].substring(k[0].indexOf("XMin") + 4); - xMin = Integer.parseInt(cutString.substring(0, cutString.indexOf(" "))); + exilePartCount++; + String partType = part.getType().replace(".Other", ""); + String type = part.getTypeDescription() != null + ? part.getTypeDescription() + : (CardType.CoreType.isValidEnum(partType) || "Permanent".equals(partType)) + ? partType.toLowerCase() : partType; + String amount = part.getAmount(); + if ("1".equals(amount)) { + withParts.add(Lang.nounWithNumeralExceptOne(1, type)); + } else { + withParts.add(Lang.getPlural(type)); + } + } + } + if (!withParts.isEmpty()) { + withDescription = Lang.joinHomogenous(withParts); + } + + // Build exile string for reminder text + if (exilePartCount > 1) { + exileString = "Exile " + withDescription + + " from among permanents you control and/or cards in your graveyard"; + } else { + final StringBuilder sb = new StringBuilder(); + for (CostPart part : kCost.getCostParts()) { + if (part instanceof CostExile) { + int xMin = 0; + if (k[0].contains("XMin")) { + String cutString = k[0].substring(k[0].indexOf("XMin") + 4); + xMin = Integer.parseInt(cutString.substring(0, cutString.indexOf(" "))); + } + sb.append(((CostExile) part).exileMultiZoneCostString(true, xMin)); + break; } - sb.append(((CostExile) part).exileMultiZoneCostString(true, xMin)); - break; } + exileString = sb.toString(); + } + } + + @Override + public String getTitle() { + if (!withDescription.isEmpty()) { + return "Craft with " + withDescription + " " + manaString; } - exileString = sb.toString(); + return super.getTitle(); } diff --git a/forge-game/src/main/java/forge/game/keyword/Keyword.java b/forge-game/src/main/java/forge/game/keyword/Keyword.java index 2ab22a95fbe..8a00afe801e 100644 --- a/forge-game/src/main/java/forge/game/keyword/Keyword.java +++ b/forge-game/src/main/java/forge/game/keyword/Keyword.java @@ -16,7 +16,7 @@ public enum Keyword { AMPLIFY("Amplify", Amplify.class, false, "As this creature enters, put {%d:+1/+1 counter} on it for each %s card you reveal in your hand."), ANNIHILATOR("Annihilator", KeywordWithAmount.class, false, "Whenever this creature attacks, defending player sacrifices {%d:permanent}."), ASCEND("Ascend", SimpleKeyword.class, true, "If you control ten or more permanents, you get the city's blessing for the rest of the game."), - ASSIST("Assist", SimpleKeyword.class, true, "Another player can pay up to %s of this spell's cost."), + ASSIST("Assist", SimpleKeyword.class, true, "Another player can help pay the generic mana cost of this spell."), AURA_SWAP("Aura swap", KeywordWithCost.class, false, "%s: You may exchange this Aura with an Aura card in your hand."), AWAKEN("Awaken", KeywordWithCostAndAmount.class, false, "If you cast this spell for %s, also put {%d:+1/+1 counter} on target land you control and it becomes a 0/0 Elemental creature with haste. It's still a land."), BACKUP("Backup", KeywordWithAmount.class, false, "When this creature enters, put {%1$d:+1/+1 counter} on target creature. If that's another creature, it gains the following ability until end of turn."), @@ -104,7 +104,7 @@ public enum Keyword { HORSEMANSHIP("Horsemanship", SimpleKeyword.class, true, "This creature can't be blocked except by creatures with horsemanship."), IMPENDING("Impending", KeywordWithCostAndAmount.class, false, "If you cast this spell for its impending cost, it enters with {%2$d:time counter} and isn't a creature until the last is removed. At the beginning of your end step, remove a time counter from it."), IMPROVISE("Improvise", SimpleKeyword.class, true, "Your artifacts can help cast this spell. Each artifact you tap after you're done activating mana abilities pays for {1}."), - INDESTRUCTIBLE("Indestructible", SimpleKeyword.class, true, "Effects that say \"destroy\" don't destroy this."), + INDESTRUCTIBLE("Indestructible", SimpleKeyword.class, true, "A permanent with indestructible can't be destroyed."), INFECT("Infect", SimpleKeyword.class, true, "This creature deals damage to creatures in the form of -1/-1 counters and to players in the form of poison counters."), INGEST("Ingest", SimpleKeyword.class, false, "Whenever this creature deals combat damage to a player, that player exiles the top card of their library."), INTIMIDATE("Intimidate", SimpleKeyword.class, true, "This creature can't be blocked except by artifact creatures and/or creatures that share a color with it."), @@ -142,7 +142,7 @@ public enum Keyword { PHASING("Phasing", SimpleKeyword.class, true, "This phases in or out before you untap during each of your untap steps. While it's phased out, it's treated as though it doesn't exist."), PLOT("Plot", KeywordWithCost.class, false, "You may pay %s and exile this card from your hand. Cast it as a sorcery on a later turn without paying its mana cost. Plot only as a sorcery."), POISONOUS("Poisonous", KeywordWithAmount.class, false, "Whenever this creature deals combat damage to a player, that player gets {%d:poison counter}."), - PROTECTION("Protection", Protection.class, true, "This creature can't be blocked, targeted, dealt damage, or equipped/enchanted by %s."), + PROTECTION("Protection", Protection.class, true, "This permanent can't be blocked, targeted, dealt damage, enchanted, or equipped by %s."), PROTOTYPE("Prototype", KeywordWithCost.class, false, "You may cast this spell with different mana cost, color, and size. It keeps its abilities and types."), PROVOKE("Provoke", SimpleKeyword.class, false, "Whenever this creature attacks, you may have target creature defending player controls untap and block it if able."), PROWESS("Prowess", SimpleKeyword.class, false, "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of turn."), diff --git a/forge-game/src/main/java/forge/game/keyword/KeywordAction.java b/forge-game/src/main/java/forge/game/keyword/KeywordAction.java new file mode 100644 index 00000000000..4208f6a876a --- /dev/null +++ b/forge-game/src/main/java/forge/game/keyword/KeywordAction.java @@ -0,0 +1,168 @@ +package forge.game.keyword; + +import forge.util.Localizer; + +/** + * Keyword actions — verbs that appear in rules text but aren't keyword abilities. + * Descriptions are based on the MTG comprehensive rules (section 701). + * + *

Unlike {@link Keyword} (keyword abilities that grant continuous effects or + * triggered/static abilities), keyword actions are one-shot game actions performed + * when instructed by a spell or ability.

+ * + *

Actions marked {@code basic=true} are fundamental game actions (destroy, exile, + * sacrifice, etc.) that every player knows — UI code may choose to omit these from + * tooltips to avoid clutter.

+ * + *

Display names and reminder text are stored in {@code en-US.properties} under + * keys derived from the enum name (e.g. {@code lblKwActionActivate}, + * {@code lblKwActionActivateReminder}).

+ */ +public enum KeywordAction { + // 701.2 – 701.13: Basic game actions + ACTIVATE(true), + ATTACH(true), + BEHOLD(false), + CAST(true), + COUNTER(true), + CREATE(true), + DESTROY(true), + DISCARD(true), + DOUBLE(true), + TRIPLE(true), + EXCHANGE(true), + EXILE(true), + + // 701.14 – 701.17: Combat & graveyard actions + FIGHT(false), + GOAD(false), + INVESTIGATE(false), + MILL(false), + + // 701.18 – 701.21: More basic actions + PLAY(true), + REGENERATE(false), + REVEAL(true), + SACRIFICE(true), + + // 701.22 – 701.28: Library & transform actions + SCRY(false), + SEARCH(true), + SHUFFLE(true), + SURVEIL(false), + TAP_UNTAP(true), + TRANSFORM(false), + CONVERT(false), + + // 701.29 – 701.30: Opponent manipulation + FATESEAL(false), + CLASH(false), + + // 701.31 – 701.33: Supplemental format actions + PLANESWALK(false), + SET_IN_MOTION(true), + ABANDON(true), + + // 701.34 – 701.36: Counter & token actions + PROLIFERATE(false), + DETAIN(false), + POPULATE(false), + + // 701.37 – 701.39: Creature enhancement + MONSTROSITY(false), + VOTE(false), + BOLSTER(false), + + // 701.40 – 701.44: Face-down & explore + MANIFEST(false), + SUPPORT(false), + MELD(false), + EXERT(false), + EXPLORE(false), + + // 701.45 – 701.48: Un-set & learning + ASSEMBLE(false), + ADAPT(false), + AMASS(false), + LEARN(false), + + // 701.49 – 701.50: Dungeon & connive + VENTURE(false), + CONNIVE(false), + + // 701.51 – 701.52: Attraction actions + OPEN_AN_ATTRACTION(false), + ROLL_TO_VISIT(false), + + // 701.53 – 701.54: Incubate & ring + INCUBATE(false), + THE_RING_TEMPTS_YOU(false), + + // 701.55 – 701.56: Villainous choice & time travel + FACE_A_VILLAINOUS_CHOICE(false), + TIME_TRAVEL(false), + + // 701.57 – 701.60: Discover, cloak, evidence, suspect + DISCOVER(false), + CLOAK(false), + COLLECT_EVIDENCE(false), + SUSPECT(false), + + // 701.61 – 701.64: Bloomburrow & beyond + FORAGE(false), + MANIFEST_DREAD(false), + ENDURE(false), + HARNESS(false), + + // 701.65 – 701.68: Avatar & Lorwyn Eclipsed + AIRBEND(false), + EARTHBEND(false), + WATERBEND(false), + BLIGHT(false), + + // Game concepts (not 701.x actions, but useful to explain) + DEVOTION(false), + DOMAIN(false), + METALCRAFT(false), + THRESHOLD(false), + DELIRIUM(false); + + /** True for fundamental game actions (destroy, exile, sacrifice, etc.) that don't need tooltip explanations. */ + public final boolean basic; + /** Translation key prefix derived from enum name, e.g. "lblKwActionActivate". */ + private final String translationKey; + + KeywordAction(boolean basic) { + this.basic = basic; + this.translationKey = "lblKwAction" + toCamelCase(name()); + } + + private static String toCamelCase(String enumName) { + StringBuilder sb = new StringBuilder(); + boolean capitalize = true; + for (char c : enumName.toCharArray()) { + if (c == '_') { + capitalize = true; + } else { + sb.append(capitalize ? c : Character.toLowerCase(c)); + capitalize = false; + } + } + return sb.toString(); + } + + /** Returns the localized display name. */ + public String getDisplayName() { + return Localizer.getInstance().getMessage(translationKey); + } + + /** Returns the localized reminder text. */ + public String getReminderText() { + return Localizer.getInstance().getMessage(translationKey + "Reminder"); + } + + @Override + public String toString() { + return getDisplayName(); + } +} diff --git a/forge-game/src/main/java/forge/game/keyword/KeywordWithType.java b/forge-game/src/main/java/forge/game/keyword/KeywordWithType.java index e75b9154145..1d883a06595 100644 --- a/forge-game/src/main/java/forge/game/keyword/KeywordWithType.java +++ b/forge-game/src/main/java/forge/game/keyword/KeywordWithType.java @@ -19,9 +19,21 @@ public class KeywordWithType extends KeywordInstance implements @Override public String getTitle() { - StringBuilder sb = new StringBuilder(); - sb.append(this.getKeyword()).append(" ").append(descType); - return sb.toString(); + switch (getKeyword()) { + case LANDWALK: + // "Swampwalk", "Islandwalk", etc. instead of "Landwalk Swamp" + return Character.toUpperCase(descType.charAt(0)) + + descType.substring(1) + "walk"; + case AFFINITY: + // "Affinity for artifacts" instead of "Affinity artifact". + // Some types are already plural (e.g. "Plains"), so skip + // pluralisation if descType already ends in "s". + final String affinityPlural = descType.endsWith("s") + ? descType : Lang.getPlural(descType); + return "Affinity for " + affinityPlural; + default: + return this.getKeyword() + " " + descType; + } } @Override diff --git a/forge-game/src/main/java/forge/game/keyword/Partner.java b/forge-game/src/main/java/forge/game/keyword/Partner.java index cdd7f90c227..70d7ac363c6 100644 --- a/forge-game/src/main/java/forge/game/keyword/Partner.java +++ b/forge-game/src/main/java/forge/game/keyword/Partner.java @@ -14,7 +14,7 @@ public String getTitle() { @Override protected void parse(String details) { - with = details; + with = details.isEmpty() ? null : details; } @Override diff --git a/forge-game/src/main/java/forge/game/keyword/Protection.java b/forge-game/src/main/java/forge/game/keyword/Protection.java index cad79097fdd..430b0e73345 100644 --- a/forge-game/src/main/java/forge/game/keyword/Protection.java +++ b/forge-game/src/main/java/forge/game/keyword/Protection.java @@ -10,6 +10,13 @@ public String getTitle() { @Override protected void parse(String details) { + fromWhat = details.startsWith("from ") ? details.substring(5) : details; + // Details may use "type:description" format (e.g. "Card.MultiColor:multicolored", + // "instants:instants") — use the description part for display + final int colon = fromWhat.indexOf(':'); + if (colon >= 0) { + fromWhat = fromWhat.substring(colon + 1); + } } @Override diff --git a/forge-game/src/main/java/forge/game/keyword/Trample.java b/forge-game/src/main/java/forge/game/keyword/Trample.java index d79d0e5482d..7f019c6796a 100644 --- a/forge-game/src/main/java/forge/game/keyword/Trample.java +++ b/forge-game/src/main/java/forge/game/keyword/Trample.java @@ -4,7 +4,7 @@ public class Trample extends KeywordWithType { @Override public String getTitle() { if (!type.isEmpty()) { - return "Trample over planeswalkers"; + return "Trample Over Planeswalkers"; } return "Trample"; } diff --git a/forge-gui-desktop/src/main/java/forge/CachedCardImage.java b/forge-gui-desktop/src/main/java/forge/CachedCardImage.java index 33b5a8c2c3e..bd40965f23b 100644 --- a/forge-gui-desktop/src/main/java/forge/CachedCardImage.java +++ b/forge-gui-desktop/src/main/java/forge/CachedCardImage.java @@ -14,7 +14,7 @@ public abstract class CachedCardImage implements ImageFetcher.Callback { final int width; final int height; - static final SwingImageFetcher fetcher = new SwingImageFetcher(); + public static final SwingImageFetcher fetcher = new SwingImageFetcher(); public CachedCardImage(final CardView card, final Iterable viewers, final int width, final int height) { this.card = card; diff --git a/forge-gui-desktop/src/main/java/forge/ImageCache.java b/forge-gui-desktop/src/main/java/forge/ImageCache.java index 90c3e070594..e8be420d329 100644 --- a/forge-gui-desktop/src/main/java/forge/ImageCache.java +++ b/forge-gui-desktop/src/main/java/forge/ImageCache.java @@ -40,9 +40,12 @@ import com.google.common.cache.LoadingCache; import com.mortennobel.imagescaling.ResampleOp; +import forge.card.CardStateName; import forge.card.CardSplitType; +import forge.card.MagicColor; import forge.game.card.Card; import forge.game.card.CardView; +import forge.game.card.CardView.CardStateView; import forge.game.player.PlayerView; import forge.gui.FThreads; import forge.gui.GuiBase; @@ -200,6 +203,10 @@ public static Pair getCardOriginalImageInfo(String image return getOriginalImageInternal(imageKey, useDefaultIfNotFound, null); } + public static Pair getCardOriginalImageInfo(String imageKey, boolean useDefaultIfNotFound, CardView cardView) { + return getOriginalImageInternal(imageKey, useDefaultIfNotFound, cardView); + } + // return the pair of image and a flag to indicate if it is a placeholder image. private static Pair getOriginalImageInternal(String imageKey, boolean useDefaultIfNotFound, CardView cardView) { if (null == imageKey) { @@ -319,7 +326,6 @@ private static Pair getOriginalImageInternal(String imag float screenScale = GuiBase.getInterface().getScreenScale(); int width = Math.round(488 * screenScale), height = Math.round(680 * screenScale); BufferedImage art = original; - CardView card = ipc != null ? Card.getCardForUi(ipc).getView() : cardView; String legalString = null; original = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); if (art != null) { @@ -328,7 +334,21 @@ private static Pair getOriginalImageInternal(String imag int year = cal.get(Calendar.YEAR); legalString = "Illus. " + ipc.getArtist() + " ©" + year + " WOTC"; } - FCardImageRenderer.drawCardImage(original.createGraphics(), card, altState, width, height, art, legalString); + // For specialize faces, render the specific CardStateView directly + // rather than relying on CardView's current/alternate state lookup + CardStateView specState = null; + if (ipc != null && !specColor.isEmpty()) { + CardStateName specStateName = specColorToStateName(specColor); + if (specStateName != null) { + specState = CardView.getState(Card.getCardForUi(ipc), specStateName); + } + } + if (specState != null) { + FCardImageRenderer.drawCardStateImage(original.createGraphics(), specState, width, height, art, legalString); + } else { + CardView card = ipc != null ? Card.getCardForUi(ipc).getView() : cardView; + FCardImageRenderer.drawCardImage(original.createGraphics(), card, altState, width, height, art, legalString); + } // Skip store cache since the rendering speed seems to be fast enough // Also the scaleImage below will already cache re-sized image for CardPanel anyway // if (art != null || !fetcherEnabled) @@ -350,6 +370,12 @@ private static boolean isWhiteBorderSet(String setCode) { setCode.equals("6E") || setCode.equals("7E") || setCode.equals("8E") || setCode.equals("9E"); } + private static CardStateName specColorToStateName(String specColor) { + MagicColor.Color color = MagicColor.Color.fromName(specColor); + if (color == MagicColor.Color.COLORLESS) return null; + return CardStateName.smartValueOf("Specialize" + color.getShortName()); + } + public static boolean isSupportedImageSize(final int width, final int height) { return !((3 > width && -1 != width) || (3 > height && -1 != height)); } diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 746850ff9f2..ba1a5fa185f 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -26,6 +26,8 @@ import forge.model.FModel; import forge.screens.home.settings.VSubmenuPreferences.KeyboardShortcutField; import forge.screens.match.CMatchUI; +import forge.screens.match.views.VField; +import forge.screens.match.views.VHand; import forge.toolbox.special.CardZoomer; import forge.util.Localizer; import forge.view.KeyboardShortcutsDialog; @@ -246,6 +248,34 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Toggle hover tooltips. */ + final Action actHoverTooltips = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + final ForgePreferences prefs = FModel.getPreferences(); + final boolean newValue = !prefs.getPrefBoolean(FPref.UI_SHOW_HOVER_TOOLTIPS); + prefs.setPref(FPref.UI_SHOW_HOVER_TOOLTIPS, newValue); + prefs.save(); + if (!newValue && matchUI != null) { + hideAllCardInfoPopups(matchUI); + } + } + }; + + /** Toggle zoom view tooltips. */ + final Action actZoomTooltips = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + final ForgePreferences prefs = FModel.getPreferences(); + prefs.setPref(FPref.UI_SHOW_ZOOM_TOOLTIPS, + !prefs.getPrefBoolean(FPref.UI_SHOW_ZOOM_TOOLTIPS)); + prefs.save(); + CardZoomer.SINGLETON_INSTANCE.refreshIfOpen(); + } + }; + /** Show keyboard shortcuts dialog. */ final Action actShowHotkeys = new AbstractAction() { @Override @@ -276,6 +306,8 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWHOTKEYS, localizer.getMessage("lblSHORTCUT_SHOWHOTKEYS"), actShowHotkeys, am, im)); list.add(new Shortcut(FPref.SHORTCUT_PANELTABS, localizer.getMessage("lblSHORTCUT_PANELTABS"), actPanelTabs, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CARDOVERLAYS, localizer.getMessage("lblSHORTCUT_CARDOVERLAYS"), actCardOverlays, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_HOVERTOOLTIPS, localizer.getMessage("lblSHORTCUT_HOVERTOOLTIPS"), actHoverTooltips, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_ZOOMTOOLTIPS, localizer.getMessage("lblSHORTCUT_ZOOMTOOLTIPS"), actZoomTooltips, am, im)); cachedShortcuts = list; return list; } // End initMatchShortcuts() @@ -433,4 +465,20 @@ public static void addKeyCode(final KeyEvent e) { ksf.setCodeString(StringUtils.join(existingCodes, ' ')); } + + /** Hide card info tooltips on all battlefield and hand panels. */ + private static void hideAllCardInfoPopups(final CMatchUI matchUI) { + final List fields = matchUI.getFieldViews(); + if (fields != null) { + for (final VField f : fields) { + f.getTabletop().hideCardInfoPopup(); + } + } + final List hands = matchUI.getHandViews(); + if (hands != null) { + for (final VHand h : hands) { + h.getHandArea().hideCardInfoPopup(); + } + } + } } diff --git a/forge-gui-desktop/src/main/java/forge/menus/LayoutMenu.java b/forge-gui-desktop/src/main/java/forge/menus/LayoutMenu.java index 48d18f57b01..7c14155448d 100644 --- a/forge-gui-desktop/src/main/java/forge/menus/LayoutMenu.java +++ b/forge-gui-desktop/src/main/java/forge/menus/LayoutMenu.java @@ -79,6 +79,8 @@ private JMenu getMenu_ViewOptions() { menu.add(getMenuItem_ShowTabs()); if (currentScreen != null && currentScreen.isMatchScreen()) { menu.add(getMenuItem_ShowBackgroundImage()); + menu.add(getMenuItem_ShowCardPicture()); + menu.add(getMenuItem_ShowCardDetail()); menu.addSeparator(); menu.add(getMenu_LogPane()); @@ -269,6 +271,40 @@ private static void relayoutMatchFields() { } } + private static JMenuItem getMenuItem_ShowCardPicture() { + final Localizer localizer = Localizer.getInstance(); + final JCheckBoxMenuItem menuItem = new JCheckBoxMenuItem(localizer.getMessage("lblCardPicturePanel")); + menuItem.setState(prefs.getPrefBoolean(FPref.UI_MATCH_CARD_PICTURE_VISIBLE)); + menuItem.addActionListener(e -> { + prefs.setPref(FPref.UI_MATCH_CARD_PICTURE_VISIBLE, menuItem.getState()); + prefs.save(); + relayoutCardPanels(); + }); + return menuItem; + } + + private static JMenuItem getMenuItem_ShowCardDetail() { + final Localizer localizer = Localizer.getInstance(); + final JCheckBoxMenuItem menuItem = new JCheckBoxMenuItem(localizer.getMessage("lblCardDetailPanel")); + menuItem.setState(prefs.getPrefBoolean(FPref.UI_MATCH_CARD_DETAIL_VISIBLE)); + menuItem.addActionListener(e -> { + prefs.setPref(FPref.UI_MATCH_CARD_DETAIL_VISIBLE, menuItem.getState()); + prefs.save(); + relayoutCardPanels(); + }); + return menuItem; + } + + private static void relayoutCardPanels() { + final FScreen screen = Singletons.getControl().getCurrentScreen(); + if (screen != null && screen.isMatchScreen()) { + final IVTopLevelUI view = screen.getView(); + if (view instanceof VMatchUI) { + ((VMatchUI) view).relayoutCardPanels(); + } + } + } + private static JMenu getMenu_LogPane() { final Localizer localizer = Localizer.getInstance(); final JMenu menu = new JMenu(localizer.getMessage("lblLogPanel")); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index acc21fd27fa..938c6ef004b 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -156,6 +156,8 @@ public void initialize() { lstControls.add(Pair.of(view.getCbOpenPacksIndiv(), FPref.UI_OPEN_PACKS_INDIV)); lstControls.add(Pair.of(view.getCbTokensInSeparateRow(), FPref.UI_TOKENS_IN_SEPARATE_ROW)); lstControls.add(Pair.of(view.getCbStackCreatures(), FPref.UI_STACK_CREATURES)); + lstControls.add(Pair.of(view.getCbHoverTooltipsEnabled(), FPref.UI_SHOW_HOVER_TOOLTIPS)); + lstControls.add(Pair.of(view.getCbZoomTooltipsEnabled(), FPref.UI_SHOW_ZOOM_TOOLTIPS)); lstControls.add(Pair.of(view.getCbManaLostPrompt(), FPref.UI_MANA_LOST_PROMPT)); lstControls.add(Pair.of(view.getCbEscapeEndsTurn(), FPref.UI_ALLOW_ESC_TO_END_TURN)); lstControls.add(Pair.of(view.getCbDetailedPaymentDesc(), FPref.UI_DETAILED_SPELLDESC_IN_PROMPT)); @@ -187,6 +189,7 @@ public void initialize() { FModel.getMagicDb().setEnableSmartCardArtSelection(isEnabled); }); + view.getBtnReset().setCommand((UiCommand) CSubmenuPreferences.this::resetForgeSettingsToDefault); view.getBtnDeleteEditorUI().setCommand((UiCommand) CSubmenuPreferences.this::resetDeckEditorLayout); @@ -201,6 +204,9 @@ public void initialize() { view.getBtnTokenPreviewer().setCommand((UiCommand) CSubmenuPreferences.this::openTokenPreviewer); + view.getBtnCardOverlaySettings().setCommand((UiCommand) () -> + forge.screens.match.menus.CardOverlaySettingsDialog.show(null)); + view.getBtnContentDirectoryUI().setCommand((UiCommand) CSubmenuPreferences.this::openContentDirectory); view.getCbCheckSnapshot().addItemListener(e -> { Singletons.getView().getNavigationBar().setUpdaterVisibility(); @@ -253,6 +259,7 @@ public void update() { for(final Pair kv: lstControls) { kv.getKey().setSelected(prefs.getPrefBoolean(kv.getValue())); } + view.reloadShortcuts(); SwingUtilities.invokeLater(() -> view.getCbRemoveSmall().requestFocusInWindow()); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index c2068b6b785..2fb87c70120 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -60,6 +60,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final FLabel btnClearImageCache = new FLabel.Builder().opaque(true).hoverable(true).text(localizer.getMessage("btnClearImageCache")).build(); private final FLabel btnTokenPreviewer = new FLabel.Builder().opaque(true).hoverable(true).text(localizer.getMessage("btnTokenPreviewer")).build(); private final FLabel btnCustomLogSettings = new FLabel.Builder().opaque(true).hoverable(true).text(localizer.getMessage("lblCustomLogSettings")).build(); + private final FLabel btnCardOverlaySettings = new FLabel.Builder().opaque(true).hoverable(true).text(localizer.getMessage("lblCardOverlaySettings")).build(); private final FLabel btnPlayerName = new FLabel.Builder().opaque(true).hoverable(true).text("").build(); private final FLabel btnServerPort = new FLabel.Builder().opaque(true).hoverable(true).text("").build(); @@ -111,6 +112,8 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbOpenPacksIndiv = new OptionsCheckBox(localizer.getMessage("cbOpenPacksIndiv")); private final JCheckBox cbTokensInSeparateRow = new OptionsCheckBox(localizer.getMessage("cbTokensInSeparateRow")); private final JCheckBox cbStackCreatures = new OptionsCheckBox(localizer.getMessage("cbStackCreatures")); + private final JCheckBox cbHoverTooltipsEnabled = new OptionsCheckBox(localizer.getMessage("cbHoverTooltipsEnabled")); + private final JCheckBox cbZoomTooltipsEnabled = new OptionsCheckBox(localizer.getMessage("cbZoomTooltipsEnabled")); private final JCheckBox cbFilterLandsByColorId = new OptionsCheckBox(localizer.getMessage("cbFilterLandsByColorId")); private final JCheckBox cbShowStormCount = new OptionsCheckBox(localizer.getMessage("cbShowStormCount")); private final JCheckBox cbRemindOnPriority = new OptionsCheckBox(localizer.getMessage("cbRemindOnPriority")); @@ -441,6 +444,12 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbStackCreatures, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlStackCreatures")), descriptionConstraints); + pnlPrefs.add(cbHoverTooltipsEnabled, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlHoverTooltipsEnabled")), descriptionConstraints); + + pnlPrefs.add(cbZoomTooltipsEnabled, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlZoomTooltipsEnabled")), descriptionConstraints); + pnlPrefs.add(cbTimedTargOverlay, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlTimedTargOverlay")), descriptionConstraints); @@ -459,6 +468,8 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbSROptimize, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlSrOptimize")), descriptionConstraints); + pnlPrefs.add(btnCardOverlaySettings, "w 25%!, h 30px!, gap 25px 0 0 20px, span 2 1, al left"); + // Sound options pnlPrefs.add(new SectionLabel(localizer.getMessage("SoundOptions")), sectionConstraints + ", gaptop 2%"); @@ -996,6 +1007,14 @@ public final JCheckBox getCbStackCreatures() { return cbStackCreatures; } + public final JCheckBox getCbHoverTooltipsEnabled() { + return cbHoverTooltipsEnabled; + } + + public final JCheckBox getCbZoomTooltipsEnabled() { + return cbZoomTooltipsEnabled; + } + public final JCheckBox getCbManaLostPrompt() { return cbManaLostPrompt; } @@ -1056,6 +1075,7 @@ public final FLabel getBtnDeleteWorkshopUI() { public final FLabel getBtnClearImageCache() { return btnClearImageCache; } public final FLabel getBtnTokenPreviewer() { return btnTokenPreviewer; } public FLabel getBtnCustomLogSettings() { return btnCustomLogSettings; } + public FLabel getBtnCardOverlaySettings() { return btnCardOverlaySettings; } /* (non-Javadoc) * @see forge.gui.framework.IVDoc#getDocumentID() diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 59b852713b7..a95cc741a4f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -124,6 +124,7 @@ import forge.util.collect.FCollection; import forge.util.collect.FCollectionView; import forge.view.FView; +import forge.view.arcane.CardInfoPopup; import forge.view.arcane.CardPanel; import forge.view.arcane.FloatingZone; import net.miginfocom.layout.LinkHandler; @@ -818,6 +819,7 @@ public void enableOverlay() { @Override public void finishGame() { FloatingZone.closeAll(); //ensure floating card areas cleared and closed after the game + CardInfoPopup.hideActive(); if (isNetGame()) { writeMatchPreferences(); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java b/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java index b634c13cc91..c1cd21900d6 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java @@ -5,6 +5,7 @@ import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.Point; import java.awt.Rectangle; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; @@ -27,8 +28,11 @@ import forge.game.card.CardView; import forge.game.player.PlayerView; import forge.gui.MouseUtil; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; +import forge.toolbox.special.CardZoomer; +import forge.view.arcane.CardInfoPopup; import forge.toolbox.FScrollPane; import forge.toolbox.FSkin; import forge.toolbox.FSkin.SkinFont; @@ -47,6 +51,7 @@ public class GameLogPanel extends JPanel { private JLayer layer; private boolean isScrollBarVisible = false; private Consumer onCardHover; + private CardInfoPopup cardInfoPopup; public GameLogPanel() { setMyLayout(); @@ -60,6 +65,10 @@ public void setOnCardHover(final Consumer callback) { } public void reset() { + if (cardInfoPopup != null) { + cardInfoPopup.dispose(); + cardInfoPopup = null; + } scrollablePanel.removeAll(); scrollablePanel.validate(); } @@ -189,6 +198,48 @@ private JTextArea createNewLogEntryJTextArea(final String text, final boolean us return tar; } + private void showCardInfoPopupForEntry(final LogEntryTextArea entry) { + if (CardZoomer.SINGLETON_INSTANCE.isZoomerOpen()) { + return; + } + final Object cardProp = entry.getClientProperty(CARD_VIEW_KEY); + if (!(cardProp instanceof CardView) || ((CardView) cardProp).isFaceDown()) { + return; + } + final java.awt.Window ownerWindow = SwingUtilities.getWindowAncestor(this); + if (ownerWindow == null || !ownerWindow.isActive()) { + return; + } + final ForgePreferences prefs = FModel.getPreferences(); + if (!prefs.getPrefBoolean(FPref.UI_SHOW_HOVER_TOOLTIPS)) { + return; + } + final boolean showKeywords = prefs.getPrefBoolean(FPref.UI_POPUP_KEYWORD_INFO); + final boolean showRelated = prefs.getPrefBoolean(FPref.UI_POPUP_RELATED_CARDS); + final boolean showCardImage = prefs.getPrefBoolean(FPref.UI_POPUP_CARD_IMAGE); + if (!showKeywords && !showRelated && !showCardImage) { + return; + } + if (cardInfoPopup == null) { + cardInfoPopup = new CardInfoPopup(ownerWindow); + } + try { + // Use the full entry bounds (not just the inline image) so the popup + // appears outside the log entry footprint and doesn't cause flickering. + final Point entryLoc = entry.getLocationOnScreen(); + cardInfoPopup.showForCard((CardView) cardProp, entryLoc, entry.getSize(), + showKeywords, showRelated, showCardImage); + } catch (final java.awt.IllegalComponentStateException ignored) { + // Component not yet showing on screen + } + } + + private void hideCardInfoPopup() { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + } + /** A log entry with an inline miniature card image, following VStack's StackInstanceTextArea pattern. */ private final class LogEntryTextArea extends SkinnedTextArea { private static final int PADDING = 3; @@ -298,10 +349,15 @@ protected void processMouseEvent(final MouseEvent e, final JLayer doc = docId.getDoc(); + if (doc != null && doc.getParentCell() != null) { + DragCell parent = doc.getParentCell(); + parent.removeDoc(doc); + doc.setParentCell(null); + if (parent.getDocs().isEmpty()) { + SRearrangingUtil.fillGap(parent); + FView.SINGLETON_INSTANCE.removeDragCell(parent); + } else { + parent.setSelected(parent.getDocs().get(0)); + } + } + } + + public void relayoutCardPanels() { + final ForgePreferences prefs = FModel.getPreferences(); + + // Picture processed first (bottom half); detail second (top half). + relayoutCardPanel(prefs, FPref.UI_MATCH_CARD_PICTURE_VISIBLE, + EDocID.CARD_PICTURE, EDocID.CARD_DETAIL, false); + relayoutCardPanel(prefs, FPref.UI_MATCH_CARD_DETAIL_VISIBLE, + EDocID.CARD_DETAIL, EDocID.CARD_PICTURE, true); + + SResizingUtil.resizeWindow(); + SRearrangingUtil.updateBorders(); + } + + private void relayoutCardPanel(ForgePreferences prefs, FPref pref, + EDocID docId, EDocID siblingId, boolean insertAboveSibling) { + IVDoc doc = docId.getDoc(); + if (doc == null) { return; } + boolean visible = prefs.getPrefBoolean(pref); + + if (!visible && doc.getParentCell() != null) { + // Hide: remove from cell, fill gap if empty + DragCell parent = doc.getParentCell(); + parent.removeDoc(doc); + doc.setParentCell(null); + if (parent.getDocs().isEmpty()) { + SRearrangingUtil.fillGap(parent); + FView.SINGLETON_INSTANCE.removeDragCell(parent); + } else { + parent.setSelected(parent.getDocs().get(0)); + } + } else if (visible && doc.getParentCell() == null) { + // Show: split sibling's cell if visible, otherwise create + // a new column on the right side of the window. + IVDoc sibling = siblingId.getDoc(); + if (sibling != null && sibling.getParentCell() != null) { + splitCellVertically(sibling.getParentCell(), doc, + insertAboveSibling); + } else { + addDocAsRightColumn(doc); + } + } + } + + /** + * Splits an existing cell in half vertically and places the new doc + * in the top or bottom half. + */ + private void splitCellVertically(DragCell existingCell, + IVDoc newDoc, boolean newOnTop) { + final RectangleOfDouble b = existingCell.getRoughBounds(); + final double halfH = b.getH() / 2.0; + final RectangleOfDouble topBounds = new RectangleOfDouble( + b.getX(), b.getY(), b.getW(), halfH); + final RectangleOfDouble bottomBounds = new RectangleOfDouble( + b.getX(), b.getY() + halfH, b.getW(), halfH); + + final DragCell newCell = new DragCell(); + if (newOnTop) { + existingCell.setRoughBounds(bottomBounds); + newCell.setRoughBounds(topBounds); + } else { + existingCell.setRoughBounds(topBounds); + newCell.setRoughBounds(bottomBounds); + } + FView.SINGLETON_INSTANCE.addDragCell(newCell); + newCell.addDoc(newDoc); + } + + /** + * Creates a new column on the right side of the window for the given doc + * by shrinking cells that currently touch the right edge. + */ + private void addDocAsRightColumn(IVDoc doc) { + final double columnWidth = 0.2; + // Shrink cells whose right edge is at 1.0 to make room + for (final DragCell cell : FView.SINGLETON_INSTANCE.getDragCells()) { + final RectangleOfDouble b = cell.getRoughBounds(); + if (b.getX() + b.getW() > 1.0 - 0.001) { + cell.setRoughBounds(new RectangleOfDouble( + b.getX(), b.getY(), + b.getW() - columnWidth, b.getH())); + } + } + // Create new cell on the right spanning full height + final DragCell newCell = new DragCell(); + newCell.setRoughBounds(new RectangleOfDouble( + 1.0 - columnWidth, 0.0, columnWidth, 1.0)); + FView.SINGLETON_INSTANCE.addDragCell(newCell); + newCell.addDoc(doc); + } + public CMatchUI getControl() { return this.control; } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardInfoPopupMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardInfoPopupMenu.java new file mode 100644 index 00000000000..b61c934f413 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardInfoPopupMenu.java @@ -0,0 +1,137 @@ +package forge.screens.match.menus; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.event.MouseEvent; + +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.KeyStroke; +import javax.swing.SwingConstants; + +import forge.control.KeyboardShortcuts; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.FSkin; +import forge.toolbox.FSkin.SkinnedLabel; +import forge.toolbox.FSkin.SkinnedSlider; +import forge.util.Localizer; + +/** + * Submenu under the Game menu for toggling card info popup sections + * (keyword explanations and related cards) during a match. + */ +public final class CardInfoPopupMenu { + private static final ForgePreferences prefs = FModel.getPreferences(); + + public CardInfoPopupMenu() { + } + + public JMenu getMenu() { + final Localizer localizer = Localizer.getInstance(); + final JMenu menu = new JMenu(localizer.getMessage("lblCardInfoTooltips")); + + // --- Hover Tooltip section --- + menu.add(getShowToggle(localizer.getMessage("lblHoverTooltip"), + FPref.UI_SHOW_HOVER_TOOLTIPS, FPref.SHORTCUT_HOVERTOOLTIPS)); + menu.add(getCheckboxItem(localizer.getMessage("lblCardImage"), + FPref.UI_POPUP_CARD_IMAGE)); + menu.add(getCheckboxItem(localizer.getMessage("lblRelatedCards"), + FPref.UI_POPUP_RELATED_CARDS)); + menu.add(getCheckboxItem(localizer.getMessage("lblKeywordExplanations"), + FPref.UI_POPUP_KEYWORD_INFO)); + menu.add(getCheckboxItem(localizer.getMessage("lblIncludeCardOverlays"), + FPref.UI_POPUP_CARD_OVERLAYS)); + final JMenuItem overlaySettings = new JMenuItem(localizer.getMessage("lblCardOverlaySettings")); + overlaySettings.addActionListener(e -> CardOverlaySettingsDialog.show(null)); + menu.add(overlaySettings); + + menu.add(new JSeparator()); + + // --- Card Zoom View section --- + menu.add(getShowToggle(localizer.getMessage("lblCardZoomView"), + FPref.UI_SHOW_ZOOM_TOOLTIPS, FPref.SHORTCUT_ZOOMTOOLTIPS)); + menu.add(getCheckboxItem(localizer.getMessage("lblRelatedCards"), + FPref.UI_ZOOM_RELATED_CARDS)); + menu.add(getCheckboxItem(localizer.getMessage("lblKeywordExplanations"), + FPref.UI_ZOOM_KEYWORD_INFO)); + + menu.add(new JSeparator()); + menu.add(buildImageSizePanel(localizer)); + return menu; + } + + private static JCheckBoxMenuItem getShowToggle(final String label, + final FPref pref, final FPref shortcutPref) { + final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label); + final KeyStroke ks = KeyboardShortcuts.getKeyStrokeForPref(shortcutPref); + if (ks != null) { item.setAccelerator(ks); } + item.setState(prefs.getPrefBoolean(pref)); + item.addActionListener(e -> { + prefs.setPref(pref, !prefs.getPrefBoolean(pref)); + prefs.save(); + }); + return item; + } + + private static JCheckBoxMenuItem getCheckboxItem(final String label, final FPref pref) { + final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label) { + @Override + protected void processMouseEvent(final MouseEvent e) { + if (e.getID() == MouseEvent.MOUSE_RELEASED && contains(e.getPoint())) { + doClick(0); + setArmed(true); + } else { + super.processMouseEvent(e); + } + } + }; + item.setState(prefs.getPrefBoolean(pref)); + item.addActionListener(e -> { + final boolean newState = !prefs.getPrefBoolean(pref); + prefs.setPref(pref, newState); + prefs.save(); + }); + return item; + } + + private static JPanel buildImageSizePanel(final Localizer localizer) { + final Color bg = FSkin.getColor(FSkin.Colors.CLR_THEME2).getColor(); + final Color fg = FSkin.getColor(FSkin.Colors.CLR_TEXT).getColor(); + final int rawValue = prefs.getPrefInt(FPref.UI_POPUP_IMAGE_SIZE); + final int currentValue = Math.max(250, Math.min(500, rawValue)); + + final JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(bg); + + final SkinnedLabel label = new SkinnedLabel(); + label.setText(localizer.getMessage("lblImageSize")); + label.setForeground(fg); + label.setFont(FSkin.getFont()); + label.setOpaque(true); + label.setBackground(bg); + + final SkinnedSlider slider = new SkinnedSlider(SwingConstants.HORIZONTAL, 250, 500, currentValue); + slider.setMajorTickSpacing(50); + slider.setMinorTickSpacing(25); + slider.setPaintTicks(true); + slider.setPaintLabels(true); + slider.setBackground(bg); + slider.setForeground(fg); + slider.setFont(FSkin.getFont()); + + slider.addChangeListener(e -> { + slider.repaint(); + prefs.setPref(FPref.UI_POPUP_IMAGE_SIZE, String.valueOf(slider.getValue())); + prefs.save(); + }); + + panel.add(label, BorderLayout.NORTH); + panel.add(slider, BorderLayout.CENTER); + return panel; + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaySettingsDialog.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaySettingsDialog.java new file mode 100644 index 00000000000..b244c510e7a --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaySettingsDialog.java @@ -0,0 +1,137 @@ +package forge.screens.match.menus; + +import javax.swing.JPanel; + +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.FButton; +import forge.toolbox.FCheckBox; +import forge.toolbox.FSkin; +import forge.toolbox.FSkin.SkinnedLabel; +import forge.util.Localizer; +import forge.view.FDialog; +import net.miginfocom.swing.MigLayout; + +import java.awt.Color; +import java.awt.Dimension; + +@SuppressWarnings("serial") +public class CardOverlaySettingsDialog extends FDialog { + + private static final int PADDING = 10; + + private static final FPref[] FIELD_PREFS = { + FPref.UI_OVERLAY_CARD_NAME, + FPref.UI_OVERLAY_CARD_MANA_COST, + FPref.UI_OVERLAY_CARD_PERPETUAL_MANA_COST, + FPref.UI_OVERLAY_CARD_POWER, + FPref.UI_OVERLAY_CARD_ID, + FPref.UI_OVERLAY_ABILITY_ICONS, + }; + + private static final FPref[] HOVER_PREFS = { + FPref.UI_HOVER_OVERLAY_CARD_NAME, + FPref.UI_HOVER_OVERLAY_CARD_MANA_COST, + FPref.UI_HOVER_OVERLAY_CARD_PERPETUAL_MANA_COST, + FPref.UI_HOVER_OVERLAY_CARD_POWER, + FPref.UI_HOVER_OVERLAY_CARD_ID, + FPref.UI_HOVER_OVERLAY_ABILITY_ICONS, + }; + + private static final String[] LABEL_KEYS = { + "lblCardName", + "lblManaCost", + "lblPerpetualManaCost", + "lblPowerOrToughness", + "lblCardID", + "lblAbilityIcon", + }; + + public CardOverlaySettingsDialog(final Runnable onFieldChange) { + super(); + final Localizer localizer = Localizer.getInstance(); + setTitle(localizer.getMessage("lblCardOverlaySettings").replace("...", "")); + + final ForgePreferences prefs = FModel.getPreferences(); + final Color textColor = FSkin.getColor(FSkin.Colors.CLR_TEXT).getColor(); + final Color sepColor = new Color(textColor.getRed(), textColor.getGreen(), + textColor.getBlue(), 60); + + // 4 columns: label | field-cb | separator | hover-cb + final JPanel content = new JPanel(new MigLayout( + "insets " + PADDING + ", gapy 6", + "[grow, left]16[90!, center]4[1!]4[90!, center]", + "[]")); + content.setOpaque(false); + + // Column headers — use HTML for wrapping in fixed-width columns + final String headerHtml = "
%s
"; + content.add(new SkinnedLabel("")); // empty label column + final SkinnedLabel fieldHeader = new SkinnedLabel( + String.format(headerHtml, localizer.getMessage("lblCardOverlaysColumn"))); + fieldHeader.setForeground(textColor); + fieldHeader.setFont(FSkin.getBoldFont(12)); + content.add(fieldHeader, "center"); + content.add(new SkinnedLabel("")); // separator column placeholder + final SkinnedLabel hoverHeader = new SkinnedLabel( + String.format(headerHtml, localizer.getMessage("lblHoverTooltipOverlaysColumn"))); + hoverHeader.setForeground(textColor); + hoverHeader.setFont(FSkin.getBoldFont(12)); + content.add(hoverHeader, "center, wrap"); + + // Rows + for (int i = 0; i < LABEL_KEYS.length; i++) { + final FPref fieldPref = FIELD_PREFS[i]; + final FPref hoverPref = HOVER_PREFS[i]; + + final SkinnedLabel rowLabel = new SkinnedLabel( + localizer.getMessage(LABEL_KEYS[i])); + rowLabel.setForeground(textColor); + rowLabel.setFont(FSkin.getFont(14)); + content.add(rowLabel); + + final FCheckBox fieldCb = new FCheckBox(); + fieldCb.setSelected(prefs.getPrefBoolean(fieldPref)); + fieldCb.addActionListener(e -> { + prefs.setPref(fieldPref, fieldCb.isSelected()); + prefs.save(); + if (onFieldChange != null) { + onFieldChange.run(); + } + }); + content.add(fieldCb, "center"); + + // Vertical line in separator column + final JPanel line = new JPanel(); + line.setOpaque(true); + line.setBackground(sepColor); + content.add(line, "w 1!, growy"); + + final FCheckBox hoverCb = new FCheckBox(); + hoverCb.setSelected(prefs.getPrefBoolean(hoverPref)); + hoverCb.addActionListener(e -> { + prefs.setPref(hoverPref, hoverCb.isSelected()); + prefs.save(); + }); + content.add(hoverCb, "center, wrap"); + } + + // Close button + final FButton btnClose = new FButton(localizer.getMessage("lblClose")); + btnClose.setCommand(() -> setVisible(false)); + content.add(btnClose, "span, center, gaptop 10, gapbottom 6, w 140!, h 28!"); + + // Size to fit content snugly + final Dimension pref = content.getPreferredSize(); + add(content, PADDING, PADDING, pref.width, pref.height); + this.pack(); + this.setSize(pref.width + 2 * PADDING, pref.height + 2 * PADDING + getTitleBar().getHeight()); + } + + public static void show(final Runnable onFieldChange) { + final CardOverlaySettingsDialog dialog = new CardOverlaySettingsDialog(onFieldChange); + dialog.setVisible(true); + dialog.dispose(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java index ce5dc4e4d20..c189dd27f4b 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java @@ -1,8 +1,5 @@ package forge.screens.match.menus; -import java.awt.Component; -import java.awt.event.ActionListener; - import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JMenuItem; @@ -23,26 +20,16 @@ public CardOverlaysMenu(final CMatchUI matchUI) { } private static ForgePreferences prefs = FModel.getPreferences(); - private static boolean showOverlays = prefs.getPrefBoolean(FPref.UI_SHOW_CARD_OVERLAYS); public JMenu getMenu() { JMenu menu = new JMenu(Localizer.getInstance().getMessage("lblCardOverlays")); menu.add(getMenuItem_ShowOverlays()); menu.addSeparator(); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblCardName"), FPref.UI_OVERLAY_CARD_NAME)); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblManaCost"), FPref.UI_OVERLAY_CARD_MANA_COST)); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblPerpetualManaCost"), FPref.UI_OVERLAY_CARD_PERPETUAL_MANA_COST)); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblPowerOrToughness"), FPref.UI_OVERLAY_CARD_POWER)); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblCardID"), FPref.UI_OVERLAY_CARD_ID)); - menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblAbilityIcon"), FPref.UI_OVERLAY_ABILITY_ICONS)); - return menu; - } - - private JMenuItem getMenuItem_CardOverlay(String menuCaption, FPref pref) { - JCheckBoxMenuItem menu = new JCheckBoxMenuItem(menuCaption); - menu.setState(prefs.getPrefBoolean(pref)); - menu.setEnabled(showOverlays); - menu.addActionListener(getCardOverlaysAction(pref)); + JMenuItem settingsItem = new JMenuItem(Localizer.getInstance().getMessage("lblCardOverlaySettings")); + settingsItem.addActionListener(e -> + CardOverlaySettingsDialog.show(() -> + SwingUtilities.invokeLater(matchUI::repaintCardOverlays))); + menu.add(settingsItem); return menu; } @@ -51,48 +38,12 @@ private JMenuItem getMenuItem_ShowOverlays() { final KeyStroke ks = KeyboardShortcuts.getKeyStrokeForPref(FPref.SHORTCUT_CARDOVERLAYS); if (ks != null) { menu.setAccelerator(ks); } menu.setState(prefs.getPrefBoolean(FPref.UI_SHOW_CARD_OVERLAYS)); - menu.addActionListener(getShowOverlaysAction()); + menu.addActionListener(e -> { + boolean isOverlayEnabled = !prefs.getPrefBoolean(FPref.UI_SHOW_CARD_OVERLAYS); + prefs.setPref(FPref.UI_SHOW_CARD_OVERLAYS, isOverlayEnabled); + prefs.save(); + SwingUtilities.invokeLater(matchUI::repaintCardOverlays); + }); return menu; } - - private ActionListener getShowOverlaysAction() { - return e -> toggleCardOverlayDisplay((JMenuItem)e.getSource()); - } - - private void toggleCardOverlayDisplay(JMenuItem showMenu) { - toggleShowOverlaySetting(); - repaintCardOverlays(); - // Enable/disable overlay menu items based on state of "Show" menu. - for (Component c : showMenu.getParent().getComponents()) { - if (c instanceof JMenuItem) { - JMenuItem m = (JMenuItem)c; - if (m != showMenu) { - m.setEnabled(prefs.getPrefBoolean(FPref.UI_SHOW_CARD_OVERLAYS)); - } - } - } - } - - private static void toggleShowOverlaySetting() { - boolean isOverlayEnabled = !prefs.getPrefBoolean(FPref.UI_SHOW_CARD_OVERLAYS); - prefs.setPref(FPref.UI_SHOW_CARD_OVERLAYS, isOverlayEnabled); - prefs.save(); - } - - private ActionListener getCardOverlaysAction(final FPref overlaySetting) { - return e -> { - toggleOverlaySetting(overlaySetting); - repaintCardOverlays(); - }; - } - - private static void toggleOverlaySetting(FPref overlaySetting) { - boolean isOverlayEnabled = !prefs.getPrefBoolean(overlaySetting); - prefs.setPref(overlaySetting, isOverlayEnabled); - prefs.save(); - } - - private void repaintCardOverlays() { - SwingUtilities.invokeLater(matchUI::repaintCardOverlays); - } } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 5a3c2ffa77c..4d0050f5886 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -50,6 +50,7 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); + menu.add(new CardInfoPopupMenu().getMenu()); menu.add(getMenuItem_AutoYields()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java index 34443a344f4..6fd1b8e0969 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java @@ -42,14 +42,19 @@ import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; import forge.gui.framework.IVDoc; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.screens.match.controllers.CDock.ArcState; import forge.screens.match.controllers.CStack; import forge.toolbox.FMouseAdapter; import forge.toolbox.FScrollPanel; import forge.toolbox.FSkin; import forge.toolbox.FSkin.SkinnedTextArea; +import forge.toolbox.special.CardZoomer; import forge.util.Localizer; import forge.util.collect.FCollectionView; +import forge.view.arcane.CardInfoPopup; import net.miginfocom.swing.MigLayout; /** @@ -69,6 +74,7 @@ public class VStack implements IVDoc { // Other fields private final AbilityMenu abilityMenu = new AbilityMenu(); + private CardInfoPopup cardInfoPopup; private StackInstanceTextArea hoveredItem; @@ -116,6 +122,7 @@ public void updateStack() { final GameView model = controller.getMatchUI().getGameView(); if (model == null) { + disposePopup(); return; } @@ -126,6 +133,7 @@ public void updateStack() { if (parentCell == null || !parentCell.getSelected().equals(this)) { return; } hoveredItem = null; + hideStackPopup(); scroller.removeAll(); boolean isFirst = true; @@ -204,7 +212,7 @@ public void mouseEntered(final MouseEvent e) { hoveredItem = StackInstanceTextArea.this; } controller.getMatchUI().setCard(item.getSourceCard()); - + showStackPopup(StackInstanceTextArea.this); } @Override @@ -214,8 +222,9 @@ public void mouseExited(final MouseEvent e) { hoveredItem = null; } } + hideStackPopup(); } - + @Override public void mouseClicked(final MouseEvent e) { if (controller.getMatchUI().getCDock().getArcState() == ArcState.ON) { @@ -277,6 +286,55 @@ public void paintComponent(final Graphics g) { } } + // --- Hover tooltip --- + + private void showStackPopup(final StackInstanceTextArea tar) { + if (CardZoomer.SINGLETON_INSTANCE.isZoomerOpen()) { + hideStackPopup(); + return; + } + final java.awt.Window owner = SwingUtilities.getWindowAncestor(scroller); + if (owner == null || !owner.isActive()) { + hideStackPopup(); + return; + } + final ForgePreferences prefs = FModel.getPreferences(); + if (!prefs.getPrefBoolean(FPref.UI_SHOW_HOVER_TOOLTIPS)) { + if (cardInfoPopup != null) { cardInfoPopup.hidePopup(); } + return; + } + final boolean showKw = prefs.getPrefBoolean(FPref.UI_POPUP_KEYWORD_INFO); + final boolean showRel = prefs.getPrefBoolean(FPref.UI_POPUP_RELATED_CARDS); + final boolean showImg = prefs.getPrefBoolean(FPref.UI_POPUP_CARD_IMAGE); + if (showKw || showRel || showImg) { + if (cardInfoPopup == null) { + cardInfoPopup = new CardInfoPopup(owner); + } + final Point screenLoc = tar.getLocationOnScreen(); + if (screenLoc == null) { + return; + } + cardInfoPopup.showForCard(tar.getItem().getSourceCard(), + screenLoc, tar.getSize(), + showKw, showRel, showImg); + } else { + hideStackPopup(); + } + } + + private void hideStackPopup() { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + } + + private void disposePopup() { + if (cardInfoPopup != null) { + cardInfoPopup.dispose(); + cardInfoPopup = null; + } + } + //========= Custom class handling private final class AbilityMenu extends JPopupMenu { diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/imaging/FCardImageRenderer.java b/forge-gui-desktop/src/main/java/forge/toolbox/imaging/FCardImageRenderer.java index 9110ea89c8e..15d2170bfd1 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/imaging/FCardImageRenderer.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/imaging/FCardImageRenderer.java @@ -286,6 +286,52 @@ public static void drawCardImage(Graphics2D g, CardView card, boolean altState, g.dispose(); } + /** + * Render a single card state with full boilerplate (border, legal text, etc.). + * Use this when you have a specific CardStateView to render rather than a CardView + * (e.g. for specialize faces that aren't the CardView's current or alternate state). + */ + public static void drawCardStateImage(Graphics2D g, CardStateView state, int width, int height, BufferedImage art, String legalString) { + if (!isInitialed) { + initialize(); + } + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + float ratio = Math.min((float)width / BASE_IMAGE_WIDTH, (float)height / BASE_IMAGE_HEIGHT); + BLACK_BORDER_THICKNESS = Math.round(10 * ratio); + g.setColor(Color.BLACK); + g.fillRect(0, 0, width, height); + + if (legalString != null) { + TEXT_COLOR = Color.LIGHT_GRAY; + int x = BLACK_BORDER_THICKNESS * 3; + int y = height - BLACK_BORDER_THICKNESS * 3; + int w = width; + boolean hasPTBox = (state.isCreature() && !state.getKeywordKey().contains("Level up")) + || state.isPlaneswalker() || state.isBattle() || state.isVehicle(); + if (hasPTBox) { + w -= PT_BOX_WIDTH + BLACK_BORDER_THICKNESS * 5; + } else { + w -= BLACK_BORDER_THICKNESS * 6; + } + int h = BLACK_BORDER_THICKNESS * 3; + drawVerticallyCenteredString(g, legalString, new Rectangle(x, y, w, h), ARTIST_FONT, ARTIST_SIZE); + } + + width -= 2 * BLACK_BORDER_THICKNESS; + height -= 2 * BLACK_BORDER_THICKNESS; + g.translate(BLACK_BORDER_THICKNESS, BLACK_BORDER_THICKNESS); + TEXT_COLOR = Color.BLACK; + CARD_ART_RATIO = 1.37f; + if (art != null && Math.abs((float)art.getWidth() / (float)art.getHeight() - CARD_ART_RATIO) > 0.1f) { + CARD_ART_RATIO = (float)art.getWidth() / (float)art.getHeight(); + } + updateAreaSizes(ratio, ratio); + drawCardStateImage(g, state, state.getOracleText(), width, height, art); + g.dispose(); + } + private static void drawCardStateImage(Graphics2D g, CardStateView state, String text, int w, int h, BufferedImage art) { int x = 0, y = 0; diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/CardZoomer.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/CardZoomer.java index 9ac05fc7654..f6616059054 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/CardZoomer.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/CardZoomer.java @@ -23,23 +23,41 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; - +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; import javax.swing.JPanel; +import javax.swing.ScrollPaneConstants; import javax.swing.Timer; +import forge.CachedCardImage; import forge.StaticData; import forge.game.card.Card; +import forge.game.card.CardView; import forge.game.card.CardView.CardStateView; import forge.game.keyword.Keyword; import forge.gui.SOverlayUtils; +import forge.gui.card.KeywordInfoUtil; +import forge.gui.card.KeywordInfoUtil.KeywordData; import forge.item.PaperCard; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; +import forge.model.FModel; import forge.toolbox.FOverlay; +import forge.toolbox.FScrollPane; import forge.toolbox.FSkin; import forge.toolbox.FSkin.SkinnedLabel; import forge.toolbox.imaging.FImagePanel; import forge.toolbox.imaging.FImagePanel.AutoSizeImageMode; import forge.toolbox.imaging.FImageUtil; +import forge.view.arcane.CardInfoPopup; +import forge.view.arcane.CardInfoPopup.RelatedCardEntry; import net.miginfocom.swing.MigLayout; /** @@ -219,12 +237,225 @@ private void setImage() { final BufferedImage xlhqImage = FImageUtil.getImageXlhq(thisCard); imagePanel.setImage(xlhqImage == null ? FImageUtil.getImage(thisCard) : xlhqImage, getInitialRotation(), AutoSizeImageMode.SOURCE); + final boolean zoomEnabled = FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ZOOM_TOOLTIPS); + final boolean showKeywords = zoomEnabled + && FModel.getPreferences().getPrefBoolean(FPref.UI_ZOOM_KEYWORD_INFO); + final boolean showRelated = zoomEnabled + && FModel.getPreferences().getPrefBoolean(FPref.UI_ZOOM_RELATED_CARDS); + pnlMain.removeAll(); - pnlMain.add(imagePanel, "w 80%!, h 80%!"); + final boolean isFaceDown = thisCard != null && thisCard.getCard() != null + && thisCard.getCard().isFaceDown(); + if ((showKeywords || showRelated) && thisCard != null && !isFaceDown) { + final JPanel sidePanel = buildSidePanel(showKeywords, showRelated); + if (sidePanel != null) { + pnlMain.setLayout(new MigLayout("insets 0, ax center, ay center")); + pnlMain.add(imagePanel, "w 45%!, h 80%!"); + pnlMain.add(sidePanel, sidePanel.getClientProperty("widthConstraint") + ", h 80%!"); + } else { + pnlMain.add(imagePanel, "w 80%!, h 80%!"); + } + } else { + pnlMain.add(imagePanel, "w 80%!, h 80%!"); + } pnlMain.validate(); setFlipIndicator(); } + /** + * Build the side panel for the zoom view. Keywords go in a fixed header, + * related cards go in a scrollable area below. + */ + private JPanel buildSidePanel(final boolean showKeywords, final boolean showRelated) { + // Check for related card entries first to determine column width + List relatedEntries = List.of(); + if (showRelated) { + final String cardName = thisCard.getName(); + final CardView cardView = thisCard.getCard(); + if (cardName != null && !cardName.isEmpty() && cardView != null) { + relatedEntries = CardInfoPopup.buildRelatedCardsStatic(cardName, cardView); + for (final RelatedCardEntry entry : relatedEntries) { + if ((entry.image == null || entry.placeholder) + && entry.imageKey != null && !entry.imageKey.isEmpty()) { + CachedCardImage.fetcher.fetchImage(entry.imageKey, () -> { }); + } + } + } + } + final boolean hasRelated = !relatedEntries.isEmpty(); + + final int screenWidth = overlay.getWidth() > 0 + ? overlay.getWidth() + : java.awt.Toolkit.getDefaultToolkit().getScreenSize().width; + // Padding: scrollbar (~12px) + left content padding (8px) + slack (2px) + final int sidePadding = 22; + + // Compute column width based on actual content + int columnPx; + int maxWidth; + int thumbnailHeight = 0; + + if (hasRelated) { + final int maxColumnPx = Math.max(200, (int) (screenWidth * 0.40)); + final int rawSize = FModel.getPreferences().getPrefInt(FPref.UI_POPUP_IMAGE_SIZE); + final int popupThumbHeight = Math.max(100, Math.min(500, rawSize)); + + // Use user's preferred size; populateRelatedCards constrains + // to fit the column width when needed + thumbnailHeight = popupThumbHeight; + + int naturalContentWidth = 0; + final Map> grouped = new LinkedHashMap<>(); + for (final RelatedCardEntry entry : relatedEntries) { + grouped.computeIfAbsent(entry.label, k -> new ArrayList<>()).add(entry); + } + for (final List cards : grouped.values()) { + final int perRow = Math.min(cards.size(), 2); + final int thumbWidth = (int) (thumbnailHeight * 0.716); + final int groupWidth = perRow * thumbWidth + 18; + naturalContentWidth = Math.max(naturalContentWidth, groupWidth); + } + columnPx = Math.max(200, + Math.min(naturalContentWidth + sidePadding, maxColumnPx)); + maxWidth = columnPx - sidePadding; + } else { + columnPx = Math.max(200, (int) (screenWidth * 0.25)); + maxWidth = columnPx - sidePadding; + } + + // Build keyword panel + JPanel kwPanel = null; + if (showKeywords) { + final String keywordKey = thisCard.getKeywordKey(); + final String oracleText = thisCard.getOracleText(); + final String cardName = thisCard.getName(); + final List keywords = new ArrayList<>(); + final Set addedNames = new LinkedHashSet<>(); + if (keywordKey != null && !keywordKey.isEmpty()) { + keywords.addAll(KeywordInfoUtil.buildKeywords(keywordKey, addedNames)); + } + KeywordInfoUtil.addMissingKeywordsFromFlags(keywords, thisCard, addedNames); + if (oracleText != null && !oracleText.isEmpty()) { + KeywordInfoUtil.addKeywordActions(keywords, oracleText, addedNames, + cardName != null ? cardName : ""); + } + KeywordInfoUtil.sortByOracleText(keywords, oracleText); + KeywordInfoUtil.annotateKeywordCounts(keywords, thisCard.getCard()); + KeywordInfoUtil.addGraveyardCounts(keywords, thisCard.getCard()); + if (!keywords.isEmpty()) { + kwPanel = new JPanel(); + kwPanel.setLayout(new BoxLayout(kwPanel, BoxLayout.Y_AXIS)); + kwPanel.setOpaque(false); + kwPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 0)); + // Cap keyword width to 1-card pill width when related cards shown + final int kwWidth; + if (hasRelated) { + final int thumbW = (int) (thumbnailHeight * 0.716); + kwWidth = Math.min(thumbW + 18, maxWidth); + } else { + kwWidth = maxWidth; + } + CardInfoPopup.populateKeywords(kwPanel, keywords, kwWidth); + } + } + + if (kwPanel == null && relatedEntries.isEmpty()) { + return null; + } + + // Assemble: keywords and related cards in a single scrollable panel + final JPanel side = new JPanel(new java.awt.BorderLayout()); + side.setOpaque(false); + side.putClientProperty("widthConstraint", "w " + columnPx + "!"); + + // Build a single content panel with keywords on top and related cards below + final JPanel content = new JPanel(); + content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS)); + content.setOpaque(false); + + if (kwPanel != null) { + content.add(kwPanel); + } + + if (!relatedEntries.isEmpty()) { + final JPanel relPanel = new JPanel(); + relPanel.setLayout(new BoxLayout(relPanel, BoxLayout.Y_AXIS)); + relPanel.setOpaque(false); + relPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 0)); + CardInfoPopup.populateRelatedCards(relPanel, relatedEntries, + thumbnailHeight, maxWidth, 2, true, null); + content.add(relPanel); + } + + // Wrap entire content in a scroll pane so everything scrolls together + final JPanel wrapper = new JPanel(new java.awt.BorderLayout()); + wrapper.setOpaque(false); + wrapper.add(content, java.awt.BorderLayout.NORTH); + final FScrollPane scroll = new FScrollPane(wrapper, false, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + side.add(scroll, java.awt.BorderLayout.CENTER); + + // Click anywhere on the side panel dismisses the zoomer + // Use mouseClicked (not mouseReleased) so scroll wheel release doesn't dismiss + final MouseAdapter dismissClick = new MouseAdapter() { + @Override public void mouseClicked(final MouseEvent e) { + closeZoomer(); + } + }; + addMouseListenerRecursive(side, dismissClick); + + // Increase scroll speed for comfortable browsing + scroll.getVerticalScrollBar().setUnitIncrement(48); + + // Replace scroll pane's default wheel behavior: + // scroll-down dismisses unless scrollbar can still scroll further + for (final java.awt.event.MouseWheelListener l : scroll.getMouseWheelListeners()) { + scroll.removeMouseWheelListener(l); + } + scroll.addMouseWheelListener(e -> { + if (isButtonMode || !isMouseWheelEnabled) { + e.consume(); + return; + } + isMouseWheelEnabled = false; + if (e.getWheelRotation() > 0) { + final javax.swing.JScrollBar vbar = scroll.getVerticalScrollBar(); + if (vbar.isVisible() + && vbar.getValue() + vbar.getVisibleAmount() < vbar.getMaximum()) { + vbar.setValue(vbar.getValue() + + e.getUnitsToScroll() * vbar.getUnitIncrement(1)); + startMouseWheelCoolDownTimer(250); + } else { + closeZoomer(); + } + } else { + final javax.swing.JScrollBar vbar = scroll.getVerticalScrollBar(); + if (vbar.isVisible() && vbar.getValue() > 0) { + vbar.setValue(vbar.getValue() + + e.getUnitsToScroll() * vbar.getUnitIncrement(1)); + startMouseWheelCoolDownTimer(250); + } else { + toggleCardImage(); + startMouseWheelCoolDownTimer(250); + } + } + e.consume(); + }); + + return side; + } + + private static void addMouseListenerRecursive(final java.awt.Component comp, + final MouseAdapter listener) { + comp.addMouseListener(listener); + if (comp instanceof java.awt.Container) { + for (final java.awt.Component child : ((java.awt.Container) comp).getComponents()) { + addMouseListenerRecursive(child, listener); + } + } + } + private int getInitialRotation() { if (thisCard == null) { return 0; @@ -263,6 +494,13 @@ public void closeZoomer() { lastClosedTime = System.currentTimeMillis(); } + /** Rebuild the zoom view content (e.g. after toggling tooltip preferences). */ + public void refreshIfOpen() { + if (isOpen) { + setImage(); + } + } + /** * If the zoomer is ativated using the mouse wheel then ignore * wheel for a short period of time after opening. This will diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardInfoPopup.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardInfoPopup.java new file mode 100644 index 00000000000..796105e4bb2 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardInfoPopup.java @@ -0,0 +1,1861 @@ +package forge.view.arcane; + +import java.awt.AlphaComposite; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Window; +import java.awt.Graphics2D; +import java.awt.event.WindowEvent; +import java.awt.event.WindowFocusListener; +import java.awt.font.TextAttribute; +import java.awt.font.TextLayout; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.text.AttributedString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +import org.apache.commons.lang3.tuple.Pair; + +import forge.CachedCardImage; +import forge.ImageCache; +import forge.ImageKeys; +import forge.StaticData; +import forge.card.CardRules; +import forge.card.CardSplitType; +import forge.card.CardStateName; +import forge.card.ICardFace; +import forge.card.mana.ManaCost; +import forge.game.card.Card; +import forge.game.card.CardView; +import forge.game.card.CardView.CardStateView; +import forge.game.card.CounterType; +import forge.game.zone.ZoneType; +import forge.gui.card.KeywordInfoUtil; +import forge.gui.card.KeywordInfoUtil.KeywordData; +import forge.item.PaperCard; +import forge.item.PaperToken; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.CardFaceSymbols; +import forge.toolbox.FSkin; +import forge.toolbox.imaging.FImagePanel; +import forge.toolbox.imaging.FImageUtil; +import forge.util.CardTranslation; + +/** + * A floating popup that displays keyword explanations and related card images + * when hovering over a card during a match. + */ +public class CardInfoPopup { + private static final int POPUP_WIDTH = 260; + private static final int GAP = 4; + private static final int SHOW_DELAY_MS = 100; + private static final int PADDING = 8; + private static final double MTG_ASPECT_RATIO = 0.716; + + // Dark overlay colors + private static final Color BG_COLOR = new Color(30, 30, 30); + private static final Color BORDER_COLOR = new Color(80, 80, 80); + private static final Color PILL_BG = new Color(45, 45, 45); + private static final Color PILL_BORDER = new Color(70, 70, 70); + private static final Color TEXT_PRIMARY = Color.WHITE; + private static final Color TEXT_SECONDARY = new Color(210, 210, 210); + private static final int PILL_CORNER = 12; + private static final int PILL_PAD = 8; + + // Ensures only one popup is visible across all CardPanelContainer instances + private static CardInfoPopup activePopup; + + private final Window owner; + private final JWindow window; + private final JPanel mainPanel; + private final JPanel contentPanel; + private final FImagePanel cardImagePanel; + private final JPanel keywordsPanel; + private final JPanel relatedCardsPanel; + private final Timer showTimer; + + // Cache + private String cachedKeywordKey = ""; + private String cachedCardName = ""; + private boolean cachedHasKeywords = false; + private boolean cachedHasRelated = false; + private int cachedImageSize = -1; + private boolean cachedShowCardImage = false; + private boolean cachedShowOverlays = false; + private String cachedOverlayState = ""; + private String cachedCardImageKey = ""; + private boolean cachedDisableImages = false; + + // Pending show state + private Point pendingLocation; + + // Saved parameters for auto-download callback re-invocation + private CardView lastCardView; + private Point lastCardScreenLocation; + private Dimension lastCardSize; + private boolean lastShowKeywords; + private boolean lastShowRelatedCards; + private boolean lastShowCardImage; + + public CardInfoPopup(final Window owner) { + this.owner = owner; + window = new JWindow(owner); + window.setFocusableWindowState(false); + window.setAlwaysOnTop(true); + window.setBackground(new Color(0, 0, 0, 0)); // fully transparent window + + // Card image panel (WEST side) + cardImagePanel = new FImagePanel(); + cardImagePanel.setOpaque(false); + cardImagePanel.setVisible(false); + + // Content panel (CENTER side — keywords on top, related cards below) + contentPanel = new JPanel(new BorderLayout(0, GAP)); + contentPanel.setOpaque(false); + + keywordsPanel = new JPanel(); + keywordsPanel.setLayout(new BoxLayout(keywordsPanel, BoxLayout.Y_AXIS)); + keywordsPanel.setOpaque(false); + + relatedCardsPanel = new JPanel(); + relatedCardsPanel.setLayout(new BoxLayout(relatedCardsPanel, BoxLayout.Y_AXIS)); + relatedCardsPanel.setOpaque(false); + + // Keywords fixed at top, related cards fill remaining space below + contentPanel.add(keywordsPanel, BorderLayout.NORTH); + contentPanel.add(relatedCardsPanel, BorderLayout.CENTER); + + // Wrap contentPanel so it top-aligns instead of stretching to card image height + final JPanel contentWrapper = new JPanel(new BorderLayout()); + contentWrapper.setOpaque(false); + contentWrapper.add(contentPanel, BorderLayout.NORTH); + + // Main panel wraps card image + content with spacing (transparent background) + mainPanel = new JPanel(new BorderLayout(GAP, 0)); + mainPanel.setOpaque(false); + mainPanel.setBorder(BorderFactory.createEmptyBorder( + PADDING, PADDING, PADDING, PADDING)); + mainPanel.add(cardImagePanel, BorderLayout.WEST); + mainPanel.add(contentWrapper, BorderLayout.CENTER); + + window.getContentPane().setLayout(new BorderLayout()); + ((JPanel) window.getContentPane()).setOpaque(false); + window.getContentPane().add(mainPanel, BorderLayout.CENTER); + + showTimer = new Timer(SHOW_DELAY_MS, e -> { + if (pendingLocation != null && isOwnerFocused()) { + if (activePopup != null && activePopup != this) { + activePopup.hidePopup(); + } + activePopup = this; + window.setLocation(pendingLocation); + window.setVisible(true); + } + }); + showTimer.setRepeats(false); + + // Hide popup when owner window loses focus (e.g. ALT-TAB) + if (owner != null) { + owner.addWindowFocusListener(new WindowFocusListener() { + @Override + public void windowGainedFocus(final WindowEvent e) { } + + @Override + public void windowLostFocus(final WindowEvent e) { + hidePopup(); + } + }); + } + } + + private static int getThumbnailHeight() { + final int value = FModel.getPreferences().getPrefInt(FPref.UI_POPUP_IMAGE_SIZE); + return Math.max(250, Math.min(500, value)); + } + + /** + * Show the popup for the given card, displaying keyword explanations and/or + * related card images based on the toggle flags. + */ + public void showForCard(final CardView cardView, final Point cardScreenLocation, + final Dimension cardSize, final boolean showKeywords, + final boolean showRelatedCards, final boolean showCardImage) { + if (cardView == null || cardView.isFaceDown()) { + hidePopup(); + return; + } + + final CardStateView state = cardView.getCurrentState(); + if (state == null) { + hidePopup(); + return; + } + + // Save parameters for auto-download callback + lastCardView = cardView; + lastCardScreenLocation = cardScreenLocation; + lastCardSize = cardSize; + lastShowKeywords = showKeywords; + lastShowRelatedCards = showRelatedCards; + lastShowCardImage = showCardImage; + + final int thumbnailHeight = getThumbnailHeight(); + final String keywordKey = showKeywords ? nullSafe(state.getKeywordKey()) : ""; + final String cardName = showRelatedCards ? nullSafe(state.getName()) : ""; + final String cardImageKey = showCardImage ? nullSafe(state.getImageKey()) : ""; + final boolean showOverlays = showCardImage + && FModel.getPreferences().getPrefBoolean(FPref.UI_POPUP_CARD_OVERLAYS); + final boolean disableImages = FModel.getPreferences().getPrefBoolean( + FPref.UI_DISABLE_CARD_IMAGES); + // Build a lightweight state key from dynamic overlay properties so the + // cache is only invalidated when actual overlay content changes, not on + // every mouse move event. + final String overlayState = showOverlays + ? buildOverlayStateKey(cardView, state) : ""; + + // Check cache — skip rebuilding if same content and settings + if (keywordKey.equals(cachedKeywordKey) && cardName.equals(cachedCardName) + && thumbnailHeight == cachedImageSize + && showCardImage == cachedShowCardImage + && showOverlays == cachedShowOverlays + && overlayState.equals(cachedOverlayState) + && cardImageKey.equals(cachedCardImageKey) + && disableImages == cachedDisableImages) { + if (!cachedHasKeywords && !cachedHasRelated && !showCardImage) { + hidePopup(); + return; + } + positionAndShow(cardScreenLocation, cardSize); + return; + } + + // Rebuild content + boolean hasKeywords = false; + boolean hasRelated = false; + + // --- Card image section --- + if (showCardImage) { + BufferedImage cardImg = FImageUtil.getImage(state); + if (cardImg != null && !ImageCache.isDefaultImage(cardImg)) { + if (showOverlays) { + cardImg = paintOverlaysOnImage(cardImg, cardView); + } + cardImagePanel.setImage(cardImg); + final int imgWidth = (int) (thumbnailHeight * MTG_ASPECT_RATIO); + final Dimension imgSize = new Dimension(imgWidth, thumbnailHeight); + cardImagePanel.setPreferredSize(imgSize); + cardImagePanel.setMinimumSize(imgSize); + cardImagePanel.setMaximumSize(imgSize); + cardImagePanel.setVisible(true); + } else { + cardImagePanel.setVisible(false); + // Trigger auto-download for the selected card image + fetchIfMissing(state.getImageKey()); + } + } else { + cardImagePanel.setVisible(false); + } + + // --- Keyword section --- + List keywordList = List.of(); + if (showKeywords) { + keywordList = new ArrayList<>(); + final Set addedNames = new LinkedHashSet<>(); + if (!keywordKey.isEmpty()) { + keywordList.addAll(KeywordInfoUtil.buildKeywords(keywordKey, addedNames)); + } + // Catch granted abilities that the keywordKey parsing may have missed + KeywordInfoUtil.addMissingKeywordsFromFlags(keywordList, state, addedNames); + // Scan oracle text for keyword actions (goad, scry, etc.) + final String oracleText = nullSafe(state.getOracleText()); + if (!oracleText.isEmpty()) { + KeywordInfoUtil.addKeywordActions(keywordList, oracleText, addedNames, + nullSafe(state.getName())); + } + // Sort by card text order + KeywordInfoUtil.sortByOracleText(keywordList, oracleText); + // Append dynamic count annotations (e.g. devotion, domain) + KeywordInfoUtil.annotateKeywordCounts(keywordList, cardView); + KeywordInfoUtil.addGraveyardCounts(keywordList, cardView); + hasKeywords = !keywordList.isEmpty(); + } + + // --- Related cards section --- + List relatedEntries = List.of(); + if (showRelatedCards && !cardName.isEmpty()) { + relatedEntries = buildRelatedCards(cardName, cardView); + hasRelated = !relatedEntries.isEmpty(); + } + + // Nothing to show + if (!hasKeywords && !hasRelated && !cardImagePanel.isVisible()) { + cachedKeywordKey = keywordKey; + cachedCardName = cardName; + cachedHasKeywords = false; + cachedHasRelated = false; + cachedImageSize = thumbnailHeight; + cachedShowCardImage = showCardImage; + cachedShowOverlays = showOverlays; + cachedOverlayState = overlayState; + cachedCardImageKey = cardImageKey; + cachedDisableImages = disableImages; + hidePopup(); + return; + } + + // Hide content panel if only card image is shown (no keywords, no related) + contentPanel.setVisible(hasKeywords || hasRelated); + + // Calculate max content width based on available space within owner window + final Rectangle ownerBounds = getOwnerBounds(); + final int rightSpace = ownerBounds.x + ownerBounds.width + - cardScreenLocation.x - cardSize.width - GAP; + final int leftSpace = cardScreenLocation.x - ownerBounds.x - GAP; + final int maxPopupWidth = Math.max(rightSpace, leftSpace); + final int cardImgWidth = cardImagePanel.isVisible() + ? cardImagePanel.getPreferredSize().width : 0; + int maxContentWidth = maxPopupWidth - cardImgWidth - 2 * PADDING - 2 - GAP; + maxContentWidth = Math.max(maxContentWidth, POPUP_WIDTH); + + // Compute natural width for related cards based on thumbnail count + int relatedWidth = 0; + int effectiveThumbHeight = thumbnailHeight; + int numGroups = 0; + if (hasRelated) { + final int thumbWidth = (int) (thumbnailHeight * MTG_ASPECT_RATIO); + final int onePillWidth = thumbWidth + 2 * PILL_PAD + 2; + final Set groups = new LinkedHashSet<>(); + for (final RelatedCardEntry e : relatedEntries) { + groups.add(e.label); + } + numGroups = groups.size(); + if (numGroups > 1) { + // Multi-group: 2 pills side-by-side per row + final int pillsInRow = Math.min(numGroups, 2); + relatedWidth = pillsInRow * onePillWidth + + (pillsInRow - 1) * GAP; + } else { + // Single group: up to HOVER_MAX_CARDS thumbnails + final int perRow = Math.min(relatedEntries.size(), + HOVER_MAX_CARDS); + relatedWidth = perRow * thumbWidth + 2 * PILL_PAD + 2; + } + } + // Content width: use natural related-cards width, capped by available space + final int contentWidth = Math.max(POPUP_WIDTH, + Math.min(relatedWidth, maxContentWidth)); + // Keywords wrap at 1-pill width when related cards are present + final int keywordWidth; + if (hasRelated) { + final int thumbWidth = (int) (thumbnailHeight * MTG_ASPECT_RATIO); + final int onePillWidth = thumbWidth + 2 * PILL_PAD + 2; + keywordWidth = Math.min(onePillWidth, contentWidth); + } else { + keywordWidth = contentWidth; + } + + // Update keywords + keywordsPanel.setVisible(hasKeywords); + keywordsPanel.removeAll(); + if (hasKeywords) { + populateKeywords(keywordsPanel, keywordList, keywordWidth); + } + + // Calculate available height for related cards: fit within owner bounds + relatedCardsPanel.setVisible(hasRelated); + relatedCardsPanel.removeAll(); + if (hasRelated) { + // Measure keyword panel height so we can reserve space for it + int kwHeight = 0; + if (hasKeywords) { + keywordsPanel.setPreferredSize(null); + kwHeight = keywordsPanel.getPreferredSize().height + GAP; + } + final int maxPopupHeight = ownerBounds.height - 2 * PADDING; + final int availableHeight = maxPopupHeight - kwHeight; + // Multi-group: pairs of pills per row; single group: 1 row + final int displayRows = numGroups > 1 + ? (int) Math.ceil(numGroups / 2.0) : 1; + // Each row: ~28px overhead (label + spacing), plus inter-row gaps + final int overhead = displayRows * 28 + (displayRows - 1) * 4 + 20; + final int availableForThumbs = availableHeight - overhead; + effectiveThumbHeight = Math.min(effectiveThumbHeight, + Math.max(80, availableForThumbs / displayRows)); + populateRelatedCards(relatedCardsPanel, relatedEntries, + effectiveThumbHeight, contentWidth); + } + + // Update cache + cachedKeywordKey = keywordKey; + cachedCardName = cardName; + cachedHasKeywords = hasKeywords; + cachedHasRelated = hasRelated; + cachedImageSize = thumbnailHeight; + cachedShowCardImage = showCardImage; + cachedShowOverlays = showOverlays; + cachedOverlayState = overlayState; + cachedCardImageKey = cardImageKey; + cachedDisableImages = disableImages; + + // Defer pack/show to let layout complete + final Point loc = cardScreenLocation; + final Dimension size = cardSize; + final int finalMaxPopup = Math.max(maxPopupWidth, POPUP_WIDTH); + final int maxHeight = ownerBounds.height; + SwingUtilities.invokeLater(() -> { + keywordsPanel.setPreferredSize(null); + window.pack(); + if (window.getWidth() > finalMaxPopup) { + window.setSize(finalMaxPopup, window.getHeight()); + } + if (window.getHeight() > maxHeight) { + window.setSize(window.getWidth(), maxHeight); + } + positionAndShow(loc, size); + }); + } + + public void hidePopup() { + showTimer.stop(); + window.setVisible(false); + if (activePopup == this) { + activePopup = null; + } + } + + /** Hide whichever popup is currently showing (if any). */ + public static void hideActive() { + if (activePopup != null) { + activePopup.hidePopup(); + } + } + + /** Release the popup window and its resources. */ + public void dispose() { + hidePopup(); + window.dispose(); + } + + // --- Auto-download --- + + private void fetchIfMissing(final String imageKey) { + if (imageKey == null || imageKey.isEmpty()) { + return; + } + CachedCardImage.fetcher.fetchImage(imageKey, () -> { + // Invalidate cache and re-show with the downloaded image + cachedCardImageKey = ""; + cachedCardName = ""; + if (lastCardView != null) { + showForCard(lastCardView, lastCardScreenLocation, lastCardSize, + lastShowKeywords, lastShowRelatedCards, lastShowCardImage); + } + }); + } + + // --- Keyword display --- + + /** Creates a JLabel that uses grayscale AA (LCD AA breaks on transparent windows). */ + public static javax.swing.JLabel createAALabel(final String text) { + return new javax.swing.JLabel(text) { + @Override + protected void paintComponent(final java.awt.Graphics g) { + ((Graphics2D) g).setRenderingHint( + java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, + java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + super.paintComponent(g); + } + }; + } + + public static JPanel createPillPanel() { + final JPanel pill = new JPanel() { + @Override + protected void paintComponent(final java.awt.Graphics g) { + final Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, + java.awt.RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(PILL_BG); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), + PILL_CORNER, PILL_CORNER); + g2.setColor(PILL_BORDER); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, + PILL_CORNER, PILL_CORNER); + g2.dispose(); + } + }; + pill.setLayout(new BoxLayout(pill, BoxLayout.Y_AXIS)); + pill.setBorder(BorderFactory.createEmptyBorder( + PILL_PAD, PILL_PAD, PILL_PAD, PILL_PAD)); + pill.setOpaque(false); + pill.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + return pill; + } + + public static void populateKeywords(final JPanel panel, + final List keywords, + final int maxWidth) { + final FSkin.SkinFont boldFont = FSkin.getBoldFont(12); + final FSkin.SkinFont reminderFont = FSkin.getFont(12); + final int textWidth = maxWidth - 2 * PILL_PAD - 2; // account for pill border+pad + + for (int i = 0; i < keywords.size(); i++) { + if (i > 0) { + panel.add(javax.swing.Box.createRigidArea(new Dimension(0, 4))); + } + final KeywordData kw = keywords.get(i); + + final JPanel pill = createPillPanel(); + + // Keyword name label (encode mana symbols for Swing HTML) + final javax.swing.JLabel nameLabel = createAALabel( + FSkin.encodeSymbols(kw.name, false)); + nameLabel.setFont(boldFont.getBaseFont()); + nameLabel.setForeground(TEXT_PRIMARY); + nameLabel.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + // Use the HTML View to compute wrapped height at constrained width + final javax.swing.text.View nameView = (javax.swing.text.View) + nameLabel.getClientProperty( + javax.swing.plaf.basic.BasicHTML.propertyKey); + int nameHeight; + if (nameView != null) { + nameView.setSize(textWidth, 0); + nameHeight = (int) Math.ceil( + nameView.getPreferredSpan(javax.swing.text.View.Y_AXIS)); + } else { + nameHeight = nameLabel.getPreferredSize().height; + } + nameLabel.setPreferredSize(new Dimension(textWidth, nameHeight)); + nameLabel.setMaximumSize(new Dimension(textWidth, nameHeight)); + pill.add(nameLabel); + + // Reminder text (encode mana symbols for Swing HTML) + if (!kw.reminderText.isEmpty()) { + final javax.swing.JLabel reminderLabel = createAALabel( + FSkin.encodeSymbols(kw.reminderText, false)); + reminderLabel.setFont(reminderFont.getBaseFont()); + reminderLabel.setForeground(TEXT_SECONDARY); + reminderLabel.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + // Use the HTML View to compute wrapped height at constrained width + final javax.swing.text.View view = (javax.swing.text.View) + reminderLabel.getClientProperty( + javax.swing.plaf.basic.BasicHTML.propertyKey); + int prefHeight; + if (view != null) { + view.setSize(textWidth, 0); + prefHeight = (int) Math.ceil( + view.getPreferredSpan(javax.swing.text.View.Y_AXIS)); + } else { + prefHeight = reminderLabel.getPreferredSize().height; + } + reminderLabel.setPreferredSize(new Dimension(textWidth, prefHeight)); + reminderLabel.setMaximumSize(new Dimension(textWidth, prefHeight)); + pill.add(reminderLabel); + } + + pill.setMaximumSize(new Dimension(maxWidth, Integer.MAX_VALUE)); + panel.add(pill); + } + } + + // --- Related cards building --- + + /** + * Build related card entries (static, no auto-download side effects). + * Callers that need auto-download should iterate entries and call + * fetchIfMissing for those with null/default images. + */ + public static List buildRelatedCardsStatic(final String cardName, + final CardView cardView) { + final List entries = new ArrayList<>(); + + try { + final StaticData data = StaticData.instance(); + if (data == null) { + return entries; + } + final CardRules rules = data.getCommonCards().getRules(cardName); + if (rules == null) { + return entries; + } + + final CardSplitType splitType = rules.getSplitType(); + + // Tokens — for multi-face cards, only show if the current face + // creates them (the token list is shared across all faces) + boolean showTokens = true; + if (splitType != null && splitType != CardSplitType.None) { + final CardStateView curState = cardView.getCurrentState(); + final String oracle = curState != null + ? curState.getOracleText() : null; + showTokens = oracle != null + && oracle.toLowerCase().contains("token"); + } + if (showTokens) { + final List tokenNames = rules.getTokens(); + if (tokenNames != null && !tokenNames.isEmpty()) { + for (final String tokenName : tokenNames) { + final PaperToken pt = data.getAllTokens().getToken( + tokenName); + if (pt != null) { + final CardView tokenView = + Card.getCardForUi(pt).getView(); + final String imageKey = pt.getCardImageKey(); + final Pair info = + ImageCache.getCardOriginalImageInfo( + imageKey, true, tokenView); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Creates", + pt.getName(), img, imageKey, + info.getRight())); + } + } + } + } + } + // Amass tokens — not declared via TokenScript$, detect from oracle text + addAmassTokenEntry(entries, cardView, data); + + if (splitType != null) { + switch (splitType) { + case Transform: + addOtherFaceEntry(entries, cardView, "Transforms Into"); + break; + case Modal: + addOtherFaceEntry(entries, cardView, "Other Face"); + break; + case Meld: + addNamedCardEntry(entries, rules.getMeldWith(), "Meld", data); + addOtherFaceEntry(entries, cardView, "Meld"); + break; + case Flip: + addFlipFaceEntry(entries, cardView); + break; + case Specialize: + addSpecializeFaces(entries, rules, cardName, data); + break; + default: + // Split, Adventure, None — skip + break; + } + } + + // Partner with + addNamedCardEntry(entries, rules.getPartnerWith(), "Partner", data); + + // Spellbook entries + addSpellbookEntries(entries, rules, data); + + // ChooseFromList entries (e.g. Garth One-Eye) + addChooseFromListEntries(entries, rules, data); + + // The Ring emblem for cards with "the Ring tempts you" + final CardStateView curState = cardView.getCurrentState(); + final String oracle = curState != null ? curState.getOracleText() : null; + if (oracle != null && oracle.toLowerCase().contains("the ring tempts you")) { + addRingEmblemEntry(entries, data); + } + + } catch (Exception e) { + // Guard against any lookup failures + } + + return entries; + } + + private List buildRelatedCards(final String cardName, + final CardView cardView) { + final List entries = buildRelatedCardsStatic(cardName, cardView); + // Trigger auto-download for entries with null/default images + for (final RelatedCardEntry entry : entries) { + if (entry.image == null || entry.placeholder) { + fetchIfMissing(entry.imageKey); + } + } + return entries; + } + + private static void addOtherFaceEntry(final List entries, + final CardView cardView, final String label) { + final CardStateView altState = cardView.getAlternateState(); + if (altState == null) { + return; + } + final Pair info = ImageCache.getCardOriginalImageInfo( + altState.getImageKey(), true, altState.getCard()); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry(label, altState.getName(), + img, altState.getImageKey(), info.getRight())); + } + } + + private static void addFlipFaceEntry(final List entries, + final CardView cardView) { + final CardStateView altState = cardView.getAlternateState(); + if (altState == null) { + return; + } + final Pair info = ImageCache.getCardOriginalImageInfo( + altState.getImageKey(), true, altState.getCard()); + final BufferedImage img = info.getLeft(); + if (img != null) { + final BufferedImage displayImg = info.getRight() + ? img : rotateImage180(img); + entries.add(new RelatedCardEntry("Flips Into", altState.getName(), + displayImg, altState.getImageKey(), info.getRight())); + } + } + + private static BufferedImage rotateImage180(final BufferedImage src) { + final int w = src.getWidth(); + final int h = src.getHeight(); + final BufferedImage rotated = new BufferedImage(w, h, src.getType()); + final Graphics2D g = rotated.createGraphics(); + g.rotate(Math.PI, w / 2.0, h / 2.0); + g.drawImage(src, 0, 0, null); + g.dispose(); + return rotated; + } + + private static void addSpecializeFaces(final List entries, + final CardRules rules, final String cardName, + final StaticData data) { + final Map specParts = rules.getSpecializeParts(); + if (specParts == null || specParts.isEmpty()) { + return; + } + final PaperCard baseCard = data.getCommonCards().getCard(cardName); + if (baseCard == null) { + return; + } + // Use c:-prefixed keys with $wspec/$uspec/etc. postfixes (same format as + // CardFactory) so ImageCache can resolve specialize faces for both file + // lookup and Forge renderer fallback + final String baseKey = baseCard.getImageKey(false); + for (final Map.Entry entry : specParts.entrySet()) { + try { + final String imageKey = specImageKey(baseKey, entry.getKey()); + if (imageKey == null) { + continue; + } + final Pair info = + ImageCache.getCardOriginalImageInfo( + imageKey, true); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Specializes Into", + entry.getValue().getName(), img, imageKey, + info.getRight())); + } + } catch (Exception e) { + // Skip faces that can't be resolved + } + } + } + + private static String specImageKey(final String baseKey, + final CardStateName state) { + final String name = state.name(); + if (!name.startsWith("Specialize") || name.length() != 11) return null; + return baseKey + "$" + Character.toLowerCase(name.charAt(10)) + "spec"; + } + + private static void addSpellbookEntries(final List entries, + final CardRules rules, final StaticData data) { + final Set spellbookNames = extractSpellbookNames(rules.getMainPart()); + if (spellbookNames.isEmpty()) { + return; + } + for (final String rawName : spellbookNames) { + // Spellbook$ uses ";" instead of "," for card names that contain commas + final String cardName = rawName.replace(";", ",").trim(); + try { + final PaperCard pc = data.getCommonCards().getCard(cardName); + if (pc != null) { + final String imageKey = pc.getImageKey(false); + final Pair info = + ImageCache.getCardOriginalImageInfo(imageKey, true); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Spellbook", cardName, + img, imageKey, info.getRight())); + } + } + } catch (Exception e) { + // Skip cards that can't be resolved + } + } + } + + private static void addNamedCardEntry(final List entries, + final String name, final String label, + final StaticData data) { + if (name == null || name.isEmpty()) { + return; + } + try { + final PaperCard pc = data.getCommonCards().getCard(name); + if (pc != null) { + final String imageKey = pc.getImageKey(false); + final Pair info = + ImageCache.getCardOriginalImageInfo(imageKey, true); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry(label, name, img, imageKey, + info.getRight())); + } + } + } catch (Exception e) { + // Skip cards that can't be resolved + } + } + + private static Set extractSpellbookNames(final ICardFace face) { + final Set names = new LinkedHashSet<>(); + // Search abilities + if (face.getAbilities() != null) { + for (final String ability : face.getAbilities()) { + extractSpellbookFromLine(ability, names); + } + } + // Search triggers (some reference SVars containing Spellbook$) + if (face.getTriggers() != null) { + for (final String trigger : face.getTriggers()) { + extractSpellbookFromLine(trigger, names); + } + } + // Search SVars (e.g. SVar:TrigDraft:DB$ Draft | Spellbook$ ...) + if (face.getVariables() != null) { + for (final Entry svar : face.getVariables()) { + extractSpellbookFromLine(svar.getValue(), names); + } + } + return names; + } + + private static void extractSpellbookFromLine(final String line, final Set names) { + if (line == null || !line.contains("Spellbook$")) { + return; + } + final int start = line.indexOf("Spellbook$") + "Spellbook$".length(); + // Find end: next " |" delimiter or end of string + int end = line.indexOf(" | ", start); + if (end < 0) { + end = line.length(); + } + final String value = line.substring(start, end).trim(); + if (!value.isEmpty()) { + for (final String name : value.split(",")) { + final String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + } + } + + private static void addChooseFromListEntries(final List entries, + final CardRules rules, + final StaticData data) { + final Set names = extractChooseFromListNames(rules.getMainPart()); + if (names.isEmpty()) { + return; + } + for (final String cardName : names) { + try { + final PaperCard pc = data.getCommonCards().getCard(cardName); + if (pc != null) { + final String imageKey = pc.getImageKey(false); + final Pair info = + ImageCache.getCardOriginalImageInfo(imageKey, true); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Related", cardName, + img, imageKey, info.getRight())); + } + } + } catch (Exception e) { + // Skip cards that can't be resolved + } + } + } + + private static Set extractChooseFromListNames(final ICardFace face) { + final Set names = new LinkedHashSet<>(); + if (face.getAbilities() != null) { + for (final String ability : face.getAbilities()) { + extractChooseFromListLine(ability, names); + } + } + if (face.getTriggers() != null) { + for (final String trigger : face.getTriggers()) { + extractChooseFromListLine(trigger, names); + } + } + if (face.getVariables() != null) { + for (final Entry svar : face.getVariables()) { + extractChooseFromListLine(svar.getValue(), names); + } + } + return names; + } + + private static void extractChooseFromListLine(final String line, + final Set names) { + if (line == null || !line.contains("ChooseFromList$")) { + return; + } + final int start = line.indexOf("ChooseFromList$") + + "ChooseFromList$".length(); + int end = line.indexOf(" | ", start); + if (end < 0) { + end = line.length(); + } + final String value = line.substring(start, end).trim(); + if (!value.isEmpty()) { + for (final String name : value.split(",")) { + final String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + } + } + + private static final String RING_ORACLE_TEXT = + "1: Your Ring-bearer is legendary and can't be blocked by creatures " + + "with greater power.\n" + + "2: Whenever your Ring-bearer attacks, draw a card, then discard a card.\n" + + "3: Whenever your Ring-bearer becomes blocked by a creature, that " + + "creature's controller sacrifices it at end of combat.\n" + + "4: Whenever your Ring-bearer deals combat damage to a player, each " + + "opponent loses 3 life."; + + private static void addRingEmblemEntry(final List entries, + final StaticData data) { + final String ringKey = data.getOtherImageKey( + ImageKeys.THE_RING_IMAGE, null); + // Build a lightweight CardView with oracle text so the renderer + // can paint a proper fallback. Pure view-layer, no Game needed. + final Card ringCard = new Card(-1, null, null); + ringCard.setName("The Ring"); + ringCard.setOracleText(RING_ORACLE_TEXT); + final CardView ringView = ringCard.getView(); + final Pair info = + ImageCache.getCardOriginalImageInfo(ringKey, true, ringView); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Emblem", + "The Ring", img, ringKey, info.getRight())); + } + } + + private static final java.util.regex.Pattern AMASS_PATTERN = + java.util.regex.Pattern.compile("amass (\\w+)", java.util.regex.Pattern.CASE_INSENSITIVE); + + private static void addAmassTokenEntry(final List entries, + final CardView cardView, + final StaticData data) { + final CardStateView curState = cardView.getCurrentState(); + final String oracle = curState != null ? curState.getOracleText() : null; + if (oracle == null) { + return; + } + final java.util.regex.Matcher m = AMASS_PATTERN.matcher(oracle); + if (!m.find()) { + return; + } + // e.g. "Zombies" → "zombie", "Orcs" → "orc" + String type = m.group(1).toLowerCase(); + if (type.endsWith("s")) { + type = type.substring(0, type.length() - 1); + } + final String tokenName = "b_0_0_" + type + "_army"; + final PaperToken pt = data.getAllTokens().getToken(tokenName); + if (pt == null) { + return; + } + final CardView tokenView = Card.getCardForUi(pt).getView(); + final String imageKey = pt.getCardImageKey(); + final Pair info = + ImageCache.getCardOriginalImageInfo(imageKey, true, tokenView); + final BufferedImage img = info.getLeft(); + if (img != null) { + entries.add(new RelatedCardEntry("Creates", + pt.getName(), img, imageKey, info.getRight())); + } + } + + private static final int FULL_SIZE_MAX = 3; + private static final int HOVER_MAX_CARDS = 2; + + public static void populateRelatedCards(final JPanel targetPanel, + final List entries, + final int thumbnailHeight, + final int maxContentWidth) { + // Hover tooltip: cap per group, with per-group overflow labels + final LinkedHashMap> grouped = new LinkedHashMap<>(); + for (final RelatedCardEntry entry : entries) { + grouped.computeIfAbsent(entry.label, k -> new ArrayList<>()).add(entry); + } + final List visible = new ArrayList<>(); + final Map overflowByGroup = new LinkedHashMap<>(); + if (grouped.size() > 1) { + // Multi-group: show 1 card per group, overflow the rest + for (final Map.Entry> group + : grouped.entrySet()) { + final List cards = group.getValue(); + visible.add(cards.get(0)); + if (cards.size() > 1) { + overflowByGroup.put(group.getKey(), cards.size() - 1); + } + } + populateRelatedCards(targetPanel, visible, thumbnailHeight, + maxContentWidth, 1, false, + overflowByGroup.isEmpty() ? null : overflowByGroup); + } else { + // Single group: up to HOVER_MAX_CARDS per group + for (final Map.Entry> group + : grouped.entrySet()) { + final List cards = group.getValue(); + if (cards.size() > HOVER_MAX_CARDS) { + visible.addAll(cards.subList(0, HOVER_MAX_CARDS)); + overflowByGroup.put(group.getKey(), + cards.size() - HOVER_MAX_CARDS); + } else { + visible.addAll(cards); + } + } + populateRelatedCards(targetPanel, visible, thumbnailHeight, + maxContentWidth, HOVER_MAX_CARDS, false, + overflowByGroup.isEmpty() ? null : overflowByGroup); + } + } + + public static void populateRelatedCards(final JPanel targetPanel, + final List entries, + final int thumbnailHeight, + final int maxContentWidth, + final int maxPerRow, + final boolean alwaysFullSize, + final Map overflowCounts) { + // Group entries by label + final LinkedHashMap> grouped = new LinkedHashMap<>(); + for (final RelatedCardEntry entry : entries) { + grouped.computeIfAbsent(entry.label, k -> new ArrayList<>()).add(entry); + } + + final FSkin.SkinFont boldFont = FSkin.getBoldFont(12); + boolean firstGroup = true; + + // Multi-group hover: lay pills side-by-side, 2 per row + final boolean compactRow = grouped.size() > 1 && !alwaysFullSize; + final int pillsPerRow = compactRow ? Math.min(grouped.size(), 2) : 1; + final int pillMaxWidth = compactRow + ? (maxContentWidth - (pillsPerRow - 1) * GAP) / pillsPerRow + : maxContentWidth; + + JPanel rowPanel = null; + int pillsInCurrentRow = 0; + + for (final Map.Entry> group : grouped.entrySet()) { + final List cards = group.getValue(); + + if (compactRow) { + // Start a new row every pillsPerRow pills + if (pillsInCurrentRow == 0) { + if (!firstGroup) { + targetPanel.add(javax.swing.Box.createRigidArea( + new Dimension(0, 4))); + } + rowPanel = new JPanel(); + rowPanel.setLayout(new BoxLayout(rowPanel, BoxLayout.X_AXIS)); + rowPanel.setOpaque(false); + rowPanel.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + targetPanel.add(rowPanel); + } else { + rowPanel.add(javax.swing.Box.createRigidArea( + new Dimension(GAP, 0))); + } + pillsInCurrentRow++; + if (pillsInCurrentRow >= pillsPerRow) { + pillsInCurrentRow = 0; + } + } else if (!firstGroup) { + targetPanel.add(javax.swing.Box.createRigidArea( + new Dimension(0, 4))); + } + firstGroup = false; + + // Wrap each section group in a pill + final JPanel pill = createPillPanel(); + + // Section label — include total count when truncated or overflowing + final int overflowForLabel = overflowCounts != null + && overflowCounts.containsKey(group.getKey()) + ? overflowCounts.get(group.getKey()) : 0; + final int totalInGroup = cards.size() + overflowForLabel; + final String labelText = group.getKey() + + (totalInGroup > maxPerRow + ? " (" + totalInGroup + ")" : ""); + final javax.swing.JLabel sectionLabel = createAALabel(labelText); + sectionLabel.setFont(boldFont.getBaseFont()); + sectionLabel.setForeground(TEXT_PRIMARY); + sectionLabel.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + sectionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE, + sectionLabel.getPreferredSize().height)); + pill.add(sectionLabel); + pill.add(javax.swing.Box.createRigidArea(new Dimension(0, 4))); + + // Full size when group is small or forced by caller (zoom view) + final boolean fullSize = alwaysFullSize + || cards.size() <= FULL_SIZE_MAX; + final int perRow = fullSize + ? Math.min(cards.size(), Math.min(FULL_SIZE_MAX, maxPerRow)) + : maxPerRow; + final int pillInnerWidth = pillMaxWidth - 2 * PILL_PAD - 2; + int effectiveHeight = fullSize + ? thumbnailHeight : Math.max(80, thumbnailHeight / 2); + + // Constrain thumbnail size so cards fit within pill inner width + final int maxThumbWidth = pillInnerWidth / perRow; + final int maxHeightForWidth = (int) (maxThumbWidth / MTG_ASPECT_RATIO); + effectiveHeight = Math.min(effectiveHeight, maxHeightForWidth); + + // Build rows manually (FlowLayout doesn't wrap with pack()) + JPanel currentRow = null; + int colIndex = 0; + int thumbWidth = (int) (effectiveHeight * MTG_ASPECT_RATIO); + int thumbHeight = effectiveHeight; + if (thumbWidth > maxThumbWidth) { + thumbWidth = maxThumbWidth; + thumbHeight = (int) (thumbWidth / MTG_ASPECT_RATIO); + } + final Dimension thumbSize = new Dimension(thumbWidth, thumbHeight); + + for (int ci = 0; ci < cards.size(); ci++) { + if (colIndex == 0) { + currentRow = new JPanel(); + currentRow.setLayout(new BoxLayout(currentRow, BoxLayout.X_AXIS)); + currentRow.setOpaque(false); + currentRow.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + + // Center-justify incomplete last row + final int remaining = cards.size() - ci; + if (remaining < perRow) { + currentRow.add(javax.swing.Box.createHorizontalGlue()); + } + + pill.add(currentRow); + } + final FImagePanel imgPanel = new FImagePanel(); + imgPanel.setImage(cards.get(ci).image); + imgPanel.setPreferredSize(thumbSize); + imgPanel.setMinimumSize(thumbSize); + imgPanel.setMaximumSize(thumbSize); + currentRow.add(imgPanel); + colIndex++; + if (colIndex >= perRow) { + colIndex = 0; + } + } + // Close trailing glue for centered last row + if (colIndex > 0 && colIndex < perRow) { + currentRow.add(javax.swing.Box.createHorizontalGlue()); + } + + // Add overflow label inside pill if applicable + if (overflowCounts != null + && overflowCounts.containsKey(group.getKey())) { + final int overflow = overflowCounts.get(group.getKey()); + final javax.swing.JLabel overflowLabel = createAALabel( + "+" + overflow + " more (zoom for full list)."); + overflowLabel.setForeground(TEXT_SECONDARY); + overflowLabel.setFont(FSkin.getBoldFont(11).getBaseFont()); + overflowLabel.setAlignmentX(java.awt.Component.LEFT_ALIGNMENT); + overflowLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE, + overflowLabel.getPreferredSize().height)); + overflowLabel.setBorder(javax.swing.BorderFactory.createEmptyBorder( + 4, 0, 0, 0)); + pill.add(overflowLabel); + } + + final int actualPillWidth = perRow * thumbWidth + 2 * PILL_PAD + 2; + pill.setMaximumSize(new Dimension(actualPillWidth, Integer.MAX_VALUE)); + if (compactRow) { + rowPanel.add(pill); + } else { + targetPanel.add(pill); + } + } + } + + // --- Card overlay state key --- + + /** Build a lightweight key capturing dynamic overlay state so the cache + * can detect when a rebuild is actually needed. */ + private static String buildOverlayStateKey(final CardView cv, + final CardStateView state) { + final StringBuilder sb = new StringBuilder(64); + sb.append(state.getPower()).append('/').append(state.getToughness()); + sb.append(',').append(state.getLoyalty()); + sb.append(',').append(cv.getDamage()); + sb.append(',').append(cv.isAttacking() ? 'A' : cv.isBlocking() ? 'B' : '-'); + sb.append(',').append(cv.isSick() ? 'S' : '-'); + sb.append(',').append(cv.isPhasedOut() ? 'P' : '-'); + final Map counters = cv.getCounters(); + if (counters != null && !counters.isEmpty()) { + sb.append(','); + for (final Map.Entry e : counters.entrySet()) { + sb.append(e.getKey().getName()).append('=').append(e.getValue()).append(';'); + } + } + // Append hover overlay pref state + final ForgePreferences prefs = FModel.getPreferences(); + sb.append(','); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_NAME) ? '1' : '0'); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_POWER) ? '1' : '0'); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_MANA_COST) ? '1' : '0'); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_PERPETUAL_MANA_COST) ? '1' : '0'); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_ID) ? '1' : '0'); + sb.append(prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_ABILITY_ICONS) ? '1' : '0'); + return sb.toString(); + } + + // --- Card overlay painting --- + + /** + * Composite the same overlays that CardPanel draws (P/T, name, ability + * icons, counters, combat status) directly onto a copy of the card image. + */ + static BufferedImage paintOverlaysOnImage(final BufferedImage src, + final CardView cardView) { + final ForgePreferences prefs = FModel.getPreferences(); + final CardStateView state = cardView.getCurrentState(); + if (state == null) { + return src; + } + + final int w = src.getWidth(); + final int h = src.getHeight(); + final BufferedImage copy = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + final Graphics2D g = copy.createGraphics(); + g.drawImage(src, 0, 0, null); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + // Scale factor: slightly larger than CardPanel's h/340 since + // tooltip thumbnails are viewed at a larger effective size + final float scale = h / 270f; + final Font baseFont = new Font("Dialog", Font.BOLD, + Math.max(9, Math.round(13 * scale))); + final Color outlineColor = Color.BLACK; + + // --- Card name --- + if (prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_NAME)) { + final String name = CardTranslation.getTranslatedName( + state.getName()); + if (name != null && !name.isEmpty()) { + final int titleX = Math.round(w * (24f / 480)); + final int titleY = Math.round(h * (52f / 640)); + drawOutlinedText(g, name, baseFont, Color.WHITE, + outlineColor, titleX, titleY); + } + } + + // --- P/T, loyalty, vehicle --- + if (prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_POWER)) { + String pt = ""; + if (state.isCreature() && state.isPlaneswalker()) { + pt = state.getPower() + "/" + state.getToughness() + + " (" + state.getLoyalty() + ")"; + } else if (state.isCreature()) { + pt = state.getPower() + "/" + state.getToughness(); + } else if (state.isVehicle()) { + pt = "[" + state.getPower() + "/" + state.getToughness() + "]"; + } else if (state.isPlaneswalker()) { + pt = state.getLoyalty(); + } + if (!pt.isEmpty()) { + final Font ptFont = baseFont.deriveFont( + baseFont.getSize2D() * 1.1f); + final FontMetrics fm = g.getFontMetrics(ptFont); + final int ptX = Math.round(w * (410f / 480)) + - fm.stringWidth(pt) / 2; + final int ptY = Math.round(h * (650f / 680)); + drawOutlinedText(g, pt, ptFont, Color.WHITE, + outlineColor, ptX, ptY); + + // Damage marker above P/T + final int damage = cardView.getDamage(); + if (damage > 0) { + final String dmgStr = "\u00BB " + damage + " \u00AB"; + final int dmgX = Math.round(w * (410f / 480)) + - fm.stringWidth(dmgStr) / 2; + final int dmgY = ptY - Math.round(16 * scale); + drawOutlinedText(g, dmgStr, ptFont, Color.RED, + outlineColor, dmgX, dmgY); + } + } + } + + // --- Card ID --- + if (prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_ID)) { + final String id = state.getDisplayId(); + if (id != null && !id.isEmpty()) { + final Font idFont = baseFont.deriveFont(Font.PLAIN, + baseFont.getSize2D() * 0.85f); + final int idX = Math.round(w * (24f / 480)); + final int idY = Math.round(h * (650f / 680)); + drawOutlinedText(g, id, idFont, new Color(200, 200, 200), + outlineColor, idX, idY); + } + } + + // --- Mana cost --- + if (prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_CARD_MANA_COST)) { + final boolean perpetual = prefs.getPrefBoolean( + FPref.UI_HOVER_OVERLAY_CARD_PERPETUAL_MANA_COST); + final ManaCost cost = perpetual + ? state.getManaCost() : state.getOriginalManaCost(); + if (cost != null && !cost.isNoCost()) { + final int symSize = Math.max(10, Math.round(16 * scale)); + final int manaWidth = cost.getGlyphCount() * (symSize + 1); + final int manaX = (w / 2) - (manaWidth / 2); + final int manaY = h / 2 - symSize / 2; + CardFaceSymbols.draw(g, cost, manaX, manaY, symSize); + } + } + + // --- Ability icons (battlefield only) --- + if (prefs.getPrefBoolean(FPref.UI_HOVER_OVERLAY_ABILITY_ICONS) + && ZoneType.Battlefield.equals(cardView.getZone())) { + final int abiScale = w / 7; + final int abiX = w / 2 + w / 3; + final int abiSpace = w / 7; + int abiY = w < 200 ? 25 : 50; + abiY = Math.round(abiY * scale); + + abiY = drawAbilityIcons(g, state, cardView, + abiX, abiY, abiScale, abiSpace); + } + + // --- Combat status icons --- + if (ZoneType.Battlefield.equals(cardView.getZone())) { + final int symSize = Math.max(16, Math.round(32 * scale)); + final int combatX = w / 4 - symSize / 2; + final int stateX = w / 2 - symSize / 2; + final int ySymbols = h - h / 8 - symSize / 2; + + if (cardView.isAttacking()) { + CardFaceSymbols.drawAbilitySymbol("attack", g, + combatX, ySymbols, symSize, symSize); + } else if (cardView.isBlocking()) { + CardFaceSymbols.drawAbilitySymbol("defend", g, + combatX, ySymbols, symSize, symSize); + } + if (cardView.isSick()) { + CardFaceSymbols.drawAbilitySymbol("summonsick", g, + stateX, ySymbols, symSize, symSize); + } + if (cardView.isPhasedOut()) { + CardFaceSymbols.drawAbilitySymbol("phasing", g, + stateX, ySymbols, symSize, symSize); + } + } + + // --- Counters --- + final Map counters = cardView.getCounters(); + if (counters != null && !counters.isEmpty()) { + drawCounterOverlay(g, counters, w, h, scale); + } + + g.dispose(); + return copy; + } + + /** Draw ability icons on the right side of the card, returning updated Y. */ + private static int drawAbilityIcons(final Graphics2D g, + final CardStateView state, + final CardView cardView, + final int abiX, int abiY, + final int abiScale, + final int abiSpace) { + if (cardView.isCommander()) { + CardFaceSymbols.drawAbilitySymbol("commander", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (cardView.isRingBearer()) { + CardFaceSymbols.drawAbilitySymbol("ringbearer", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasFlying()) { + CardFaceSymbols.drawAbilitySymbol("flying", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasHaste()) { + CardFaceSymbols.drawAbilitySymbol("haste", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasDoubleStrike()) { + CardFaceSymbols.drawAbilitySymbol("doublestrike", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (state.hasFirstStrike()) { + CardFaceSymbols.drawAbilitySymbol("firststrike", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasAnnihilator()) { + CardFaceSymbols.drawAbilitySymbol("annihilator", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasExalted()) { + CardFaceSymbols.drawAbilitySymbol("exalted", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasDeathtouch()) { + CardFaceSymbols.drawAbilitySymbol("deathtouch", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasToxic()) { + CardFaceSymbols.drawAbilitySymbol("toxic", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasIndestructible()) { + CardFaceSymbols.drawAbilitySymbol("indestructible", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasMenace()) { + CardFaceSymbols.drawAbilitySymbol("menace", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasFear()) { + CardFaceSymbols.drawAbilitySymbol("fear", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasIntimidate()) { + CardFaceSymbols.drawAbilitySymbol("intimidate", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasShadow()) { + CardFaceSymbols.drawAbilitySymbol("shadow", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasHorsemanship()) { + CardFaceSymbols.drawAbilitySymbol("horsemanship", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasHexproof()) { + if (!state.getHexproofKey().isEmpty()) { + final String[] splitK = state.getHexproofKey().split(":"); + final List listHK = Arrays.asList(splitK); + if (listHK.contains("generic")) { + CardFaceSymbols.drawAbilitySymbol("hexproof", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("R")) { + CardFaceSymbols.drawAbilitySymbol("hexproofR", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("B")) { + CardFaceSymbols.drawAbilitySymbol("hexproofB", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("U")) { + CardFaceSymbols.drawAbilitySymbol("hexproofU", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("G")) { + CardFaceSymbols.drawAbilitySymbol("hexproofG", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("W")) { + CardFaceSymbols.drawAbilitySymbol("hexproofW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (listHK.contains("monocolored")) { + CardFaceSymbols.drawAbilitySymbol("hexproofC", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + } else { + CardFaceSymbols.drawAbilitySymbol("hexproof", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + } else if (state.hasShroud()) { + CardFaceSymbols.drawAbilitySymbol("shroud", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasVigilance()) { + CardFaceSymbols.drawAbilitySymbol("vigilance", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasTrample()) { + CardFaceSymbols.drawAbilitySymbol("trample", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasReach()) { + CardFaceSymbols.drawAbilitySymbol("reach", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasLifelink()) { + CardFaceSymbols.drawAbilitySymbol("lifelink", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasWard()) { + CardFaceSymbols.drawAbilitySymbol("ward", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasWither()) { + CardFaceSymbols.drawAbilitySymbol("wither", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + if (state.hasDefender()) { + CardFaceSymbols.drawAbilitySymbol("defender", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + // Protection icons + if (!state.getProtectionKey().isEmpty()) { + abiY = drawProtectionIcons(g, state.getProtectionKey(), + abiX, abiY, abiScale, abiSpace); + } + return abiY; + } + + /** Draw protection icons matching CardPanel logic. */ + private static int drawProtectionIcons(final Graphics2D g, + final String protKey, + final int abiX, int abiY, + final int abiScale, + final int abiSpace) { + if (protKey.contains("everything") || protKey.contains("allcolors")) { + CardFaceSymbols.drawAbilitySymbol("protectAll", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.contains("coloredspells")) { + CardFaceSymbols.drawAbilitySymbol("protectColoredSpells", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.equals("R")) { + CardFaceSymbols.drawAbilitySymbol("protectR", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.equals("G")) { + CardFaceSymbols.drawAbilitySymbol("protectG", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.equals("B")) { + CardFaceSymbols.drawAbilitySymbol("protectB", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.equals("U")) { + CardFaceSymbols.drawAbilitySymbol("protectU", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.equals("W")) { + CardFaceSymbols.drawAbilitySymbol("protectW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "R", "G")) { + CardFaceSymbols.drawAbilitySymbol("protectRG", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "R", "B")) { + CardFaceSymbols.drawAbilitySymbol("protectRB", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "R", "U")) { + CardFaceSymbols.drawAbilitySymbol("protectRU", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "R", "W")) { + CardFaceSymbols.drawAbilitySymbol("protectRW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "G", "B")) { + CardFaceSymbols.drawAbilitySymbol("protectGB", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "G", "U")) { + CardFaceSymbols.drawAbilitySymbol("protectGU", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "G", "W")) { + CardFaceSymbols.drawAbilitySymbol("protectGW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "B", "U")) { + CardFaceSymbols.drawAbilitySymbol("protectBU", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "B", "W")) { + CardFaceSymbols.drawAbilitySymbol("protectBW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (isProtKeyPair(protKey, "U", "W")) { + CardFaceSymbols.drawAbilitySymbol("protectUW", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } else if (protKey.contains("generic") || protKey.length() > 2) { + CardFaceSymbols.drawAbilitySymbol("protectGeneric", g, + abiX, abiY, abiScale, abiScale); + abiY += abiSpace; + } + return abiY; + } + + private static boolean isProtKeyPair(final String key, + final String a, final String b) { + return key.equals(a + b) || key.equals(b + a); + } + + /** Draw counter tabs or images onto the card overlay. */ + private static void drawCounterOverlay(final Graphics2D g, + final Map counters, + final int w, final int h, + final float scale) { + final ForgeConstants.CounterDisplayType displayType = + ForgeConstants.CounterDisplayType.from( + FModel.getPreferences().getPref( + FPref.UI_CARD_COUNTER_DISPLAY_TYPE)); + final boolean atTop = ForgeConstants.CounterDisplayLocation.from( + FModel.getPreferences().getPref( + FPref.UI_CARD_COUNTER_DISPLAY_LOCATION)) + == ForgeConstants.CounterDisplayLocation.TOP; + + // Counter image mode (simple icon) + if (displayType == ForgeConstants.CounterDisplayType.IMAGE + || displayType == ForgeConstants.CounterDisplayType.HYBRID) { + int total = 0; + for (final int c : counters.values()) { + total += c; + } + if (total > 0) { + final int symSize = Math.max(24, Math.round(32 * scale)); + final int yCounters = h - h / 3 - symSize; + final String symName; + if (total == 1) symName = "counters1"; + else if (total == 2) symName = "counters2"; + else if (total == 3) symName = "counters3"; + else symName = "countersMulti"; + CardFaceSymbols.drawAbilitySymbol(symName, g, + -Math.round(15 * scale), yCounters, symSize, symSize); + } + } + + // Counter text tabs + if (displayType == ForgeConstants.CounterDisplayType.TEXT + || displayType == ForgeConstants.CounterDisplayType.OLD_WHEN_SMALL + || displayType == ForgeConstants.CounterDisplayType.HYBRID) { + final Font smallFont = new Font("Dialog", Font.BOLD, + Math.max(8, Math.round(9 * scale))); + final Font largeFont = new Font("Dialog", Font.BOLD, + Math.max(9, Math.round(12 * scale))); + final int titleY = Math.round(h * (54f / 640)) - 15; + final int spaceFromTop = titleY + Math.round(60 * scale); + final int boxHeight = Math.round(24 * scale); + final int boxBaseWidth = Math.round(58 * scale); + final int boxSpacing = 2; + int idx = 0; + for (final Map.Entry entry : counters.entrySet()) { + final CounterType ct = entry.getKey(); + final int count = entry.getValue(); + final FontMetrics lgFm = g.getFontMetrics(largeFont); + final int boxW = boxBaseWidth + + lgFm.stringWidth(String.valueOf(count)); + final int yOff = atTop + ? spaceFromTop - boxHeight + idx * (boxHeight + boxSpacing) + : h - spaceFromTop / 2 - boxHeight + idx * (boxHeight + boxSpacing); + + g.setColor(new Color(0, 0, 0, 200)); + g.fill(new RoundRectangle2D.Float(0, yOff, boxW, boxHeight, 9, 9)); + g.fillRect(0, yOff, 9, boxHeight); + + g.setColor(new Color(ct.getRed(), ct.getGreen(), ct.getBlue(), 180)); + g.setFont(smallFont); + g.drawString(ct.getCounterOnCardDisplayName(), 8, yOff + boxHeight / 2 + g.getFontMetrics().getAscent() / 2 - 1); + g.setFont(largeFont); + g.drawString(String.valueOf(count), + Math.round(52 * scale), + yOff + boxHeight / 2 + lgFm.getAscent() / 2 - 1); + idx++; + } + } + } + + /** Draw text with a 1px outline, matching OutlinedLabel's pattern. */ + private static void drawOutlinedText(final Graphics2D g, final String text, + final Font font, final Color fg, + final Color outline, + final int x, final int y) { + if (text == null || text.isEmpty()) { + return; + } + final AttributedString as = new AttributedString(text); + as.addAttribute(TextAttribute.FONT, font); + final TextLayout layout = new TextLayout( + as.getIterator(), g.getFontRenderContext()); + + // Drop shadow: draw twice for a thicker effect + g.setColor(Color.BLACK); + g.setComposite(AlphaComposite.getInstance( + AlphaComposite.SRC_OVER, 0.6f)); + layout.draw(g, x + 2, y + 2); + layout.draw(g, x + 3, y + 3); + + // Outline: draw 4x at 1px offsets with partial alpha + g.setColor(outline); + g.setComposite(AlphaComposite.getInstance( + AlphaComposite.SRC_OVER, 0.8f)); + layout.draw(g, x + 1, y - 1); + layout.draw(g, x + 1, y + 1); + layout.draw(g, x - 1, y - 1); + layout.draw(g, x - 1, y + 1); + + // Foreground: full alpha + g.setColor(fg); + g.setComposite(AlphaComposite.getInstance( + AlphaComposite.SRC_OVER, 1.0f)); + layout.draw(g, x, y); + } + + // --- Positioning --- + + private void positionAndShow(final Point cardScreenLocation, final Dimension cardSize) { + if (!isOwnerFocused()) { + hidePopup(); + return; + } + + final int popupWidth = Math.max(window.getWidth(), POPUP_WIDTH); + final int popupHeight = window.getHeight(); + + final Rectangle ownerBounds = getOwnerBounds(); + + // Try to position to the right of the card + int x = cardScreenLocation.x + cardSize.width + GAP; + int y = cardScreenLocation.y; + + // If it extends past the right edge, position to the left + if (x + popupWidth > ownerBounds.x + ownerBounds.width) { + x = cardScreenLocation.x - popupWidth - GAP; + } + + // If it extends below the owner window, shift up + if (y + popupHeight > ownerBounds.y + ownerBounds.height) { + y = ownerBounds.y + ownerBounds.height - popupHeight; + } + + // Ensure not above the owner window + if (y < ownerBounds.y) { + y = ownerBounds.y; + } + + pendingLocation = new Point(x, y); + + if (window.isVisible()) { + // Already visible, just reposition immediately + window.setLocation(pendingLocation); + } else { + // Hide any other popup before showing this one + if (activePopup != null && activePopup != this) { + activePopup.hidePopup(); + } + activePopup = this; + // Start show delay + showTimer.restart(); + } + } + + private boolean isOwnerFocused() { + return owner != null && owner.isActive(); + } + + private Rectangle getOwnerBounds() { + if (owner != null) { + return owner.getBounds(); + } + return GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration().getBounds(); + } + + private static String nullSafe(final String s) { + return s == null ? "" : s; + } + + public static class RelatedCardEntry { + public final String label; + public final String name; + public final BufferedImage image; + public final String imageKey; + public final boolean placeholder; + + RelatedCardEntry(final String label, final String name, + final BufferedImage image, final String imageKey, + final boolean placeholder) { + this.label = label; + this.name = name; + this.image = image; + this.imageKey = imageKey; + this.placeholder = placeholder; + } + } +} diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanelContainer.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanelContainer.java index 647314afd60..b486a6400b3 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanelContainer.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanelContainer.java @@ -18,6 +18,7 @@ package forge.view.arcane; import java.awt.Dimension; +import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -29,6 +30,9 @@ import forge.game.card.CardView; import forge.gui.FThreads; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.toolbox.FScrollPane; import forge.toolbox.FSkin.SkinnedPanel; @@ -56,6 +60,7 @@ public abstract class CardPanelContainer extends SkinnedPanel { private CardPanel hoveredPanel; private CardPanel mouseDownPanel; private CardPanel mouseDragPanel; + private CardInfoPopup cardInfoPopup; private final List listeners = new ArrayList<>(2); private int mouseDragOffsetX, mouseDragOffsetY; @@ -78,12 +83,18 @@ protected final CMatchUI getMatchUI() { private void mouseWheelZoom(final CardView card) { if (canZoom(card)) { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } CardZoomer.SINGLETON_INSTANCE.setCard(card.getCurrentState(), false); CardZoomer.SINGLETON_INSTANCE.doMouseWheelZoom(); } } private void mouseButtonZoom(final CardView card) { if (canZoom(card)) { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } CardZoomer.SINGLETON_INSTANCE.setCard(card.getCurrentState(), false); CardZoomer.SINGLETON_INSTANCE.doMouseButtonZoom(); } @@ -230,6 +241,7 @@ public void mouseMoved(final MouseEvent evt) { final CardPanel hitPanel = getCardPanel(evt.getX(), evt.getY()); if (hoveredPanel == hitPanel) { // no big change + updateCardInfoPopup(hitPanel); return; } @@ -243,9 +255,8 @@ public void mouseMoved(final MouseEvent evt) { hoveredPanel = hitPanel; hoveredPanel.setSelected(true); mouseOver(hitPanel, evt); + updateCardInfoPopup(hitPanel); } - - // System.err.format("%d %d over %s%n", evt.getX(), evt.getY(), hitPanel == null ? null : hitPanel.getCard().getName()); } }; this.addMouseMotionListener(mml); @@ -256,11 +267,66 @@ private void mouseOutPanel(final MouseEvent evt) { if (this.hoveredPanel == null) { return; } + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } this.hoveredPanel.setSelected(false); this.mouseOut(this.hoveredPanel, evt); this.hoveredPanel = null; } + private void updateCardInfoPopup(final CardPanel panel) { + if (panel == null || CardZoomer.SINGLETON_INSTANCE.isZoomerOpen()) { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + return; + } + // Don't show popup if owner window is not focused + final java.awt.Window ownerWindow = SwingUtilities.getWindowAncestor(this); + if (ownerWindow == null || !ownerWindow.isActive()) { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + return; + } + final ForgePreferences prefs = FModel.getPreferences(); + if (!prefs.getPrefBoolean(FPref.UI_SHOW_HOVER_TOOLTIPS)) { + if (cardInfoPopup != null) { cardInfoPopup.hidePopup(); } + return; + } + final boolean showKeywords = prefs.getPrefBoolean(FPref.UI_POPUP_KEYWORD_INFO); + final boolean showRelated = prefs.getPrefBoolean(FPref.UI_POPUP_RELATED_CARDS); + final boolean showCardImage = prefs.getPrefBoolean(FPref.UI_POPUP_CARD_IMAGE); + if (showKeywords || showRelated || showCardImage) { + if (cardInfoPopup == null) { + cardInfoPopup = new CardInfoPopup( + SwingUtilities.getWindowAncestor(this)); + } + // When tapped, use component bounds (which encompass the rotated visual) + // instead of logical card bounds to prevent popup overlapping the card + final Point screenLoc; + final Dimension cardSize; + if (panel.isTapped()) { + screenLoc = panel.getLocationOnScreen(); + cardSize = panel.getSize(); + } else { + screenLoc = panel.getCardLocationOnScreen(); + cardSize = new Dimension(panel.getCardWidth(), panel.getCardHeight()); + } + cardInfoPopup.showForCard(panel.getCard(), screenLoc, + cardSize, showKeywords, showRelated, showCardImage); + } else if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + } + + public void hideCardInfoPopup() { + if (cardInfoPopup != null) { + cardInfoPopup.hidePopup(); + } + } + protected abstract CardPanel getCardPanel(int x, int y); public CardPanel addCard(final CardView card) { @@ -337,6 +403,10 @@ public final void clear() { } public final void clear(final boolean repaint) { FThreads.assertExecutedByEdt(true); + if (cardInfoPopup != null) { + cardInfoPopup.dispose(); + cardInfoPopup = null; + } for (final CardPanel p : getCardPanels()) { p.dispose(); } diff --git a/forge-gui/res/editions/The Lord of the Rings Tales of Middle-earth.txt b/forge-gui/res/editions/The Lord of the Rings Tales of Middle-earth.txt index 56f94a7a92d..99e39f37390 100644 --- a/forge-gui/res/editions/The Lord of the Rings Tales of Middle-earth.txt +++ b/forge-gui/res/editions/The Lord of the Rings Tales of Middle-earth.txt @@ -880,4 +880,4 @@ A-246 M A-The One Ring @Veli Nyström 12 c_a_treasure_sac @Valera Lutfullina [other] -13 the_ring @Viko Menezes \ No newline at end of file +H13 the_ring @Viko Menezes \ No newline at end of file diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index eebff573a9b..8811770950a 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -136,6 +136,8 @@ cbCardTextHideReminder=Hide Reminder Text for Card Text Renderer cbOpenPacksIndiv=Open Packs Individually cbTokensInSeparateRow=Display Tokens in a Separate Row cbStackCreatures=Stack Creatures +cbHoverTooltipsEnabled=Enable card information tooltips on hover +cbZoomTooltipsEnabled=Enable card information tooltips in zoom view cbFilterLandsByColorId=Filter Lands by Color in Activated Abilities cbShowStormCount=Show Storm Count in Prompt Pane cbRemindOnPriority=Visually Alert on Receipt of Priority @@ -243,6 +245,8 @@ nlCardTextHideReminder=When render card images, skip rendering reminder text. nlOpenPacksIndiv=When opening Fat Packs and Booster Boxes, booster packs will be opened and displayed one at a time. nlTokensInSeparateRow=Displays tokens in a separate row on the battlefield below the non-token creatures. nlStackCreatures=Stacks identical creatures on the battlefield like lands, artifacts, and enchantments. +nlHoverTooltipsEnabled=Shows keyword explanations, related cards, and card images when hovering over cards during a match. Display options can be configured through the Forge > Game > Card Info Tooltips menu. +nlZoomTooltipsEnabled=Shows keyword explanations and related cards alongside the card image in zoom view. Display options can be configured through the Forge > Game > Card Info Tooltips menu. nlTimedTargOverlay=Enables throttling-based optimization of targeting overlay to reduce CPU use (only disable if you experience choppiness on older hardware, requires starting a new match). nlCounterDisplayType=Selects the style of the in-game counter display for cards. Text-based is a new tab-like display on the cards. Image-based is the old counter image. Hybrid displays both at once. nlCounterDisplayLocation=Determines where to position the text-based counters on the card: close to the top or close to the bottom. @@ -393,6 +397,8 @@ lblFieldPanelsSplit=Split lblFieldPanelsTabbedTooltip=Multiple players in the same row share a panel and appear as tabs. lblFieldPanelsSplitTooltip=Each player gets their own visible panel, shown side by side. lblPanelTabs=Panel Tabs +lblCardPicturePanel=Card Picture Panel +lblCardDetailPanel=Card Detail Panel lblSaveCurrentLayout=Save Current Layout lblRefresh=Refresh lblSetWindowSize=Set Window Size @@ -492,6 +498,8 @@ lblSHORTCUT_CARD_ZOOM=Match: zoom the currently selected card lblSHORTCUT_SHOWHOTKEYS=Match: show keyboard shortcuts lblSHORTCUT_PANELTABS=Match: toggle panel tabs lblSHORTCUT_CARDOVERLAYS=Match: toggle card overlays +lblSHORTCUT_HOVERTOOLTIPS=Match: toggle hover tooltips +lblSHORTCUT_ZOOMTOOLTIPS=Match: toggle zoom view tooltips lblKeyboardShortcuts=Keyboard Shortcuts lblMenuShortcuts=Menu Shortcuts (Not Configurable) #VSubmenuDraft.java @@ -1105,6 +1113,18 @@ lblBattlefieldTextureFiltering=Battlefield Texture Filtering lblCompactListItems=Compact List Items lblCompactTabs=Compact Tabs lblCardOverlays=Card Overlays +lblCardOverlaySettings=Card Overlay Settings... +lblCardOverlaysColumn=Card Overlays +lblHoverTooltipOverlaysColumn=Hover Tooltip Overlays +lblHoverTooltipOverlays=Hover Tooltip Overlays +lblIncludeCardOverlays=Show Overlays on Card Image +lblCardInfoTooltips=Card Info Tooltips +lblHoverTooltip=Show Hover Tooltips +lblCardZoomView=Show Card Zoom Tooltips +lblKeywordExplanations=Keyword Explanations +lblRelatedCards=Related Cards +lblCardImage=Card Image +lblImageSize=Card Image Size lblCJKFont=CJK Font lblDisableCardEffect=Disable Card ''Effect'' Images lblDynamicBackgroundPlanechase=Dynamic Background Planechase @@ -3373,4 +3393,164 @@ lblDataMigrationMsg=Data Migration completed!\nPlease check your Inventory and E #AdventureDeckEditor.java lblRemoveUnsupportedCard=Remove unsupported card lblRemoveAllUnsupportedCards=Unsupported cards have been removed from your inventory. -lbldisableCrackedItems=Disable the possibility of your items breaking after losing a boss fight. \ No newline at end of file +lbldisableCrackedItems=Disable the possibility of your items breaking after losing a boss fight. + +#KeywordAction.java — keyword action display names and reminder text +lblKwActionActivate=Activate +lblKwActionActivateReminder=Put an activated ability on the stack. +lblKwActionAttach=Attach +lblKwActionAttachReminder=Move an Aura, Equipment, or Fortification onto another object or player. +lblKwActionBehold=Behold +lblKwActionBeholdReminder=Reveal a card with the required quality from your hand, or choose a permanent you control with that quality. +lblKwActionCast=Cast +lblKwActionCastReminder=Put a spell on the stack. +lblKwActionCounter=Counter +lblKwActionCounterReminder=Remove a spell or ability from the stack. It doesn''t resolve and none of its effects happen. +lblKwActionCreate=Create +lblKwActionCreateReminder=Put a token onto the battlefield. +lblKwActionDestroy=Destroy +lblKwActionDestroyReminder=Move a permanent from the battlefield to its owner''s graveyard. +lblKwActionDiscard=Discard +lblKwActionDiscardReminder=Put a card from your hand into its owner''s graveyard. +lblKwActionDouble=Double +lblKwActionDoubleReminder=Double a creature''s power and toughness, double the number of counters, or double the number of tokens. +lblKwActionTriple=Triple +lblKwActionTripleReminder=Triple a value \u2014 for example, triple a creature''s power and toughness. +lblKwActionExchange=Exchange +lblKwActionExchangeReminder=Swap values or control of objects between two players or permanents. +lblKwActionExile=Exile +lblKwActionExileReminder=Move an object to the exile zone. +lblKwActionFight=Fight +lblKwActionFightReminder=Each creature deals damage equal to its power to the other. +lblKwActionGoad=Goad +lblKwActionGoadReminder=A goaded creature attacks each combat if able and attacks a player other than you if able. +lblKwActionInvestigate=Investigate +lblKwActionInvestigateReminder=Create a Clue token. It''s an artifact with "'{2}', Sacrifice this: Draw a card." +lblKwActionMill=Mill +lblKwActionMillReminder=Put the top N cards of your library into your graveyard. +lblKwActionPlay=Play +lblKwActionPlayReminder=Play a land or cast a spell. +lblKwActionRegenerate=Regenerate +lblKwActionRegenerateReminder=Instead of being destroyed, tap this permanent, remove all damage from it, and remove it from combat. +lblKwActionReveal=Reveal +lblKwActionRevealReminder=Show a card to all players. +lblKwActionSacrifice=Sacrifice +lblKwActionSacrificeReminder=Move a permanent you control from the battlefield to its owner''s graveyard. +lblKwActionScry=Scry +lblKwActionScryReminder=Look at the top N cards of your library, then put any number on the bottom and the rest on top in any order. +lblKwActionSearch=Search +lblKwActionSearchReminder=Look through a zone for a card meeting certain criteria. +lblKwActionShuffle=Shuffle +lblKwActionShuffleReminder=Randomize the order of cards in a library. +lblKwActionSurveil=Surveil +lblKwActionSurveilReminder=Look at the top N cards of your library, then put any number into your graveyard and the rest on top in any order. +lblKwActionTapUntap=Tap/Untap +lblKwActionTapUntapReminder=Rotate a permanent sideways to show it''s been used, or straighten it to show it''s ready. +lblKwActionTransform=Transform +lblKwActionTransformReminder=Turn this double-faced card over to its other face. +lblKwActionConvert=Convert +lblKwActionConvertReminder=Turn this double-faced card over to its other face. Convert and transform are interchangeable. +lblKwActionFateseal=Fateseal +lblKwActionFatesealReminder=Look at the top N cards of an opponent''s library, then put any number on the bottom and the rest on top in any order. +lblKwActionClash=Clash +lblKwActionClashReminder=Each clashing player reveals the top card of their library, then puts it on the top or bottom. You win if your card''s mana value is higher. +lblKwActionPlaneswalk=Planeswalk +lblKwActionPlaneswalkReminder=Move to a new plane by turning over the next card in the planar deck. +lblKwActionSetInMotion=Set in Motion +lblKwActionSetInMotionReminder=Move the top card of your scheme deck off the top and turn it face up. +lblKwActionAbandon=Abandon +lblKwActionAbandonReminder=Turn a face-up ongoing scheme face down and put it on the bottom of its owner''s scheme deck. +lblKwActionProliferate=Proliferate +lblKwActionProliferateReminder=Choose any number of permanents and/or players, then give each another counter of each kind already there. +lblKwActionDetain=Detain +lblKwActionDetainReminder=Until your next turn, that permanent can''t attack or block and its activated abilities can''t be activated. +lblKwActionPopulate=Populate +lblKwActionPopulateReminder=Create a token that''s a copy of a creature token you control. +lblKwActionMonstrosity=Monstrosity +lblKwActionMonstrosityReminder=If this creature isn''t monstrous, put N +1/+1 counters on it and it becomes monstrous. +lblKwActionVote=Vote +lblKwActionVoteReminder=Each player votes for one of the given options. The outcome depends on which option gets more votes. +lblKwActionBolster=Bolster +lblKwActionBolsterReminder=Choose a creature you control with the least toughness and put N +1/+1 counters on it. +lblKwActionManifest=Manifest +lblKwActionManifestReminder=Put the top card of your library onto the battlefield face down as a 2/2 creature. Turn it face up any time for its mana cost if it''s a creature card. +lblKwActionSupport=Support +lblKwActionSupportReminder=Put a +1/+1 counter on each of up to N target creatures. +lblKwActionMeld=Meld +lblKwActionMeldReminder=Exile two specific cards and combine them into one oversized card on the battlefield. +lblKwActionExert=Exert +lblKwActionExertReminder=An exerted creature won''t untap during your next untap step. +lblKwActionExplore=Explore +lblKwActionExploreReminder=Reveal the top card of your library. Put it into your hand if it''s a land. Otherwise, put a +1/+1 counter on this creature, then you may put the card back or into your graveyard. +lblKwActionAssemble=Assemble +lblKwActionAssembleReminder=Place the top card of your Contraption deck face up onto one of your sprockets. +lblKwActionAdapt=Adapt +lblKwActionAdaptReminder=If this creature has no +1/+1 counters on it, put N +1/+1 counters on it. +lblKwActionAmass=Amass +lblKwActionAmassReminder=Put N +1/+1 counters on an Army you control. If you don''t control one, create a 0/0 black Zombie Army creature token first. +lblKwActionLearn=Learn +lblKwActionLearnReminder=You may reveal a Lesson card from outside the game and put it into your hand, or discard a card to draw a card. +lblKwActionVenture=Venture +lblKwActionVentureReminder=Move to the next room of a dungeon. If you''re not in one, enter the first room of a dungeon of your choice. +lblKwActionConnive=Connive +lblKwActionConniveReminder=Draw a card, then discard a card. If you discarded a nonland card, put a +1/+1 counter on this creature. +lblKwActionOpenAnAttraction=Open an Attraction +lblKwActionOpenAnAttractionReminder=Put the top card of your Attraction deck onto the battlefield face up. +lblKwActionRollToVisit=Roll to visit your Attractions +lblKwActionRollToVisitReminder=Roll a six-sided die. Each Attraction you control whose lit-up numbers include the result is visited. +lblKwActionIncubate=Incubate +lblKwActionIncubateReminder=Create an Incubator token with N +1/+1 counters on it. It has "'{2}': Transform this artifact." It transforms into a 0/0 Phyrexian artifact creature. +lblKwActionTheRingTemptsYou=The Ring Tempts You +lblKwActionTheRingTemptsYouReminder=Choose a creature you control as your Ring-bearer. Your Ring gains its next ability. +lblKwActionFaceAVillainousChoice=Face a Villainous Choice +lblKwActionFaceAVillainousChoiceReminder=Choose one of two options presented by an opponent. The chosen option''s effects happen. +lblKwActionTimeTravel=Time Travel +lblKwActionTimeTravelReminder=For each suspended card you own and each permanent you control with a time counter, you may add or remove a time counter. +lblKwActionDiscover=Discover +lblKwActionDiscoverReminder=Exile cards from the top of your library until you exile a nonland card with lower mana value. Cast it without paying its mana cost or put it into your hand. +lblKwActionCloak=Cloak +lblKwActionCloakReminder=Put a card onto the battlefield face down as a 2/2 creature with ward '{2}'. Turn it face up any time for its mana cost if it''s a creature card. +lblKwActionCollectEvidence=Collect Evidence +lblKwActionCollectEvidenceReminder=Exile cards from your graveyard with total mana value N or greater. +lblKwActionSuspect=Suspect +lblKwActionSuspectReminder=A suspected creature has menace and can''t block. +lblKwActionForage=Forage +lblKwActionForageReminder=Exile three cards from your graveyard or sacrifice a Food. +lblKwActionManifestDread=Manifest Dread +lblKwActionManifestDreadReminder=Look at the top two cards of your library. Put one onto the battlefield face down as a 2/2 creature and the other into your graveyard. Turn it face up any time for its mana cost if it''s a creature card. +lblKwActionEndure=Endure +lblKwActionEndureReminder=Choose to either put N +1/+1 counters on this creature or create an N/N white Spirit creature token. +lblKwActionHarness=Harness +lblKwActionHarnessReminder=This permanent becomes harnessed. It stays harnessed until it leaves the battlefield. +lblKwActionAirbend=Airbend +lblKwActionAirbendReminder=Exile a permanent. Its owner may cast it for '{2}' as long as it remains exiled. +lblKwActionEarthbend=Earthbend +lblKwActionEarthbendReminder=Target land you control becomes a 0/0 creature with haste. Put N +1/+1 counters on it. When it dies or is exiled, return it to the battlefield tapped. +lblKwActionWaterbend=Waterbend +lblKwActionWaterbendReminder=While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}. +lblKwActionBlight=Blight +lblKwActionBlightReminder=Put N -1/-1 counters on a creature you control. +lblKwActionDevotion=Devotion +lblKwActionDevotionReminder=Your devotion to a color is the number of mana symbols of that color among mana costs of permanents you control. +lblKwActionDomain=Domain +lblKwActionDomainReminder=The number of basic land types among lands you control. +lblKwActionMetalcraft=Metalcraft +lblKwActionMetalcraftReminder=You control three or more artifacts. +lblKwActionThreshold=Threshold +lblKwActionThresholdReminder=You have seven or more cards in your graveyard. +lblKwActionDelirium=Delirium +lblKwActionDeliriumReminder=There are four or more card types among cards in your graveyard. +lblAffinityCount=(You currently control {0} {1}.) +lblDevotionCountTitle=Devotion to {0} ({1}) +lblDevotionCount=(Your devotion to {0} is currently {1}.) +lblDevotionDualCountTitle=Devotion to {0} and {1} ({2}) +lblDevotionDualCount=(Your devotion to {0} and {1} is currently {2}.) +lblDomainCount=(You currently have {0} basic land type{1}.) +lblMetalcraftCount=(You currently control {0} artifact{1}.) +lblThresholdCount=(You currently have {0} card{1} in your graveyard.) +lblDeliriumCount=(You currently have {0} card type{1} in your graveyard{2}.) +lblCardTypesAllGraveyards=(There are currently {0} card type{1} among cards in all graveyards{2}.) +lblCardTypesYourGraveyard=(You currently have {0} card type{1} in your graveyard{2}.) +lblCardTypesOpponentsGraveyards=(There are currently {0} card type{1} among cards in your opponents'' graveyards{2}.) +lblCreaturesAllGraveyards=(There are currently {0} creature card{1} in all graveyards.) +lblCreaturesYourGraveyard=(You currently have {0} creature card{1} in your graveyard.) diff --git a/forge-gui/src/main/java/forge/gui/card/KeywordInfoUtil.java b/forge-gui/src/main/java/forge/gui/card/KeywordInfoUtil.java new file mode 100644 index 00000000000..8669c143d95 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/card/KeywordInfoUtil.java @@ -0,0 +1,1076 @@ +package forge.gui.card; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import forge.StaticData; +import forge.card.CardRules; +import forge.card.CardType; +import forge.card.CardTypeView; +import forge.card.ICardFace; +import forge.card.MagicColor; +import forge.card.mana.ManaCost; +import forge.card.mana.ManaCostShard; +import forge.card.mana.ManaAtom; +import forge.game.card.CardView; +import forge.game.card.CardView.CardStateView; +import forge.game.keyword.Equip; +import forge.game.keyword.Keyword; +import forge.game.keyword.KeywordAction; +import forge.game.keyword.KeywordInterface; +import forge.game.keyword.KeywordWithTypeInterface; +import forge.game.player.PlayerView; +import forge.game.zone.ZoneType; +import forge.util.Localizer; +import forge.util.collect.FCollectionView; + +/** + * Platform-neutral utility for building keyword info (names + reminder text) + * from card data. Returns raw strings with {W}-style mana symbols — callers + * are responsible for platform-specific symbol rendering (e.g. FSkin on desktop). + */ +public final class KeywordInfoUtil { + + private KeywordInfoUtil() { } + + /** A keyword name paired with its reminder text. Both are raw strings. */ + public static class KeywordData { + public final String name; + public final String reminderText; + /** Valid type expression for type-parameterised keywords (e.g. Affinity). */ + public final String typeParam; + + public KeywordData(final String name, final String reminderText) { + this(name, reminderText, null); + } + + public KeywordData(final String name, final String reminderText, + final String typeParam) { + this.name = name; + this.reminderText = reminderText; + this.typeParam = typeParam; + } + } + + /** + * Parse a comma-separated keyword key string into keyword data entries. + * @param keywordKey the comma-separated keyword string from CardStateView + * @param addedNames tracks already-added keyword names (lowercase) to deduplicate + * @return list of keyword data with raw name and reminder text + */ + public static List buildKeywords(final String keywordKey, + final Set addedNames) { + final String[] tokens = keywordKey.split(","); + final java.util.Map seenIdx = new java.util.LinkedHashMap<>(); + final java.util.Map rawTitles = new java.util.HashMap<>(); + final List result = new ArrayList<>(); + + for (final String token : tokens) { + if (token.isEmpty()) { + continue; + } + try { + final KeywordInterface inst = Keyword.getInstance(token); + final Keyword kw = inst.getKeyword(); + if (kw == Keyword.UNDEFINED || kw == Keyword.ENCHANT) { + continue; + } + if (seenIdx.containsKey(kw) && kw != Keyword.EQUIP + && kw != Keyword.TRAMPLE) { + // Merge parameterised duplicates (e.g. multiple Protections) + final String title = inst.getTitle(); + final String prefix = kw.toString() + " "; + if (title.startsWith(prefix)) { + final int idx = seenIdx.get(kw); + final String rawTitle = rawTitles.get(idx); + final String extra = title.substring( + kw.toString().length() + 1); // "from Dragon" + final String combined = rawTitle + " and " + extra; + rawTitles.put(idx, combined); + // Merge reminder text: append new "by X" subject + String mergedReminder = result.get(idx).reminderText; + try { + final String newReminder = inst.getReminderText(); + final int byIdx = newReminder.lastIndexOf(" by "); + final int existByIdx = mergedReminder.lastIndexOf("."); + if (byIdx >= 0 && existByIdx >= 0) { + final String newSubject = newReminder.substring( + byIdx + 4).replaceAll("\\.$", ""); + mergedReminder = mergedReminder.substring( + 0, existByIdx) + " or " + newSubject + "."; + } + } catch (Exception ex) { /* keep existing */ } + result.set(idx, new KeywordData( + colorNamesToSymbols(combined), + mergedReminder, + result.get(idx).typeParam)); + } + continue; + } + String reminderText; + try { + reminderText = inst.getReminderText(); + } catch (Exception ex) { + reminderText = ""; + } + final String title; + if (kw == Keyword.EQUIP + && inst instanceof Equip) { + // Include type qualifier for non-default equip variants + // (e.g. "Equip commander {3}" vs plain "Equip {5}") + final String equipType = ((Equip) inst).getValidDescription(); + if (!"creature".equals(equipType)) { + title = "Equip " + equipType + " " + inst.getTitle() + .substring(kw.toString().length()).trim(); + } else { + title = inst.getTitle(); + } + } else { + title = inst.getTitle(); + } + seenIdx.put(kw, result.size()); + rawTitles.put(result.size(), title); + String typeParam = null; + if (kw == Keyword.AFFINITY + && inst instanceof KeywordWithTypeInterface) { + typeParam = ((KeywordWithTypeInterface) inst) + .getValidType(); + } + result.add(new KeywordData( + colorNamesToSymbols(title), reminderText, typeParam)); + addedNames.add(kw.toString().toLowerCase()); + } catch (Exception e) { + // Skip malformed keyword tokens + } + } + return result; + } + + /** + * Scan oracle text for keyword actions that aren't already shown as keyword + * abilities, and append them to the keyword list. + */ + public static void addKeywordActions(final List result, + final String oracleText, + final Set existingNames, + final String cardName) { + if (oracleText == null || oracleText.isEmpty()) { + return; + } + // Strip card name to avoid false positives (e.g., "Boseiju, Who Endures") + String lowerText = oracleText.toLowerCase(); + if (!cardName.isEmpty()) { + lowerText = lowerText.replace(cardName.toLowerCase(), ""); + } + // Collect matches with their position in the oracle text, then sort by position + // so keyword actions appear top-to-bottom in card text order + final List pendingIndices = new ArrayList<>(); // [oraclePos, enumOrdinal] + for (final KeywordAction action : KeywordAction.values()) { + if (action.basic) { + continue; + } + final String name = action.getDisplayName(); + if (existingNames.contains(name.toLowerCase())) { + continue; + } + // Transform is redundant when Craft is present (craft explains the transformation) + if (action == KeywordAction.TRANSFORM && existingNames.contains("craft")) { + continue; + } + // Manifest is redundant when Manifest Dread is present + if (action == KeywordAction.MANIFEST && lowerText.contains("manifest dread")) { + continue; + } + // Match whole word (case-insensitive): "goad", "goads", "goaded" + final String lowerName = name.toLowerCase(); + // Try base form first, then -ies conjugation for -y verbs (scry → scries) + String matchTerm = lowerName; + if (!lowerText.contains(matchTerm) && lowerName.endsWith("y")) { + matchTerm = lowerName.substring(0, lowerName.length() - 1) + "ies"; + } + if (lowerText.contains(matchTerm)) { + // Verify word boundaries: start must not be preceded by a letter, + // and end must be followed by a verb suffix (s/d/ed/es/ing) or + // non-letter — prevents "planeswalk" matching "planeswalker" + int idx = lowerText.indexOf(matchTerm); + while (idx >= 0) { + final char prevChar = idx > 0 ? lowerText.charAt(idx - 1) : ' '; + final boolean startOk = idx == 0 + || (!Character.isLetter(prevChar) && prevChar != '-'); + final int endIdx = idx + matchTerm.length(); + final boolean endOk = endIdx >= lowerText.length() + || !Character.isLetter(lowerText.charAt(endIdx)) + || isVerbSuffix(lowerText, endIdx); + if (startOk && endOk) { + // Actions with "N" in reminder require a number after + // the keyword — reject generic uses (e.g. "life-support") + if (action.getReminderText().contains("N")) { + int numPos = endIdx; + while (numPos < lowerText.length() + && !Character.isLetterOrDigit( + lowerText.charAt(numPos))) { + numPos++; + } + if (parseNumber(lowerText, numPos) == null) { + idx = lowerText.indexOf(matchTerm, idx + 1); + continue; + } + } + pendingIndices.add(new int[]{idx, action.ordinal()}); + existingNames.add(lowerName); + break; + } + idx = lowerText.indexOf(matchTerm, idx + 1); + } + } + } + pendingIndices.sort((a, b) -> Integer.compare(a[0], b[0])); + final KeywordAction[] allActions = KeywordAction.values(); + for (final int[] pair : pendingIndices) { + final KeywordAction action = allActions[pair[1]]; + final String lowerName = action.getDisplayName().toLowerCase(); + String displayName = action.getDisplayName(); + String reminder = action.getReminderText(); + if (reminder.contains("N")) { + int pos = pair[0] + lowerName.length(); + // Skip inflected suffix letters (s, ed, ing, etc.) + while (pos < lowerText.length() + && Character.isLetter(lowerText.charAt(pos))) { + pos++; + } + // Skip whitespace to reach the number (or a type word before it) + if (pos < lowerText.length() + && lowerText.charAt(pos) == ' ') { + pos++; + String resolved = parseNumber(lowerText, pos); + // If no number found, a type word may precede it + // (e.g. "amass zombies 2") — capture the word and skip past it + String typeWord = null; + if (resolved == null) { + final int wordStart = pos; + while (pos < lowerText.length() + && Character.isLetter(lowerText.charAt(pos))) { + pos++; + } + if (pos > wordStart && pos < lowerText.length() + && lowerText.charAt(pos) == ' ') { + typeWord = lowerText.substring(wordStart, pos); + pos++; + resolved = parseNumber(lowerText, pos); + } + } + if (resolved != null) { + reminder = reminder.replace("N", resolved); + if (typeWord != null) { + // Capitalize type word for display + final String capType = Character.toUpperCase( + typeWord.charAt(0)) + typeWord.substring(1); + displayName = displayName + " " + capType + + " " + resolved; + } else { + displayName = displayName + " " + resolved; + } + if ("1".equals(resolved)) { + reminder = reminder.replace("counters", "counter") + .replace("cards", "card"); + } + } else { + // Can't resolve number (e.g. "mill half their library") + // — omit the N so text still reads naturally + reminder = reminder.replace("N ", "") + .replace(" N", ""); + } + } + } + result.add(new KeywordData(displayName, reminder)); + } + } + + /** + * Cross-check CardStateView boolean keyword flags against already-parsed + * keywords. Adds any keywords whose flag is true but were missed by + * keywordKey string parsing. + */ + public static void addMissingKeywordsFromFlags(final List result, + final CardStateView state, + final Set addedNames) { + if (state == null) { + return; + } + addFlagKeyword(result, addedNames, state.hasFlying(), Keyword.FLYING); + addFlagKeyword(result, addedNames, state.hasFirstStrike(), Keyword.FIRST_STRIKE); + addFlagKeyword(result, addedNames, state.hasDoubleStrike(), Keyword.DOUBLE_STRIKE); + addFlagKeyword(result, addedNames, state.hasDeathtouch(), Keyword.DEATHTOUCH); + addFlagKeyword(result, addedNames, state.hasDefender(), Keyword.DEFENDER); + addFlagKeyword(result, addedNames, state.hasFear(), Keyword.FEAR); + addFlagKeyword(result, addedNames, state.hasHaste(), Keyword.HASTE); + addFlagKeyword(result, addedNames, state.hasHexproof(), Keyword.HEXPROOF); + addFlagKeyword(result, addedNames, state.hasIndestructible(), Keyword.INDESTRUCTIBLE); + addFlagKeyword(result, addedNames, state.hasIntimidate(), Keyword.INTIMIDATE); + addFlagKeyword(result, addedNames, state.hasLifelink(), Keyword.LIFELINK); + addFlagKeyword(result, addedNames, state.hasMenace(), Keyword.MENACE); + addFlagKeyword(result, addedNames, state.hasReach(), Keyword.REACH); + addFlagKeyword(result, addedNames, state.hasShadow(), Keyword.SHADOW); + addFlagKeyword(result, addedNames, state.hasShroud(), Keyword.SHROUD); + addFlagKeyword(result, addedNames, state.hasTrample(), Keyword.TRAMPLE); + addFlagKeyword(result, addedNames, state.hasVigilance(), Keyword.VIGILANCE); + addFlagKeyword(result, addedNames, state.hasInfect(), Keyword.INFECT); + addFlagKeyword(result, addedNames, state.hasWither(), Keyword.WITHER); + addFlagKeyword(result, addedNames, state.hasHorsemanship(), Keyword.HORSEMANSHIP); + } + + /** + * Sort keywords so they appear in the same order they are mentioned in the + * oracle text, rather than alphabetical or parse order. + */ + public static void sortByOracleText(final List keywords, + final String oracleText) { + if (oracleText == null || oracleText.isEmpty() || keywords.size() <= 1) { + return; + } + final String lowerText = oracleText.toLowerCase(); + keywords.sort((a, b) -> { + final int posA = findKeywordPosition(lowerText, a.name); + final int posB = findKeywordPosition(lowerText, b.name); + return Integer.compare(posA, posB); + }); + } + + /** + * Post-process keyword entries to append dynamic count annotations where + * applicable (e.g. Affinity, Devotion, Domain, Metalcraft, Threshold, + * Delirium). Counts are computed client-side from view objects. + * Modifies both the keyword name (short count) and reminder text (detail). + */ + public static void annotateKeywordCounts(final List keywords, + final CardView cardView) { + if (keywords.isEmpty() || cardView == null) { + return; + } + final PlayerView controller = cardView.getController(); + if (controller == null) { + return; + } + final Localizer localizer = Localizer.getInstance(); + for (int i = 0; i < keywords.size(); i++) { + final KeywordData kw = keywords.get(i); + final String lowerName = kw.name.toLowerCase() + .replaceAll("\\{[^}]+}", "").trim(); + KeywordData annotated = null; + + if (lowerName.startsWith("affinity for ")) { + annotated = annotateAffinity(kw, lowerName, controller, localizer); + } else if (lowerName.equals("devotion")) { + annotated = annotateDevotion(kw, cardView, controller, localizer); + } else if (lowerName.equals("domain")) { + annotated = annotateDomain(kw, controller, localizer); + } else if (lowerName.equals("metalcraft")) { + annotated = annotateMetalcraft(kw, controller, localizer); + } else if (lowerName.equals("threshold")) { + annotated = annotateThreshold(kw, controller, localizer); + } else if (lowerName.equals("delirium")) { + annotated = annotateDelirium(kw, controller, localizer); + } else if (lowerName.equals("the ring tempts you")) { + annotated = annotateRingLevel(kw, controller); + } + + if (annotated != null) { + keywords.set(i, annotated); + } + } + } + + private static KeywordData annotateAffinity(final KeywordData kw, + final String lowerName, + final PlayerView controller, + final Localizer localizer) { + // Use typeParam (valid type expression from keyword parser) for + // accurate matching — properly cased for hasStringType. + String matchType = kw.typeParam; + if (matchType == null) { + // Fallback: extract from display name (works for core types only) + matchType = lowerName.substring("affinity for ".length()).trim(); + if (matchType.endsWith("s")) { + matchType = matchType.substring(0, matchType.length() - 1); + } + } + int count = 0; + final FCollectionView afBattlefield = + controller.getBattlefield(); + if (afBattlefield != null) { + for (final CardView c : afBattlefield) { + if (cardMatchesAffinityType(c, matchType)) { + count++; + } + } + } + // Build display text from typeParam (properly cased singular), + // falling back to parsing the title + final String singular; + if (kw.typeParam != null) { + singular = kw.typeParam.toLowerCase(); + } else { + final String typeText = lowerName.substring( + "affinity for ".length()).trim(); + singular = typeText.endsWith("s") + ? typeText.substring(0, typeText.length() - 1) : typeText; + } + // Types already ending in "s" (e.g. "plains") are their own plural + final String displayType = count == 1 ? singular + : (singular.endsWith("s") ? singular : singular + "s"); + final String reminder = localizer.getMessage("lblAffinityCount", + count, displayType); + return new KeywordData(kw.name + " (" + count + ")", + appendAnnotation(kw.reminderText, reminder), kw.typeParam); + } + + /** Check whether a card matches an Affinity type expression. */ + private static boolean cardMatchesAffinityType(final CardView card, + final String typeExpr) { + final CardStateView st = card.getCurrentState(); + if (st == null || st.getType() == null) { + return false; + } + if (!typeExpr.contains(".")) { + return st.getType().hasStringType(typeExpr); + } + // Compound expression: all dot-separated parts must match + for (final String part : typeExpr.split("\\.")) { + if (!matchTypePart(card, st, part)) { + return false; + } + } + return true; + } + + /** Match a single component of a dot-separated type expression. */ + private static boolean matchTypePart(final CardView card, + final CardStateView st, + final String part) { + // "Permanent" — any card on the battlefield qualifies + if ("Permanent".equalsIgnoreCase(part)) { + return true; + } + // "token" — check CardView flag + if ("token".equalsIgnoreCase(part)) { + return card.isToken(); + } + // "Historic" — artifact, legendary, or Saga + if ("Historic".equalsIgnoreCase(part)) { + return st.getType().isArtifact() + || st.getType().hasSupertype( + CardType.Supertype.Legendary) + || st.getType().hasSubtype("Saga"); + } + // "Outlaw" — Assassin, Mercenary, Pirate, Rogue, or Warlock + if ("Outlaw".equalsIgnoreCase(part)) { + final CardTypeView t = st.getType(); + return t.hasCreatureType("Assassin") + || t.hasCreatureType("Mercenary") + || t.hasCreatureType("Pirate") + || t.hasCreatureType("Rogue") + || t.hasCreatureType("Warlock"); + } + // "withAffinity" — check keyword key for "Affinity" + if ("withAffinity".equalsIgnoreCase(part)) { + final String keys = st.getKeywordKey(); + return keys != null + && keys.toLowerCase().contains("affinity"); + } + // Standard type/subtype/supertype + return st.getType().hasStringType(part); + } + + private static KeywordData annotateDevotion(final KeywordData kw, + final CardView cardView, + final PlayerView controller, + final Localizer localizer) { + // Look up devotion colors from the card's SVars (handles both single + // and dual devotion correctly, including hybrid mana counting) + String color1 = null; + String color2 = null; + try { + final StaticData data = StaticData.instance(); + if (data != null) { + final CardRules rules = data.getCommonCards() + .getRules(cardView.getOracleName()); + if (rules != null) { + final ICardFace face = rules.getMainPart(); + if (face != null && face.getVariables() != null) { + for (final Map.Entry svar + : face.getVariables()) { + final String val = svar.getValue(); + if (val.startsWith("Count$DevotionDual.")) { + final String[] parts = val.split("\\."); + if (parts.length >= 3) { + color1 = parts[1]; + color2 = parts[2]; + } + break; + } else if (val.startsWith("Count$Devotion.")) { + final String[] parts = val.split("\\."); + if (parts.length >= 2) { + color1 = parts[1]; + } + break; + } + } + } + } + } + } catch (Exception e) { + // Fall through to oracle text fallback + } + + // Fallback: parse oracle text for single-color devotion + if (color1 == null) { + final CardStateView state = cardView.getCurrentState(); + if (state == null) { + return null; + } + final String oracle = state.getOracleText(); + if (oracle == null) { + return null; + } + final String lowerOracle = oracle.toLowerCase(); + final String[] colorNames = { + "white", "blue", "black", "red", "green" + }; + for (final String name : colorNames) { + if (lowerOracle.contains("devotion to " + name)) { + color1 = name; + break; + } + } + } + if (color1 == null) { + return null; + } + + // Build combined color code (handles both single and dual) + byte colorCode = ManaAtom.fromName(color1); + if (color2 != null) { + colorCode |= ManaAtom.fromName(color2); + } + + // Count devotion using isColor — correctly counts hybrid mana once + int devotion = 0; + final FCollectionView devBattlefield = + controller.getBattlefield(); + if (devBattlefield != null) { + for (final CardView c : devBattlefield) { + final CardStateView st = c.getCurrentState(); + if (st == null) { + continue; + } + final ManaCost cost = st.getManaCost(); + if (cost == null) { + continue; + } + for (final ManaCostShard shard : cost) { + if (shard.isColor(colorCode)) { + devotion++; + } + } + } + } + + // Format display strings — use mana symbols in both header and reminder + final String symbol1 = MagicColor.toSymbol(color1); + if (color2 != null) { + final String symbol2 = MagicColor.toSymbol(color2); + final String newName = localizer.getMessage( + "lblDevotionDualCountTitle", + symbol1, symbol2, devotion); + final String reminder = localizer.getMessage( + "lblDevotionDualCount", + symbol1, symbol2, devotion); + return new KeywordData(newName, + appendAnnotation(kw.reminderText, reminder)); + } + final String newName = localizer.getMessage("lblDevotionCountTitle", + symbol1, devotion); + final String reminder = localizer.getMessage("lblDevotionCount", + symbol1, devotion); + return new KeywordData(newName, + appendAnnotation(kw.reminderText, reminder)); + } + + private static KeywordData annotateDomain(final KeywordData kw, + final PlayerView controller, + final Localizer localizer) { + final String[] basicLandTypes = { + "Plains", "Island", "Swamp", "Mountain", "Forest" + }; + final Set found = new HashSet<>(); + final FCollectionView domBattlefield = + controller.getBattlefield(); + if (domBattlefield != null) { + for (final CardView c : domBattlefield) { + final CardStateView st = c.getCurrentState(); + if (st == null || st.getType() == null) { + continue; + } + final CardTypeView type = st.getType(); + if (!type.isLand()) { + continue; + } + for (final String blt : basicLandTypes) { + if (type.hasSubtype(blt)) { + found.add(blt); + } + } + } + } + final int count = found.size(); + final String reminder = localizer.getMessage("lblDomainCount", + count, count == 1 ? "" : "s"); + return new KeywordData(kw.name + " (" + count + ")", + appendAnnotation(kw.reminderText, reminder)); + } + + private static KeywordData annotateMetalcraft(final KeywordData kw, + final PlayerView controller, + final Localizer localizer) { + int count = 0; + final FCollectionView mcBattlefield = + controller.getBattlefield(); + if (mcBattlefield != null) { + for (final CardView c : mcBattlefield) { + final CardStateView st = c.getCurrentState(); + if (st != null && st.getType() != null + && st.getType().isArtifact()) { + count++; + } + } + } + final String reminder = localizer.getMessage("lblMetalcraftCount", + count, count == 1 ? "" : "s"); + return new KeywordData(kw.name + " (" + count + "/3)", + appendAnnotation(kw.reminderText, reminder)); + } + + private static KeywordData annotateThreshold(final KeywordData kw, + final PlayerView controller, + final Localizer localizer) { + final FCollectionView thGraveyard = + controller.getGraveyard(); + final int count = thGraveyard != null ? thGraveyard.size() : 0; + final String reminder = localizer.getMessage("lblThresholdCount", + count, count == 1 ? "" : "s"); + return new KeywordData(kw.name + " (" + count + "/7)", + appendAnnotation(kw.reminderText, reminder)); + } + + private static KeywordData annotateDelirium(final KeywordData kw, + final PlayerView controller, + final Localizer localizer) { + final Set types = new HashSet<>(); + final FCollectionView delGraveyard = + controller.getGraveyard(); + if (delGraveyard != null) { + for (final CardView c : delGraveyard) { + final CardStateView st = c.getCurrentState(); + if (st == null || st.getType() == null) { + continue; + } + for (final CardType.CoreType ct + : st.getType().getCoreTypes()) { + types.add(ct); + } + } + } + final int count = types.size(); + final List typeNames = new ArrayList<>(); + for (final CardType.CoreType ct : CardType.CoreType.values()) { + if (types.contains(ct)) { + typeNames.add(ct.name().substring(0, 1) + + ct.name().substring(1).toLowerCase()); + } + } + final String typeList = count == 0 ? "" + : ": " + String.join(", ", typeNames); + final String reminder = localizer.getMessage("lblDeliriumCount", + count, count == 1 ? "" : "s", typeList); + return new KeywordData(kw.name + " (" + count + "/4)", + appendAnnotation(kw.reminderText, reminder)); + } + + private static KeywordData annotateRingLevel(final KeywordData kw, + final PlayerView controller) { + final FCollectionView commandZone = + controller.getCards(ZoneType.Command); + if (commandZone == null) { + return null; + } + for (final CardView c : commandZone) { + final int level = c.getRingLevel(); + if (level > 0) { + final String annotation = "(Currently at level " + level + ")"; + return new KeywordData(kw.name + " (" + level + ")", + appendAnnotation(kw.reminderText, annotation)); + } + } + return null; + } + + /** + * Detect graveyard-count cards (Tarmogoyf-family card types, Lhurgoyf-family + * creature counts) and append dynamic count entries to the keyword list. + * Uses SVar API for card-type detection and oracle text for creature counts. + */ + public static void addGraveyardCounts(final List keywords, + final CardView cardView) { + if (cardView == null || cardView.isFaceDown()) { + return; + } + final PlayerView controller = cardView.getController(); + if (controller == null) { + return; + } + final CardStateView state = cardView.getCurrentState(); + if (state == null) { + return; + } + + // Look up card rules for SVar scanning + ICardFace face = null; + String oracleText = null; + try { + final StaticData data = StaticData.instance(); + if (data != null) { + final CardRules rules = data.getCommonCards() + .getRules(cardView.getOracleName()); + if (rules != null) { + face = rules.getMainPart(); + if (face != null) { + oracleText = face.getOracleText(); + } + } + } + } catch (Exception e) { + // Fallback to state oracle text + } + if (oracleText == null) { + oracleText = state.getOracleText(); + } + + final Localizer localizer = Localizer.getInstance(); + + // --- Card-type detection (Tarmogoyf family) via SVar API --- + if (face != null) { + final String scope = detectCardTypeScope(face); + if (scope != null) { + // Skip if Delirium already covers your-graveyard card types + if (!"your".equals(scope) || !hasKeywordNamed(keywords, "delirium")) { + addCardTypeCount(keywords, scope, controller, localizer); + } + } + } + + // --- Creature-count detection (Lhurgoyf family) via oracle text --- + if (oracleText != null) { + final String lowerOracle = oracleText.toLowerCase(); + if (lowerOracle.contains("number of creature cards in")) { + final String scope = lowerOracle.contains("number of creature cards in all graveyards") + ? "all" + : lowerOracle.contains("number of creature cards in your graveyard") + ? "your" : "all"; + addCreatureCount(keywords, scope, controller, localizer); + } + } + } + + /** + * Scan an ICardFace's SVars, static abilities, triggers, and abilities + * for strings containing both "ValidGraveyard" and "CardTypes". + * @return "your", "opponents", "all", or null if not found + */ + private static String detectCardTypeScope(final ICardFace face) { + // Check SVars + if (face.getVariables() != null) { + for (final Map.Entry entry : face.getVariables()) { + final String scope = checkCardTypeString(entry.getValue()); + if (scope != null) { + return scope; + } + } + } + // Check static abilities + if (face.getStaticAbilities() != null) { + for (final String sa : face.getStaticAbilities()) { + final String scope = checkCardTypeString(sa); + if (scope != null) { + return scope; + } + } + } + // Check triggers + if (face.getTriggers() != null) { + for (final String trig : face.getTriggers()) { + final String scope = checkCardTypeString(trig); + if (scope != null) { + return scope; + } + } + } + // Check abilities + if (face.getAbilities() != null) { + for (final String ab : face.getAbilities()) { + final String scope = checkCardTypeString(ab); + if (scope != null) { + return scope; + } + } + } + return null; + } + + /** + * Check if a string contains both "ValidGraveyard" and "CardTypes", + * and determine scope from ".YouOwn" / ".OppOwn" markers. + */ + private static String checkCardTypeString(final String text) { + if (text == null || !text.contains("ValidGraveyard") + || !text.contains("CardTypes")) { + return null; + } + if (text.contains(".YouOwn")) { + return "your"; + } + if (text.contains(".OppOwn")) { + return "opponents"; + } + return "all"; + } + + private static boolean hasKeywordNamed(final List keywords, + final String name) { + for (final KeywordData kw : keywords) { + if (kw.name.toLowerCase().replaceAll("\\{[^}]+}", "").trim() + .startsWith(name)) { + return true; + } + } + return false; + } + + private static void addCardTypeCount(final List keywords, + final String scope, + final PlayerView controller, + final Localizer localizer) { + final Set types = new HashSet<>(); + if ("your".equals(scope)) { + collectCardTypes(controller.getGraveyard(), types); + } else if ("opponents".equals(scope)) { + for (final PlayerView opp : controller.getOpponents()) { + collectCardTypes(opp.getGraveyard(), types); + } + } else { + // "all" — controller + opponents + collectCardTypes(controller.getGraveyard(), types); + for (final PlayerView opp : controller.getOpponents()) { + collectCardTypes(opp.getGraveyard(), types); + } + } + + final int count = types.size(); + final List typeNames = new ArrayList<>(); + for (final CardType.CoreType ct : CardType.CoreType.values()) { + if (types.contains(ct)) { + typeNames.add(ct.name().substring(0, 1) + + ct.name().substring(1).toLowerCase()); + } + } + final String typeList = count == 0 ? "" + : ": " + String.join(", ", typeNames); + + final String header; + final String labelKey; + if ("your".equals(scope)) { + header = "Card types in your graveyard"; + labelKey = "lblCardTypesYourGraveyard"; + } else if ("opponents".equals(scope)) { + header = "Card types in opponents' graveyards"; + labelKey = "lblCardTypesOpponentsGraveyards"; + } else { + header = "Card types in graveyards"; + labelKey = "lblCardTypesAllGraveyards"; + } + + final String reminder = localizer.getMessage(labelKey, + count, count == 1 ? "" : "s", typeList); + keywords.add(new KeywordData(header + " (" + count + ")", reminder)); + } + + private static void addCreatureCount(final List keywords, + final String scope, + final PlayerView controller, + final Localizer localizer) { + int count = 0; + if ("your".equals(scope)) { + count = countCreatureCards(controller.getGraveyard()); + } else { + // "all" — controller + opponents + count = countCreatureCards(controller.getGraveyard()); + for (final PlayerView opp : controller.getOpponents()) { + count += countCreatureCards(opp.getGraveyard()); + } + } + + final String header; + final String labelKey; + if ("your".equals(scope)) { + header = "Creatures in your graveyard"; + labelKey = "lblCreaturesYourGraveyard"; + } else { + header = "Creatures in graveyards"; + labelKey = "lblCreaturesAllGraveyards"; + } + + final String reminder = localizer.getMessage(labelKey, + count, count == 1 ? "" : "s"); + keywords.add(new KeywordData(header + " (" + count + ")", reminder)); + } + + private static void collectCardTypes(final FCollectionView zone, + final Set types) { + if (zone == null) { + return; + } + for (final CardView c : zone) { + final CardStateView st = c.getCurrentState(); + if (st == null || st.getType() == null) { + continue; + } + for (final CardType.CoreType ct : st.getType().getCoreTypes()) { + types.add(ct); + } + } + } + + private static int countCreatureCards(final FCollectionView zone) { + if (zone == null) { + return 0; + } + int count = 0; + for (final CardView c : zone) { + final CardStateView st = c.getCurrentState(); + if (st != null && st.getType() != null + && st.getType().isCreature()) { + count++; + } + } + return count; + } + + private static String appendAnnotation(final String reminderText, + final String annotation) { + return reminderText.isEmpty() ? annotation + : reminderText + " " + annotation; + } + + // --- Private helpers --- + + private static void addFlagKeyword(final List result, + final Set addedNames, + final boolean hasFlag, final Keyword kw) { + if (!hasFlag) { + return; + } + if (addedNames.contains(kw.toString().toLowerCase())) { + return; + } + addedNames.add(kw.toString().toLowerCase()); + result.add(new KeywordData(kw.toString(), kw.getReminderText())); + } + + private static int findKeywordPosition(final String lowerOracleText, + final String keywordName) { + // Strip markup (e.g. {W} symbols) to get plain text for matching + final String plain = keywordName.replaceAll("\\{[^}]+}", "").trim().toLowerCase(); + int pos = lowerOracleText.indexOf(plain); + if (pos >= 0) { + return pos; + } + // Try first word only (e.g. "bestow" from "Bestow {2}{W}{U}{B}{R}{G}") + final int space = plain.indexOf(' '); + if (space > 0) { + pos = lowerOracleText.indexOf(plain.substring(0, space)); + if (pos >= 0) { + return pos; + } + } + return Integer.MAX_VALUE; + } + + /** Replace standalone MTG color names with mana symbols for display. */ + private static String colorNamesToSymbols(final String text) { + // Use word boundaries so "red" inside "multicolored" isn't matched + return text + .replaceAll("\\b[Ww]hite\\b", "{W}") + .replaceAll("\\b[Bb]lue\\b", "{U}") + .replaceAll("\\b[Bb]lack\\b", "{B}") + .replaceAll("\\b[Rr]ed\\b", "{R}") + .replaceAll("\\b[Gg]reen\\b", "{G}"); + } + + /** Parse a digit or number word at the given position. Returns the digit string or null. */ + private static String parseNumber(final String text, final int pos) { + // Try digits first: "3", "10" + int numEnd = pos; + while (numEnd < text.length() && Character.isDigit(text.charAt(numEnd))) { + numEnd++; + } + if (numEnd > pos) { + return text.substring(pos, numEnd); + } + // Try "a"/"an" as 1 (e.g. "mill a card", "create an artifact") + if (text.startsWith("a ", pos) || text.startsWith("an ", pos)) { + return "1"; + } + // Try variable "X" (e.g. "monstrosity x", "collect evidence x") + if (text.startsWith("x", pos)) { + final int end = pos + 1; + if (end >= text.length() || !Character.isLetter(text.charAt(end))) { + return "X"; + } + } + // Try number words: "one" through "twenty" + final String[] words = { + "one", "two", "three", "four", "five", "six", "seven", "eight", + "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", + "sixteen", "seventeen", "eighteen", "nineteen", "twenty" + }; + for (int i = 0; i < words.length; i++) { + if (text.startsWith(words[i], pos)) { + final int end = pos + words[i].length(); + if (end >= text.length() || !Character.isLetter(text.charAt(end))) { + return String.valueOf(i + 1); + } + } + } + return null; + } + + /** + * Check if the text at {@code pos} starts with a common English verb suffix + * (s, d, ed, es, ing) followed by a non-letter. This allows matching + * "goads"/"goaded"/"goading" but rejects "planeswalker" (suffix "er"). + */ + private static boolean isVerbSuffix(final String text, final int pos) { + final String[] suffixes = {"ing", "ed", "es", "s", "d"}; + for (final String suffix : suffixes) { + if (text.startsWith(suffix, pos)) { + final int end = pos + suffix.length(); + if (end >= text.length() || !Character.isLetter(text.charAt(end))) { + return true; + } + } + } + return false; + } +} diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 96f9dccb2ad..3e4a43273cf 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -104,6 +104,21 @@ public enum FPref implements PreferencesStore.IPref { UI_OPEN_PACKS_INDIV ("false"), UI_STACK_CREATURES ("false"), UI_TOKENS_IN_SEPARATE_ROW("false"), + UI_SHOW_HOVER_TOOLTIPS("true"), + UI_POPUP_KEYWORD_INFO("true"), + UI_POPUP_RELATED_CARDS("true"), + UI_POPUP_CARD_IMAGE("true"), + UI_POPUP_IMAGE_SIZE("375"), // thumbnail height in pixels (250–500) + UI_POPUP_CARD_OVERLAYS("false"), + UI_HOVER_OVERLAY_CARD_NAME("false"), + UI_HOVER_OVERLAY_CARD_POWER("true"), + UI_HOVER_OVERLAY_CARD_MANA_COST("false"), + UI_HOVER_OVERLAY_CARD_PERPETUAL_MANA_COST("false"), + UI_HOVER_OVERLAY_CARD_ID("false"), + UI_HOVER_OVERLAY_ABILITY_ICONS("true"), + UI_SHOW_ZOOM_TOOLTIPS("true"), + UI_ZOOM_KEYWORD_INFO("true"), + UI_ZOOM_RELATED_CARDS("true"), UI_UPLOAD_DRAFT ("false"), UI_SCALE_LARGER ("true"), UI_RENDER_BLACK_BORDERS ("true"), @@ -135,6 +150,8 @@ public enum FPref implements PreferencesStore.IPref { UI_CURRENT_AI_PROFILE ("Default"), UI_CLONE_MODE_SOURCE ("false"), UI_MATCH_IMAGE_VISIBLE ("true"), + UI_MATCH_CARD_PICTURE_VISIBLE ("true"), + UI_MATCH_CARD_DETAIL_VISIBLE ("true"), UI_THEMED_COMBOBOX ("true"), // Now applies to all theme settings, not just Combo. UI_LOCK_TITLE_BAR ("false"), UI_HIDE_GAME_TABS ("false"), // Visibility of tabs in match screen. @@ -300,6 +317,8 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_SHOWHOTKEYS("72"), SHORTCUT_PANELTABS("17 84"), SHORTCUT_CARDOVERLAYS("17 79"), + SHORTCUT_HOVERTOOLTIPS("17 72"), // Ctrl+H + SHORTCUT_ZOOMTOOLTIPS("17 73"), // Ctrl+I LAST_IMPORTED_CUBE_ID("");