Skip to content
Open
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ec1c136
Add card info popup with card image, spellbook, and size controls
MostCromulent Feb 15, 2026
51cff1d
Improve card info popup: single-instance enforcement, focus handling,…
MostCromulent Feb 15, 2026
4aa4a40
Constrain CardInfoPopup to Forge window bounds instead of screen bounds
MostCromulent Feb 16, 2026
8573d9a
Improve card info popup: layout, sizing, and related card categories
MostCromulent Feb 19, 2026
c2527b0
Restyle card info popup with dark pill panels and proper text rendering
MostCromulent Feb 19, 2026
41e8057
Add keyword actions to card info popup and improve layout
MostCromulent Feb 19, 2026
5ac627c
Merge branch 'Card-Forge:master' into hoveroptions
MostCromulent Feb 19, 2026
f9f51c1
Clean up card info popup: remove dead code and duplicate state
MostCromulent Feb 19, 2026
24a7fab
Fix popup during zoom and related card rendering without art
MostCromulent Feb 19, 2026
80af802
Improve keyword popup text and ordering
MostCromulent Feb 19, 2026
27bf7f1
Add keyword and related card info to card zoom view
MostCromulent Feb 19, 2026
6f5e495
Fix granted keyword detection and clean up popup labels
MostCromulent Feb 19, 2026
f47e697
Improve zoom view layout and fix related card image loading
MostCromulent Feb 19, 2026
5db88c6
Add translation keys for keyword action names and reminder text
MostCromulent Feb 19, 2026
59b0115
Remove duplicate English strings from KeywordAction enum
MostCromulent Feb 19, 2026
6709ad0
Move keyword detection logic to shared forge-gui module
MostCromulent Feb 19, 2026
a54d3dc
Fix Assemble/Set In Motion descriptions and mark scheme actions as basic
MostCromulent Feb 20, 2026
be48e91
Handle scry conjugation in keyword matching and fix zoom scroll layout
MostCromulent Feb 20, 2026
c79e6f6
Address PR reviewer feedback on keyword display and zoom view
MostCromulent Feb 21, 2026
8a2ce7d
Fix tooltip flickering on tapped cards by using component bounds for …
MostCromulent Feb 21, 2026
bc46a36
Improve hover tooltip and zoom view layout and fix keyword display is…
MostCromulent Feb 21, 2026
b1e6ca3
Fix specialize face rendering and Scryfall download for hover tooltip
MostCromulent Feb 21, 2026
c9da360
Add dynamic keyword count annotations to hover tooltips and zoom view
MostCromulent Feb 21, 2026
4f15496
Improve Affinity keyword matching and add stack hover tooltips
MostCromulent Feb 21, 2026
adfee29
Add card overlay painting to hover tooltip image
MostCromulent Feb 21, 2026
b2b0498
Remove dead code from CardInfoPopup
MostCromulent Feb 21, 2026
efc1247
Merge branch 'Card-Forge:master' into hoveroptions
MostCromulent Feb 21, 2026
9f1f468
Add menu options to hide card picture and detail panels
MostCromulent Feb 22, 2026
6600fe0
Fix card panel re-enable to create right column instead of stacking i…
MostCromulent Feb 22, 2026
f1b0fd9
Fix face-down info leak, enable keyword popup by default, and cleanup…
MostCromulent Feb 22, 2026
7e52afd
Adjust tooltip overlay positions and hide popup on game end
MostCromulent Feb 22, 2026
150c7d7
Merge upstream/master into hoveroptions
MostCromulent Feb 24, 2026
cfcd562
Show hover tooltip on game log inline card images
MostCromulent Feb 24, 2026
6820fbf
Add Card Overlay Settings dialog with independent hover overlay prefs
MostCromulent Feb 24, 2026
2843e96
Improve hover tooltip keywords, related cards, and preference handling
MostCromulent Feb 25, 2026
5af05ac
Show Amass Army tokens as related cards in hover tooltip
MostCromulent Feb 25, 2026
cbdfa8e
Merge upstream/master into hoveroptions
MostCromulent Feb 25, 2026
2ce6e02
Minor comment cleanup
MostCromulent Feb 25, 2026
e415e74
Rework tooltip preferences: master toggles, hotkeys, and updated defa…
MostCromulent Feb 25, 2026
3b990c0
Fix devotion tooltip: support dual-color devotion and immediate hotke…
MostCromulent Feb 25, 2026
2d8d828
Remove dead code: unused JSeparator and unreachable overload
MostCromulent Feb 25, 2026
985075c
Address PR review feedback: keyword display and layout fixes
MostCromulent Feb 26, 2026
00f5230
Merge upstream/master into hoveroptions
MostCromulent Mar 1, 2026
7c6552f
Fix Escape/Craft keyword headers: wrapping and full cost display
MostCromulent Mar 1, 2026
da9e502
Hover/zoom tooltip: 1-card-wide keywords + compact multi-group relate…
MostCromulent Mar 2, 2026
9fbd234
Fix Support false positives, suppress Manifest when Manifest Dread pr…
MostCromulent Mar 2, 2026
e18a9f0
Add graveyard count tooltips for Tarmogoyf/Lhurgoyf-family cards
MostCromulent Mar 3, 2026
cde78be
Merge upstream/master into hoveroptions
MostCromulent Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions forge-core/src/main/java/forge/util/ImageUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ public static String getScryfallDownloadUrl(PaperCard cp, String face, String se
faceParam = (face.equals("back") && cp.getRules().getSplitType() != CardSplitType.Flip
? "&face=back"
: "&face=front");
} else if (cp.getRules().getSplitType() == CardSplitType.Specialize) {
// Specialize faces have their own Scryfall entries with collector
// number = base number + color letter (e.g. "2w", "2u", "2b", "2r", "2g")
String colorSuffix = specFaceToCollectorSuffix(face);
if (colorSuffix != null) {
cardCollectorNumber += colorSuffix;
}
}

if (cardCollectorNumber.endsWith("☇")) {
Expand All @@ -284,6 +291,17 @@ public static String getScryfallTokenDownloadUrl(String collectorNumber, String
langCode, versionParam, faceParam);
}

private static String specFaceToCollectorSuffix(String face) {
switch (face) {
case "white": return "w";
case "blue": return "u";
case "black": return "b";
case "red": return "r";
case "green": return "g";
default: return null;
}
}

private static String encodeUtf8(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
Expand Down
6 changes: 3 additions & 3 deletions forge-game/src/main/java/forge/game/keyword/Keyword.java
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -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."),
Expand Down Expand Up @@ -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."),
Expand Down
168 changes: 168 additions & 0 deletions forge-game/src/main/java/forge/game/keyword/KeywordAction.java
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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}).</p>
*/
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();
}
}
18 changes: 15 additions & 3 deletions forge-game/src/main/java/forge/game/keyword/KeywordWithType.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,21 @@ public class KeywordWithType extends KeywordInstance<KeywordWithType> 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
Expand Down
2 changes: 1 addition & 1 deletion forge-game/src/main/java/forge/game/keyword/Partner.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public String getTitle() {

@Override
protected void parse(String details) {
with = details;
with = details.isEmpty() ? null : details;
}

@Override
Expand Down
7 changes: 7 additions & 0 deletions forge-game/src/main/java/forge/game/keyword/Protection.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion forge-game/src/main/java/forge/game/keyword/Trample.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
2 changes: 1 addition & 1 deletion forge-gui-desktop/src/main/java/forge/CachedCardImage.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerView> viewers, final int width, final int height) {
this.card = card;
Expand Down
34 changes: 32 additions & 2 deletions forge-gui-desktop/src/main/java/forge/ImageCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@
import com.google.common.cache.LoadingCache;
import com.mortennobel.imagescaling.ResampleOp;

import forge.card.CardStateName;
import forge.card.CardSplitType;
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;
Expand Down Expand Up @@ -200,6 +202,10 @@ public static Pair<BufferedImage, Boolean> getCardOriginalImageInfo(String image
return getOriginalImageInternal(imageKey, useDefaultIfNotFound, null);
}

public static Pair<BufferedImage, Boolean> 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<BufferedImage, Boolean> getOriginalImageInternal(String imageKey, boolean useDefaultIfNotFound,
CardView cardView) {
Expand Down Expand Up @@ -336,7 +342,6 @@ private static Pair<BufferedImage, Boolean> 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) {
Expand All @@ -345,7 +350,21 @@ private static Pair<BufferedImage, Boolean> 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)
Expand All @@ -367,6 +386,17 @@ private static boolean isWhiteBorderSet(String setCode) {
setCode.equals("6E") || setCode.equals("7E") || setCode.equals("8E") || setCode.equals("9E");
}

private static CardStateName specColorToStateName(String specColor) {
switch (specColor) {
case "white": return CardStateName.SpecializeW;
case "blue": return CardStateName.SpecializeU;
case "black": return CardStateName.SpecializeB;
case "red": return CardStateName.SpecializeR;
case "green": return CardStateName.SpecializeG;
default: return null;
}
}

public static boolean isSupportedImageSize(final int width, final int height) {
return !((3 > width && -1 != width) || (3 > height && -1 != height));
}
Expand Down
Loading