/** @author pepijn */ public class WPTileProvider implements org.pepsoft.util.swing.TileProvider, Dimension.Listener, Tile.Listener { public WPTileProvider( Dimension dimension, ColourScheme colourScheme, BiomeScheme biomeScheme, CustomBiomeManager customBiomeManager, Collection<Layer> hiddenLayers, boolean contourLines, int contourSeparation, TileRenderer.LightOrigin lightOrigin, boolean showBorder, org.pepsoft.util.swing.TileProvider surroundingTileProvider, boolean active) { tileProvider = dimension; this.colourScheme = colourScheme; this.biomeScheme = biomeScheme; this.hiddenLayers = (hiddenLayers != null) ? new HashSet<>(hiddenLayers) : null; this.contourLines = contourLines; this.contourSeparation = contourSeparation; this.lightOrigin = lightOrigin; this.active = active; this.customBiomeManager = customBiomeManager; this.surroundingTileProvider = surroundingTileProvider; this.showBorder = showBorder; tileRendererRef = createNewTileRendererRef(); } public WPTileProvider( TileProvider tileProvider, ColourScheme colourScheme, BiomeScheme biomeScheme, CustomBiomeManager customBiomeManager, Collection<Layer> hiddenLayers, boolean contourLines, int contourSeparation, TileRenderer.LightOrigin lightOrigin, boolean showBorder, org.pepsoft.util.swing.TileProvider surroundingTileProvider) { this.tileProvider = tileProvider; this.colourScheme = colourScheme; this.biomeScheme = biomeScheme; this.hiddenLayers = (hiddenLayers != null) ? new HashSet<>(hiddenLayers) : null; this.contourLines = contourLines; this.contourSeparation = contourSeparation; this.lightOrigin = lightOrigin; active = false; this.customBiomeManager = customBiomeManager; this.surroundingTileProvider = surroundingTileProvider; this.showBorder = showBorder; tileRendererRef = createNewTileRendererRef(); } public synchronized void addHiddenLayer(Layer layer) { hiddenLayers.add(layer); tileRendererRef = createNewTileRendererRef(); } public synchronized void removeHiddenLayer(Layer layer) { hiddenLayers.remove(layer); tileRendererRef = createNewTileRendererRef(); } @Override public int getTileSize() { return TILE_SIZE; } @Override public boolean isTilePresent(int x, int y) { if (zoom == 0) { return getUnzoomedTileType(x, y) != TileType.SURROUNDS || ((surroundingTileProvider != null) && surroundingTileProvider.isTilePresent(x, y)); } else { final int scale = 1 << -zoom; for (int dx = 0; dx < scale; dx++) { for (int dy = 0; dy < scale; dy++) { switch (getUnzoomedTileType(x * scale + dx, y * scale + dy)) { case WORLD: case BORDER: case WALL: return true; case SURROUNDS: if ((surroundingTileProvider != null) && surroundingTileProvider.isTilePresent(x, y)) { return true; } break; } } } return false; } } @Override public void paintTile( final Image tileImage, final int x, final int y, final int imageX, final int imageY) { try { if (zoom == 0) { paintUnzoomedTile(tileImage, x, y, imageX, imageY); } else { Graphics2D g2 = (Graphics2D) tileImage.getGraphics(); try { BufferedImage surroundingTileImage = null; final Color waterColour = new Color(colourScheme.getColour(BLK_WATER)); final Color lavaColour = new Color(colourScheme.getColour(BLK_LAVA)); final Color voidColour = new Color(VoidRenderer.getColour()); final Color bedrockColour = new Color(colourScheme.getColour(BLK_BEDROCK)); final int scale = 1 << -zoom; final int subSize = TILE_SIZE / scale; for (int dx = 0; dx < scale; dx++) { for (int dy = 0; dy < scale; dy++) { TileType tileType = getUnzoomedTileType(x * scale + dx, y * scale + dy); switch (tileType) { case WORLD: Tile tile = tileProvider.getTile(x * scale + dx, y * scale + dy); if (tile.hasLayer(NotPresent.INSTANCE)) { if (surroundingTileProvider != null) { if (surroundingTileImage == null) { surroundingTileImage = new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB); surroundingTileProvider.paintTile(surroundingTileImage, x, y, 0, 0); } g2.drawImage( surroundingTileImage, imageX + dx * subSize, imageY + dy * subSize, imageX + (dx + 1) * subSize, imageY + (dy + 1) * subSize, imageX + dx * subSize, imageY + dy * subSize, imageX + (dx + 1) * subSize, imageY + (dy + 1) * subSize, null); } else { g2.setColor(voidColour); g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize); } } TileRenderer tileRenderer = tileRendererRef.get(); tileRenderer.setTile(tile); tileRenderer.renderTile(tileImage, dx * subSize, dy * subSize); break; case BORDER: Color colour; switch (((Dimension) tileProvider).getBorder()) { case WATER: colour = waterColour; break; case LAVA: colour = lavaColour; break; case VOID: colour = voidColour; break; default: throw new InternalError(); } g2.setColor(colour); g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize); // Draw border lines g2.setColor(Color.BLACK); g2.setStroke( new BasicStroke( 2, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0.0f, new float[] {4.0f, 4.0f}, 0.0f)); if (tileProvider.isTilePresent(x * scale + dx, y * scale + dy - 1)) { g2.drawLine( imageX + dx * subSize, imageY + dy * subSize, imageX + (dx + 1) * subSize - 1, imageY + dy * subSize); } if (tileProvider.isTilePresent(x * scale + dx + 1, y * scale + dy)) { g2.drawLine( imageX + (dx + 1) * subSize - 1, imageY + dy * subSize, imageX + (dx + 1) * subSize - 1, imageY + (dy + 1) * subSize - 1); } if (tileProvider.isTilePresent(x * scale + dx, y * scale + dy + 1)) { g2.drawLine( imageX + dx * subSize, imageY + (dy + 1) * subSize - 1, imageX + (dx + 1) * subSize - 1, imageY + (dy + 1) * subSize - 1); } if (tileProvider.isTilePresent(x * scale + dx - 1, y * scale + dy)) { g2.drawLine( imageX + dx * subSize, imageY + dy * subSize, imageX + dx * subSize, imageY + (dy + 1) * subSize - 1); } break; case SURROUNDS: case WALL: if (surroundingTileProvider != null) { if (surroundingTileImage == null) { surroundingTileImage = new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB); surroundingTileProvider.paintTile(surroundingTileImage, x, y, 0, 0); } g2.drawImage( surroundingTileImage, imageX + dx * subSize, imageY + dy * subSize, imageX + (dx + 1) * subSize, imageY + (dy + 1) * subSize, imageX + dx * subSize, imageY + dy * subSize, imageX + (dx + 1) * subSize, imageY + (dy + 1) * subSize, null); } else { g2.setColor(voidColour); g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize); } if (tileType == TileType.WALL) { g2.setColor(bedrockColour); TileType neighbourType = getUnzoomedTileType(x * scale + dx, y * scale + dy - 1); int wallWidth = Math.max(subSize / 8, 1); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, wallWidth); } neighbourType = getUnzoomedTileType(x * scale + dx + 1, y * scale + dy); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect( imageX + (dx + 1) * subSize - wallWidth, imageY + dy * subSize, wallWidth, subSize); } neighbourType = getUnzoomedTileType(x * scale + dx, y * scale + dy + 1); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect( imageX + dx * subSize, imageY + (dy + 1) * subSize - wallWidth, subSize, wallWidth); } neighbourType = getUnzoomedTileType(x * scale + dx - 1, y * scale + dy); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, wallWidth, subSize); } } break; } } } } finally { g2.dispose(); } } } catch (Throwable e) { // Log at debug level because this tends to happen when zooming in // and out, probably due to some state getting out of sync. It // doesn't so far appear to have any visible consequences. logger.error("Exception while generating image for tile at " + x + ", " + y, e); } } @Override public int getTilePriority(int x, int y) { if (zoom == 0) { return (getUnzoomedTileType(x, y) == TileType.WORLD) ? 1 : 0; } else { final int scale = 1 << -zoom; for (int dx = 0; dx < scale; dx++) { for (int dy = 0; dy < scale; dy++) { if (getUnzoomedTileType(x * scale + dx, y * scale + dy) == TileType.WORLD) { return 1; } } } return 0; } } @Override public Rectangle getExtent() { Rectangle sourceExtent = tileProvider.getExtent(); if (sourceExtent != null) { if (zoom == 0) { return sourceExtent; } else if (zoom < 0) { return new Rectangle( sourceExtent.x >> -zoom, sourceExtent.y >> -zoom, sourceExtent.width >> -zoom, sourceExtent.height >> -zoom); } else { return new Rectangle( sourceExtent.x << zoom, sourceExtent.y << zoom, sourceExtent.width << zoom, sourceExtent.height << zoom); } } else { return null; } } @Override public void addTileListener(TileListener tileListener) { if (active && listeners.isEmpty()) { ((Dimension) tileProvider).addDimensionListener(this); for (Tile tile : ((Dimension) tileProvider).getTiles()) { tile.addListener(this); } } if (!listeners.contains(tileListener)) { listeners.add(tileListener); } } @Override public void removeTileListener(TileListener tileListener) { listeners.remove(tileListener); if (active && listeners.isEmpty()) { for (Tile tile : ((Dimension) tileProvider).getTiles()) { tile.removeListener(this); } ((Dimension) tileProvider).removeDimensionListener(this); } } @Override public boolean isZoomSupported() { return true; } @Override public int getZoom() { return zoom; } @Override public void setZoom(int zoom) { if (zoom != this.zoom) { if (zoom > 0) { throw new UnsupportedOperationException("Zooming in not supported"); } this.zoom = zoom; tileRendererRef = createNewTileRendererRef(); if (surroundingTileProvider != null) { surroundingTileProvider.setZoom(zoom); } } } // Dimension.Listener @Override public void tilesAdded(Dimension dimension, Set<Tile> tiles) { for (Tile tile : tiles) { tile.addListener(this); } fireTilesChangedIncludeBorder(tiles); } @Override public void tilesRemoved(Dimension dimension, Set<Tile> tiles) { for (Tile tile : tiles) { tile.removeListener(this); } fireTilesChangedIncludeBorder(tiles); } // Tile.Listener @Override public void heightMapChanged(Tile tile) { fireTileChanged(tile); } @Override public void terrainChanged(Tile tile) { fireTileChanged(tile); } @Override public void waterLevelChanged(Tile tile) { fireTileChanged(tile); } @Override public void layerDataChanged(Tile tile, Set<Layer> changedLayers) { fireTileChanged(tile); } @Override public void allBitLayerDataChanged(Tile tile) { fireTileChanged(tile); } @Override public void allNonBitlayerDataChanged(Tile tile) { fireTileChanged(tile); } @Override public void seedsChanged(Tile tile) { fireTileChanged(tile); } private TileType getUnzoomedTileType(int x, int y) { if (tileProvider.isTilePresent(x, y)) { return TileType.WORLD; } else if (showBorder && (tileProvider instanceof Dimension)) { Dimension dimension = (Dimension) tileProvider; if (dimension.isBorderTile(x, y)) { return TileType.BORDER; } else if (dimension.isBedrockWall() && ((dimension.getBorder() != null) ? (dimension.isBorderTile(x - 1, y) || dimension.isBorderTile(x, y - 1) || dimension.isBorderTile(x + 1, y) || dimension.isBorderTile(x, y + 1)) : (tileProvider.isTilePresent(x - 1, y) || tileProvider.isTilePresent(x, y - 1) || tileProvider.isTilePresent(x + 1, y) || tileProvider.isTilePresent(x, y + 1)))) { return TileType.WALL; } } return TileType.SURROUNDS; } private void paintUnzoomedTile( final Image tileImage, final int x, final int y, final int dx, final int dy) { TileType tileType = getUnzoomedTileType(x, y); switch (tileType) { case WORLD: Tile tile = tileProvider.getTile(x, y); if (tile.hasLayer(NotPresent.INSTANCE) && (surroundingTileProvider != null)) { surroundingTileProvider.paintTile(tileImage, x, y, dx, dy); } TileRenderer tileRenderer = tileRendererRef.get(); tileRenderer.setTile(tile); tileRenderer.renderTile(tileImage, dx, dy); break; case BORDER: int colour; switch (((Dimension) tileProvider).getBorder()) { case WATER: colour = colourScheme.getColour(BLK_WATER); break; case LAVA: colour = colourScheme.getColour(BLK_LAVA); break; case VOID: colour = VoidRenderer.getColour(); break; default: throw new InternalError(); } Graphics2D g2 = (Graphics2D) tileImage.getGraphics(); try { g2.setColor(new Color(colour)); g2.fillRect(dx, dy, TILE_SIZE, TILE_SIZE); // Draw border lines g2.setColor(Color.BLACK); g2.setStroke( new BasicStroke( 2, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0.0f, new float[] {4.0f, 4.0f}, 0.0f)); if (tileProvider.isTilePresent(x, y - 1)) { g2.drawLine(dx + 1, dy + 1, dx + TILE_SIZE - 1, dy + 1); } if (tileProvider.isTilePresent(x + 1, y)) { g2.drawLine(dx + TILE_SIZE - 1, dy + 1, dx + TILE_SIZE - 1, dy + TILE_SIZE - 1); } if (tileProvider.isTilePresent(x, y + 1)) { g2.drawLine(dx + 1, dy + TILE_SIZE - 1, dx + TILE_SIZE - 1, dy + TILE_SIZE - 1); } if (tileProvider.isTilePresent(x - 1, y)) { g2.drawLine(dx + 1, dy + 1, dx + 1, dy + TILE_SIZE - 1); } } finally { g2.dispose(); } break; case WALL: if (surroundingTileProvider != null) { surroundingTileProvider.paintTile(tileImage, x, y, dx, dy); } g2 = (Graphics2D) tileImage.getGraphics(); try { if (surroundingTileProvider == null) { // A surrounding tile provider would have completely // filled the image, but since there isn't one we have // to make sure of that ourselves g2.setColor(new Color(VoidRenderer.getColour())); g2.fillRect(dx, dy, TILE_SIZE, TILE_SIZE); } g2.setColor(new Color(colourScheme.getColour(BLK_BEDROCK))); TileType neighbourType = getUnzoomedTileType(x, y - 1); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(dx, dy, TILE_SIZE, 16); } neighbourType = getUnzoomedTileType(x + 1, y); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(dx + TILE_SIZE - 16, dy, 16, TILE_SIZE); } neighbourType = getUnzoomedTileType(x, y + 1); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(dx, dy + TILE_SIZE - 16, TILE_SIZE, 16); } neighbourType = getUnzoomedTileType(x - 1, y); if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) { g2.fillRect(dx, dy, 16, TILE_SIZE); } } finally { g2.dispose(); } break; case SURROUNDS: if (surroundingTileProvider != null) { surroundingTileProvider.paintTile(tileImage, x, y, dx, dy); } break; default: throw new InternalError(); } } private void fireTileChanged(Tile tile) { Point coords = getTileCoordinates(tile); for (TileListener listener : listeners) { listener.tileChanged(this, coords.x, coords.y); } } private void fireTilesChangedIncludeBorder(Set<Tile> tiles) { if (showBorder && (tileProvider instanceof Dimension) && (((Dimension) tileProvider).getDim() == DIM_NORMAL) && (((Dimension) tileProvider).getBorder() != null)) { final Set<Point> coordSet = new HashSet<>(); for (Tile tile : tiles) { final int tileX = tile.getX(), tileY = tile.getY(), borderSize = ((Dimension) tileProvider).getBorderSize(); for (int dx = -borderSize; dx <= borderSize; dx++) { for (int dy = -borderSize; dy <= borderSize; dy++) { coordSet.add(getTileCoordinates(tileX + dx, tileY + dy)); } } } for (TileListener listener : listeners) { listener.tilesChanged(this, coordSet); } } else { Set<Point> coords = tiles.stream().map(this::getTileCoordinates).collect(Collectors.toSet()); for (TileListener listener : listeners) { listener.tilesChanged(this, coords); } } } /** * Convert the actual tile coordinates to zoom-corrected (tile provider coordinate system) * coordinates. * * @param tile The tile of which to convert the coordinates. * @return The coordinates of the tile in the tile provider coordinate system (corrected for * zoom). */ private Point getTileCoordinates(Tile tile) { return getTileCoordinates(tile.getX(), tile.getY()); } /** * Convert the actual tile coordinates to zoom-corrected (tile provider coordinate system) * coordinates. * * @param tileX The X tile coordinate to convert. * @param tileY The Y tile coordinate to convert. * @return The coordinates of the tile in the tile provider coordinate system (corrected for * zoom). */ private Point getTileCoordinates(final int tileX, final int tileY) { if (zoom == 0) { return new Point(tileX, tileY); } else if (zoom < 0) { return new Point(tileX >> -zoom, tileY >> -zoom); } else { return new Point(tileX << zoom, tileY << zoom); } } @NotNull private ThreadLocal<TileRenderer> createNewTileRendererRef() { return new ThreadLocal<TileRenderer>() { @Override protected TileRenderer initialValue() { TileRenderer tileRenderer = new TileRenderer(tileProvider, colourScheme, biomeScheme, customBiomeManager, zoom); synchronized (WPTileProvider.this) { if (hiddenLayers != null) { tileRenderer.addHiddenLayers(hiddenLayers); } } tileRenderer.setContourLines(contourLines); tileRenderer.setContourSeparation(contourSeparation); tileRenderer.setLightOrigin(lightOrigin); return tileRenderer; } }; } private final TileProvider tileProvider; private final ColourScheme colourScheme; private final BiomeScheme biomeScheme; private final Set<Layer> hiddenLayers; private final boolean contourLines, active, showBorder; private final int contourSeparation; private final TileRenderer.LightOrigin lightOrigin; private final List<TileListener> listeners = new ArrayList<>(); private final CustomBiomeManager customBiomeManager; private final org.pepsoft.util.swing.TileProvider surroundingTileProvider; private int zoom = 0; private volatile ThreadLocal<TileRenderer> tileRendererRef; private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(WPTileProvider.class); private enum TileType { /** The tile is part of the WorldPainter world. */ WORLD, /** The tile is part of the WorldPainter border. */ BORDER, /** The tile contains no WorldPainter-generated chunks. */ SURROUNDS, /** The tile is outside the WorldPainter world and border but does contain part of a wall. */ WALL } }
/** * A localised operation which uses the mouse or tablet to indicate where and how it should be * applied. * * @author pepijn */ public abstract class MouseOrTabletOperation extends AbstractOperation implements PenListener, MouseListener, MouseMotionListener { /** * Creates a new one-shot operation (an operation which performs a single action when clicked). * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for * these operations. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param view The WorldPainter view through which the dimension that is being edited is being * displayed and on which the operation should install its listeners to register user mouse, * keyboard and tablet actions. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. */ protected MouseOrTabletOperation( String name, String description, WorldPainterView view, String statisticsKey) { this(name, description, view, -1, true, statisticsKey, null); } /** * Creates a new one-shot operation (an operation which performs a single action when clicked). * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for * these operations. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param view The WorldPainter view through which the dimension that is being edited is being * displayed and on which the operation should install its listeners to register user mouse, * keyboard and tablet actions. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. * @param iconName The base name of the icon for the operation. */ protected MouseOrTabletOperation( String name, String description, WorldPainterView view, String statisticsKey, String iconName) { this(name, description, view, -1, true, statisticsKey, iconName); } /** * Creates a new continuous operation (an operation which is continually performed while e.g. the * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked * every <code>delay</code> milliseconds during each activation of these operations, with the * <code>first</code> parameter set to <code>true</code> for the first invocation per activation, * and set to <code>false</code> for all subsequent invocations per activation. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param view The WorldPainter view through which the dimension that is being edited is being * displayed and on which the operation should install its listeners to register user mouse, * keyboard and tablet actions. * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean, * boolean, float)} while this operation is being applied by the user. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. */ protected MouseOrTabletOperation( String name, String description, WorldPainterView view, int delay, String statisticsKey) { this(name, description, view, delay, false, statisticsKey, null); } /** * Creates a new continuous operation (an operation which is continually performed while e.g. the * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked * every <code>delay</code> milliseconds during each activation of these operations, with the * <code>first</code> parameter set to <code>true</code> for the first invocation per activation, * and set to <code>false</code> for all subsequent invocations per activation. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param view The WorldPainter view through which the dimension that is being edited is being * displayed and on which the operation should install its listeners to register user mouse, * keyboard and tablet actions. * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean, * boolean, float)} while this operation is being applied by the user. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. * @param iconName The base name of the icon for the operation. */ protected MouseOrTabletOperation( String name, String description, WorldPainterView view, int delay, String statisticsKey, String iconName) { this(name, description, view, delay, false, statisticsKey, iconName); } /** * Creates a new one-shot operation (an operation which performs a single action when clicked). * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for * these operations. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. */ protected MouseOrTabletOperation(String name, String description, String statisticsKey) { this(name, description, null, -1, true, statisticsKey, null); } /** * Creates a new continuous operation (an operation which is continually performed while e.g. the * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked * every <code>delay</code> milliseconds during each activation of these operations, with the * <code>first</code> parameter set to <code>true</code> for the first invocation per activation, * and set to <code>false</code> for all subsequent invocations per activation. * * @param name The short name of the operation. May be displayed on the operation's tool button. * @param description A longer description of the operation. May be displayed to the user as a * tooltip. * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean, * boolean, float)} while this operation is being applied by the user. * @param statisticsKey The key with which use of this operation will be logged in the usage data * sent back to the developer. Should start with a reverse-DNS style identifier, optionally * followed by some basic or fundamental setting, if it has one. */ protected MouseOrTabletOperation( String name, String description, int delay, String statisticsKey) { this(name, description, null, delay, false, statisticsKey, null); } private MouseOrTabletOperation( String name, String description, WorldPainterView view, int delay, boolean oneshot, String statisticsKey, String iconName) { super( name, description, (iconName != null) ? iconName : name.toLowerCase().replaceAll("\\s", "")); setView(view); this.delay = delay; this.oneShot = oneshot; this.statisticsKey = statisticsKey; statisticsKeyUndo = statisticsKey + ".undo"; legacy = (SystemUtils.isMac() && System.getProperty("os.version").startsWith("10.4.")) || "true" .equalsIgnoreCase( System.getProperty("org.pepsoft.worldpainter.disableTabletSupport")); if (legacy) { logger.warn("Tablet support disabled for operation " + name); } } public Dimension getDimension() { return view.getDimension(); } public final WorldPainterView getView() { return view; } @Override public final void setView(WorldPainterView view) { if (this.view != null) { deactivate(); } this.view = view; } public float getLevel() { return level; } public void setLevel(float level) { if ((level < 0.0f) || (level > 1.0f)) { throw new IllegalArgumentException(); } this.level = level; } // PenListener (these methods are invoked in non-legacy mode, even for mouse events) @Override public void penLevelEvent(PLevelEvent ple) { for (PLevel pLevel : ple.levels) { switch (pLevel.getType()) { case PRESSURE: dynamicLevel = pLevel.value; break; case X: x = pLevel.value; break; case Y: y = pLevel.value; break; default: // Do nothing } } } @Override public void penButtonEvent(PButtonEvent pbe) { PKind.Type penKindType = pbe.pen.getKind().getType(); final boolean stylus = penKindType == PKind.Type.STYLUS; final boolean eraser = penKindType == PKind.Type.ERASER; if ((!stylus) && (!eraser) && (penKindType != PKind.Type.CURSOR)) { // We don't want events from keyboards, etc. return; } final PButton.Type buttonType = pbe.button.getType(); switch (buttonType) { case ALT: altDown = pbe.button.value; break; case CONTROL: ctrlDown = pbe.button.value; break; case SHIFT: shiftDown = pbe.button.value; break; case LEFT: case RIGHT: if (pbe.button.value) { // Button pressed first = true; undo = eraser || (buttonType == PButton.Type.RIGHT) || altDown; if (!oneShot) { if (timer == null) { timer = new Timer( delay, e -> { Point worldCoords = view.viewToWorld((int) x, (int) y); tick( worldCoords.x, worldCoords.y, undo, first, (stylus || eraser) ? dynamicLevel : 1.0f); view.updateStatusBar(worldCoords.x, worldCoords.y); first = false; }); timer.setInitialDelay(0); timer.start(); // start = System.currentTimeMillis(); } } else { Point worldCoords = view.viewToWorld((int) x, (int) y); SwingUtilities.invokeLater( () -> { tick(worldCoords.x, worldCoords.y, undo, true, 1.0f); view.updateStatusBar(worldCoords.x, worldCoords.y); Dimension dimension = getDimension(); if (dimension != null) { dimension.armSavePoint(); } logOperation(undo ? statisticsKeyUndo : statisticsKey); }); } } else { // Button released if (!oneShot) { SwingUtilities.invokeLater( () -> { if (timer != null) { logOperation(undo ? statisticsKeyUndo : statisticsKey); timer.stop(); timer = null; } finished(); Dimension dimension = getDimension(); if (dimension != null) { dimension.armSavePoint(); } }); } } break; } } @Override public void penKindEvent(PKindEvent pke) {} @Override public void penScrollEvent(PScrollEvent pse) {} @Override public void penTock(long l) {} // MouseListener (these methods are only invoked in legacy mode) @Override public void mousePressed(MouseEvent me) { x = me.getX(); y = me.getY(); altDown = me.isAltDown() || me.isAltGraphDown(); undo = (me.getButton() == MouseEvent.BUTTON3) || altDown; ctrlDown = me.isControlDown() || me.isMetaDown(); shiftDown = me.isShiftDown(); first = true; if (!oneShot) { if (timer == null) { timer = new Timer( delay, e -> { Point worldCoords = view.viewToWorld((int) x, (int) y); tick(worldCoords.x, worldCoords.y, undo, first, 1.0f); view.updateStatusBar(worldCoords.x, worldCoords.y); first = false; }); timer.setInitialDelay(0); timer.start(); // start = System.currentTimeMillis(); } } else { Point worldCoords = view.viewToWorld((int) x, (int) y); tick(worldCoords.x, worldCoords.y, undo, true, 1.0f); view.updateStatusBar(worldCoords.x, worldCoords.y); Dimension dimension = getDimension(); if (dimension != null) { dimension.armSavePoint(); } logOperation(undo ? statisticsKeyUndo : statisticsKey); } } @Override public void mouseReleased(MouseEvent me) { if (!oneShot) { if (timer != null) { logOperation(undo ? statisticsKeyUndo : statisticsKey); timer.stop(); timer = null; } finished(); Dimension dimension = getDimension(); if (dimension != null) { dimension.armSavePoint(); } } } @Override public void mouseClicked(MouseEvent me) {} @Override public void mouseEntered(MouseEvent me) {} @Override public void mouseExited(MouseEvent me) {} // MouseMotionListener (these methods are only invoked in legacy mode) @Override public void mouseDragged(MouseEvent me) { x = me.getX(); y = me.getY(); } @Override public void mouseMoved(MouseEvent me) { altDown = me.isAltDown() || me.isAltGraphDown(); ctrlDown = me.isControlDown() || me.isMetaDown(); shiftDown = me.isShiftDown(); } @Override protected void activate() throws PropertyVetoException { if (legacy) { view.addMouseListener(this); view.addMouseMotionListener(this); } else { AwtPenToolkit.addPenListener(view, this); } // Prevent hanging modifiers altDown = ctrlDown = shiftDown = false; } @Override protected void deactivate() { if (legacy) { view.removeMouseMotionListener(this); view.removeMouseListener(this); } else { AwtPenToolkit.removePenListener(view, this); } } /** * Apply the operation. * * @param centreX The x coordinate of the center of the brush, in world coordinates. * @param centreY The y coordinate of the center of the brush, in world coordinates. * @param inverse Whether to perform the "inverse" operation instead of the regular operation, if * applicable. If the operation has no inverse it should just apply the normal operation. * @param first Whether this is the first tick of a continuous operation. For a one shot operation * this will always be <code>true</code>. * @param dynamicLevel The dynamic level (from 0.0f to 1.0f inclusive) to apply in addition to the * <code>level</code> property, for instance due to a pressure sensitive stylus being used. In * other words, <strong>not</strong> the total level at which to apply the operation! * Operations are free to ignore this if it is not applicable. If the operation is being * applied through a means which doesn't provide a dynamic level (for instance the mouse), * this will be <em>exactly</em> <code>1.0f</code>. */ protected abstract void tick( int centreX, int centreY, boolean inverse, boolean first, float dynamicLevel); /** * Invoked after the last {@link #tick(int, int, boolean, boolean, float)} when the user ceases to * apply the operation (except for one shot operations). */ protected void finished() { // Do nothing } /** * Determine whether the Alt (PC/Mac), AltGr (PC) or Option (Mac) key is currently depressed. * <strong>Warning:</strong> this key is also used to invert operations! It is probably a bad idea * to overload it with anything else. * * @return <code>true</code> if the Alt, AltGr or Option key is currently depressed. */ protected final boolean isAltDown() { return altDown; } /** * Determine whether the Ctrl (PC/Mac), Windows (PC) or Command (Mac) key is currently depressed. * * @return <code>true</code> if the Ctrl (PC/Mac), Windows (PC) or Command (Mac) key is currently * depressed. */ protected final boolean isCtrlDown() { return ctrlDown; } /** * Determine whether the Shift key is currently depressed. * * @return <code>true</code> if the Shift key is currently depressed. */ protected final boolean isShiftDown() { return shiftDown; } public static void flushEvents(EventLogger eventLogger) { synchronized (operationCounts) { for (Map.Entry<String, Long> entry : operationCounts.entrySet()) { eventLogger.logEvent(new EventVO(entry.getKey()).count(entry.getValue())); } operationCounts.clear(); } } private static void logOperation(String key) { synchronized (operationCounts) { if (operationCounts.containsKey(key)) { operationCounts.put(key, operationCounts.get(key) + 1); } else { operationCounts.put(key, 1L); } } } protected final boolean legacy; private final int delay; private final boolean oneShot; private final String statisticsKey, statisticsKeyUndo; private WorldPainterView view; private volatile Timer timer; private volatile boolean altDown, ctrlDown, shiftDown, first = true, undo; private volatile float dynamicLevel = 1.0f; private volatile float x, y; private float level = 1.0f; // private long start; private static final Map<String, Long> operationCounts = new HashMap<>(); private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MouseOrTabletOperation.class); }