diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 353737ab63..edcd93213b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,6 +94,7 @@ jsvg = { group = "com.github.weisj", name = "jsvg", version = "1.4.0" } handlebars = { group = "com.github.jknack", name = "handlebars", version.ref = "handlebars" } handlebars-helpers = { group = "com.github.jknack", name = "handlebars-helpers", version.ref = "handlebars" } +handlebars-json = { group = "com.github.jknack", name = "handlebars-jackson2", version = "4.3.1" } # Apache commons and other utilities # parsing of configuration data @@ -213,7 +214,7 @@ flatlaf = [ "flatlaf-extras", "flatlaf-jide-oss", ] -handlebars = ["handlebars", "handlebars-helpers"] +handlebars = ["handlebars", "handlebars-helpers", "handlebars-json"] junit = ["junit-api", "junit-engine", "junit-params"] jai-imageio = ["jai-imageio-core", "jai-imageio-jpeg"] graalvm-js = ["graalvm-js", "graalvm-js-scriptengine"] diff --git a/src/main/java/net/rptools/maptool/client/events/TokenHoverEnter.java b/src/main/java/net/rptools/maptool/client/events/TokenHoverEnter.java index 34c73d74db..644710d53b 100644 --- a/src/main/java/net/rptools/maptool/client/events/TokenHoverEnter.java +++ b/src/main/java/net/rptools/maptool/client/events/TokenHoverEnter.java @@ -24,5 +24,7 @@ * @param zone the zone that the token is in. * @param shiftDown is the shift key down. * @param controlDown is the control key down. + * @param altDown is the alt-key down. */ -public record TokenHoverEnter(Token token, Zone zone, boolean shiftDown, boolean controlDown) {} +public record TokenHoverEnter( + Token token, Zone zone, boolean shiftDown, boolean controlDown, boolean altDown) {} diff --git a/src/main/java/net/rptools/maptool/client/events/TokenHoverExit.java b/src/main/java/net/rptools/maptool/client/events/TokenHoverExit.java index 12a18fd132..a17cc670e9 100644 --- a/src/main/java/net/rptools/maptool/client/events/TokenHoverExit.java +++ b/src/main/java/net/rptools/maptool/client/events/TokenHoverExit.java @@ -24,5 +24,7 @@ * @param zone the zone for the event. * @param shiftDown is the shift key down. * @param controlDown is the control key down. + * @param altDown is the alt-key down. */ -public record TokenHoverExit(Token token, Zone zone, boolean shiftDown, boolean controlDown) {} +public record TokenHoverExit( + Token token, Zone zone, boolean shiftDown, boolean controlDown, boolean altDown) {} diff --git a/src/main/java/net/rptools/maptool/client/functions/ShapeFunctions.java b/src/main/java/net/rptools/maptool/client/functions/ShapeFunctions.java index 0edb8b6288..65457004e2 100644 --- a/src/main/java/net/rptools/maptool/client/functions/ShapeFunctions.java +++ b/src/main/java/net/rptools/maptool/client/functions/ShapeFunctions.java @@ -614,6 +614,7 @@ private Object getProperties( seg, coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], coords[6])); pi.next(); } + StringBuilder stringBuilder = new StringBuilder(sd.toNonLocalisedString()); stringBuilder.append("segments=").append(String.join(",", segments)).append(";"); diff --git a/src/main/java/net/rptools/maptool/client/swing/SwingUtil.java b/src/main/java/net/rptools/maptool/client/swing/SwingUtil.java index 3f51a03de2..24fc96c23f 100644 --- a/src/main/java/net/rptools/maptool/client/swing/SwingUtil.java +++ b/src/main/java/net/rptools/maptool/client/swing/SwingUtil.java @@ -108,6 +108,22 @@ public static boolean isShiftDown(int modifiers) { return (modifiers & InputEvent.SHIFT_DOWN_MASK) == InputEvent.SHIFT_DOWN_MASK; } + public static boolean isAltDown(InputEvent e) { + return isAltDown(e.getModifiersEx()); + } + + /** + * Passed the event's extended modifiers this method returns true if the Alt key, + * Right Alt key or Alt-Graph key is down. + * + * @param modifiers as returned by {@link InputEvent#getModifiersEx()} + * @return true if Alt/Right-Alt/Alt-Graph key is down + */ + public static boolean isAltDown(int modifiers) { + return (modifiers & InputEvent.ALT_DOWN_MASK) == InputEvent.ALT_DOWN_MASK + || (modifiers & InputEvent.ALT_GRAPH_DOWN_MASK) == InputEvent.ALT_GRAPH_DOWN_MASK; + } + /** * Centers the innerWindow over the outerWindow. Basically, this method finds the centerpoint of * the outerWindow and sets the location of innerWindow so that it's diff --git a/src/main/java/net/rptools/maptool/client/tool/PointerTool.java b/src/main/java/net/rptools/maptool/client/tool/PointerTool.java index 774a4e1f65..3d95863e4a 100644 --- a/src/main/java/net/rptools/maptool/client/tool/PointerTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/PointerTool.java @@ -645,7 +645,8 @@ public void mouseMoved(MouseEvent e) { oldTokenUnderMouse, getZone(), SwingUtil.isShiftDown(keysDown), - SwingUtil.isControlDown(keysDown))); + SwingUtil.isControlDown(keysDown), + SwingUtil.isAltDown(keysDown))); } } else if (tokenUnderMouse != oldTokenUnderMouse) { statSheet = null; @@ -657,7 +658,8 @@ public void mouseMoved(MouseEvent e) { oldTokenUnderMouse, getZone(), SwingUtil.isShiftDown(keysDown), - SwingUtil.isControlDown(keysDown))); + SwingUtil.isControlDown(keysDown), + SwingUtil.isAltDown(keysDown))); } new MapToolEventBus() .getMainEventBus() @@ -666,7 +668,8 @@ public void mouseMoved(MouseEvent e) { tokenUnderMouse, getZone(), SwingUtil.isShiftDown(keysDown), - SwingUtil.isControlDown(keysDown))); + SwingUtil.isControlDown(keysDown), + SwingUtil.isAltDown(keysDown))); } Token marker = renderer.getMarkerAt(mouseX, mouseY); if (!AppUtil.tokenIsVisible(renderer.getZone(), marker, renderer.getPlayerView())) { diff --git a/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheet.java b/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheet.java index 2ed423b840..296fce4286 100644 --- a/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheet.java +++ b/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheet.java @@ -14,36 +14,38 @@ */ package net.rptools.maptool.client.ui.sheet.stats; +import com.github.jknack.handlebars.*; import java.io.IOException; import java.net.URL; +import java.util.*; import javafx.application.Platform; import net.rptools.maptool.client.AppConstants; import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.events.TokenHoverEnter; import net.rptools.maptool.client.ui.htmlframe.HTMLContent; -import net.rptools.maptool.model.Token; import net.rptools.maptool.model.sheet.stats.StatSheetContext; import net.rptools.maptool.model.sheet.stats.StatSheetLocation; import net.rptools.maptool.util.HandlebarsUtil; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Class that represents a pop up stat sheet. */ public class StatSheet { - /** Object for logging messages. */ - private static final Logger log = LogManager.getLogger(StatSheet.class); + private static final Logger log = LoggerFactory.getLogger(StatSheet.class); /** * Sets the content for the stat sheet. The content is a HTML page that is rendered using the * Handlebars template engine. * - * @param token the token to render the stat sheet for. + * @param event the token hover event triggering the stat-sheet rendering. * @param content the content of the stat sheet. * @param location the location of the stat sheet. */ - public void setContent(Token token, String content, URL entry, StatSheetLocation location) { + public void setContent( + TokenHoverEnter event, String content, URL entry, StatSheetLocation location) { try { - var statSheetContext = new StatSheetContext(token, MapTool.getPlayer(), location); + var statSheetContext = new StatSheetContext(event, MapTool.getPlayer(), location); var output = HTMLContent.htmlFromString(new HandlebarsUtil<>(content, entry).apply(statSheetContext)) .injectURLBase(entry); diff --git a/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheetListener.java b/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheetListener.java index 9e7c001e03..c39201e107 100644 --- a/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheetListener.java +++ b/src/main/java/net/rptools/maptool/client/ui/sheet/stats/StatSheetListener.java @@ -58,9 +58,9 @@ public void onHoverEnter(TokenHoverEnter event) { var token = event.token(); if (MapTool.getPlayer().isGM() || AppUtil.playerOwns(token) - || token.getType() != Type.NPC) { + || token.getType() == Type.NPC) { statSheet.setContent( - event.token(), + event, ssManager.getStatSheetContent(ssId), ssRecord.entry(), ssProperties.location()); diff --git a/src/main/java/net/rptools/maptool/model/sheet/stats/StatSheetContext.java b/src/main/java/net/rptools/maptool/model/sheet/stats/StatSheetContext.java index 1895823f0b..e7d023822f 100644 --- a/src/main/java/net/rptools/maptool/model/sheet/stats/StatSheetContext.java +++ b/src/main/java/net/rptools/maptool/model/sheet/stats/StatSheetContext.java @@ -14,26 +14,35 @@ */ package net.rptools.maptool.model.sheet.stats; -import java.awt.Dimension; -import java.util.ArrayList; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.text.Collator; +import java.util.*; import java.util.List; -import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Stream; import net.rptools.lib.AwtUtil; import net.rptools.lib.MD5Key; import net.rptools.maptool.client.AppPreferences; import net.rptools.maptool.client.AppUtil; import net.rptools.maptool.client.MapTool; import net.rptools.maptool.client.MapToolVariableResolver; -import net.rptools.maptool.model.Token; +import net.rptools.maptool.client.events.TokenHoverEnter; +import net.rptools.maptool.client.ui.token.AbstractTokenOverlay; +import net.rptools.maptool.client.ui.token.BarTokenOverlay; import net.rptools.maptool.model.player.Player; import net.rptools.maptool.util.HTMLUtil; import net.rptools.maptool.util.ImageManager; +import org.apache.commons.beanutils.BeanUtilsBean; +import org.apache.commons.beanutils.PropertyUtilsBean; /** Class that extracts and provides the information needed to render a stat sheet. */ +@SuppressWarnings("unused") public class StatSheetContext { - /** Class that represents a token property on a stat sheet. */ - static class Property { + public static class Property { /** Name of the property. */ private final String name; @@ -56,7 +65,7 @@ static class Property { * @param displayName Display Name of the property. * @param value Value of the property. * @param gmOnly True if the property is GM only. - * @note GM only properties are only extracted if the player is a GM. + * @implNote GM only properties are only extracted if the player is a GM. */ Property(String name, String displayName, String shortName, Object value, boolean gmOnly) { this.name = name; @@ -127,6 +136,9 @@ public String getShortName() { /** The portrait asset of the token. */ private final MD5Key portraitAsset; + /** The handout asset of the token. */ + private final MD5Key handoutAsset; + /** The width of the portrait on the stat sheet. */ private final int portraitWidth; @@ -139,6 +151,12 @@ public String getShortName() { /** The properties of the token. */ private final List properties = new ArrayList<>(); + /** The bars shown on the token. */ + private final List> bars = new ArrayList<>(); + + /** The states set on the token. */ + private final List> states = new ArrayList<>(); + /** The notes of the token. */ private final String notes; @@ -160,19 +178,41 @@ public String getShortName() { /** True if the player is a GM. */ private final boolean gm; + /** Hover event */ + private final TokenHoverEnter event; + /** * Creates a new instance of the class. * - * @param token The token to extract the information from. + * @param hoverEvent the token hover event to build the stat-sheet for. * @param player The player to extract the information for. * @param location The location of the stat sheet. */ - public StatSheetContext(Token token, Player player, StatSheetLocation location) { + public StatSheetContext(TokenHoverEnter hoverEvent, Player player, StatSheetLocation location) { + this.event = hoverEvent; + var token = event.token(); + + final boolean playerOwns = AppUtil.playerOwns(token); + final boolean playerIsGm = player.isGM(); name = token.getName(); tokenType = token.getType().name(); - if (player.isGM()) { + /* Combined list of Bar and State names */ + final List OVERLAY_NAMES = + Stream.concat( + MapTool.getCampaign().getTokenBarsMap().keySet().stream(), + MapTool.getCampaign().getTokenStatesMap().keySet().stream()) + .toList(); + + for (String stateName : OVERLAY_NAMES) { + Object stateValue = token.getState(stateName); + if (stateValue != null) { + addBarOrState(stateName, stateValue, playerOwns, playerIsGm); + } + } + + if (playerIsGm) { gmName = token.getGMName(); gmNotes = token.getGMNotes(); gmNotesType = token.getNotesType(); @@ -183,10 +223,12 @@ public StatSheetContext(Token token, Player player, StatSheetLocation location) gmNotesType = null; gm = false; } - notes = AppUtil.playerOwns(token) ? token.getNotes() : null; - notesType = AppUtil.playerOwns(token) ? token.getNotesType() : null; + notes = playerOwns ? token.getNotes() : null; + notesType = playerOwns ? token.getNotesType() : null; speechName = token.getSpeechName(); + handoutAsset = token.getCharsheetImage(); + if (AppPreferences.showPortrait.get()) { imageAsset = token.getImageAssetId(); portraitAsset = token.getPortraitImage(); @@ -202,11 +244,11 @@ public StatSheetContext(Token token, Player player, StatSheetLocation location) .forEach( tp -> { if (tp.isShowOnStatSheet()) { - if (tp.isGMOnly() && !MapTool.getPlayer().isGM()) { + if (tp.isGMOnly() && !playerIsGm) { return; } - if (tp.isOwnerOnly() && !AppUtil.playerOwns(token)) { + if (tp.isOwnerOnly() && !playerOwns) { return; } @@ -215,10 +257,8 @@ public StatSheetContext(Token token, Player player, StatSheetLocation location) return; } - if (value instanceof String svalue) { - if (svalue.isBlank()) { - return; - } + if (value instanceof String sValue && sValue.isBlank()) { + return; } properties.add( new Property( @@ -232,11 +272,9 @@ public StatSheetContext(Token token, Player player, StatSheetLocation location) Dimension dim; if (token.getPortraitImage() != null) { - var image = ImageManager.getImage(token.getPortraitImage()); - dim = new Dimension(image.getWidth(), image.getHeight()); + dim = getImageDimensions.apply(token.getPortraitImage()); } else { - var image = ImageManager.getImage(token.getImageAssetId()); - dim = new Dimension(image.getWidth(), image.getHeight()); + dim = getImageDimensions.apply(token.getImageAssetId()); } AwtUtil.constrainTo(dim, AppPreferences.portraitSize.get()); portraitWidth = dim.width; @@ -255,6 +293,106 @@ public StatSheetContext(Token token, Player player, StatSheetLocation location) }; } + private static final Function getImageDimensions = + md5Key -> { + BufferedImage image = ImageManager.getImage(md5Key); + return new Dimension(image.getWidth(), image.getHeight()); + }; + + /** Comparator for sorting State Groups */ + private static final Comparator> stateComparator = + (o1, o2) -> { + String s1 = o1.get("group").toString(); + String s2 = o2.get("group").toString(); + // for different groups use natural order by group value + int result = Collator.getInstance().compare(s1, s2); + if (result != 0) { + return result; + } + // for the same group, use the "order" value - should always be present + if (Objects.equals(s1, s2) + && o1.get("order") instanceof Integer i1 + && o2.get("order") instanceof Integer i2) { + return i1.compareTo(i2); + } + return 0; // should never reach this point + }; + + /** + * Method for filtering overlays and adding them to the appropriate data set + * + * @param overlayName Name of bar or state + * @param overlayValue Value attached to bar or state + * @param playerOwns Used for filtering what to display + * @param playerIsGm Used for filtering what to display + */ + private void addBarOrState( + String overlayName, Object overlayValue, boolean playerOwns, boolean playerIsGm) { + + AbstractTokenOverlay ato; + if (MapTool.getCampaign().getTokenBarsMap().containsKey(overlayName)) { + ato = MapTool.getCampaign().getTokenBarsMap().get(overlayName); + } else { + ato = MapTool.getCampaign().getTokenStatesMap().get(overlayName); + } + if (ato == null) { + return; + } + if ((ato.isShowOthers() && !playerOwns) + || (playerOwns && ato.isShowOwner()) + || (playerIsGm && ato.isShowGM())) { + Map featureMap = new HashMap<>(); + featureMap.put( + "type", + ato.getClass() + .getSimpleName() + .replaceAll("BarTokenOverlay", "") + .replaceAll("TokenOverlay", "")); + try { + PropertyUtilsBean pub = BeanUtilsBean.getInstance().getPropertyUtils(); + featureMap.putAll(pub.describe(ato)); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException(e); + } + String mName; + Map aspectRatioMap = new HashMap<>(); + for (Map.Entry entry : featureMap.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Color color) { + featureMap.put( + entry.getKey(), + String.format( + "rgba(%d,%d,%d,%#.3f)", + color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha() / 255f)); + } else if (value instanceof MD5Key id) { + featureMap.put(entry.getKey(), String.format("asset://%s", id)); + Dimension dim = getImageDimensions.apply(id); + aspectRatioMap.put(entry.getKey() + "AspectRatio", dim.getWidth() / dim.getHeight()); + } else if (value instanceof MD5Key[] idArray) { + String[] strOut = new String[idArray.length]; + double[] arOut = new double[idArray.length]; + for (int i = 0; i < idArray.length; i++) { + strOut[i] = String.format("asset://%s", idArray[i].toString()); + Dimension dim = getImageDimensions.apply(idArray[i]); + arOut[i] = dim.getWidth() / dim.getHeight(); + } + aspectRatioMap.put(entry.getKey() + "AspectRatio", arOut); + featureMap.put(entry.getKey(), strOut); + } + } + featureMap.putAll(aspectRatioMap); + if (ato instanceof BarTokenOverlay) { + featureMap.put( + "value", overlayValue instanceof BigDecimal bd ? bd.doubleValue() : overlayValue); + featureMap.remove("group"); // does not apply to bars + featureMap.remove("order"); // does not apply to bars + bars.add(featureMap); + } else { + states.add(featureMap); + } + } + } + /** * Returns the name of the token. * @@ -328,9 +466,9 @@ public List getProperties() { } /** - * Returns the css class for the location of the stat sheet. + * Returns the CSS class for the location of the stat sheet. * - * @return The css class for the location of the stat sheet. + * @return The CSS class for the location of the stat sheet. */ public String getStatSheetLocation() { return statSheetLocation; @@ -388,4 +526,19 @@ public String getTokenType() { public boolean isGm() { return gm; } + + /** + * @return States set on the token. + */ + public List> getStates() { + states.sort(stateComparator); + return states; + } + + /** + * @return Bars available on the token. + */ + public List> getBars() { + return bars; + } } diff --git a/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java b/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java index 18d1ab83b2..ab2406e9d6 100644 --- a/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java +++ b/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java @@ -14,11 +14,7 @@ */ package net.rptools.maptool.util; -import com.github.jknack.handlebars.Context; -import com.github.jknack.handlebars.Handlebars; -import com.github.jknack.handlebars.Helper; -import com.github.jknack.handlebars.Options; -import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.*; import com.github.jknack.handlebars.context.JavaBeanValueResolver; import com.github.jknack.handlebars.helper.ConditionalHelpers; import com.github.jknack.handlebars.helper.StringHelpers; @@ -40,6 +36,7 @@ import java.util.Arrays; import java.util.Base64; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import net.rptools.maptool.model.Token; import net.rptools.maptool.model.library.Library; import net.rptools.maptool.model.library.LibraryManager; @@ -52,6 +49,19 @@ * @param The type of the bean to apply the template to. */ public class HandlebarsUtil { + static Handlebars getHandlebarsInstance(@Nullable TemplateLoader loader) { + Handlebars handlebars = new Handlebars(loader); + StringHelpers.register(handlebars); + Arrays.stream(ConditionalHelpers.values()).forEach(h -> handlebars.registerHelper(h.name(), h)); + handlebars.registerHelper("json", Jackson2Helper.INSTANCE); + NumberHelper.register(handlebars); + handlebars.registerHelper(AssignHelper.NAME, AssignHelper.INSTANCE); + handlebars.registerHelper(IncludeHelper.NAME, IncludeHelper.INSTANCE); + Arrays.stream(MapToolHelpers.values()).forEach(h -> handlebars.registerHelper(h.name(), h)); + + return handlebars; + } + public static boolean isAssetFileHandlebars(String filename) { if (filename == null) { return false; @@ -155,16 +165,8 @@ public Object apply(final Object context, final Options options) { * @throws IOException If there is an error compiling the template. */ private HandlebarsUtil(String stringTemplate, TemplateLoader loader) throws IOException { + Handlebars handlebars = getHandlebarsInstance(loader); try { - Handlebars handlebars = new Handlebars(loader); - StringHelpers.register(handlebars); - Arrays.stream(ConditionalHelpers.values()) - .forEach(h -> handlebars.registerHelper(h.name(), h)); - NumberHelper.register(handlebars); - handlebars.registerHelper(AssignHelper.NAME, AssignHelper.INSTANCE); - handlebars.registerHelper(IncludeHelper.NAME, IncludeHelper.INSTANCE); - Arrays.stream(MapToolHelpers.values()).forEach(h -> handlebars.registerHelper(h.name(), h)); - template = handlebars.compileInline(stringTemplate); } catch (IOException e) { log.error("Handlebars Error: {}", e.getMessage());