Compare commits
4 Commits
6cc6fbbfe8
...
nix-flake
| Author | SHA1 | Date | |
|---|---|---|---|
| b95f90b7ec | |||
| c55d862e14 | |||
| df29d4f1c3 | |||
| 25e086b62e |
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# new-shapes
|
||||||
|
|
||||||
|
A shape editor desktop application built with Java Swing. Supports rectangles, circles, triangles, text, shape collections, resize handles, and export to HTML/SVG.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- JDK >= 16 (project targets Java 16)
|
||||||
|
- A graphical display (X11/Wayland) since this is a Swing GUI application
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
### With Nix (recommended)
|
||||||
|
|
||||||
|
If you have [direnv](https://direnv.net/) installed, the environment activates automatically:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv allow # one-time, then auto-activates on cd
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, enter the dev shell manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
Both provide JDK 17 and Maven 3.9.x preconfigured. No other setup needed.
|
||||||
|
|
||||||
|
### Without Nix
|
||||||
|
|
||||||
|
Install JDK 16+ and use the bundled Maven wrapper — no separate Maven installation required:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./mvnw --version # downloads Maven 3.9.12 on first run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the application
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./mvnw compile exec:java -Dexec.mainClass="ovh.gasser.newshapes.App"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./mvnw test
|
||||||
|
```
|
||||||
|
|
||||||
|
To run the full verification pipeline (compile, test, JaCoCo coverage check with 50% minimum line coverage):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./mvnw verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage reports are generated in `target/site/jacoco/`.
|
||||||
2
TODO.md
2
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [X] Box selection (drag to select multiple shapes)
|
- [ ] Box selection (drag to select multiple shapes)
|
||||||
- [ ] Undo/redo stack
|
- [ ] Undo/redo stack
|
||||||
- [ ] Copy/paste functionality
|
- [ ] Copy/paste functionality
|
||||||
- [ ] Group/Ungroup shapes
|
- [ ] Group/Ungroup shapes
|
||||||
|
|||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1774386573,
|
||||||
|
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
48
flake.nix
Normal file
48
flake.nix
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
description = "new-shapes - Java Swing shape editor";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
# JDK 16 (project target) is non-LTS and unavailable in nixpkgs.
|
||||||
|
# JDK 17 (LTS) is fully backward-compatible with --source 16 --target 16.
|
||||||
|
javaVersion = 17;
|
||||||
|
|
||||||
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
|
|
||||||
|
forEachSystem = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems (system: f {
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ self.overlays.default ];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
overlays.default = final: prev:
|
||||||
|
let
|
||||||
|
jdk = prev."jdk${toString javaVersion}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit jdk;
|
||||||
|
# Override Maven to use the same JDK version
|
||||||
|
maven = prev.maven.override { jdk_headless = jdk; };
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = forEachSystem ({ pkgs }: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
jdk
|
||||||
|
maven
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "new-shapes dev shell"
|
||||||
|
echo " Java: $(java -version 2>&1 | head -1)"
|
||||||
|
echo " Maven: $(mvn -version 2>&1 | head -1)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ public class SText extends AbstractShape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void updateMeasuredBounds(int width, int height) {
|
public void updateMeasuredBounds(int width, int height) {
|
||||||
bounds.setSize(Math.max(width, 0), Math.max(height, 0));
|
getBounds().setSize(Math.max(width, 0), Math.max(height, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
package ovh.gasser.newshapes.shapes;
|
|
||||||
|
|
||||||
import ovh.gasser.newshapes.App;
|
|
||||||
import ovh.gasser.newshapes.attributes.SelectionAttributes;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class EdgeCaseTest {
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// NegativeCoordinates
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class NegativeCoordinates {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleAtNegativeCoordsShouldPreserveBounds() {
|
|
||||||
SRectangle rect = SRectangle.create(-10, -20, 50, 50);
|
|
||||||
Rectangle bounds = rect.getBounds();
|
|
||||||
assertEquals(-10, bounds.x, "x should be -10");
|
|
||||||
assertEquals(-20, bounds.y, "y should be -20");
|
|
||||||
assertEquals(50, bounds.width, "width should be 50");
|
|
||||||
assertEquals(50, bounds.height, "height should be 50");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void circleAtNegativeCoordsShouldPreserveBounds() {
|
|
||||||
SCircle circle = SCircle.create(-30, -40, 25);
|
|
||||||
Rectangle bounds = circle.getBounds();
|
|
||||||
assertEquals(-30, bounds.x, "x should be -30");
|
|
||||||
assertEquals(-40, bounds.y, "y should be -40");
|
|
||||||
assertEquals(50, bounds.width, "width should be radius*2 = 50");
|
|
||||||
assertEquals(50, bounds.height, "height should be radius*2 = 50");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void triangleAtNegativeCoordsShouldPreserveBounds() {
|
|
||||||
STriangle tri = STriangle.create(-5, -15, 60, Color.RED, Color.BLACK);
|
|
||||||
Rectangle bounds = tri.getBounds();
|
|
||||||
assertEquals(-5, bounds.x, "x should be -5");
|
|
||||||
assertEquals(-15, bounds.y, "y should be -15");
|
|
||||||
assertEquals(60, bounds.width, "width should be 60");
|
|
||||||
assertEquals(60, bounds.height, "height should be 60");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void translateToNegativePositionShouldUpdateBoundsCorrectly() {
|
|
||||||
SRectangle rect = SRectangle.create(10, 20, 80, 40);
|
|
||||||
rect.translate(-50, -70);
|
|
||||||
Rectangle bounds = rect.getBounds();
|
|
||||||
assertEquals(-40, bounds.x, "x after translate should be -40");
|
|
||||||
assertEquals(-50, bounds.y, "y after translate should be -50");
|
|
||||||
// dimensions must be unchanged
|
|
||||||
assertEquals(80, bounds.width, "width must remain 80 after translate");
|
|
||||||
assertEquals(40, bounds.height, "height must remain 40 after translate");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// ZeroDimensions
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class ZeroDimensions {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleWithZeroWidthShouldReturnZeroWidthFromFactory() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 0, 50);
|
|
||||||
assertEquals(0, rect.getBounds().width,
|
|
||||||
"factory should allow zero width without clamping");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleWithZeroHeightShouldReturnZeroHeightFromFactory() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 50, 0);
|
|
||||||
assertEquals(0, rect.getBounds().height,
|
|
||||||
"factory should allow zero height without clamping");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void circleWithZeroRadiusShouldHaveZeroBoundsDimensions() {
|
|
||||||
SCircle circle = SCircle.create(5, 10, 0);
|
|
||||||
assertEquals(0, circle.getRadius(), "radius should be 0");
|
|
||||||
assertEquals(0, circle.getBounds().width, "bounds width should be 0 (radius*2)");
|
|
||||||
assertEquals(0, circle.getBounds().height, "bounds height should be 0 (radius*2)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// ResizeClamping
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class ResizeClamping {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleResizedToNegativeShouldClampWidthToOne() {
|
|
||||||
// Start with a 10x10 rect and drag the E handle far to the left
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 10, 10);
|
|
||||||
rect.resize(ResizeHandle.E, -200, 0);
|
|
||||||
assertTrue(rect.getBounds().width >= 1,
|
|
||||||
"width must be clamped to a minimum of 1 after negative resize");
|
|
||||||
assertEquals(1, rect.getBounds().width,
|
|
||||||
"width should be exactly 1 after extreme negative resize on E handle");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleResizedToNegativeShouldClampHeightToOne() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 10, 10);
|
|
||||||
rect.resize(ResizeHandle.S, 0, -200);
|
|
||||||
assertTrue(rect.getBounds().height >= 1,
|
|
||||||
"height must be clamped to a minimum of 1 after negative resize");
|
|
||||||
assertEquals(1, rect.getBounds().height,
|
|
||||||
"height should be exactly 1 after extreme negative resize on S handle");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void circleResizedToNegativeShouldClampToTwo() {
|
|
||||||
SCircle circle = SCircle.create(0, 0, 50);
|
|
||||||
// Drag E handle far to the left → shrink
|
|
||||||
circle.resize(ResizeHandle.E, -500, 0);
|
|
||||||
assertTrue(circle.getBounds().width >= 2,
|
|
||||||
"circle width must be clamped to a minimum of 2");
|
|
||||||
assertTrue(circle.getBounds().height >= 2,
|
|
||||||
"circle height must be clamped to a minimum of 2");
|
|
||||||
assertEquals(2, circle.getBounds().width,
|
|
||||||
"circle width should be exactly 2 after extreme negative resize");
|
|
||||||
assertEquals(2, circle.getBounds().height,
|
|
||||||
"circle height should be exactly 2 after extreme negative resize");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// NullHandling
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class NullHandling {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void textCreatedWithNullShouldReturnPlaceholder() {
|
|
||||||
SText text = SText.create(0, 0, null);
|
|
||||||
assertEquals(SText.PLACEHOLDER_TEXT, text.getText(),
|
|
||||||
"null input should be normalized to the placeholder text");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void textCreatedWithBlankStringShouldReturnPlaceholder() {
|
|
||||||
SText text = SText.create(0, 0, " ");
|
|
||||||
assertEquals(SText.PLACEHOLDER_TEXT, text.getText(),
|
|
||||||
"blank string input should be normalized to the placeholder text");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getAttributesWithUnknownKeyShouldReturnNull() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 50, 50);
|
|
||||||
assertNull(rect.getAttributes("NONEXISTENT"),
|
|
||||||
"getAttributes() with an unknown key must return null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void emptyCollectionCreationShouldNotThrow() {
|
|
||||||
assertDoesNotThrow(() -> SCollection.of(),
|
|
||||||
"SCollection.of() with no arguments must not throw");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void emptyCollectionGetBoundsShouldReturnWindowSize() {
|
|
||||||
SCollection collection = SCollection.of();
|
|
||||||
Rectangle bounds = collection.getBounds();
|
|
||||||
assertNotNull(bounds, "getBounds() on empty collection must not return null");
|
|
||||||
assertEquals(App.WIN_SIZE.width, bounds.width,
|
|
||||||
"empty collection bounds width should equal WIN_SIZE.width");
|
|
||||||
assertEquals(App.WIN_SIZE.height, bounds.height,
|
|
||||||
"empty collection bounds height should equal WIN_SIZE.height");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// LargeValues
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class LargeValues {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleAtMaxIntHalfCoordsShouldNotThrow() {
|
|
||||||
int halfMax = Integer.MAX_VALUE / 2;
|
|
||||||
assertDoesNotThrow(() -> SRectangle.create(halfMax, halfMax, 100, 100),
|
|
||||||
"SRectangle.create() with very large coordinates must not throw");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rectangleAtMaxIntHalfCoordsPreservesBounds() {
|
|
||||||
int halfMax = Integer.MAX_VALUE / 2;
|
|
||||||
SRectangle rect = SRectangle.create(halfMax, halfMax, 100, 100);
|
|
||||||
assertEquals(halfMax, rect.getBounds().x, "x should be Integer.MAX_VALUE/2");
|
|
||||||
assertEquals(halfMax, rect.getBounds().y, "y should be Integer.MAX_VALUE/2");
|
|
||||||
assertEquals(100, rect.getBounds().width, "width should be 100");
|
|
||||||
assertEquals(100, rect.getBounds().height, "height should be 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void translateWithLargeValuesShouldNotThrow() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 50, 50);
|
|
||||||
// Integer overflow is a known platform behaviour; we only assert no exception
|
|
||||||
assertDoesNotThrow(() -> rect.translate(Integer.MAX_VALUE, Integer.MAX_VALUE),
|
|
||||||
"translate() with Integer.MAX_VALUE must not throw (overflow is acceptable)");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void translateWithLargeNegativeValuesShouldNotThrow() {
|
|
||||||
SRectangle rect = SRectangle.create(0, 0, 50, 50);
|
|
||||||
assertDoesNotThrow(() -> rect.translate(Integer.MIN_VALUE, Integer.MIN_VALUE),
|
|
||||||
"translate() with Integer.MIN_VALUE must not throw (overflow is acceptable)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,40 +44,4 @@ class STextTest {
|
|||||||
SText text = SText.create(0, 0, null);
|
SText text = SText.create(0, 0, null);
|
||||||
assertEquals(SText.PLACEHOLDER_TEXT, text.getText());
|
assertEquals(SText.PLACEHOLDER_TEXT, text.getText());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testUpdateMeasuredBoundsActuallyUpdatesBounds() {
|
|
||||||
SText text = SText.create(10, 20, "Hello");
|
|
||||||
text.updateMeasuredBounds(200, 30);
|
|
||||||
Rectangle bounds = text.getBounds();
|
|
||||||
assertEquals(200, bounds.width, "updateMeasuredBounds must update width");
|
|
||||||
assertEquals(30, bounds.height, "updateMeasuredBounds must update height");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testUpdateMeasuredBoundsDoesNotChangePosition() {
|
|
||||||
SText text = SText.create(10, 20, "Hello");
|
|
||||||
text.updateMeasuredBounds(200, 30);
|
|
||||||
Rectangle bounds = text.getBounds();
|
|
||||||
assertEquals(10, bounds.x, "updateMeasuredBounds must not change x");
|
|
||||||
assertEquals(20, bounds.y, "updateMeasuredBounds must not change y");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testUpdateMeasuredBoundsNegativeClampedToZero() {
|
|
||||||
SText text = SText.create(0, 0, "Hello");
|
|
||||||
text.updateMeasuredBounds(-5, -10);
|
|
||||||
Rectangle bounds = text.getBounds();
|
|
||||||
assertEquals(0, bounds.width, "negative width must be clamped to 0");
|
|
||||||
assertEquals(0, bounds.height, "negative height must be clamped to 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testUpdateMeasuredBoundsZeroIsAllowed() {
|
|
||||||
SText text = SText.create(0, 0, "Hello");
|
|
||||||
text.updateMeasuredBounds(0, 0);
|
|
||||||
Rectangle bounds = text.getBounds();
|
|
||||||
assertEquals(0, bounds.width);
|
|
||||||
assertEquals(0, bounds.height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user