diff --git a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EntityScript.java b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EntityScript.java index cb4c14404..e64830a54 100644 --- a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EntityScript.java +++ b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EntityScript.java @@ -14,17 +14,16 @@ public abstract class EntityScript extends AbstractScript /// Called from the entity's loaded event after its environment is available. /// @throws Exception if initialization fails. - protected void onLoaded() throws Exception { this.loaded(); } + protected void onLoaded() throws Exception {} /// Called when the entity controller is detached or the entity is removed. /// @throws Exception if cleanup fails. - protected void onUnloaded() throws Exception { this.unloaded(); } + protected void onUnloaded() throws Exception {} /// Called for messages delivered to the attached entity. /// @param event The message event. /// @throws Exception if handling fails. protected void onMessage(EntityMessageEvent event) throws Exception { - this.message(event); if (event != null) { this.onMessage(event.getMessage(), event.getSource()); } @@ -137,28 +136,6 @@ public int getMaxHealth() { return this.host() instanceof de.gurkenlabs.litiengine.entities.ICombatEntity combat ? combat.getHitPoints().getMax() : 0; } - /// Legacy callback invoked after the entity environment becomes available. - /// - /// @deprecated Override [#onLoaded()] in new scripts. - /// @throws Exception if handling fails. - @Deprecated - protected void loaded() throws Exception {} - - /// Legacy callback invoked when the script is unloaded. - /// - /// @deprecated Override [#onUnloaded()] in new scripts. - /// @throws Exception if handling fails. - @Deprecated - protected void unloaded() throws Exception {} - - /// Legacy callback invoked when an entity message is received. - /// - /// @param event The message event. - /// @throws Exception if handling fails. - /// @deprecated Override [#onMessage(EntityMessageEvent)] or [#onMessage(String, Object)] in new scripts. - @Deprecated - protected void message(EntityMessageEvent event) throws Exception {} - final void dispatchMessage(EntityMessageEvent event) throws Exception { this.onMessage(event); } diff --git a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EnvironmentScript.java b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EnvironmentScript.java index ff3a34995..f20a5b406 100644 --- a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EnvironmentScript.java +++ b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/EnvironmentScript.java @@ -9,10 +9,10 @@ public abstract class EnvironmentScript extends AbstractScript { @Override protected final void detached() throws Exception { this.onUnloaded(); } /// Called after the environment has loaded and is the current world environment. - protected void onLoaded() throws Exception { this.loaded(); } + protected void onLoaded() throws Exception {} /// Called when the environment binding is detached during unload. - protected void onUnloaded() throws Exception { this.unloaded(); } + protected void onUnloaded() throws Exception {} /// Called when the attached environment is cleared while it remains active. protected void onCleared() throws Exception {} @@ -23,14 +23,6 @@ protected void onEntityAdded(IEntity entity) throws Exception {} /// Called when an entity is removed from the attached environment. protected void onEntityRemoved(IEntity entity) throws Exception {} - /// @deprecated Override [#onLoaded()] in new scripts. - @Deprecated - protected void loaded() throws Exception {} - - /// @deprecated Override [#onUnloaded()] in new scripts. - @Deprecated - protected void unloaded() throws Exception {} - final void dispatchCleared() throws Exception { this.onCleared(); } diff --git a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/GameScript.java b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/GameScript.java index 50ecdfb01..335321c52 100644 --- a/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/GameScript.java +++ b/litiengine/src/main/java/de/gurkenlabs/litiengine/scripting/GameScript.java @@ -12,10 +12,10 @@ public abstract class GameScript extends AbstractScript { @Override protected final void detached() throws Exception { this.onStopped(); } /// Called after the binding enters the running game lifecycle. - protected void onStarted() throws Exception { this.started(); } + protected void onStarted() throws Exception {} /// Called before the binding leaves the game lifecycle. - protected void onStopped() throws Exception { this.stopped(); } + protected void onStopped() throws Exception {} /// Loads an environment map by map name. public void loadMap(String mapName) { @@ -57,11 +57,4 @@ public void exit() { Game.terminate(); } - /// @deprecated Override [#onStarted()] in new scripts. - @Deprecated - protected void started() throws Exception {} - - /// @deprecated Override [#onStopped()] in new scripts. - @Deprecated - protected void stopped() throws Exception {} } diff --git a/litiengine/src/main/java/de/gurkenlabs/litiengine/sound/SoundEngine.java b/litiengine/src/main/java/de/gurkenlabs/litiengine/sound/SoundEngine.java index cbb5dbbf6..7ee6ecb6c 100644 --- a/litiengine/src/main/java/de/gurkenlabs/litiengine/sound/SoundEngine.java +++ b/litiengine/src/main/java/de/gurkenlabs/litiengine/sound/SoundEngine.java @@ -4,6 +4,7 @@ import de.gurkenlabs.litiengine.ILaunchable; import de.gurkenlabs.litiengine.IUpdateable; import de.gurkenlabs.litiengine.entities.IEntity; +import de.gurkenlabs.litiengine.graphics.ICamera; import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.litiengine.tweening.TweenFunction; import java.awt.geom.Point2D; @@ -46,7 +47,7 @@ public Thread newThread(Runnable r) { private static final Logger log = Logger.getLogger(SoundEngine.class.getName()); private Point2D listenerLocation; - private UnaryOperator listenerLocationCallback = old -> Game.world().camera().getFocus(); + private UnaryOperator listenerLocationCallback = SoundEngine::getCameraFocus; private int maxDist = DEFAULT_MAX_DISTANCE; private MusicPlayback music; private final Collection allMusic = ConcurrentHashMap.newKeySet(); @@ -497,7 +498,7 @@ public void setListenerLocationCallback(UnaryOperator callback) { @Override public void start() { - listenerLocation = Game.world().camera().getFocus(); + listenerLocation = getCameraFocus(listenerLocation); } @Override @@ -563,6 +564,13 @@ void addSound(SFXPlayback playback) { this.sounds.add(playback); } + private static Point2D getCameraFocus(Point2D fallback) { + ICamera camera = Game.world().camera(); + return camera != null + ? camera.getFocus() + : fallback != null ? fallback : new Point2D.Double(0, 0); + } + private SFXPlayback playSound( Sound sound, Supplier supplier, boolean loop, int range, float volume) { if (sound == null) { diff --git a/litiengine/src/test/java/de/gurkenlabs/litiengine/GameTest.java b/litiengine/src/test/java/de/gurkenlabs/litiengine/GameTest.java index b6f7dd62a..a87d9a902 100644 --- a/litiengine/src/test/java/de/gurkenlabs/litiengine/GameTest.java +++ b/litiengine/src/test/java/de/gurkenlabs/litiengine/GameTest.java @@ -1,7 +1,8 @@ package de.gurkenlabs.litiengine; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -86,6 +87,15 @@ public void started() { assertTrue(started.wasCalled); } + @Test + void soundEngineUpdateToleratesMissingCamera() { + Game.terminate(); + Game.init(Game.COMMANDLINE_ARG_NOGUI); + Game.world().setCamera(null); + + assertDoesNotThrow(Game.audio()::update); + } + @Test void vetoedExitPreparationDoesNotDetachRunningGameScripts() { Game.terminate(); diff --git a/litiengine/src/test/java/de/gurkenlabs/litiengine/scripting/ScriptRuntimeTests.java b/litiengine/src/test/java/de/gurkenlabs/litiengine/scripting/ScriptRuntimeTests.java index 6f49a3301..89f217e0d 100644 --- a/litiengine/src/test/java/de/gurkenlabs/litiengine/scripting/ScriptRuntimeTests.java +++ b/litiengine/src/test/java/de/gurkenlabs/litiengine/scripting/ScriptRuntimeTests.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -57,6 +58,19 @@ void detachScripts() { JavaEntityScript.reset(); } + @Test + void legacyLifecycleBridgeMethodsAreNotPartOfScriptApi() { + assertThrows(NoSuchMethodException.class, () -> EntityScript.class.getDeclaredMethod("loaded")); + assertThrows(NoSuchMethodException.class, () -> EntityScript.class.getDeclaredMethod("unloaded")); + assertThrows( + NoSuchMethodException.class, + () -> EntityScript.class.getDeclaredMethod("message", EntityMessageEvent.class)); + assertThrows(NoSuchMethodException.class, () -> EnvironmentScript.class.getDeclaredMethod("loaded")); + assertThrows(NoSuchMethodException.class, () -> EnvironmentScript.class.getDeclaredMethod("unloaded")); + assertThrows(NoSuchMethodException.class, () -> GameScript.class.getDeclaredMethod("started")); + assertThrows(NoSuchMethodException.class, () -> GameScript.class.getDeclaredMethod("stopped")); + } + @Test void bindingCodecPreservesOrderStateAndParameters() { ScriptBinding second = new ScriptBinding("second"); @@ -585,9 +599,9 @@ public static final class JavaEntityScript extends EntityScript { public JavaEntityScript() {} - @Override protected void loaded() { loaded++; configuredSpeed = this.speed; } - @Override protected void unloaded() { unloaded++; } - @Override protected void message(EntityMessageEvent event) { messages++; } + @Override protected void onLoaded() { loaded++; configuredSpeed = this.speed; } + @Override protected void onUnloaded() { unloaded++; } + @Override protected void onMessage(EntityMessageEvent event) { messages++; } @Override public void update() { updates++; } static void reset() { @@ -1265,7 +1279,7 @@ public static final class RecoverableFailScript extends EntityScript static int updateCount = 0; @Override - protected void loaded() { + protected void onLoaded() { if (shouldFailLoad) { throw new RuntimeException("Simulated load failure"); } diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/Editor.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/Editor.java index eb9cc2a20..59b769b7c 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/Editor.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/Editor.java @@ -36,6 +36,7 @@ import de.gurkenlabs.utiliti.view.components.SpritesheetImportPanel; import de.gurkenlabs.utiliti.view.renderers.WorkspaceRenderer; import de.gurkenlabs.utiliti.view.components.Tray; +import de.gurkenlabs.utiliti.view.components.Toast; import de.gurkenlabs.utiliti.view.components.UI; import de.gurkenlabs.utiliti.view.dialogs.ConfirmDialog; import de.gurkenlabs.utiliti.view.dialogs.EditorFileChooser; @@ -63,6 +64,7 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; @@ -80,6 +82,7 @@ public class Editor extends Screen { private static UserPreferences preferences; private final List loadedCallbacks; + private final List> projectPathChangedCallbacks; private final MapComponent mapComponent; private ResourceBundle gameFile = new ResourceBundle(); @@ -107,6 +110,7 @@ private Editor() { Game.scripts().setEnabled(false); Game.physics().setEnabled(false); this.loadedCallbacks = new CopyOnWriteArrayList<>(); + this.projectPathChangedCallbacks = new CopyOnWriteArrayList<>(); this.mapComponent = new MapComponent(); this.mapComponent.onMapLoaded(map -> this.windowMetadataDirty.set(true)); } @@ -361,6 +365,7 @@ public void shutdown() { } public void setProjectPath(Path projectPath) { + Path previousProjectPath = this.projectPath; this.projectPath = projectPath; this.currentResourceFile = projectPath; this.projectModel = projectPath == null ? null : this.projectBuildService.resolve(projectPath); @@ -370,9 +375,24 @@ public void setProjectPath(Path projectPath) { Game.scripts().setProjectJavaVersion(Runtime.version().feature()); } this.windowMetadataDirty.set(true); + if (!Objects.equals(previousProjectPath, projectPath)) { + for (BiConsumer callback : this.projectPathChangedCallbacks) { + callback.accept(previousProjectPath, projectPath); + } + } de.gurkenlabs.utiliti.view.components.UI.updateRunControlStates(); } + public void onProjectPathChanged(BiConsumer callback) { + if (callback != null) { + this.projectPathChangedCallbacks.add(callback); + } + } + + public void removeProjectPathChangedListener(BiConsumer callback) { + this.projectPathChangedCallbacks.remove(callback); + } + static long buildConfigurationStamp(Path projectRoot) { if (projectRoot == null) return 0; List candidates = new ArrayList<>(); @@ -413,6 +433,10 @@ public void create() { return; } + if (!UI.notifyPendingChanges()) { + return; + } + if (Game.world().environment() != null) { Game.world().unloadEnvironment(); } @@ -454,6 +478,7 @@ public void create() { } this.setCurrentStatus(Resources.strings().get("status_project_created")); + Toast.show(Resources.strings().get("status_project_created")); } public void load() { @@ -487,6 +512,7 @@ public void close(boolean force) { UI.getAssetController().refresh(); } this.setCurrentStatus(Resources.strings().get("status_gamefile_closed")); + Toast.show(Resources.strings().get("status_gamefile_closed")); } public void load(Path gameFile, boolean force) { @@ -584,6 +610,7 @@ public void load(Path gameFile, boolean force) { this.gamefileLoaded(); this.setCurrentStatus(Resources.strings().get("status_gamefile_loaded")); + Toast.show(Resources.strings().get("status_gamefile_loaded")); } finally { Cursors.apply(Cursors.DEFAULT); log.log(Level.INFO, "Loading gamefile {0} took: {1} ms", new Object[] {gameFile, (System.nanoTime() - currentTime) / 1000000.0}); @@ -677,6 +704,7 @@ public void loadAsync(Path gameFile, boolean force, Runnable onComplete) { this.gamefileLoaded(); this.setCurrentStatus(Resources.strings().get("status_gamefile_loaded")); + Toast.show(Resources.strings().get("status_gamefile_loaded")); if (onComplete != null) { onComplete.run(); } @@ -1318,11 +1346,13 @@ private void saveGameFile(Path target) { getCurrentResourceFile() }); this.setCurrentStatus(Resources.strings().get("status_gamefile_saved")); + Toast.show(Resources.strings().get("status_gamefile_saved")); this.saveMaps(); } catch (IOException e) { log.log(Level.SEVERE, "Failed to save game file: " + e.getMessage(), e); this.setCurrentStatus(Resources.strings().get("status_gamefile_save_error", e.getMessage())); + Toast.show(Resources.strings().get("status_gamefile_save_error", e.getMessage())); } } diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/MapComponent.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/MapComponent.java index 6969e4ab2..50e035f9b 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/MapComponent.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/MapComponent.java @@ -27,6 +27,8 @@ import de.gurkenlabs.litiengine.environment.tilemap.xml.MapObjectLayer; import de.gurkenlabs.litiengine.environment.tilemap.xml.Tileset; import de.gurkenlabs.litiengine.environment.tilemap.xml.TmxMap; +import de.gurkenlabs.litiengine.entities.IEntity; +import de.gurkenlabs.litiengine.entities.Rotation; import de.gurkenlabs.litiengine.graphics.ICamera; import de.gurkenlabs.litiengine.graphics.Spritesheet; import de.gurkenlabs.litiengine.graphics.emitters.Emitter; @@ -39,6 +41,7 @@ import de.gurkenlabs.litiengine.resources.ImageFormat; import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.litiengine.resources.SpritesheetResource; +import de.gurkenlabs.litiengine.util.Imaging; import de.gurkenlabs.litiengine.util.geom.GeometricUtilities; import de.gurkenlabs.litiengine.util.io.FileUtilities; import de.gurkenlabs.utiliti.controller.Transform.TransformMode; @@ -49,6 +52,7 @@ import de.gurkenlabs.utiliti.view.components.CreaturePanel; import de.gurkenlabs.utiliti.view.components.PropPanel; import de.gurkenlabs.utiliti.view.components.SceneGraph; +import de.gurkenlabs.utiliti.view.components.Toast; import de.gurkenlabs.utiliti.view.components.UI; import java.awt.Point; import de.gurkenlabs.utiliti.view.dialogs.ConfirmDialog; @@ -559,7 +563,8 @@ static List mapObjectsAt(IMap map, Point2D location) { for (IMapObject mapObject : layer.getMapObjects()) { if (mapObject != null && MapObjectType.get(mapObject.getType()) != null - && GeometricUtilities.intersects(point, mapObject.getBoundingBox())) { + && GeometricUtilities.intersects(point, mapObject.getBoundingBox()) + && hitsVisibleMapObjectPixel(mapObject, location)) { matches.add(mapObject); } } @@ -567,6 +572,87 @@ static List mapObjectsAt(IMap map, Point2D location) { return matches; } + static boolean hitsVisibleMapObjectPixel(IMapObject mapObject, Point2D location) { + MapObjectType type = MapObjectType.get(mapObject.getType()); + if (type != MapObjectType.PROP && type != MapObjectType.CREATURE) { + return true; + } + + Rectangle2D bounds = mapObject.getBoundingBox(); + if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) { + return true; + } + + HitTestSprite hitTestSprite = getHitTestSprite(mapObject, type); + if (hitTestSprite == null) { + return true; + } + if (hitTestSprite.image() == null) { + return false; + } + + BufferedImage sprite = hitTestSprite.image(); + Rectangle2D renderedBounds = hitTestSprite.scaled() + ? bounds + : new Rectangle2D.Double( + bounds.getCenterX() - sprite.getWidth() / 2.0, + bounds.getCenterY() - sprite.getHeight() / 2.0, + sprite.getWidth(), + sprite.getHeight()); + double relativeX = (location.getX() - renderedBounds.getX()) / renderedBounds.getWidth(); + double relativeY = (location.getY() - renderedBounds.getY()) / renderedBounds.getHeight(); + if (relativeX < 0 || relativeX >= 1 || relativeY < 0 || relativeY >= 1) { + return false; + } + + int x = Math.min((int) (relativeX * sprite.getWidth()), sprite.getWidth() - 1); + int y = Math.min((int) (relativeY * sprite.getHeight()), sprite.getHeight() - 1); + return (sprite.getRGB(x, y) >>> 24) != 0; + } + + private static HitTestSprite getHitTestSprite(IMapObject mapObject, MapObjectType type) { + Environment environment = Game.world().environment(); + if (environment != null + && environment.getMap() != null + && environment.getMap().getMapObject(mapObject.getId()) == mapObject) { + IEntity entity = environment.get(mapObject.getId()); + if (entity != null && entity.animations() != null) { + BufferedImage currentImage = entity.animations().getCurrentImage(); + return new HitTestSprite(currentImage, entity.animations().isAutoScaling()); + } + } + + Spritesheet spritesheet = SpriteVariantSelector.getPreviewSpritesheet(null, mapObject); + if (spritesheet == null || spritesheet.getTotalNumberOfSprites() == 0) { + return null; + } + BufferedImage preview = transformPropSprite(spritesheet.getSprite(0), mapObject, type); + return new HitTestSprite( + preview, mapObject.getBoolValue(MapObjectProperty.SCALE_SPRITE, false)); + } + + private record HitTestSprite(BufferedImage image, boolean scaled) {} + + private static BufferedImage transformPropSprite( + BufferedImage sprite, IMapObject mapObject, MapObjectType type) { + if (sprite == null || type != MapObjectType.PROP) { + return sprite; + } + + Rotation rotation = mapObject.getEnumValue( + MapObjectProperty.PROP_ROTATION, Rotation.class, Rotation.NONE); + if (rotation != Rotation.NONE) { + sprite = Imaging.rotate(sprite, rotation); + } + if (mapObject.getBoolValue(MapObjectProperty.PROP_FLIPHORIZONTALLY, false)) { + sprite = Imaging.horizontalFlip(sprite); + } + if (mapObject.getBoolValue(MapObjectProperty.PROP_FLIPVERTICALLY, false)) { + sprite = Imaging.verticalFlip(sprite); + } + return sprite; + } + public boolean addMapObjectFromAsset(Object asset, Point2D location) { if (asset == null || location == null || UI.getLayerController() == null || UI.getLayerController().getCurrentLayer() == null) { @@ -774,20 +860,31 @@ public void delete() { return; } - UndoManager.instance().beginOperation(); + List deletedObjects = List.copyOf(getSelectedMapObjects()); + UndoManager undoManager = UndoManager.instance(); + undoManager.beginOperation(); try { - for (IMapObject deleteObject : getSelectedMapObjects()) { + for (IMapObject deleteObject : deletedObjects) { if (deleteObject == null) { continue; } // call the undomanager first because otherwise the information about // the object's layer will be lost - UndoManager.instance().mapObjectDeleted(deleteObject); + undoManager.mapObjectDeleted(deleteObject); this.delete(deleteObject); } } finally { - UndoManager.instance().endOperation(); + undoManager.endOperation(); + } + + if (!deletedObjects.isEmpty()) { + long deletionRevision = undoManager.getRevision(); + String message = deletedObjects.size() == 1 + ? Resources.strings().get("panel_objectDeleted") + : Resources.strings().get( + "panel_objectsDeleted", Integer.toString(deletedObjects.size())); + Toast.show(message, () -> undoManager.undoIfRevision(deletionRevision)); } } @@ -975,43 +1072,8 @@ public void setTransformMode(TransformMode transformMode) { } public static IMapObject resolveParentEntity(IMapObject mapObject) { - IMap map = - Game.world() != null && Game.world().environment() != null - ? Game.world().environment().getMap() - : null; - return resolveParentEntity(mapObject, map); - } - - static IMapObject resolveParentEntity(IMapObject mapObject, IMap map) { - if (mapObject == null) { - return null; - } - MapObjectType type = MapObjectType.get(mapObject.getType()); - if (type == MapObjectType.PROP - || type == MapObjectType.CREATURE - || type == MapObjectType.SOUNDSOURCE - || type == MapObjectType.LIGHTSOURCE) { - return mapObject; - } - - if (map != null && mapObject.getBoundingBox() != null) { - for (IMapObjectLayer layer : map.getMapObjectLayers()) { - if (layer == null || !isLayerEffectivelyVisible(map, layer)) { - continue; - } - for (IMapObject other : layer.getMapObjects()) { - if (other == null || other.equals(mapObject)) { - continue; - } - MapObjectType otherType = MapObjectType.get(other.getType()); - if ((otherType == MapObjectType.PROP || otherType == MapObjectType.CREATURE) - && other.getBoundingBox() != null - && other.getBoundingBox().intersects(mapObject.getBoundingBox())) { - return other; - } - } - } - } + // Standalone map objects do not encode a parent relationship. Geometric overlap alone must + // not turn a selected collision box, trigger, or area into an unrelated prop or creature. return mapObject; } @@ -2253,6 +2315,10 @@ private void evaluateFocus() { if (type == null || !GeometricUtilities.intersects(rect, mapObject.getBoundingBox())) { continue; } + if (rect.getWidth() == 0 && rect.getHeight() == 0 + && !hitsVisibleMapObjectPixel(mapObject, new Point2D.Double(rect.getX(), rect.getY()))) { + continue; + } if (getFocusedMapObject() != null && mapObject.getId() == getFocusedMapObject().getId()) { diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactory.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactory.java index 08c599f6f..0d5ec7b79 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactory.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactory.java @@ -32,17 +32,14 @@ public static String generateTemplate(String id, ScriptHostType host, String tar + "import de.gurkenlabs.litiengine.resources.*;\n" + "import de.gurkenlabs.litiengine.scripting.*;\n" + "import java.awt.event.KeyEvent;\n\n" - + "/**\n" - + " * Global game lifecycle script controller (entry point).\n" - + " *\n" - + " *

Responsibilities:\n" - + " *

    \n" - + " *
  • Initialize persistent game state: {@code globals.put(\"score\", 0)}
  • \n" - + " *
  • Load starting map: {@code loadMap(\"map1\")}
  • \n" - + " *
  • Play background soundtracks: {@code playMusic(\"theme\")}
  • \n" - + " *
  • Register global inputs: pause, restart, hotkeys
  • \n" - + " *
\n" - + " */\n" + + "/// Global game lifecycle script controller (entry point).\n" + + "///\n" + + "/// Responsibilities:\n" + + "///\n" + + "/// - Initialize persistent game state: `globals.put(\"score\", 0)`\n" + + "/// - Load the starting map: `loadMap(\"map1\")`\n" + + "/// - Play background soundtracks: `playMusic(\"theme\")`\n" + + "/// - Register global inputs: pause, restart, and hotkeys\n" + "@ScriptInfo(id = \"" + id + "\", host = ScriptHostType.GAME)\n" + "public class " + className + " extends " + base + " {\n" + " @Override\n" @@ -76,16 +73,13 @@ public static String generateTemplate(String id, ScriptHostType host, String tar + "import de.gurkenlabs.litiengine.environment.Environment;\n" + "import de.gurkenlabs.litiengine.resources.*;\n" + "import de.gurkenlabs.litiengine.scripting.*;\n\n" - + "/**\n" - + " * Map environment script controller.\n" - + " *\n" - + " *

Responsibilities:\n" - + " *

    \n" - + " *
  • Map initialization & wave spawning on {@code onLoaded()}
  • \n" - + " *
  • Objective tracking: {@code onEntityRemoved(IEntity)}
  • \n" - + " *
  • Level clear transitions & ambient cinematics
  • \n" - + " *
\n" - + " */\n" + + "/// Map environment script controller.\n" + + "///\n" + + "/// Responsibilities:\n" + + "///\n" + + "/// - Initialize the map and spawn waves in `onLoaded()`\n" + + "/// - Track objectives in `onEntityRemoved(IEntity)`\n" + + "/// - Run level-clear transitions and ambient cinematics\n" + "@ScriptInfo(id = \"" + id + "\", host = ScriptHostType.ENVIRONMENT)\n" + "public class " + className + " extends " + base + " {\n" + " @Override\n" @@ -122,16 +116,13 @@ public static String generateTemplate(String id, ScriptHostType host, String tar + "import de.gurkenlabs.litiengine.resources.*;\n" + "import de.gurkenlabs.litiengine.scripting.*;\n" + "import java.awt.Color;\n\n" - + "/**\n" - + " * Entity script controller for {@link " + targetSimple + "}.\n" - + " *\n" - + " *

Responsibilities:\n" - + " *

    \n" - + " *
  • AI movement & navigation: {@code moveTowards(target)}
  • \n" - + " *
  • Combat abilities & projectiles: {@code createAbility()}, {@code spawnProjectile()}
  • \n" - + " *
  • Reactions: {@code onHit(event)}, {@code onDeath(entity, hitEvent)}
  • \n" - + " *
\n" - + " */\n" + + "/// Entity script controller for [" + targetSimple + "].\n" + + "///\n" + + "/// Responsibilities:\n" + + "///\n" + + "/// - Move and navigate with `moveTowards(target)`\n" + + "/// - Create combat abilities and projectiles with `createAbility()` and `spawnProjectile()`\n" + + "/// - React in `onHit(event)` and `onDeath(entity, hitEvent)`\n" + "@ScriptInfo(id = \"" + id + "\", host = ScriptHostType.ENTITY, target = " + targetSimple + ".class)\n" + "public class " + className + " extends " + base + " {\n" + " @Override\n" diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelector.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelector.java index 6807b70ed..a5ae46e41 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelector.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelector.java @@ -339,14 +339,14 @@ public static Spritesheet getPreviewSpritesheet(IEntity entity, IMapObject mapOb if (mapObject != null) { String type = mapObject.getType(); if (MapObjectType.CREATURE.name().equalsIgnoreCase(type)) { - String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME); + String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME, null); Direction dir = mapObject.getEnumValue(MapObjectProperty.SPAWN_DIRECTION, Direction.class, Direction.UNDEFINED); boolean dead = CreaturePanel.isStartDead(mapObject); String spriteName = selectCreatureSpriteName(base, dir, dead, Resources.spritesheets().getAll()); return spriteName != null ? CreaturePanel.getOrLoadSpritesheet(spriteName) : null; } if (MapObjectType.PROP.name().equalsIgnoreCase(type)) { - String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME); + String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME, null); PropState state = PropPanel.resolvePropState(mapObject); String spriteName = selectPropSpriteName(base, state, Resources.spritesheets().getAll()); if (spriteName != null) { @@ -396,13 +396,13 @@ public static Icon getEntityIcon(IEntity entity, IMapObject mapObject, int size) if (mapObject != null) { String type = mapObject.getType(); if (MapObjectType.CREATURE.name().equalsIgnoreCase(type)) { - String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME); + String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME, null); Direction dir = mapObject.getEnumValue(MapObjectProperty.SPAWN_DIRECTION, Direction.class, Direction.UNDEFINED); boolean dead = CreaturePanel.isStartDead(mapObject); return getCreatureIcon(base, dir, dead, size); } if (MapObjectType.PROP.name().equalsIgnoreCase(type)) { - String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME); + String base = mapObject.getStringValue(MapObjectProperty.SPRITESHEETNAME, null); PropState state = PropPanel.resolvePropState(mapObject); Rotation rot = mapObject.getEnumValue(MapObjectProperty.PROP_ROTATION, Rotation.class, Rotation.NONE); boolean flipH = mapObject.getBoolValue(MapObjectProperty.PROP_FLIPHORIZONTALLY, false); diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanel.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanel.java index d6be74841..1681b8b02 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanel.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanel.java @@ -31,6 +31,7 @@ import java.awt.BasicStroke; import java.awt.BorderLayout; import java.awt.Color; +import java.awt.CardLayout; import java.awt.Component; import java.awt.Cursor; import java.awt.Dimension; @@ -115,12 +116,16 @@ public final class ScriptWorkspacePanel extends JPanel { private static final Logger log = Logger.getLogger(ScriptWorkspacePanel.class.getName()); private static final int BOTTOM_PANEL_HEIGHT = 190; + private static final String EDITOR_CARD = "editor"; + private static final String EMPTY_EDITOR_CARD = "empty"; static final String DEFAULT_SCRIPT_NAME = "NewScript"; private final DefaultMutableTreeNode scriptsRoot = new DefaultMutableTreeNode("Scripts"); private final DefaultTreeModel scriptsModel = new DefaultTreeModel(this.scriptsRoot); private final StyledTree scripts = new StyledTree(this.scriptsModel); private final JTextField search = createSearchTextField("Search scripts..."); private final DefaultMutableTreeNode globalsRoot = new DefaultMutableTreeNode("Globals & APIs"); + private final java.util.function.BiConsumer projectPathChangedListener = + (previous, current) -> this.projectPathChanged(); private final DefaultTreeModel globalsTreeModel = new DefaultTreeModel(this.globalsRoot); private final StyledTree globalsTree = new StyledTree(this.globalsTreeModel); private final JTextField globalsSearch = createSearchTextField("Search APIs & events..."); @@ -209,6 +214,8 @@ public void paintIcon(Component c, Graphics g, int x, int y) { private MonacoScriptEditor monaco; private ScriptTab monacoTab; private ScriptTab conflictTab; + private volatile boolean keepTabsClosedAfterProjectChange; + private boolean closingAllTabs; private final ScriptDebuggerPanel debuggerPanel = new ScriptDebuggerPanel(); private final List breakpoints = new java.util.concurrent.CopyOnWriteArrayList<>(); private final Timer breakpointSyncTimer = new Timer(300, e -> this.syncBreakpoints()); @@ -224,7 +231,13 @@ public void paintIcon(Component c, Graphics g, int x, int y) { private volatile boolean debuggerLaunchFailed; private boolean restartRequested; private Consumer selectionListener = ignored -> {}; - private final JPanel editorHost = new JPanel(new BorderLayout()); + private final CardLayout editorCards = new CardLayout(); + private final JPanel editorHost = new JPanel(this.editorCards); + private final JPanel emptyEditorState = new JPanel(); + private final JLabel emptyEditorTitle = new JLabel( + Resources.strings().get("script_editor_noScript"), Icons.SCRIPT_16, SwingConstants.CENTER); + private final JLabel emptyEditorHint = new JLabel( + Resources.strings().get("script_editor_noScript_hint"), SwingConstants.CENTER); private final ScriptTypeBadge scriptContext = new ScriptTypeBadge(); private final ScriptOverviewPanel overviewPanel; private final Consumer scriptBindingChangeListener = ignored -> @@ -355,6 +368,7 @@ public void mouseMoved(MouseEvent e) { }); this.tabs.putClientProperty("JTabbedPane.noContentBorder", Boolean.TRUE); + this.tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); this.tabs.putClientProperty("JTabbedPane.hasFullBorder", Boolean.FALSE); this.tabs.putClientProperty("JTabbedPane.contentInsets", new java.awt.Insets(0, 0, 0, 0)); this.tabs.putClientProperty("JTabbedPane.tabAreaInsets", new java.awt.Insets(0, 0, 0, 0)); @@ -383,6 +397,9 @@ public void mouseMoved(MouseEvent e) { this.mainEditorArea.add(tabStrip, BorderLayout.NORTH); this.editorHost.setBackground(Style.background()); + this.configureEmptyEditorState(); + this.editorHost.add(this.emptyEditorState, EMPTY_EDITOR_CARD); + this.editorCards.show(this.editorHost, EMPTY_EDITOR_CARD); this.mainEditorArea.add(this.editorHost, BorderLayout.CENTER); this.statusBar = new JPanel(new BorderLayout()); @@ -452,12 +469,15 @@ public void terminated() { Editor.instance().onLoaded(() -> { javax.swing.SwingUtilities.invokeLater(() -> { this.refreshScripts(); - if (UI.isScriptWorkspaceActive()) { + boolean projectChanged = this.keepTabsClosedAfterProjectChange; + this.keepTabsClosedAfterProjectChange = false; + if (!projectChanged && UI.isScriptWorkspaceActive()) { this.focusOrOpenFirstScript(); } this.refreshActiveUsages(); }); }); + Editor.instance().onProjectPathChanged(this.projectPathChangedListener); ScriptBindingService.instance().addChangeListener(this.scriptBindingChangeListener); UndoManager.onUndoStackChanged(this.undoStackChangeListener); @@ -483,6 +503,7 @@ DefaultMutableTreeNode getScriptsRoot() { } public synchronized void close() { + Editor.instance().removeProjectPathChangedListener(this.projectPathChangedListener); ScriptBindingService.instance().removeChangeListener(this.scriptBindingChangeListener); UndoManager.removeUndoStackChanged(this.undoStackChangeListener); if (this.externalChangeTimer != null) { @@ -491,12 +512,7 @@ public synchronized void close() { if (this.problemsRefreshDebounce != null) { this.problemsRefreshDebounce.stop(); } - for (ScriptTab tab : new ArrayList<>(this.openTabs.values())) { - if (tab != null) { - this.closeTab(tab); - } - } - this.openTabs.clear(); + this.closeAllTabs(); if (this.monaco != null) { this.monaco.close(); this.monaco = null; @@ -504,12 +520,51 @@ public synchronized void close() { MonacoScriptEditor.shutdownCef(); } + void closeAllTabs() { + this.closingAllTabs = true; + try { + for (ScriptTab tab : new ArrayList<>(this.openTabs.values())) { + if (tab != null) { + this.closeTab(tab); + } + } + } finally { + this.closingAllTabs = false; + } + this.openTabs.clear(); + this.tabs.removeAll(); + this.monacoTab = null; + this.conflictTab = null; + this.activeTabChanged(); + } + + int getOpenTabCount() { + return this.openTabs.size(); + } + + int getTabLayoutPolicy() { + return this.tabs.getTabLayoutPolicy(); + } + + boolean isEmptyEditorStateVisible() { + return this.emptyEditorState.isVisible(); + } + + private void projectPathChanged() { + this.keepTabsClosedAfterProjectChange = true; + if (SwingUtilities.isEventDispatchThread()) { + this.closeAllTabs(); + } else { + SwingUtilities.invokeLater(this::closeAllTabs); + } + } + @Override public void addNotify() { super.addNotify(); this.externalChangeTimer.start(); this.refreshScripts(); - if (UI.isScriptWorkspaceActive()) { + if (!this.keepTabsClosedAfterProjectChange && UI.isScriptWorkspaceActive()) { this.focusOrOpenFirstScript(); } } @@ -594,7 +649,8 @@ public void refreshScripts() { for (int row = 0; row < this.scripts.getRowCount(); row++) this.scripts.expandRow(row); if (selectedId != null) { this.selectTreeNode(selectedId); - } else if (UI.isScriptWorkspaceActive() || !this.openTabs.isEmpty()) { + } else if (!this.keepTabsClosedAfterProjectChange + && (UI.isScriptWorkspaceActive() || !this.openTabs.isEmpty())) { this.focusOrOpenFirstScript(); } this.refreshGlobals(); @@ -1210,7 +1266,11 @@ public void restartProject() { Editor.instance().stopProject(); } - private boolean saveAllScripts() { + boolean hasUnsavedScripts() { + return this.openTabs.values().stream().anyMatch(tab -> tab.dirty); + } + + boolean saveAllScripts() { for (ScriptTab tab : this.openTabs.values()) { if (tab.dirty && !tab.save()) { this.setStatus("Could not save " + displayName(tab.definition), true); @@ -1330,6 +1390,9 @@ public void refreshTheme() { if (this.monaco != null) { this.monaco.setTheme(Editor.preferences().getTheme() == Style.Theme.DARK); } + this.emptyEditorState.setBackground(Style.background()); + this.emptyEditorTitle.setForeground(Style.text()); + this.emptyEditorHint.setForeground(Style.mutedText()); this.caretStatus.setForeground(Style.mutedText()); this.languageStatus.setForeground(Style.mutedText()); this.status.setForeground(Style.mutedText()); @@ -1785,7 +1848,28 @@ private void closeTab(ScriptTab tab) { this.openTabs.values().removeIf(t -> t == tab); this.openTabs.remove(tab.key); this.tabs.remove(tab); - this.activeTabChanged(); + if (!this.closingAllTabs) { + this.activeTabChanged(); + } + } + + private void configureEmptyEditorState() { + this.emptyEditorState.setLayout(new BoxLayout(this.emptyEditorState, BoxLayout.Y_AXIS)); + this.emptyEditorState.setBackground(Style.background()); + this.emptyEditorState.setFocusable(true); + this.emptyEditorTitle.setAlignmentX(Component.CENTER_ALIGNMENT); + this.emptyEditorTitle.setFont(Style.getDefaultFont().deriveFont(Font.BOLD, 14f)); + this.emptyEditorTitle.setForeground(Style.text()); + this.emptyEditorTitle.setIconTextGap(Style.SPACE_MEDIUM); + this.emptyEditorTitle.getAccessibleContext().setAccessibleName( + Resources.strings().get("script_editor_noScript")); + this.emptyEditorHint.setAlignmentX(Component.CENTER_ALIGNMENT); + this.emptyEditorHint.setForeground(Style.mutedText()); + this.emptyEditorState.add(Box.createVerticalGlue()); + this.emptyEditorState.add(this.emptyEditorTitle); + this.emptyEditorState.add(Box.createVerticalStrut(Style.SPACE_MEDIUM)); + this.emptyEditorState.add(this.emptyEditorHint); + this.emptyEditorState.add(Box.createVerticalGlue()); } private static JTextField createSearchTextField(String placeholder) { @@ -1838,7 +1922,7 @@ private synchronized MonacoScriptEditor ensureMonaco() { default -> {} } }); - this.editorHost.add(this.monaco, BorderLayout.CENTER); + this.editorHost.add(this.monaco, EDITOR_CARD); this.refreshTheme(); this.editorHost.revalidate(); this.editorHost.repaint(); @@ -1860,6 +1944,7 @@ private void activeTabChanged() { if (active != null) { MonacoScriptEditor editor = this.ensureMonaco(); if (editor != null && !editor.isUnavailable()) { + this.editorCards.show(this.editorHost, EDITOR_CARD); this.monacoTab = active; editor.open(active.path, active.getText(), active.definition); if (editor.isReady()) editor.focusEditor(); @@ -1867,11 +1952,10 @@ private void activeTabChanged() { this.mainEditorArea.revalidate(); this.mainEditorArea.repaint(); } - } else if (this.monaco != null) { + } else { this.monacoTab = null; - if (!this.monaco.isUnavailable()) { - this.monaco.open(null, "", null); - } + this.editorCards.show(this.editorHost, EMPTY_EDITOR_CARD); + this.emptyEditorState.requestFocusInWindow(); } ScriptDefinition definition = active == null ? null : active.definition; this.scriptContext.setText(scriptContext(definition)); diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanel.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanel.java index 2fddeadb3..3d6f484dd 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanel.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanel.java @@ -11,6 +11,7 @@ import de.gurkenlabs.utiliti.controller.Editor; import de.gurkenlabs.utiliti.controller.SpinnerCellEditor; import de.gurkenlabs.utiliti.controller.UndoManager; +import de.gurkenlabs.utiliti.controller.WrapLayout; import de.gurkenlabs.utiliti.model.Icons; import de.gurkenlabs.utiliti.model.Style; import java.awt.BasicStroke; @@ -389,15 +390,18 @@ private ExpandableCard createAnimationCard() { content.add(tableScroll); content.add(Box.createVerticalStrut(8)); - JPanel durationTools = new JPanel(new BorderLayout(8, 0)); + JPanel durationTools = new JPanel(new WrapLayout(FlowLayout.LEFT, 4, 4)) { + @Override + public Dimension getMaximumSize() { + return new Dimension(Integer.MAX_VALUE, this.getPreferredSize().height); + } + }; durationTools.setOpaque(false); - JPanel apply = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); - apply.setOpaque(false); - apply.add(new JLabel(Resources.strings().get("spriteEditor_setDurationFor"))); - apply.add(this.durationScopeCombo); - apply.add(new JLabel(Resources.strings().get("spriteEditor_durationTo"))); - apply.add(this.defaultDurationSpinner); - apply.add(new JLabel("ms")); + durationTools.add(new JLabel(Resources.strings().get("spriteEditor_setDurationFor"))); + durationTools.add(this.durationScopeCombo); + durationTools.add(new JLabel(Resources.strings().get("spriteEditor_durationTo"))); + durationTools.add(this.defaultDurationSpinner); + durationTools.add(new JLabel("ms")); JButton applyButton = new JButton(Resources.strings().get("assetpanel_animation_apply")); applyButton.setPreferredSize(new Dimension( Math.max(64, applyButton.getFontMetrics(applyButton.getFont()).stringWidth(applyButton.getText()) + 24), @@ -405,10 +409,8 @@ private ExpandableCard createAnimationCard() { applyButton.setMinimumSize(applyButton.getPreferredSize()); applyButton.setMaximumSize(applyButton.getPreferredSize()); applyButton.addActionListener(_ -> applyDurationToSelection()); - apply.add(applyButton); - durationTools.add(apply, BorderLayout.WEST); - durationTools.add(this.durationSummaryLabel, BorderLayout.EAST); - durationTools.setMaximumSize(new Dimension(Integer.MAX_VALUE, durationTools.getPreferredSize().height)); + durationTools.add(applyButton); + durationTools.add(this.durationSummaryLabel); durationTools.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(durationTools); diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/Toast.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/Toast.java index 77743e139..79fb6dd80 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/Toast.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/Toast.java @@ -1,28 +1,37 @@ package de.gurkenlabs.utiliti.view.components; +import de.gurkenlabs.litiengine.Game; import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.utiliti.model.Style; import java.awt.BorderLayout; -import java.awt.Color; -import java.awt.FlowLayout; +import java.awt.Dimension; import java.awt.Font; -import java.awt.event.ActionEvent; -import java.util.Timer; -import java.util.TimerTask; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.util.Map; +import java.util.WeakHashMap; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JLabel; +import javax.swing.JLayeredPane; import javax.swing.JPanel; import javax.swing.JRootPane; -import javax.swing.SwingConstants; +import javax.swing.JFrame; import javax.swing.SwingUtilities; +import javax.swing.Timer; public class Toast extends JPanel { private static final int DISPLAY_DURATION = 4000; - private static final int FADE_STEPS = 10; - private static final int FADE_INTERVAL = 50; + private static final int BOTTOM_MARGIN = 28; + private static final int SIDE_MARGIN = 12; + private static final Map ACTIVE_TOASTS = new WeakHashMap<>(); - private Toast(String message, Runnable onUndo) { + private final JRootPane rootPane; + private final Timer dismissTimer; + private final ComponentAdapter rootResizeListener; + + private Toast(JRootPane rootPane, String message, Runnable onUndo) { + this.rootPane = rootPane; setLayout(new BorderLayout(8, 0)); setBorder(BorderFactory.createCompoundBorder( BorderFactory.createLineBorder(Style.COLOR_BORDER, 1), @@ -34,6 +43,7 @@ private Toast(String message, Runnable onUndo) { label.setFont(Style.getDefaultFont().deriveFont(Font.PLAIN, 12f)); label.setForeground(Style.COLOR_TEXT); add(label, BorderLayout.CENTER); + getAccessibleContext().setAccessibleName(message); if (onUndo != null) { JButton undoBtn = new JButton(Resources.strings().get("panel_undo")); @@ -48,56 +58,74 @@ private Toast(String message, Runnable onUndo) { }); add(undoBtn, BorderLayout.EAST); } + + this.rootResizeListener = new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + positionToast(); + } + }; + this.dismissTimer = new Timer(DISPLAY_DURATION, event -> hideToast()); + this.dismissTimer.setRepeats(false); } public static void show(JRootPane rootPane, String message) { show(rootPane, message, null); } - public static void show(JRootPane rootPane, String message, Runnable onUndo) { - Toast toast = new Toast(message, onUndo); - toast.setVisible(false); + public static void show(String message) { + show(message, null); + } - java.awt.GridBagLayout layout = new java.awt.GridBagLayout(); - JPanel overlay = new JPanel(layout); - overlay.setOpaque(false); - overlay.setBounds(0, 0, rootPane.getWidth(), rootPane.getHeight()); + public static void show(String message, Runnable onUndo) { + if (Game.window() != null && Game.window().getHostControl() instanceof JFrame window) { + show(window.getRootPane(), message, onUndo); + } + } - java.awt.GridBagConstraints gbc = new java.awt.GridBagConstraints(); - gbc.gridx = 0; - gbc.gridy = 0; - gbc.weightx = 1.0; - gbc.weighty = 0.0; - gbc.anchor = java.awt.GridBagConstraints.NORTH; - gbc.insets = new java.awt.Insets( - rootPane.getHeight() - 60, 20, 0, 20); - overlay.add(toast, gbc); + public static void show(JRootPane rootPane, String message, Runnable onUndo) { + if (rootPane == null || message == null || message.isBlank()) { + return; + } + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> show(rootPane, message, onUndo)); + return; + } - rootPane.getLayeredPane().add(overlay, javax.swing.JLayeredPane.POPUP_LAYER); - toast.setVisible(true); - rootPane.revalidate(); - rootPane.repaint(); + Toast previous = ACTIVE_TOASTS.remove(rootPane); + if (previous != null) { + previous.hideToast(); + } - new Timer(true).schedule(new TimerTask() { - @Override - public void run() { - SwingUtilities.invokeLater(() -> fadeOut(toast, overlay, rootPane)); - } - }, DISPLAY_DURATION); + Toast toast = new Toast(rootPane, message, onUndo); + ACTIVE_TOASTS.put(rootPane, toast); + rootPane.addComponentListener(toast.rootResizeListener); + rootPane.getLayeredPane().add(toast, JLayeredPane.POPUP_LAYER); + toast.positionToast(); + toast.dismissTimer.start(); } private void hideToast() { - java.awt.Container overlay = getParent(); - if (overlay != null && overlay.getParent() != null) { - overlay.getParent().remove(overlay); - overlay.getParent().revalidate(); - overlay.getParent().repaint(); + this.dismissTimer.stop(); + this.rootPane.removeComponentListener(this.rootResizeListener); + ACTIVE_TOASTS.remove(this.rootPane, this); + if (getParent() != null) { + getParent().remove(this); + this.rootPane.getLayeredPane().revalidate(); + this.rootPane.getLayeredPane().repaint(); } } - private static void fadeOut(JPanel toast, JPanel overlay, JRootPane rootPane) { - rootPane.getLayeredPane().remove(overlay); - rootPane.getLayeredPane().revalidate(); - rootPane.getLayeredPane().repaint(); + private void positionToast() { + Dimension preferred = getPreferredSize(); + int availableWidth = Math.max(0, this.rootPane.getLayeredPane().getWidth() - 2 * SIDE_MARGIN); + int width = Math.min(preferred.width, availableWidth); + int x = Math.max(SIDE_MARGIN, (this.rootPane.getLayeredPane().getWidth() - width) / 2); + int y = Math.max( + SIDE_MARGIN, + this.rootPane.getLayeredPane().getHeight() - preferred.height - BOTTOM_MARGIN); + setBounds(x, y, width, preferred.height); + revalidate(); + repaint(); } } diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/UI.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/UI.java index 0aa673d3a..876c62301 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/UI.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/UI.java @@ -107,9 +107,15 @@ public final class UI { private static final int INSPECTOR_BASE_WIDTH = 380; private static final int SCENE_GRAPH_MIN_WIDTH = 340; private static final int SCENE_GRAPH_MAX_WIDTH = 480; + static final int WORKSPACE_MIN_WIDTH = 640; + static final int WORKSPACE_COMPACT_MODE_THRESHOLD = 760; + private static final int WORKSPACE_MODE_BUTTON_SIZE = 43; + private static final int WORKSPACE_MODE_BUTTON_COMPACT_SIZE = 32; + private static final int WINDOW_MIN_HEIGHT = 480; private static final int ASSET_PANEL_MIN_HEIGHT = 280; private static final int ASSET_PANEL_MAX_HEIGHT = 420; private static final int SPLITTER_SIZE = 4; + private static final int COLLAPSIBLE_SPLITTER_SIZE = 14; private static final String INVISIBLE_SPLITTER_CONFIGURED = "Editor.invisibleSplitterConfigured"; private static final List orphanComponents = new CopyOnWriteArrayList<>(); @@ -149,6 +155,7 @@ public final class UI { private static JButton inspectorForwardButton; private static JToggleButton workspaceMapButton; private static JToggleButton workspaceScriptButton; + private static JPanel workspaceModeRail; private static KeyStroke inspectorBackShortcut; private static KeyStroke inspectorForwardShortcut; private static KeyStroke switchWorkspaceModeShortcut; @@ -179,7 +186,10 @@ public static void removeOrphanComponent(JComponent component) { public static boolean notifyPendingChanges() { Path resourceFile = Editor.instance().getCurrentResourceFile(); - if (Editor.instance().getChangedMaps().isEmpty() && !Editor.instance().isUnsavedProject()) { + boolean unsavedScripts = scriptWorkspacePanel != null && scriptWorkspacePanel.hasUnsavedScripts(); + if (Editor.instance().getChangedMaps().isEmpty() + && !Editor.instance().isUnsavedProject() + && !unsavedScripts) { return true; } @@ -187,6 +197,9 @@ public static boolean notifyPendingChanges() { Resources.strings().get("hud_saveProject"), JOptionPane.YES_NO_CANCEL_OPTION); if (n == JOptionPane.YES_OPTION) { + if (unsavedScripts && !scriptWorkspacePanel.saveAllScripts()) { + return false; + } Editor.instance().save(false); } @@ -684,7 +697,8 @@ private static void setupInterface() { Component workspaceWithBottomPanel = initRenderSplitPanel(workspaceHost, winH); - JSplitPane mainSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel, workspaceWithBottomPanel); + CollapsibleSplitPane mainSplit = new CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, leftPanel, workspaceWithBottomPanel, CollapseSide.FIRST); int inspectorMinWidth = inspectorMinimumWidth(); mapObjectPanel = new MapObjectInspector(); @@ -768,11 +782,12 @@ private static void setupInterface() { JPanel leftWorkspaceContainer = new JPanel(new BorderLayout()); leftWorkspaceContainer.add(viewportToolbar, BorderLayout.NORTH); - leftWorkspaceContainer.add(initWorkspaceModeBar(), BorderLayout.WEST); + workspaceModeRail = (JPanel) initWorkspaceModeBar(); + leftWorkspaceContainer.add(workspaceModeRail, BorderLayout.WEST); leftWorkspaceContainer.add(mainSplit, BorderLayout.CENTER); - JSplitPane centerRightSplit = new JSplitPane( - JSplitPane.HORIZONTAL_SPLIT, leftWorkspaceContainer, inspectorContainer); + CollapsibleSplitPane centerRightSplit = new CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, leftWorkspaceContainer, inspectorContainer, CollapseSide.SECOND); configureSplitPane(centerRightSplit); centerRightSplit.setContinuousLayout(false); centerRightSplit.setResizeWeight(1.0); @@ -787,6 +802,9 @@ private static void setupInterface() { } }); mainSplit.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, evt -> { + if (mainSplit.isCollapsed()) { + return; + } int location = constrainSceneGraphWidth(mainSplit.getDividerLocation()); if (location != mainSplit.getDividerLocation()) { mainSplit.setDividerLocation(location); @@ -800,10 +818,34 @@ private static void setupInterface() { mainSplit.setDividerLocation(initialHierarchyW); centerRightSplit.setDividerLocation(initialInspectorDivider); centerRightSplit.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, evt -> { + if (centerRightSplit.isCollapsed()) { + return; + } int viewportDivider = centerRightSplit.getDividerLocation() - - mainSplit.getDividerLocation() - SPLITTER_SIZE; + - mainSplit.getDividerLocation() - mainSplit.getDividerSize(); Editor.preferences().setSelectionEditSplitter(Math.max(0, viewportDivider)); }); + workspaceWithBottomPanel.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + int workspaceWidth = workspaceWithBottomPanel.getWidth(); + updateWorkspaceModeBar( + workspaceModeRail, workspaceWidth < WORKSPACE_COMPACT_MODE_THRESHOLD); + if (window.isShowing()) { + preserveWorkspaceWidth( + centerRightSplit.getWidth(), workspaceModeRail, mainSplit, centerRightSplit); + } + } + }); + centerRightSplit.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + if (window.isShowing()) { + preserveWorkspaceWidth( + centerRightSplit.getWidth(), workspaceModeRail, mainSplit, centerRightSplit); + } + } + }); initPopupMenu(canvas); window.getRootPane().setBackground(Style.COLOR_BG); @@ -816,7 +858,7 @@ private static void setupInterface() { static Component initWorkspaceModeBar() { JPanel rail = new JPanel(); rail.setLayout(new javax.swing.BoxLayout(rail, javax.swing.BoxLayout.Y_AXIS)); - rail.setPreferredSize(new Dimension(43 + Style.SPACE_MEDIUM, 0)); + rail.setPreferredSize(new Dimension(WORKSPACE_MODE_BUTTON_SIZE + Style.SPACE_MEDIUM, 0)); rail.setBackground(Style.background()); rail.setBorder(BorderFactory.createEmptyBorder(0, Style.SPACE_MEDIUM, 0, 0)); workspaceMapButton = createWorkspaceModeButton(Icons.MAP_16); @@ -893,7 +935,7 @@ protected void paintComponent(Graphics g) { button.setFocusPainted(false); button.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)); button.putClientProperty("Editor.buttonVariant", Style.ButtonVariant.TOOLBAR); - Dimension size = new Dimension(43, 42); + Dimension size = new Dimension(WORKSPACE_MODE_BUTTON_SIZE, 42); button.setPreferredSize(size); button.setMinimumSize(size); button.setMaximumSize(size); @@ -901,6 +943,75 @@ protected void paintComponent(Graphics g) { return button; } + static void updateWorkspaceModeBar(JPanel rail, boolean compact) { + if (rail == null) { + return; + } + int buttonSize = compact ? WORKSPACE_MODE_BUTTON_COMPACT_SIZE : WORKSPACE_MODE_BUTTON_SIZE; + int leftInset = compact ? Style.SPACE_SMALL : Style.SPACE_MEDIUM; + rail.setBorder(BorderFactory.createEmptyBorder(0, leftInset, 0, 0)); + rail.setPreferredSize(new Dimension(buttonSize + leftInset, 0)); + for (Component component : rail.getComponents()) { + if (component instanceof JToggleButton button) { + Dimension size = new Dimension(buttonSize, compact ? buttonSize : 42); + button.setPreferredSize(size); + button.setMinimumSize(size); + button.setMaximumSize(size); + } + } + rail.revalidate(); + rail.repaint(); + } + + static void preserveWorkspaceWidth( + int availableWidth, JPanel modeRail, CollapsibleSplitPane sceneSplit, + CollapsibleSplitPane inspectorSplit) { + if (availableWidth <= 0) { + return; + } + if (availableWidth < requiredWorkspaceWidth(modeRail, sceneSplit, inspectorSplit) + && !inspectorSplit.isCollapsed()) { + inspectorSplit.collapseAutomatically(); + } + if (availableWidth < requiredWorkspaceWidth(modeRail, sceneSplit, inspectorSplit) + && !sceneSplit.isCollapsed()) { + sceneSplit.collapseAutomatically(); + } + + if (sceneSplit.isAutomaticallyCollapsed() + && availableWidth >= requiredWorkspaceWidth( + modeRail, false, inspectorSplit.isCollapsed(), sceneSplit, inspectorSplit)) { + sceneSplit.expandAutomatically(); + } + if (inspectorSplit.isAutomaticallyCollapsed() + && availableWidth >= requiredWorkspaceWidth( + modeRail, sceneSplit.isCollapsed(), false, sceneSplit, inspectorSplit)) { + inspectorSplit.expandAutomatically(); + } + } + + private static int requiredWorkspaceWidth( + JPanel modeRail, CollapsibleSplitPane sceneSplit, CollapsibleSplitPane inspectorSplit) { + return requiredWorkspaceWidth( + modeRail, sceneSplit.isCollapsed(), inspectorSplit.isCollapsed(), sceneSplit, inspectorSplit); + } + + private static int requiredWorkspaceWidth( + JPanel modeRail, boolean sceneCollapsed, boolean inspectorCollapsed, + CollapsibleSplitPane sceneSplit, CollapsibleSplitPane inspectorSplit) { + int width = WORKSPACE_MIN_WIDTH + + modeRail.getPreferredSize().width + + sceneSplit.getDividerSize() + + inspectorSplit.getDividerSize(); + if (!sceneCollapsed) { + width += sceneSplit.expandedExtent(SCENE_GRAPH_MIN_WIDTH); + } + if (!inspectorCollapsed) { + width += inspectorSplit.expandedExtent(inspectorMinimumWidth()); + } + return width; + } + private static void installInspectorNavigationShortcuts(JFrame window) { // Unbind Ctrl+Tab from Focus Traversal Keys so it can be used for editor mode cycling try { @@ -1093,12 +1204,22 @@ private static JFrame initWindow() { } else if (Editor.preferences().getWidth() != 0 && Editor.preferences().getHeight() != 0) { window.setSize(Editor.preferences().getWidth(), Editor.preferences().getHeight()); } + window.setMinimumSize(minimumWindowSize()); return window; } + static Dimension minimumWindowSize() { + int compactRailWidth = WORKSPACE_MODE_BUTTON_COMPACT_SIZE + Style.SPACE_SMALL; + int frameAllowance = Style.SPACE_LARGE * 2; + return new Dimension( + WORKSPACE_MIN_WIDTH + compactRailWidth + COLLAPSIBLE_SPLITTER_SIZE * 2 + frameAllowance, + WINDOW_MIN_HEIGHT); + } + private static Component initRenderSplitPanel(JPanel renderPanel, int winH) { - JSplitPane renderSplitPanel = new JSplitPane(JSplitPane.VERTICAL_SPLIT, renderPanel, initBottomPanel()); + CollapsibleSplitPane renderSplitPanel = new CollapsibleSplitPane( + JSplitPane.VERTICAL_SPLIT, renderPanel, initBottomPanel(), CollapseSide.SECOND); configureSplitPane(renderSplitPanel); renderSplitPanel.setResizeWeight(1.0); if (Editor.preferences().getBottomSplitter() != 0) { @@ -1107,6 +1228,9 @@ private static Component initRenderSplitPanel(JPanel renderPanel, int winH) { renderSplitPanel.setDividerLocation((int) (winH * 0.70)); } renderSplitPanel.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, evt -> { + if (renderSplitPanel.isCollapsed()) { + return; + } int location = constrainBottomDivider( renderSplitPanel.getHeight(), renderSplitPanel.getDividerSize(), renderSplitPanel.getDividerLocation()); if (location != renderSplitPanel.getDividerLocation()) { @@ -1118,6 +1242,9 @@ private static Component initRenderSplitPanel(JPanel renderPanel, int winH) { renderSplitPanel.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent event) { + if (renderSplitPanel.isCollapsed()) { + return; + } renderSplitPanel.setDividerLocation(constrainBottomDivider( renderSplitPanel.getHeight(), renderSplitPanel.getDividerSize(), renderSplitPanel.getDividerLocation())); } @@ -1144,7 +1271,8 @@ public static void configureSplitPane(JSplitPane splitPane) { if (!Boolean.TRUE.equals(splitPane.getClientProperty(INVISIBLE_SPLITTER_CONFIGURED))) { splitPane.putClientProperty(INVISIBLE_SPLITTER_CONFIGURED, true); splitPane.addPropertyChangeListener("UI", event -> { - if (!(splitPane.getUI() instanceof InvisibleSplitPaneUI)) { + if (!(splitPane.getUI() instanceof InvisibleSplitPaneUI) + && !(splitPane.getUI() instanceof CollapsibleSplitPaneUI)) { installInvisibleSplitPaneUI(splitPane); } }); @@ -1153,7 +1281,9 @@ public static void configureSplitPane(JSplitPane splitPane) { } private static void installInvisibleSplitPaneUI(JSplitPane splitPane) { - BasicSplitPaneUI ui = new InvisibleSplitPaneUI(); + BasicSplitPaneUI ui = splitPane instanceof CollapsibleSplitPane collapsibleSplitPane + ? new CollapsibleSplitPaneUI(collapsibleSplitPane) + : new InvisibleSplitPaneUI(); splitPane.setUI(ui); if (ui.getDivider() != null) { ui.getDivider().setBorder(null); @@ -1162,7 +1292,9 @@ private static void installInvisibleSplitPaneUI(JSplitPane splitPane) { splitPane.setBorder(null); splitPane.setOpaque(true); splitPane.setBackground(Style.background()); - splitPane.setDividerSize(SPLITTER_SIZE); + splitPane.setDividerSize(splitPane instanceof CollapsibleSplitPane + ? COLLAPSIBLE_SPLITTER_SIZE + : SPLITTER_SIZE); } private static final class InvisibleSplitPaneUI extends BasicSplitPaneUI { @@ -1178,6 +1310,267 @@ public void paint(Graphics graphics) { } } + enum CollapseSide { + FIRST, + SECOND + } + + static final class CollapsibleSplitPane extends JSplitPane { + private final CollapseSide collapseSide; + private int expandedCollapsedSideExtent = -1; + private boolean collapsed; + private boolean automaticallyCollapsed; + + CollapsibleSplitPane(int orientation, Component first, Component second, CollapseSide collapseSide) { + super(orientation, first, second); + this.collapseSide = collapseSide; + this.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + if (collapsed && CollapsibleSplitPane.this.collapseSide == CollapseSide.SECOND) { + setDividerLocation(collapsedDividerLocation()); + } + } + }); + this.addPropertyChangeListener(DIVIDER_LOCATION_PROPERTY, event -> { + if (collapsed && getDividerLocation() != collapsedDividerLocation()) { + setDividerLocation(collapsedDividerLocation()); + } else if (!collapsed) { + rememberExpandedExtent(); + } + }); + } + + boolean isCollapsed() { + return this.collapsed; + } + + boolean isAutomaticallyCollapsed() { + return this.collapsed && this.automaticallyCollapsed; + } + + int expandedExtent(int fallback) { + return Math.max(fallback, this.expandedCollapsedSideExtent); + } + + void toggleCollapsed() { + this.automaticallyCollapsed = false; + this.setCollapsed(!this.collapsed); + } + + void collapseAutomatically() { + if (!this.collapsed) { + this.setCollapsed(true); + this.automaticallyCollapsed = true; + } + } + + void expandAutomatically() { + if (this.isAutomaticallyCollapsed()) { + this.automaticallyCollapsed = false; + this.setCollapsed(false); + } + } + + private void setCollapsed(boolean collapse) { + if (this.collapsed == collapse) { + return; + } + if (this.collapsed) { + this.collapsed = false; + int maximum = this.maximumDividerLocation(); + int expandedExtent = this.expandedCollapsedSideExtent >= 0 + ? this.expandedCollapsedSideExtent + : this.defaultExpandedExtent(); + int location = this.collapseSide == CollapseSide.FIRST + ? expandedExtent + : maximum - expandedExtent; + this.setDividerLocation(Math.max(0, Math.min(maximum, location))); + } else { + this.rememberExpandedExtent(); + this.collapsed = true; + this.setDividerLocation(this.collapsedDividerLocation()); + } + this.revalidate(); + this.repaint(); + } + + private void rememberExpandedExtent() { + int maximum = this.maximumDividerLocation(); + int location = this.getDividerLocation(); + if (maximum <= 0 || location < minimumExtent(this.getLeftComponent()) + || maximum - location < minimumExtent(this.getRightComponent())) { + return; + } + this.expandedCollapsedSideExtent = this.collapseSide == CollapseSide.FIRST + ? location + : maximum - location; + } + + private int minimumExtent(Component component) { + if (component == null) { + return 0; + } + Dimension minimum = component.getMinimumSize(); + return this.getOrientation() == HORIZONTAL_SPLIT ? minimum.width : minimum.height; + } + + private int defaultExpandedExtent() { + if (this.getOrientation() == VERTICAL_SPLIT) { + return ASSET_PANEL_MIN_HEIGHT; + } + return this.collapseSide == CollapseSide.FIRST ? SCENE_GRAPH_MIN_WIDTH : inspectorMinimumWidth(); + } + + private int collapsedDividerLocation() { + if (this.collapseSide == CollapseSide.FIRST) { + return 0; + } + return this.maximumDividerLocation(); + } + + private int maximumDividerLocation() { + int extent = this.getOrientation() == HORIZONTAL_SPLIT ? this.getWidth() : this.getHeight(); + return Math.max(0, extent - this.getDividerSize()); + } + } + + private static final class CollapsibleSplitPaneUI extends BasicSplitPaneUI { + private final CollapsibleSplitPane splitPane; + + private CollapsibleSplitPaneUI(CollapsibleSplitPane splitPane) { + this.splitPane = splitPane; + } + + @Override + public BasicSplitPaneDivider createDefaultDivider() { + return new BasicSplitPaneDivider(this) { + private final JButton collapseButton = createCollapseButton(); + + { + this.setLayout(new GridBagLayout()); + this.add(this.collapseButton); + } + + private JButton createCollapseButton() { + JButton button = new DockCollapseButton(CollapsibleSplitPaneUI.this.splitPane); + Dimension size = CollapsibleSplitPaneUI.this.splitPane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT + ? new Dimension(COLLAPSIBLE_SPLITTER_SIZE, 28) + : new Dimension(28, COLLAPSIBLE_SPLITTER_SIZE); + button.setPreferredSize(size); + button.setMinimumSize(size); + button.setMaximumSize(size); + button.setToolTipText(Resources.strings().get("dock_toggle_panel")); + button.getAccessibleContext().setAccessibleName(Resources.strings().get("dock_toggle_panel")); + button.addActionListener(event -> CollapsibleSplitPaneUI.this.splitPane.toggleCollapsed()); + return button; + } + + @Override + public void paint(Graphics graphics) { + graphics.setColor(getBackground()); + graphics.fillRect(0, 0, getWidth(), getHeight()); + super.paint(graphics); + } + }; + } + } + + private static final class DockCollapseButton extends JButton { + private DockCollapseButton(CollapsibleSplitPane splitPane) { + super(new DockArrowIcon(splitPane)); + this.setBorder(BorderFactory.createEmptyBorder()); + this.setContentAreaFilled(false); + this.setFocusPainted(false); + this.setMargin(new java.awt.Insets(0, 0, 0, 0)); + this.setOpaque(false); + this.setRolloverEnabled(true); + this.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)); + } + + @Override + protected void paintComponent(Graphics graphics) { + boolean highlighted = this.getModel().isRollover() || this.getModel().isPressed(); + Graphics2D g2 = (Graphics2D) graphics.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + if (highlighted) { + Color accent = Style.accent(); + int alpha = this.getModel().isPressed() ? 52 : 32; + g2.setColor(new Color(accent.getRed(), accent.getGreen(), accent.getBlue(), alpha)); + g2.fillRoundRect(1, 1, Math.max(0, getWidth() - 2), Math.max(0, getHeight() - 2), 8, 8); + } + if (this.isFocusOwner()) { + g2.setColor(Style.accent()); + g2.setStroke(new java.awt.BasicStroke(1f)); + g2.drawRoundRect(1, 1, Math.max(0, getWidth() - 3), Math.max(0, getHeight() - 3), 8, 8); + } + } finally { + g2.dispose(); + } + super.paintComponent(graphics); + } + } + + private static final class DockArrowIcon implements Icon { + private static final int SIZE = 10; + private final CollapsibleSplitPane splitPane; + + private DockArrowIcon(CollapsibleSplitPane splitPane) { + this.splitPane = splitPane; + } + + @Override + public int getIconWidth() { + return SIZE; + } + + @Override + public int getIconHeight() { + return SIZE; + } + + @Override + public void paintIcon(Component component, Graphics graphics, int x, int y) { + Graphics2D g2 = (Graphics2D) graphics.create(); + try { + boolean highlighted = component instanceof JButton button + && (button.getModel().isRollover() || button.getModel().isPressed() || button.isFocusOwner()); + g2.setColor(component.isEnabled() + ? highlighted ? Style.accent() : Style.mutedText() + : Style.disabledIconColor()); + g2.setStroke(new java.awt.BasicStroke(1.4f, java.awt.BasicStroke.CAP_ROUND, + java.awt.BasicStroke.JOIN_ROUND)); + int direction = direction(); + if (direction == javax.swing.SwingConstants.LEFT) { + g2.drawLine(x + 7, y + 2, x + 3, y + 5); + g2.drawLine(x + 3, y + 5, x + 7, y + 8); + } else if (direction == javax.swing.SwingConstants.RIGHT) { + g2.drawLine(x + 3, y + 2, x + 7, y + 5); + g2.drawLine(x + 7, y + 5, x + 3, y + 8); + } else if (direction == javax.swing.SwingConstants.TOP) { + g2.drawLine(x + 2, y + 7, x + 5, y + 3); + g2.drawLine(x + 5, y + 3, x + 8, y + 7); + } else { + g2.drawLine(x + 2, y + 3, x + 5, y + 7); + g2.drawLine(x + 5, y + 7, x + 8, y + 3); + } + } finally { + g2.dispose(); + } + } + + private int direction() { + if (this.splitPane.getOrientation() == JSplitPane.VERTICAL_SPLIT) { + return this.splitPane.isCollapsed() ? javax.swing.SwingConstants.TOP : javax.swing.SwingConstants.BOTTOM; + } + boolean pointsToFirst = this.splitPane.collapseSide == CollapseSide.FIRST + ? !this.splitPane.isCollapsed() + : this.splitPane.isCollapsed(); + return pointsToFirst ? javax.swing.SwingConstants.LEFT : javax.swing.SwingConstants.RIGHT; + } + } + static JPanel createDockPanel(Component content) { return createDockPanel(content, Style.SPACE_SMALL); } @@ -1931,7 +2324,7 @@ static int initialInspectorDivider( int windowWidth, int hierarchyWidth, int inspectorWidth, int preferredInspectorWidth, int persistedDivider) { int maximumDivider = Math.max(0, windowWidth - inspectorWidth); if (persistedDivider > 0) { - return Math.min(persistedDivider + hierarchyWidth + SPLITTER_SIZE, maximumDivider); + return Math.min(persistedDivider + hierarchyWidth + COLLAPSIBLE_SPLITTER_SIZE, maximumDivider); } return Math.max(0, windowWidth - preferredInspectorWidth); } diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportPanel.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportPanel.java index d43b7b52c..9bae35dc0 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportPanel.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportPanel.java @@ -24,6 +24,7 @@ public final class ViewportPanel extends JPanel { private final ScrollHandlerBar horizontalScroll; private final ScrollHandlerBar verticalScroll; private final JPanel corner; + private boolean viewportUpdatePending; public ViewportPanel(Canvas canvas) { super(new BorderLayout()); @@ -81,8 +82,7 @@ public ViewportPanel(Canvas canvas) { Game.world().camera().onFocus(event -> repaintRulers()); canvas.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent event) { - Scroll.updateScrollHandlers(); - repaintRulers(); + scheduleViewportUpdate(); } }); refreshTheme(); @@ -129,4 +129,17 @@ private void repaintRulers() { this.verticalRuler.repaint(); }); } + + private void scheduleViewportUpdate() { + if (this.viewportUpdatePending) { + return; + } + this.viewportUpdatePending = true; + SwingUtilities.invokeLater(() -> { + this.viewportUpdatePending = false; + Scroll.updateScrollHandlers(); + this.horizontalRuler.repaint(); + this.verticalRuler.repaint(); + }); + } } diff --git a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportToolbar.java b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportToolbar.java index 961fc4c6a..42f01234d 100644 --- a/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportToolbar.java +++ b/utiliti/src/main/java/de/gurkenlabs/utiliti/view/components/ViewportToolbar.java @@ -55,6 +55,7 @@ import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComboBox; +import javax.swing.JCheckBoxMenuItem; import javax.swing.DefaultListCellRenderer; import javax.swing.JLabel; import javax.swing.JList; @@ -76,6 +77,7 @@ public class ViewportToolbar extends JPanel { private static final int DROPDOWN_BUTTON_WIDTH = 22; private static final int TOOLBAR_VERTICAL_PADDING = 8; private static final int ICON_TEXT_GAP = 5; + private static final int COMPACT_BUTTON_WIDTH = 32; static final int MAX_HISTORY_VISIBLE_ROWS = 12; private static final Insets BUTTON_MARGIN = new Insets(0, BUTTON_HORIZONTAL_PADDING, 0, BUTTON_HORIZONTAL_PADDING); private final ZoomControls zoomControls; @@ -92,6 +94,8 @@ public class ViewportToolbar extends JPanel { private final JToggleButton btnCollision; private final List controlGroups = new ArrayList<>(); private final List groupDividers = new ArrayList<>(); + private final List compactableButtons = new ArrayList<>(); + private final List mapOverflowComponents = new ArrayList<>(); private final JButton btnRunProject; private final JButton btnDebugProject; private final JButton btnStopProject; @@ -103,6 +107,13 @@ public class ViewportToolbar extends JPanel { private final JPanel mapControlsContainer; private final JPanel scriptControlsContainer; private final JPanel rightControlsContainer; + private final JPanel leftControlsContainer; + private final JComboBox mapSelector; + private final JButton btnMoreMapActions; + private final Dimension expandedMapSelectorSize; + private boolean compactLayout; + private boolean overflowLayout; + private boolean scriptMode; public ViewportToolbar(JComboBox mapSelector) { super(new BorderLayout()); @@ -112,11 +123,14 @@ public ViewportToolbar(JComboBox mapSelector) { JPanel left = new JPanel(new FlowLayout(FlowLayout.LEADING, Style.SPACE_MEDIUM, 0)); left.setOpaque(false); + this.leftControlsContainer = left; JPanel right = new JPanel(new FlowLayout(FlowLayout.TRAILING, Style.SPACE_MEDIUM, 0)); right.setOpaque(false); - mapSelector.setPreferredSize(new Dimension(232, Style.CONTROL_HEIGHT)); + this.mapSelector = mapSelector; + this.expandedMapSelectorSize = new Dimension(232, Style.CONTROL_HEIGHT); + mapSelector.setPreferredSize(this.expandedMapSelectorSize); mapSelector.setMinimumSize(new Dimension(140, Style.CONTROL_HEIGHT)); mapSelector.setBackground(Style.surface()); mapSelector.setForeground(Style.text()); @@ -187,16 +201,22 @@ public ViewportToolbar(JComboBox mapSelector) { } addToControlStrip(this.mapControlsContainer, toolGroup); this.btnUndo = button(Resources.strings().get("menu_edit_undo"), Icons.UNDO_16, () -> UndoManager.instance().undo(), shortcut(KeyEvent.VK_Z)); + makeCompactable(this.btnUndo); this.btnUndoHistory = button(Resources.strings().get("toolbar_undoHistory"), new DropdownArrowIcon(), () -> {}); makeIconOnly(this.btnUndoHistory, DROPDOWN_BUTTON_WIDTH); this.btnUndoHistory.addActionListener(e -> showHistory(this.btnUndoHistory, true)); this.btnRedo = button(Resources.strings().get("menu_edit_redo"), Icons.REDO_16, () -> UndoManager.instance().redo(), shortcut(KeyEvent.VK_Y)); + makeCompactable(this.btnRedo); this.btnRedoHistory = button(Resources.strings().get("toolbar_redoHistory"), new DropdownArrowIcon(), () -> {}); makeIconOnly(this.btnRedoHistory, DROPDOWN_BUTTON_WIDTH); this.btnRedoHistory.addActionListener(e -> showHistory(this.btnRedoHistory, false)); - addToControlStrip(this.mapControlsContainer, - controlGroup(splitButton(this.btnUndo, this.btnUndoHistory), splitButton(this.btnRedo, this.btnRedoHistory))); - addToControlStrip(this.mapControlsContainer, controlGroup(addButton())); + JPanel historyGroup = + controlGroup(splitButton(this.btnUndo, this.btnUndoHistory), splitButton(this.btnRedo, this.btnRedoHistory)); + addToControlStrip(this.mapControlsContainer, historyGroup); + this.mapOverflowComponents.add(historyGroup); + JPanel addGroup = controlGroup(addButton()); + addToControlStrip(this.mapControlsContainer, addGroup); + this.mapOverflowComponents.add(addGroup); this.btnCopy = button(Resources.strings().get("menu_edit_copy"), Icons.COPY_16, () -> { if (Editor.instance().getMapComponent() != null) { Editor.instance().getMapComponent().copy(); @@ -219,8 +239,13 @@ public ViewportToolbar(JComboBox mapSelector) { Editor.instance().getMapComponent().paste(); } }, shortcut(KeyEvent.VK_V)); - addToControlStrip(this.mapControlsContainer, - controlGroup(this.btnCut, this.btnCopy, this.btnPaste, this.btnDelete)); + makeCompactable(this.btnCut); + makeCompactable(this.btnCopy); + makeCompactable(this.btnPaste); + makeCompactable(this.btnDelete); + JPanel clipboardGroup = controlGroup(this.btnCut, this.btnCopy, this.btnPaste, this.btnDelete); + addToControlStrip(this.mapControlsContainer, clipboardGroup); + this.mapOverflowComponents.add(clipboardGroup); left.add(this.mapControlsContainer); this.scriptControlsContainer = controlStrip(); @@ -235,17 +260,21 @@ public ViewportToolbar(JComboBox mapSelector) { JButton btnSaveScript = button("Save", Icons.SAVE_16, () -> { if (UI.getScriptWorkspacePanel() != null) UI.getScriptWorkspacePanel().saveActive(); }, null); + makeCompactable(btnNewScript); + makeCompactable(btnSaveScript); JButton btnDuplicateScript = button("Duplicate", Icons.COPY_16, () -> { if (UI.getScriptWorkspacePanel() != null) UI.getScriptWorkspacePanel().duplicateActiveOrSelected(); }, null); sizeLabeledButton(btnDuplicateScript); + makeCompactable(btnDuplicateScript); JButton btnDeleteScript = button("Delete", Icons.DELETE_16, () -> { if (UI.getScriptWorkspacePanel() != null) UI.getScriptWorkspacePanel().deleteActiveOrSelected(); }, KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0)); Style.styleButton(btnDeleteScript, Style.ButtonVariant.DESTRUCTIVE); sizeLabeledButton(btnDeleteScript); + makeCompactable(btnDeleteScript); JButton btnMoreScriptActions = button("", Icons.MISC_24, () -> {}, null); makeIconOnly(btnMoreScriptActions, 28); @@ -299,7 +328,22 @@ public ViewportToolbar(JComboBox mapSelector) { this.btnGrid = viewToggle(Resources.strings().get("toolbar_grid"), new MonochromeIcon(Icons.GRID_16), Editor.preferences().showGrid(), selected -> Editor.preferences().setShowGrid(selected), shortcut(KeyEvent.VK_G)); this.btnSnap = viewToggle(Resources.strings().get("toolbar_snap"), new MonochromeIcon(Icons.SNAP_GRID_16), Editor.preferences().snapToGrid(), selected -> Editor.preferences().setSnapToGrid(selected), null); this.btnCollision = viewToggle(Resources.strings().get("toolbar_outlines"), new MonochromeIcon(Icons.COLLISIONBOX_16), Editor.preferences().renderBoundingBoxes(), selected -> Editor.preferences().setRenderBoundingBoxes(selected), shortcut(KeyEvent.VK_H)); + makeCompactable(this.btnGrid); + makeCompactable(this.btnSnap); + makeCompactable(this.btnCollision); JPanel viewControls = controlGroup(this.btnGrid, this.btnSnap, this.btnCollision); + this.mapOverflowComponents.add(viewControls); + + this.btnMoreMapActions = button("", Icons.MISC_24, () -> {}, null); + makeIconOnly(this.btnMoreMapActions, 28); + String moreMapActions = Resources.strings().get("toolbar_moreMapActions"); + this.btnMoreMapActions.setToolTipText(moreMapActions); + this.btnMoreMapActions.getAccessibleContext().setAccessibleName(moreMapActions); + this.btnMoreMapActions.addActionListener(event -> + this.createMapActionsMenu().show( + this.btnMoreMapActions, 0, this.btnMoreMapActions.getHeight())); + JPanel mapOverflowGroup = controlGroup(this.btnMoreMapActions); + mapOverflowGroup.setVisible(false); this.zoomControls = new ZoomControls( () -> { @@ -314,13 +358,25 @@ public ViewportToolbar(JComboBox mapSelector) { Resources.strings().get("toolbar_fit")); this.zoomControls.setZoomText(formatZoom()); right.add(viewControls); + right.add(mapOverflowGroup); right.add(this.zoomControls); this.rightControlsContainer = right; - add(left, BorderLayout.WEST); + add(left, BorderLayout.CENTER); add(right, BorderLayout.EAST); } + @Override + public void doLayout() { + if (this.overflowLayout) { + this.applyOverflowLayout(false); + } + this.applyCompactLayout(this.getWidth() < this.expandedRequiredWidth()); + this.applyOverflowLayout( + !this.scriptMode && this.compactLayout && this.getWidth() < this.compactRequiredWidth()); + super.doLayout(); + } + public void updateRunState(boolean hasProject, boolean isRunning) { this.updateRunState(hasProject, isRunning, ProjectLaunchPhase.IDLE); } @@ -345,10 +401,16 @@ public void updateRunState(boolean hasProject, boolean isRunning, ProjectLaunchP } public void setScriptMode(boolean scriptMode) { + this.scriptMode = scriptMode; + if (scriptMode) { + this.applyOverflowLayout(false); + } this.mapSelectorContainer.setVisible(true); this.mapControlsContainer.setVisible(!scriptMode); this.scriptControlsContainer.setVisible(scriptMode); this.rightControlsContainer.setVisible(!scriptMode); + this.revalidate(); + this.repaint(); } JPopupMenu createScriptActionsMenu() { @@ -379,6 +441,69 @@ JPopupMenu createScriptActionsMenu() { return menu; } + JPopupMenu createMapActionsMenu() { + JPopupMenu menu = new JPopupMenu(); + menu.add(actionItem(this.btnUndo)); + menu.add(historyMenu(true)); + menu.add(actionItem(this.btnRedo)); + menu.add(historyMenu(false)); + menu.addSeparator(); + + JMenu add = new JMenu(Resources.strings().get("toolbar_add")); + add.setIcon(Icons.ADD_16); + addCreateItems(add); + menu.add(add); + menu.addSeparator(); + + menu.add(actionItem(this.btnCut)); + menu.add(actionItem(this.btnCopy)); + menu.add(actionItem(this.btnPaste)); + menu.add(actionItem(this.btnDelete)); + menu.addSeparator(); + + menu.add(toggleItem(this.btnGrid)); + menu.add(toggleItem(this.btnSnap)); + menu.add(toggleItem(this.btnCollision)); + return menu; + } + + private static JMenu historyMenu(boolean undo) { + UndoManager manager = Game.world().environment() != null + && Game.world().environment().getMap() != null ? UndoManager.instance() : null; + List history = manager == null + ? List.of() + : undo ? manager.getUndoHistory() : manager.getRedoHistory(); + JMenu menu = new JMenu(Resources.strings().get(undo ? "toolbar_undoHistory" : "toolbar_redoHistory")); + menu.setIcon(undo ? Icons.UNDO_16 : Icons.REDO_16); + if (history.isEmpty()) { + JMenuItem empty = new JMenuItem(Resources.strings().get( + undo ? "history_nothingToUndo" : "history_nothingToRedo")); + empty.setEnabled(false); + menu.add(empty); + return menu; + } + + for (int index = 0; index < history.size(); index++) { + int operations = index + 1; + String label = Resources.strings().get( + undo ? "history_undoEntry" : "history_redoEntry", history.get(index).description()); + if (operations > 1) { + label = Resources.strings().get( + "history_multipleOperations", label, Integer.toString(operations)); + } + JMenuItem item = new JMenuItem(label); + item.addActionListener(event -> { + if (undo) { + manager.undo(operations); + } else { + manager.redo(operations); + } + }); + menu.add(item); + } + return menu; + } + private void showNewScriptMenu(Component invoker) { JPopupMenu menu = new JPopupMenu(); @@ -481,6 +606,7 @@ private static void syncToggle(JToggleButton button, boolean selected) { private JPanel addButton() { JButton main = button(Resources.strings().get("toolbar_add"), Icons.ADD_16, () -> {}); + makeCompactable(main); JButton arrow = button(Resources.strings().get("toolbar_addMenu"), new DropdownArrowIcon(), () -> {}); makeIconOnly(arrow, DROPDOWN_BUTTON_WIDTH); main.addActionListener(e -> createAddPopup().show(main, 0, main.getHeight())); @@ -505,6 +631,7 @@ private JToggleButton toolButton(Tool tool) { styleToggle(button); button.repaint(); })); + makeCompactable(button); return button; } @@ -751,24 +878,47 @@ public Component getListCellRendererComponent( private static JPopupMenu createAddPopup() { JPopupMenu popup = new JPopupMenu(); - addCreateItem(popup, Resources.strings().get("menu_add_prop"), Icons.PROP_16, MapObjectType.PROP, KeyEvent.VK_1); - addCreateItem(popup, Resources.strings().get("menu_add_creature"), Icons.CREATURE_16, MapObjectType.CREATURE, KeyEvent.VK_2); - addCreateItem(popup, Resources.strings().get("menu_add_collisionbox"), Icons.COLLISIONBOX_16, MapObjectType.COLLISIONBOX, KeyEvent.VK_3); - addCreateItem(popup, Resources.strings().get("menu_add_trigger"), Icons.TRIGGER_16, MapObjectType.TRIGGER, KeyEvent.VK_4); - addCreateItem(popup, Resources.strings().get("menu_add_spawnpoint"), Icons.SPAWNPOINT_16, MapObjectType.SPAWNPOINT, KeyEvent.VK_5); - addCreateItem(popup, Resources.strings().get("menu_add_area"), Icons.MAPAREA_16, MapObjectType.AREA, KeyEvent.VK_6); - addCreateItem(popup, Resources.strings().get("menu_add_light"), Icons.BULB_16, MapObjectType.LIGHTSOURCE, KeyEvent.VK_7); - addCreateItem(popup, Resources.strings().get("menu_add_shadow"), Icons.SHADOWBOX_16, MapObjectType.STATICSHADOW, KeyEvent.VK_8); - addCreateItem(popup, Resources.strings().get("menu_add_emitter"), Icons.EMITTER_16, MapObjectType.EMITTER, KeyEvent.VK_9); - addCreateItem(popup, Resources.strings().get("menu_add_soundsource"), Icons.SOUND_16, MapObjectType.SOUNDSOURCE, KeyEvent.VK_0); + addCreateItems(popup); return popup; } - private static void addCreateItem(JPopupMenu popup, String text, javax.swing.Icon icon, MapObjectType type, int keyCode) { + private static void addCreateItems(java.awt.Container menu) { + addCreateItem(menu, Resources.strings().get("menu_add_prop"), Icons.PROP_16, MapObjectType.PROP, KeyEvent.VK_1); + addCreateItem(menu, Resources.strings().get("menu_add_creature"), Icons.CREATURE_16, MapObjectType.CREATURE, KeyEvent.VK_2); + addCreateItem(menu, Resources.strings().get("menu_add_collisionbox"), Icons.COLLISIONBOX_16, MapObjectType.COLLISIONBOX, KeyEvent.VK_3); + addCreateItem(menu, Resources.strings().get("menu_add_trigger"), Icons.TRIGGER_16, MapObjectType.TRIGGER, KeyEvent.VK_4); + addCreateItem(menu, Resources.strings().get("menu_add_spawnpoint"), Icons.SPAWNPOINT_16, MapObjectType.SPAWNPOINT, KeyEvent.VK_5); + addCreateItem(menu, Resources.strings().get("menu_add_area"), Icons.MAPAREA_16, MapObjectType.AREA, KeyEvent.VK_6); + addCreateItem(menu, Resources.strings().get("menu_add_light"), Icons.BULB_16, MapObjectType.LIGHTSOURCE, KeyEvent.VK_7); + addCreateItem(menu, Resources.strings().get("menu_add_shadow"), Icons.SHADOWBOX_16, MapObjectType.STATICSHADOW, KeyEvent.VK_8); + addCreateItem(menu, Resources.strings().get("menu_add_emitter"), Icons.EMITTER_16, MapObjectType.EMITTER, KeyEvent.VK_9); + addCreateItem(menu, Resources.strings().get("menu_add_soundsource"), Icons.SOUND_16, MapObjectType.SOUNDSOURCE, KeyEvent.VK_0); + } + + private static void addCreateItem(java.awt.Container menu, String text, javax.swing.Icon icon, + MapObjectType type, int keyCode) { JMenuItem item = new JMenuItem(text, icon); item.setAccelerator(KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)); item.addActionListener(e -> AddMenu.setCreateMode(type)); - popup.add(item); + menu.add(item); + } + + private static JMenuItem actionItem(AbstractButton source) { + String label = (String) source.getClientProperty("Editor.expandedToolbarText"); + JMenuItem item = new JMenuItem(label != null ? label : source.getAccessibleContext().getAccessibleName(), source.getIcon()); + item.setEnabled(source.isEnabled()); + item.addActionListener(event -> source.doClick()); + return item; + } + + private static JCheckBoxMenuItem toggleItem(JToggleButton source) { + String label = (String) source.getClientProperty("Editor.expandedToolbarText"); + JCheckBoxMenuItem item = new JCheckBoxMenuItem( + label != null ? label : source.getAccessibleContext().getAccessibleName(), source.isSelected()); + item.setIcon(source.getIcon()); + item.setEnabled(source.isEnabled()); + item.addActionListener(event -> source.doClick()); + return item; } private JButton button(String text, javax.swing.Icon icon, Runnable action) { @@ -864,6 +1014,94 @@ private static void makeIconOnly(AbstractButton button, int width) { button.setPreferredSize(new Dimension(width, BUTTON_SIZE.height)); } + private void makeCompactable(AbstractButton button) { + if (button.getText() == null || button.getText().isEmpty()) { + return; + } + button.putClientProperty("Editor.expandedToolbarText", button.getText()); + button.putClientProperty("Editor.expandedToolbarSize", button.getPreferredSize()); + this.compactableButtons.add(button); + } + + private void applyCompactLayout(boolean compact) { + if (this.compactLayout == compact) { + return; + } + this.compactLayout = compact; + this.mapSelector.setPreferredSize(compact + ? new Dimension(140, Style.CONTROL_HEIGHT) + : this.expandedMapSelectorSize); + for (AbstractButton button : this.compactableButtons) { + String text = (String) button.getClientProperty("Editor.expandedToolbarText"); + Dimension expandedSize = (Dimension) button.getClientProperty("Editor.expandedToolbarSize"); + button.setText(compact ? null : text); + button.setMargin(compact ? new Insets(0, 0, 0, 0) : BUTTON_MARGIN); + Dimension size = compact ? new Dimension(COMPACT_BUTTON_WIDTH, BUTTON_SIZE.height) : expandedSize; + button.setPreferredSize(size); + button.setMinimumSize(size); + button.setMaximumSize(size); + } + } + + private void applyOverflowLayout(boolean overflow) { + if (this.overflowLayout == overflow) { + return; + } + this.overflowLayout = overflow; + for (Component component : this.mapOverflowComponents) { + component.setVisible(!overflow); + } + this.btnMoreMapActions.getParent().setVisible(overflow); + } + + private int compactRequiredWidth() { + return this.leftControlsContainer.getPreferredSize().width + + visiblePreferredWidth(this.rightControlsContainer) + + Style.SPACE_MEDIUM; + } + + private int expandedRequiredWidth() { + int width = this.leftControlsContainer.getPreferredSize().width + + visiblePreferredWidth(this.rightControlsContainer) + + Style.SPACE_MEDIUM; + if (!this.compactLayout) { + return width; + } + + width += this.expandedMapSelectorSize.width - this.mapSelector.getPreferredSize().width; + for (AbstractButton button : this.compactableButtons) { + if (!isVisibleInToolbar(button)) { + continue; + } + Dimension expandedSize = (Dimension) button.getClientProperty("Editor.expandedToolbarSize"); + width += Math.max(0, expandedSize.width - button.getPreferredSize().width); + } + return width; + } + + private static int visiblePreferredWidth(Component component) { + return component.isVisible() ? component.getPreferredSize().width : 0; + } + + private boolean isVisibleInToolbar(Component component) { + Component current = component; + while (current != null && current != this) { + if (!current.isVisible()) { + return false; + } + current = current.getParent(); + } + return current == this; + } + + boolean isCompactLayout() { + return this.compactLayout; + } + + boolean isOverflowLayout() { + return this.overflowLayout; + } + private JPanel controlGroup(java.awt.Component... components) { JPanel group = new ToolbarGroupPanel(new FlowLayout(FlowLayout.LEADING, 0, 0)); group.setOpaque(false); diff --git a/utiliti/src/main/localization/strings.properties b/utiliti/src/main/localization/strings.properties index c521b5470..0cb82967b 100644 --- a/utiliti/src/main/localization/strings.properties +++ b/utiliti/src/main/localization/strings.properties @@ -488,8 +488,10 @@ menu_add_area=Area menu_add_shadow=Static Shadow menu_add_emitter=Emitter menu_add_soundsource=Sound -panel_layerDeleted=Layer deleted -panel_undo=Undo +panel_layerDeleted=Layer deleted +panel_objectDeleted=Object deleted +panel_objectsDeleted={0} objects deleted +panel_undo=Undo # Tool names tool_pointer=Pointer @@ -568,6 +570,8 @@ coordinate_ruler_horizontal=Horizontal map ruler coordinate_ruler_vertical=Vertical map ruler coordinate_ruler_decimal={0,number,0.0} panel_inspector=Inspector +dock_toggle_panel=Collapse or restore panel +toolbar_moreMapActions=More map actions inspector_back=Previous inspected item inspector_forward=Next inspected item panel_noPropertiesDefined=No properties defined @@ -903,6 +907,8 @@ keymap_switch_map_mode=Switch to Map Editor keymap_switch_script_mode=Switch to Script Editor workspace_map=Map Editor workspace_scripts=Script Editor +script_editor_noScript=No script open +script_editor_noScript_hint=Open a script from the explorer or create a new one. menu_script_save=Script: Save menu_script_format=Script: Format Code menu_script_compile=Script: Build diff --git a/utiliti/src/main/localization/strings_de_DE.properties b/utiliti/src/main/localization/strings_de_DE.properties index 5600d97d3..307eeaf4c 100644 --- a/utiliti/src/main/localization/strings_de_DE.properties +++ b/utiliti/src/main/localization/strings_de_DE.properties @@ -487,6 +487,8 @@ menu_add_shadow=Statischer Schatten menu_add_emitter=Emitter menu_add_soundsource=Sound panel_layerDeleted=Ebene gelöscht +panel_objectDeleted=Objekt gelöscht +panel_objectsDeleted={0} Objekte gelöscht panel_undo=Rückgängig # Tool names @@ -566,6 +568,8 @@ coordinate_ruler_horizontal=Horizontales Kartenlineal coordinate_ruler_vertical=Vertikales Kartenlineal coordinate_ruler_decimal={0,number,0.0} panel_inspector=Inspektor +dock_toggle_panel=Panel ein- oder ausklappen +toolbar_moreMapActions=Weitere Kartenaktionen inspector_back=Vorheriges inspiziertes Element inspector_forward=Nächstes inspiziertes Element panel_noPropertiesDefined=Keine Eigenschaften definiert @@ -901,6 +905,8 @@ keymap_switch_map_mode=Zum Map-Editor wechseln keymap_switch_script_mode=Zum Skript-Editor wechseln workspace_map=Map-Editor workspace_scripts=Skript-Editor +script_editor_noScript=Kein Skript geöffnet +script_editor_noScript_hint=Öffne ein Skript im Explorer oder erstelle ein neues. menu_script_save=Skript: Speichern menu_script_format=Skript: Code formatieren menu_script_compile=Skript: Bauen diff --git a/utiliti/src/main/localization/strings_es_ES.properties b/utiliti/src/main/localization/strings_es_ES.properties index d2f2a5c35..137593f06 100644 --- a/utiliti/src/main/localization/strings_es_ES.properties +++ b/utiliti/src/main/localization/strings_es_ES.properties @@ -487,6 +487,8 @@ menu_add_shadow=Sombra estática menu_add_emitter=Emisor menu_add_soundsource=Sonido panel_layerDeleted=Capa eliminada +panel_objectDeleted=Objeto eliminado +panel_objectsDeleted={0} objetos eliminados panel_undo=Deshacer # Tool names @@ -566,6 +568,8 @@ coordinate_ruler_horizontal=Regla horizontal del mapa coordinate_ruler_vertical=Regla vertical del mapa coordinate_ruler_decimal={0,number,0.0} panel_inspector=Inspector +dock_toggle_panel=Contraer o restaurar panel +toolbar_moreMapActions=Más acciones del mapa inspector_back=Elemento inspeccionado anterior inspector_forward=Siguiente elemento inspeccionado panel_noPropertiesDefined=No hay propiedades definidas @@ -901,6 +905,8 @@ keymap_switch_map_mode=Cambiar a editor de mapas keymap_switch_script_mode=Cambiar a editor de scripts workspace_map=Editor de mapas workspace_scripts=Editor de scripts +script_editor_noScript=Ningún script abierto +script_editor_noScript_hint=Abre un script desde el explorador o crea uno nuevo. menu_script_save=Script: Guardar menu_script_format=Script: Formatear código menu_script_compile=Script: Construir diff --git a/utiliti/src/main/localization/strings_fr_FR.properties b/utiliti/src/main/localization/strings_fr_FR.properties index 4b8e46d20..734868154 100644 --- a/utiliti/src/main/localization/strings_fr_FR.properties +++ b/utiliti/src/main/localization/strings_fr_FR.properties @@ -487,6 +487,8 @@ menu_add_shadow=Ombre statique menu_add_emitter=Émetteur menu_add_soundsource=Son panel_layerDeleted=Calque supprimé +panel_objectDeleted=Objet supprimé +panel_objectsDeleted={0} objets supprimés panel_undo=Annuler # Noms des outils @@ -566,6 +568,8 @@ coordinate_ruler_horizontal=Règle horizontale de la carte coordinate_ruler_vertical=Règle verticale de la carte coordinate_ruler_decimal={0,number,0.0} panel_inspector=Inspecteur +dock_toggle_panel=Replier ou restaurer le panneau +toolbar_moreMapActions=Autres actions de carte inspector_back=Élément inspecté précédent inspector_forward=Élément inspecté suivant panel_noPropertiesDefined=Aucune propriété définie @@ -901,6 +905,8 @@ keymap_switch_map_mode=Passer à l'éditeur de cartes keymap_switch_script_mode=Passer à l'éditeur de scripts workspace_map=Éditeur de cartes workspace_scripts=Éditeur de scripts +script_editor_noScript=Aucun script ouvert +script_editor_noScript_hint=Ouvrez un script depuis l’explorateur ou créez-en un nouveau. menu_script_save=Script : Enregistrer menu_script_format=Script : Formater le code menu_script_compile=Script : Compiler / Construire diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/MapComponentTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/MapComponentTest.java index 88f1ce856..4748289f9 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/MapComponentTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/MapComponentTest.java @@ -13,6 +13,7 @@ import de.gurkenlabs.litiengine.Game; import de.gurkenlabs.litiengine.environment.Environment; +import de.gurkenlabs.litiengine.environment.tilemap.MapObjectProperty; import de.gurkenlabs.litiengine.environment.tilemap.MapObjectType; import de.gurkenlabs.litiengine.environment.tilemap.MapOrientations; import de.gurkenlabs.litiengine.environment.tilemap.xml.GroupLayer; @@ -26,6 +27,7 @@ import java.awt.event.InputEvent; import java.awt.event.MouseEvent; import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; import java.lang.reflect.Method; import java.util.List; import org.junit.jupiter.api.Test; @@ -205,6 +207,21 @@ void findsOverlappingVisibleMapObjectsAtLocation() { assertEquals(List.of(first, second), MapComponent.mapObjectsAt(map, new Point2D.Double(7, 7))); } + @Test + void doesNotPromoteOverlappingCollisionBoxToEntity() { + TmxMap map = new TmxMap(MapOrientations.ORTHOGONAL); + MapObject collisionBox = mapObject(1, 0, 0, 100, 100); + collisionBox.setType(MapObjectType.COLLISIONBOX.name()); + MapObject creature = mapObject(2, 20, 20, 16, 16); + creature.setType(MapObjectType.CREATURE.name()); + MapObjectLayer layer = new MapObjectLayer(); + layer.addMapObject(collisionBox); + layer.addMapObject(creature); + map.addLayer(layer); + + assertSame(collisionBox, MapComponent.resolveParentEntity(collisionBox)); + } + @Test void excludesObjectsOnHiddenLayersAtLocation() { TmxMap map = new TmxMap(MapOrientations.ORTHOGONAL); @@ -217,6 +234,50 @@ void excludesObjectsOnHiddenLayersAtLocation() { assertTrue(MapComponent.mapObjectsAt(map, new Point2D.Double(5, 5)).isEmpty()); } + @Test + void ignoresTransparentPixelsWhenFindingSpriteObjects() { + BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); + image.setRGB(1, 0, 0xffffffff); + new Spritesheet(image, "prop-transparent-hit-intact.png", 2, 1); + + TmxMap map = new TmxMap(MapOrientations.ORTHOGONAL); + MapObject prop = mapObject(1, 0, 0, 2, 1); + prop.setType(MapObjectType.PROP.name()); + prop.setValue(MapObjectProperty.SPRITESHEETNAME, "transparent-hit"); + MapObject collisionBox = mapObject(2, 0, 0, 2, 1); + collisionBox.setType(MapObjectType.COLLISIONBOX.name()); + MapObjectLayer layer = new MapObjectLayer(); + layer.addMapObject(prop); + layer.addMapObject(collisionBox); + map.addLayer(layer); + + assertEquals( + List.of(collisionBox), MapComponent.mapObjectsAt(map, new Point2D.Double(0.5, 0.5))); + assertEquals( + List.of(prop, collisionBox), MapComponent.mapObjectsAt(map, new Point2D.Double(1.5, 0.5))); + } + + @Test + void previewHitTestingDoesNotCombinePixelsFromDifferentAnimationFrames() { + BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); + image.setRGB(1, 0, 0xffffffff); + new Spritesheet(image, "prop-multiframe-hit-intact.png", 1, 1); + + TmxMap map = new TmxMap(MapOrientations.ORTHOGONAL); + MapObject prop = mapObject(1, 0, 0, 1, 1); + prop.setType(MapObjectType.PROP.name()); + prop.setValue(MapObjectProperty.SPRITESHEETNAME, "multiframe-hit"); + MapObject collisionBox = mapObject(2, 0, 0, 1, 1); + collisionBox.setType(MapObjectType.COLLISIONBOX.name()); + MapObjectLayer layer = new MapObjectLayer(); + layer.addMapObject(prop); + layer.addMapObject(collisionBox); + map.addLayer(layer); + + assertEquals( + List.of(collisionBox), MapComponent.mapObjectsAt(map, new Point2D.Double(0.5, 0.5))); + } + @Test void convertsPhysicalCanvasCoordinatesUsingCameraRenderScale() { ICamera camera = mock(ICamera.class); diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactoryTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactoryTest.java index c72014bd6..535066fa1 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactoryTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScriptTemplateFactoryTest.java @@ -20,6 +20,7 @@ void testGameScriptTemplate() { assertTrue(source.contains("input().bindKeyTyped(KeyEvent.VK_ESCAPE")); assertFalse(source.contains("Input.keyboard()")); assertTrue(source.contains("void update()")); + assertUsesMarkdownDocumentation(source); } @Test @@ -30,6 +31,7 @@ void testEnvironmentScriptTemplate() { assertTrue(source.contains("public class Level1Script extends EnvironmentScript")); assertTrue(source.contains("@ScriptInfo(id = \"Level1Script\", host = ScriptHostType.ENVIRONMENT)")); assertTrue(source.contains("void onLoaded()")); + assertUsesMarkdownDocumentation(source); } @Test @@ -40,6 +42,8 @@ void testEntityCreatureScriptTemplate() { assertTrue(source.contains("public class EnemyAI extends CreatureScript")); assertTrue(source.contains("@ScriptInfo(id = \"EnemyAI\", host = ScriptHostType.ENTITY, target = Creature.class)")); assertTrue(source.contains("void onHit(EntityHitEvent event)")); + assertTrue(source.contains("/// Entity script controller for [Creature].")); + assertUsesMarkdownDocumentation(source); } @Test @@ -49,6 +53,7 @@ void testEntityCustomPropScriptTemplate() { assertTrue(source.contains("package com.example.prop;")); assertTrue(source.contains("public class ChestScript extends EntityScript")); assertTrue(source.contains("@ScriptInfo(id = \"ChestScript\", host = ScriptHostType.ENTITY, target = Prop.class)")); + assertUsesMarkdownDocumentation(source); } @Test @@ -71,4 +76,12 @@ public void onStarted() {} assertTrue(updated.contains("void onLoaded()")); assertFalse(updated.contains("void onStarted()")); } + + private static void assertUsesMarkdownDocumentation(String source) { + assertTrue(source.contains("/// Responsibilities:")); + assertTrue(source.contains("/// - ")); + assertFalse(source.contains("/**")); + assertFalse(source.contains("
    ")); + assertFalse(source.contains("{@")); + } } diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScrollTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScrollTest.java index d8ca664c8..a4fc71146 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScrollTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/ScrollTest.java @@ -9,9 +9,11 @@ class ScrollTest { @Test void fittedContentHidesScrollbar() { - Scroll.AxisModel model = Scroll.AxisModel.create(936, 1000, 32, 32, 468); + Scroll.AxisModel model = Scroll.AxisModel.create(936, 1000, 32, 32, 0); assertFalse(model.visible()); + assertEquals(468, model.minimumFocus()); + assertEquals(model.minimumFocus(), model.maximumFocus()); } @Test diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelectorTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelectorTest.java index fc61242af..75e758adf 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelectorTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/controller/SpriteVariantSelectorTest.java @@ -1,7 +1,10 @@ package de.gurkenlabs.utiliti.controller; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import de.gurkenlabs.litiengine.environment.tilemap.MapObjectType; +import de.gurkenlabs.litiengine.environment.tilemap.xml.MapObject; import de.gurkenlabs.litiengine.graphics.Spritesheet; import java.awt.image.BufferedImage; import java.util.List; @@ -74,6 +77,22 @@ void selectPropSpriteNameMatchesState() { assertEquals("prop-barrel1-destroyed", SpriteVariantSelector.selectPropSpriteName("barrel1", de.gurkenlabs.litiengine.entities.PropState.DESTROYED, sheets)); } + @Test + void previewRenderingToleratesMissingCreatureSpritesheetName() { + MapObject mapObject = new MapObject(MapObjectType.CREATURE.name()); + + assertNull(SpriteVariantSelector.getPreviewSpritesheet(null, mapObject)); + assertNull(SpriteVariantSelector.getEntityIcon(null, mapObject, 16)); + } + + @Test + void previewRenderingToleratesMissingPropSpritesheetName() { + MapObject mapObject = new MapObject(MapObjectType.PROP.name()); + + assertNull(SpriteVariantSelector.getPreviewSpritesheet(null, mapObject)); + assertNull(SpriteVariantSelector.getEntityIcon(null, mapObject, 16)); + } + private static Spritesheet sheet(String name) { return new Spritesheet(new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), name + ".png", 1, 1); } diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/EditorStyleTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/EditorStyleTest.java index 07d309f34..27fc350c8 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/EditorStyleTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/EditorStyleTest.java @@ -140,6 +140,109 @@ void workspaceModeButtonsAlignWithDockedPanelAndUseSharedStyle() { assertFalse(mapButton.isBorderPainted()); } + @Test + void workspaceModeButtonsShrinkBelowUsableEditorThreshold() { + JPanel rail = (JPanel) UI.initWorkspaceModeBar(); + JToggleButton mapButton = (JToggleButton) rail.getComponent(0); + JToggleButton scriptButton = (JToggleButton) rail.getComponent(2); + + UI.updateWorkspaceModeBar(rail, true); + + assertEquals(32, mapButton.getPreferredSize().width); + assertEquals(32, mapButton.getPreferredSize().height); + assertEquals(mapButton.getPreferredSize(), scriptButton.getPreferredSize()); + assertEquals(32 + Style.SPACE_SMALL, rail.getPreferredSize().width); + + UI.updateWorkspaceModeBar(rail, false); + + assertEquals(43, mapButton.getPreferredSize().width); + assertEquals(42, mapButton.getPreferredSize().height); + } + + @Test + void windowMinimumContainsUsableEditorInsteadOfOversizingChildComponents() { + java.awt.Dimension minimum = UI.minimumWindowSize(); + + assertEquals(728, minimum.width); + assertEquals(480, minimum.height); + assertTrue(minimum.width > UI.WORKSPACE_MIN_WIDTH); + } + + @Test + void narrowWorkspaceCollapsesInspectorBeforeSceneGraph() { + JPanel rail = (JPanel) UI.initWorkspaceModeBar(); + UI.CollapsibleSplitPane sceneSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.FIRST); + UI.CollapsibleSplitPane inspectorSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, sceneSplit, new JPanel(), UI.CollapseSide.SECOND); + sceneSplit.setSize(1000, 600); + inspectorSplit.setSize(1100, 600); + UI.configureSplitPane(sceneSplit); + UI.configureSplitPane(inspectorSplit); + + UI.preserveWorkspaceWidth(1100, rail, sceneSplit, inspectorSplit); + + assertTrue(inspectorSplit.isCollapsed()); + assertFalse(sceneSplit.isCollapsed()); + + UI.preserveWorkspaceWidth(900, rail, sceneSplit, inspectorSplit); + + assertTrue(sceneSplit.isCollapsed()); + + UI.preserveWorkspaceWidth(1100, rail, sceneSplit, inspectorSplit); + + assertFalse(sceneSplit.isCollapsed()); + assertTrue(inspectorSplit.isCollapsed()); + + UI.preserveWorkspaceWidth(1600, rail, sceneSplit, inspectorSplit); + + assertFalse(inspectorSplit.isCollapsed()); + } + + @Test + void growingWorkspaceDoesNotExpandPanelsCollapsedByUser() { + JPanel rail = (JPanel) UI.initWorkspaceModeBar(); + UI.CollapsibleSplitPane sceneSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.FIRST); + UI.CollapsibleSplitPane inspectorSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, sceneSplit, new JPanel(), UI.CollapseSide.SECOND); + sceneSplit.setSize(1000, 600); + inspectorSplit.setSize(1600, 600); + UI.configureSplitPane(sceneSplit); + UI.configureSplitPane(inspectorSplit); + inspectorSplit.toggleCollapsed(); + + UI.preserveWorkspaceWidth(1600, rail, sceneSplit, inspectorSplit); + + assertTrue(inspectorSplit.isCollapsed()); + assertFalse(inspectorSplit.isAutomaticallyCollapsed()); + } + + @Test + void automaticRestoreWaitsForTheRememberedDockWidths() { + JPanel rail = (JPanel) UI.initWorkspaceModeBar(); + UI.CollapsibleSplitPane sceneSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.FIRST); + UI.CollapsibleSplitPane inspectorSplit = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, sceneSplit, new JPanel(), UI.CollapseSide.SECOND); + sceneSplit.setSize(1200, 600); + inspectorSplit.setSize(1600, 600); + UI.configureSplitPane(sceneSplit); + UI.configureSplitPane(inspectorSplit); + sceneSplit.setDividerLocation(400); + inspectorSplit.setDividerLocation(1086); + + UI.preserveWorkspaceWidth(1500, rail, sceneSplit, inspectorSplit); + + assertTrue(inspectorSplit.isCollapsed()); + + inspectorSplit.setSize(1700, 600); + UI.preserveWorkspaceWidth(1700, rail, sceneSplit, inspectorSplit); + + assertFalse(inspectorSplit.isCollapsed()); + assertEquals(1186, inspectorSplit.getDividerLocation()); + } + @Test void expandableCardHeaderSupportsKeyboardToggle() { ExpandableCard card = new ExpandableCard("General", new JPanel(), true); @@ -234,7 +337,106 @@ void splitPaneDividerIsAnInvisibleDragTarget() { void inspectorDividerTranslatesPersistedViewportPosition() { int divider = UI.initialInspectorDivider(1920, 300, 380, 380, 1200); - assertEquals(1504, divider); + assertEquals(1514, divider); + } + + @Test + void collapsibleSplitPaneRestoresLastExpandedLocation() { + UI.CollapsibleSplitPane splitPane = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.FIRST); + splitPane.setSize(1000, 600); + UI.configureSplitPane(splitPane); + splitPane.setDividerLocation(340); + + splitPane.toggleCollapsed(); + + assertTrue(splitPane.isCollapsed()); + assertEquals(0, splitPane.getDividerLocation()); + + splitPane.toggleCollapsed(); + + assertFalse(splitPane.isCollapsed()); + assertEquals(340, splitPane.getDividerLocation()); + } + + @Test + void collapsibleBottomPanelUsesFarEdgeAndRestoresItsHeight() { + UI.CollapsibleSplitPane splitPane = new UI.CollapsibleSplitPane( + JSplitPane.VERTICAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.SECOND); + splitPane.setSize(1000, 800); + UI.configureSplitPane(splitPane); + splitPane.setDividerLocation(500); + + splitPane.toggleCollapsed(); + + assertTrue(splitPane.isCollapsed()); + assertEquals(786, splitPane.getDividerLocation()); + + splitPane.toggleCollapsed(); + + assertFalse(splitPane.isCollapsed()); + assertEquals(500, splitPane.getDividerLocation()); + } + + @Test + void collapsedFarEdgePanelKeepsItsSizeWhenContainerGrows() { + UI.CollapsibleSplitPane splitPane = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.SECOND); + splitPane.setSize(1000, 600); + UI.configureSplitPane(splitPane); + splitPane.setDividerLocation(620); + splitPane.getLeftComponent().setMinimumSize(new java.awt.Dimension(640, 0)); + splitPane.getRightComponent().setMinimumSize(new java.awt.Dimension(320, 0)); + splitPane.setDividerLocation(0); + + splitPane.collapseAutomatically(); + splitPane.setSize(1800, 600); + splitPane.expandAutomatically(); + + assertFalse(splitPane.isCollapsed()); + assertEquals(1420, splitPane.getDividerLocation()); + } + + @Test + void collapseButtonIsQuietUntilHovered() { + UI.CollapsibleSplitPane splitPane = new UI.CollapsibleSplitPane( + JSplitPane.HORIZONTAL_SPLIT, new JPanel(), new JPanel(), UI.CollapseSide.SECOND); + UI.configureSplitPane(splitPane); + BasicSplitPaneUI splitPaneUI = (BasicSplitPaneUI) splitPane.getUI(); + JButton button = (JButton) splitPaneUI.getDivider().getComponent(0); + + assertEquals(14, splitPane.getDividerSize()); + assertFalse(button.isOpaque()); + assertFalse(button.isContentAreaFilled()); + assertTrue(button.isRolloverEnabled()); + assertEquals(new java.awt.Dimension(14, 28), button.getPreferredSize()); + + button.setSize(button.getPreferredSize()); + BufferedImage quiet = paint(button); + button.getModel().setRollover(true); + BufferedImage hovered = paint(button); + + assertTrue(imagesDiffer(quiet, hovered)); + } + + private static BufferedImage paint(JComponent component) { + BufferedImage image = new BufferedImage( + component.getWidth(), component.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + component.paint(graphics); + graphics.dispose(); + return image; + } + + private static boolean imagesDiffer(BufferedImage first, BufferedImage second) { + for (int y = 0; y < first.getHeight(); y++) { + for (int x = 0; x < first.getWidth(); x++) { + if (first.getRGB(x, y) != second.getRGB(x, y)) { + return true; + } + } + } + return false; } @Test diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanelTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanelTest.java index 4b0a3ce7c..a1d9dfd20 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanelTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ScriptWorkspacePanelTest.java @@ -15,9 +15,12 @@ import de.gurkenlabs.utiliti.controller.ScriptBindingService; import de.gurkenlabs.utiliti.controller.ScriptBindingTarget; import de.gurkenlabs.utiliti.model.Icons; +import java.nio.file.Path; import java.util.List; +import javax.swing.JTabbedPane; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; @ExtendWith(SwingTestSuite.class) class ScriptWorkspacePanelTest { @@ -94,6 +97,59 @@ void monacoEditorIsLoadedOnlyAfterScriptIsDisplayed() { } } + @Test + void scriptTabsUseSingleRowOverflowInsteadOfClippedWrappedTabs() { + ScriptWorkspacePanel panel = new ScriptWorkspacePanel(); + try { + assertEquals(JTabbedPane.SCROLL_TAB_LAYOUT, panel.getTabLayoutPolicy()); + } finally { + panel.close(); + } + } + + @Test + void projectChangeAndCloseRemoveEveryOpenScriptTab(@TempDir Path tempDirectory) throws Exception { + de.gurkenlabs.litiengine.Game.init(de.gurkenlabs.litiengine.Game.COMMANDLINE_ARG_NOGUI); + Editor editor = Editor.instance(); + Path originalProject = editor.getProjectPath(); + Path firstProject = tempDirectory.resolve("first/game.litidata"); + Path secondProject = tempDirectory.resolve("second/game.litidata"); + ScriptWorkspacePanel panel = null; + try { + editor.setProjectPath(firstProject); + panel = new ScriptWorkspacePanel(); + ScriptWorkspacePanel workspace = panel; + assertTrue(workspace.isEmptyEditorStateVisible()); + ScriptDefinition first = new ScriptDefinition( + "first", "java", "scripts/First.java", "First", ScriptHostType.GAME); + workspace.open(first); + assertEquals(1, workspace.getOpenTabCount()); + assertFalse(workspace.isEmptyEditorStateVisible()); + assertFalse(workspace.hasUnsavedScripts()); + + editor.setProjectPath(firstProject); + assertEquals(1, workspace.getOpenTabCount()); + + editor.setProjectPath(secondProject); + assertEquals(0, workspace.getOpenTabCount()); + assertTrue(workspace.isEmptyEditorStateVisible()); + + ScriptDefinition second = new ScriptDefinition( + "second", "java", "scripts/Second.java", "Second", ScriptHostType.GAME); + workspace.open(second); + assertEquals(1, workspace.getOpenTabCount()); + + editor.setProjectPath(null); + assertEquals(0, workspace.getOpenTabCount()); + assertTrue(workspace.isEmptyEditorStateVisible()); + } finally { + if (panel != null) { + panel.close(); + } + editor.setProjectPath(originalProject); + } + } + @Test void usedByShowsEveryPersistedAssignmentWithoutCompatibilityOnlyNodes() { ScriptBindingService.UsageIndex usages = new ScriptBindingService.UsageIndex("test", List.of( diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanelTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanelTest.java index d082be635..e2314aedc 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanelTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/SpriteEditorPanelTest.java @@ -8,10 +8,16 @@ import de.gurkenlabs.litiengine.environment.tilemap.MapOrientations; import de.gurkenlabs.litiengine.environment.tilemap.xml.TmxMap; import de.gurkenlabs.litiengine.resources.SpritesheetResource; +import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.utiliti.controller.Editor; import de.gurkenlabs.utiliti.controller.UndoManager; +import java.awt.Component; +import java.awt.Container; import java.awt.image.BufferedImage; import java.util.List; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -96,6 +102,27 @@ void keyframeDurationsAreUndoable() throws Exception { assertEquals(120, sprite.getKeyframes()[1]); } + @Test + void durationFooterWrapsSummaryWithoutOverlappingApplyButton() { + SpriteEditorPanel panel = new SpriteEditorPanel(); + JButton apply = findButton(panel, Resources.strings().get("assetpanel_animation_apply")); + JPanel footer = (JPanel) apply.getParent(); + JLabel summary = (JLabel) footer.getComponent(footer.getComponentCount() - 1); + summary.setText(Resources.strings().get("spriteEditor_fpsEquivalent", "8.33")); + footer.setSize(320, 1); + int preferredHeight = footer.getPreferredSize().height; + footer.setSize(320, preferredHeight); + + footer.doLayout(); + + assertFalse(apply.getBounds().intersects(summary.getBounds())); + assertEquals(preferredHeight, footer.getMaximumSize().height); + for (Component component : footer.getComponents()) { + assertTrue(component.getY() + component.getHeight() <= footer.getHeight()); + } + panel.removeNotify(); + } + @Test void getFamilyVariantsIncludesMirroredCounterpartsWithIndicator() { Editor.instance().getGameFile().getSpriteSheets().clear(); @@ -169,4 +196,34 @@ void editingMirroredVariantMaterializesResourceAndIsUndoable() throws Exception Editor.instance().getGameFile().getSpriteSheets().clear(); } + + private static JButton findButton(Container root, String text) { + for (Component component : root.getComponents()) { + if (component instanceof JButton button && text.equals(button.getText())) { + return button; + } + if (component instanceof Container container) { + JButton found = findButtonOrNull(container, text); + if (found != null) { + return found; + } + } + } + throw new AssertionError("Button not found: " + text); + } + + private static JButton findButtonOrNull(Container root, String text) { + for (Component component : root.getComponents()) { + if (component instanceof JButton button && text.equals(button.getText())) { + return button; + } + if (component instanceof Container container) { + JButton found = findButtonOrNull(container, text); + if (found != null) { + return found; + } + } + } + return null; + } } diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ToastTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ToastTest.java new file mode 100644 index 000000000..db3b14129 --- /dev/null +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ToastTest.java @@ -0,0 +1,73 @@ +package de.gurkenlabs.utiliti.view.components; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Component; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JRootPane; +import javax.swing.SwingUtilities; +import org.junit.jupiter.api.Test; + +class ToastTest { + + @Test + void replacesActiveToastAndKeepsInteractionInsideToastBounds() throws Exception { + SwingUtilities.invokeAndWait(() -> { + JRootPane rootPane = new JRootPane(); + rootPane.setSize(600, 400); + rootPane.getLayeredPane().setSize(600, 400); + + Toast.show(rootPane, "First"); + Toast.show(rootPane, "Second", () -> { }); + + Toast toast = findToast(rootPane); + assertEquals(1, countToasts(rootPane)); + assertTrue(toast.getWidth() < rootPane.getWidth()); + assertTrue(toast.getY() > rootPane.getHeight() / 2); + assertEquals("Second", findChild(toast, JLabel.class).getText()); + findChild(toast, JButton.class).doClick(); + }); + } + + @Test + void actionRunsAndDismissesToast() throws Exception { + AtomicBoolean invoked = new AtomicBoolean(); + SwingUtilities.invokeAndWait(() -> { + JRootPane rootPane = new JRootPane(); + rootPane.setSize(600, 400); + rootPane.getLayeredPane().setSize(600, 400); + + Toast.show(rootPane, "Deleted", () -> invoked.set(true)); + findChild(findToast(rootPane), JButton.class).doClick(); + + assertTrue(invoked.get()); + assertEquals(0, countToasts(rootPane)); + }); + } + + private static Toast findToast(JRootPane rootPane) { + return Arrays.stream(rootPane.getLayeredPane().getComponents()) + .filter(Toast.class::isInstance) + .map(Toast.class::cast) + .findFirst() + .orElseThrow(); + } + + private static long countToasts(JRootPane rootPane) { + return Arrays.stream(rootPane.getLayeredPane().getComponents()) + .filter(Toast.class::isInstance) + .count(); + } + + private static T findChild(Toast toast, Class type) { + return Arrays.stream(toast.getComponents()) + .filter(type::isInstance) + .map(type::cast) + .findFirst() + .orElseThrow(); + } +} diff --git a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ViewportToolbarTest.java b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ViewportToolbarTest.java index 2f49b38b4..54e5d505d 100644 --- a/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ViewportToolbarTest.java +++ b/utiliti/src/test/java/de/gurkenlabs/utiliti/view/components/ViewportToolbarTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; +import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.litiengine.test.SwingTestSuite; import de.gurkenlabs.utiliti.controller.ProjectLaunchPhase; import de.gurkenlabs.utiliti.controller.UndoManager; @@ -21,6 +22,7 @@ import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; +import javax.swing.KeyStroke; import javax.swing.border.EmptyBorder; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -152,6 +154,69 @@ void advancedScriptActionsLiveInTheOverflowMenu() { assertEquals("Configure game scripts...", ((JMenuItem) menu.getComponent(4)).getText()); } + @Test + void narrowToolbarCompactsLabelsWithoutOverlappingRightControls() { + ViewportToolbar toolbar = new ViewportToolbar(new JComboBox<>()); + toolbar.setSize(900, toolbar.getPreferredSize().height); + + toolbar.doLayout(); + + assertTrue(toolbar.isCompactLayout()); + assertTrue(toolbar.isOverflowLayout()); + Component left = toolbar.getComponent(0); + Component right = toolbar.getComponent(1); + assertTrue(left.getX() + left.getWidth() <= right.getX()); + AbstractButton cut = findButton(toolbar, "Cut (" + + KeyBindings.format(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_X, + java.awt.event.InputEvent.CTRL_DOWN_MASK)) + ")"); + assertEquals(null, cut.getText()); + assertTrue(cut.getToolTipText().startsWith("Cut")); + + toolbar.setSize(2400, toolbar.getHeight()); + toolbar.doLayout(); + + assertFalse(toolbar.isCompactLayout()); + assertFalse(toolbar.isOverflowLayout()); + assertEquals("Cut", cut.getText()); + } + + @Test + void narrowMapToolbarKeepsSecondaryCommandsInOverflowMenu() { + ViewportToolbar toolbar = new ViewportToolbar(new JComboBox<>()); + toolbar.setSize(640, toolbar.getPreferredSize().height); + + toolbar.doLayout(); + + assertTrue(toolbar.isOverflowLayout()); + AbstractButton overflow = findButton(toolbar, "More map actions"); + assertTrue(overflow.isVisible()); + Component left = toolbar.getComponent(0); + Component right = toolbar.getComponent(1); + assertTrue(left.getX() + left.getWidth() <= right.getX()); + assertTrue(left.getPreferredSize().width <= left.getWidth()); + assertTrue(right.getPreferredSize().width <= right.getWidth()); + JPopupMenu menu = toolbar.createMapActionsMenu(); + assertEquals("Undo", ((JMenuItem) menu.getComponent(0)).getText()); + assertEquals("Undo history", ((JMenuItem) menu.getComponent(1)).getText()); + assertEquals("Redo", ((JMenuItem) menu.getComponent(2)).getText()); + assertEquals("Redo history", ((JMenuItem) menu.getComponent(3)).getText()); + assertEquals("Add", ((JMenuItem) menu.getComponent(5)).getText()); + assertEquals("Cut", ((JMenuItem) menu.getComponent(7)).getText()); + assertEquals("Grid", ((JMenuItem) menu.getComponent(12)).getText()); + } + + @Test + void scriptModeDoesNotReserveHiddenMapControlsOrShowMapOverflow() { + ViewportToolbar toolbar = new ViewportToolbar(new JComboBox<>()); + toolbar.setScriptMode(true); + toolbar.setSize(700, toolbar.getPreferredSize().height); + + toolbar.doLayout(); + + assertFalse(toolbar.isOverflowLayout()); + assertFalse(findButton(toolbar, Resources.strings().get("toolbar_moreMapActions")).getParent().isVisible()); + } + private static List history(int size) { List history = new ArrayList<>(size); for (int index = 1; index <= size; index++) {