diff --git a/src/main/java/org/betterx/ui/layout/components/Component.java b/src/main/java/org/betterx/ui/layout/components/Component.java new file mode 100644 index 00000000..5dc95137 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/Component.java @@ -0,0 +1,81 @@ +package org.betterx.ui.layout.components; + +import org.betterx.ui.layout.components.input.MouseEvent; +import org.betterx.ui.layout.components.render.ComponentRenderer; +import org.betterx.ui.layout.values.DynamicSize; +import org.betterx.ui.layout.values.Rectangle; + +public abstract class Component implements ComponentWithBounds { + protected final R renderer; + protected final DynamicSize width; + protected final DynamicSize height; + protected Rectangle relativeBounds; + + public Component(DynamicSize width, DynamicSize height, R renderer) { + this.width = width.attachComponent(this::getContentWidth); + this.height = height.attachComponent(this::getContentHeight); + this.renderer = renderer; + } + + protected int updateContainerWidth(int containerWidth) { + return width.setCalculatedSize(containerWidth); + } + + protected int updateContainerHeight(int containerHeight) { + return height.setCalculatedSize(containerHeight); + } + + void setRelativeBounds(int left, int top) { + relativeBounds = new Rectangle(left, top, width.calculatedSize(), height.calculatedSize()); + } + + public Rectangle getRelativeBounds() { + return relativeBounds; + } + + public abstract int getContentWidth(); + public abstract int getContentHeight(); + + public int fillWidth(int parentSize, int fillSize) { + return width.fill(fillSize); + } + + public int fillHeight(int parentSize, int fillSize) { + return height.fill(fillSize); + } + + public int getWidth() { + return width.calculatedSize(); + } + + public int getHeight() { + return height.calculatedSize(); + } + + public void render(Rectangle parentBounds, Rectangle clipRect) { + Rectangle r = relativeBounds.movedBy(parentBounds.left, parentBounds.top); + Rectangle clip = r.intersect(clipRect); + if (r.overlaps(clip)) { + renderInBounds(r, clip); + } + } + + protected void renderInBounds(Rectangle renderBounds, Rectangle clipRect) { + if (renderer != null) { + renderer.renderInBounds(renderBounds, clipRect); + } + } + + void mouseEvent(MouseEvent event, int x, int y) { + onMouseEvent(event, x, y); + } + + public boolean onMouseEvent(MouseEvent event, int x, int y) { + return false; + } + + @Override + public String toString() { + return super.toString() + "(" + relativeBounds + ", " + width.calculatedSize() + "x" + height.calculatedSize() + ")"; + } +} diff --git a/src/main/java/org/betterx/ui/layout/components/ComponentWithBounds.java b/src/main/java/org/betterx/ui/layout/components/ComponentWithBounds.java new file mode 100644 index 00000000..cae405f9 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/ComponentWithBounds.java @@ -0,0 +1,7 @@ +package org.betterx.ui.layout.components; + +import org.betterx.ui.layout.values.Rectangle; + +public interface ComponentWithBounds { + Rectangle getRelativeBounds(); +} diff --git a/src/main/java/org/betterx/ui/layout/components/HorizontalStack.java b/src/main/java/org/betterx/ui/layout/components/HorizontalStack.java new file mode 100644 index 00000000..14e10b44 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/HorizontalStack.java @@ -0,0 +1,112 @@ +package org.betterx.ui.layout.components; + +import org.betterx.ui.layout.components.render.*; +import org.betterx.ui.layout.components.input.*; +import org.betterx.ui.layout.values.*; + +import java.util.LinkedList; +import java.util.List; + +public class HorizontalStack extends Component { + protected final List> components = new LinkedList<>(); + + public HorizontalStack(DynamicSize width, DynamicSize height) { + this(width, height, null); + } + + public HorizontalStack(DynamicSize width, DynamicSize height, R renderer) { + super(width, height, renderer); + } + + @Override + public int updateContainerWidth(int containerWidth) { + int myWidth = width.calculateOrFill(containerWidth); + int fixedWidth = components.stream().map(c -> c.width.calculate(myWidth)).reduce(0, Integer::sum); + + int freeWidth = Math.max(0, myWidth - fixedWidth); + fillWidth(myWidth, freeWidth); + + for (Component c : components) { + c.updateContainerWidth(c.width.calculatedSize()); + } + + return myWidth; + } + + @Override + protected int updateContainerHeight(int containerHeight) { + int myHeight = height.calculateOrFill(containerHeight); + for (Component c : components) { + c.updateContainerHeight(myHeight); + } + return myHeight; + } + + + @Override + void setRelativeBounds(int left, int top) { + super.setRelativeBounds(left, top); + + int offset = 0; + for (Component c : components) { + c.setRelativeBounds(offset, 0); + offset += c.relativeBounds.width; + } + } + + @Override + public int getContentWidth() { + int fixedWidth = components.stream().map(c -> c.width.calculateFixed()).reduce(0, Integer::sum); + double percentage = components.stream().map(c -> c.width.calculateRelative()).reduce(0.0, Double::sum); + + return (int) (fixedWidth / (1 - percentage)); + } + + @Override + public int getContentHeight() { + return components.stream().map(c -> c.height.calculateFixed()).reduce(0, Integer::max); + } + + @Override + public int fillWidth(int parentSize, int fillSize) { + double totalFillWeight = components.stream().map(c -> c.width.fillWeight()).reduce(0.0, Double::sum); + return components.stream() + .map(c -> c.width.fill(fillSize, totalFillWeight)) + .reduce(0, Integer::sum); + } + + @Override + public int fillHeight(int parentSize, int fillSize) { + double totalFillHeight = components.stream().map(c -> c.height.fillWeight()).reduce(0.0, Double::sum); + return components.stream() + .map(c -> c.height.fill(fillSize, totalFillHeight)) + .reduce(0, Integer::sum); + } + +// @Override +// public int fillHeight(int parentSize, int fillSize) { +// return parentSize; +// } + + @Override + protected void renderInBounds(Rectangle renderBounds, Rectangle clipRect) { + super.renderInBounds(renderBounds, clipRect); + for (Component c : components) { + c.render(renderBounds, clipRect); + } + } + + @Override + void mouseEvent(MouseEvent event, int x, int y) { + if (!onMouseEvent(event, x, y)) { + for (Component c : components) { + c.mouseEvent(event, x - relativeBounds.left, y - relativeBounds.top); + } + } + } + + public HorizontalStack add(Component c) { + this.components.add(c); + return this; + } +} diff --git a/src/main/java/org/betterx/ui/layout/components/Panel.java b/src/main/java/org/betterx/ui/layout/components/Panel.java new file mode 100644 index 00000000..e0a8ce56 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/Panel.java @@ -0,0 +1,43 @@ +package org.betterx.ui.layout.components; + + +import org.betterx.ui.layout.components.input.MouseEvent; +import org.betterx.ui.layout.values.Rectangle; + +public class Panel implements ComponentWithBounds { + protected Component child; + public final Rectangle bounds; + + public Panel(int width, int height) { + bounds = new Rectangle(50, 50, width, height); + } + + public void setChild(Component c) { + this.child = c; + } + + public void mouseEvent(MouseEvent event, int x, int y) { + if (child != null) { + child.mouseEvent(event, x - bounds.left, y - bounds.top); + } + } + + public void calculateLayout() { + if (child != null) { + child.updateContainerWidth(bounds.width); + child.updateContainerHeight(bounds.height); + child.setRelativeBounds(0, 0); + } + } + + public void render() { + if (child != null) { + child.render(bounds, bounds); + } + } + + @Override + public Rectangle getRelativeBounds() { + return bounds; + } +} diff --git a/src/main/java/org/betterx/ui/layout/components/VerticalScroll.java b/src/main/java/org/betterx/ui/layout/components/VerticalScroll.java new file mode 100644 index 00000000..c530e088 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/VerticalScroll.java @@ -0,0 +1,142 @@ +package org.betterx.ui.layout.components; + +import org.betterx.ui.layout.components.input.MouseEvent; +import org.betterx.ui.layout.components.render.ComponentRenderer; +import org.betterx.ui.layout.components.render.ScrollerRenderer; +import org.betterx.ui.layout.values.DynamicSize; +import org.betterx.ui.layout.values.Rectangle; + +public class VerticalScroll extends Component { + protected Component child; + protected final RS scrollerRenderer; + + protected int dist; + protected int scrollerY; + protected int scrollerHeight; + protected int travel; + protected int topOffset; + + public VerticalScroll(DynamicSize width, DynamicSize height, RS scrollerRenderer) { + this(width, height, scrollerRenderer, null); + } + + public VerticalScroll(DynamicSize width, DynamicSize height, RS scrollerRenderer, R renderer) { + super(width, height, renderer); + this.scrollerRenderer = scrollerRenderer; + } + + public void setChild(Component c) { + this.child = c; + } + + @Override + protected int updateContainerWidth(int containerWidth) { + int myWidth = width.calculateOrFill(containerWidth); + if (child != null) { + child.updateContainerWidth(myWidth); + } + return myWidth; + } + + @Override + protected int updateContainerHeight(int containerHeight) { + int myHeight = height.calculateOrFill(containerHeight); + if (child != null) { + child.updateContainerHeight(myHeight); + } + return myHeight; + } + + @Override + public int getContentWidth() { + return child != null ? child.getContentWidth() : 0; + } + + @Override + public int getContentHeight() { + return child != null ? child.getContentHeight() : 0; + } + + @Override + void setRelativeBounds(int left, int top) { + super.setRelativeBounds(left, top); + + if (child != null) + child.setRelativeBounds(0, 0); + + updateScrollViewMetrics(); + } + + @Override + void mouseEvent(MouseEvent event, int x, int y) { + if (!onMouseEvent(event, x, y)) { + if (child != null) { + child.mouseEvent(event, x - relativeBounds.left, y - relativeBounds.top - scrollerOffset()); + } + } + } + + @Override + protected void renderInBounds(Rectangle renderBounds, Rectangle clipRect) { + super.renderInBounds(renderBounds, clipRect); + + if (showScrollBar()) { + if (child != null) { + child.render(renderBounds.movedBy(0, scrollerOffset(), scrollerRenderer.scrollerWidth(), 0), clipRect); + } + scrollerRenderer.renderScrollBar(renderBounds, saveScrollerY(), scrollerHeight); + } else { + if (child != null) { + child.render(renderBounds, clipRect); + } + } + } + + private boolean mouseDown = false; + private int mouseDownY = 0; + private int scrollerDownY = 0; + + @Override + public boolean onMouseEvent(MouseEvent event, int x, int y) { + if (event == MouseEvent.DOWN) { + Rectangle scroller = scrollerRenderer.getScrollerBounds(relativeBounds); + Rectangle picker = scrollerRenderer.getPickerBounds(relativeBounds, saveScrollerY(), scrollerHeight); + if (picker.contains(x, y)) { + mouseDown = true; + mouseDownY = y; + scrollerDownY = saveScrollerY(); + return true; + } + } else if (event == MouseEvent.UP) { + mouseDown = false; + } else if (event == MouseEvent.MOVE && mouseDown) { + int delta = y - mouseDownY; + scrollerY = scrollerDownY + delta; + return true; + } + return mouseDown; + } + + protected void updateScrollViewMetrics() { + final int view = relativeBounds.height; + final int content = child.relativeBounds.height; + + this.dist = content - view; + this.scrollerHeight = Math.max(scrollerRenderer.scrollerHeight(), (view * view) / content); + this.travel = view - this.scrollerHeight; + this.topOffset = 0; + this.scrollerY = 0; + } + + protected int saveScrollerY() { + return Math.max(0, Math.min(travel, scrollerY)); + } + + protected int scrollerOffset() { + return -((int) (((float) saveScrollerY() / travel) * this.dist)); + } + + public boolean showScrollBar() { + return child.relativeBounds.height > relativeBounds.height; + } +} diff --git a/src/main/java/org/betterx/ui/layout/components/VerticalStack.java b/src/main/java/org/betterx/ui/layout/components/VerticalStack.java new file mode 100644 index 00000000..f76b5343 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/VerticalStack.java @@ -0,0 +1,112 @@ +package org.betterx.ui.layout.components; + + +import org.betterx.ui.layout.components.input.MouseEvent; +import org.betterx.ui.layout.components.render.ComponentRenderer; +import org.betterx.ui.layout.values.DynamicSize; +import org.betterx.ui.layout.values.Rectangle; + +import java.util.LinkedList; +import java.util.List; + +public class VerticalStack extends Component { + protected final List> components = new LinkedList<>(); + + public VerticalStack(DynamicSize width, DynamicSize height) { + this(width, height, null); + } + + public VerticalStack(DynamicSize width, DynamicSize height, R renderer) { + super(width, height, renderer); + } + + @Override + protected int updateContainerWidth(int containerWidth) { + int myWidth = width.calculateOrFill(containerWidth); + for (Component c : components) { + c.updateContainerWidth(myWidth); + } + return myWidth; + } + + @Override + public int updateContainerHeight(int containerHeight) { + int myHeight = height.calculateOrFill(containerHeight); + int fixedHeight = components.stream().map(c -> c.height.calculate(myHeight)).reduce(0, Integer::sum); + + int freeHeight = Math.max(0, myHeight - fixedHeight); + fillHeight(myHeight, freeHeight); + + for (Component c : components) { + c.updateContainerHeight(c.height.calculatedSize()); + } + + return myHeight; + } + + @Override + void setRelativeBounds(int left, int top) { + super.setRelativeBounds(left, top); + + int offset = 0; + for (Component c : components) { + c.setRelativeBounds(0, offset); + offset += c.relativeBounds.height; + } + } + + @Override + public int getContentWidth() { + return components.stream().map(c -> c.width.calculateFixed()).reduce(0, Integer::max); + } + + @Override + public int getContentHeight() { + int fixedHeight = components.stream().map(c -> c.height.calculateFixed()).reduce(0, Integer::sum); + double percentage = components.stream().map(c -> c.height.calculateRelative()).reduce(0.0, Double::sum); + + return (int) (fixedHeight / (1 - percentage)); + } + + // @Override +// public int fillWidth(int parentSize, int fillSize) { +// return parentSize; +// } + @Override + public int fillWidth(int parentSize, int fillSize) { + double totalFillWeight = components.stream().map(c -> c.width.fillWeight()).reduce(0.0, Double::sum); + return components.stream() + .map(c -> c.width.fill(fillSize, totalFillWeight)) + .reduce(0, Integer::sum); + } + + @Override + public int fillHeight(int parentSize, int fillSize) { + double totalFillHeight = components.stream().map(c -> c.height.fillWeight()).reduce(0.0, Double::sum); + return components.stream() + .map(c -> c.height.fill(fillSize, totalFillHeight)) + .reduce(0, Integer::sum); + } + + @Override + protected void renderInBounds(Rectangle renderBounds, Rectangle clipRect) { + super.renderInBounds(renderBounds, clipRect); + for (Component c : components) { + c.render(renderBounds, clipRect); + } + } + + @Override + void mouseEvent(MouseEvent event, int x, int y) { + if (!onMouseEvent(event, x, y)) { + for (Component c : components) { + c.mouseEvent(event, x - relativeBounds.left, y - relativeBounds.top); + } + } + } + + public VerticalStack add(Component c) { + this.components.add(c); + return this; + } +} diff --git a/src/main/java/org/betterx/ui/layout/components/input/MouseEvent.java b/src/main/java/org/betterx/ui/layout/components/input/MouseEvent.java new file mode 100644 index 00000000..5bffb78b --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/input/MouseEvent.java @@ -0,0 +1,5 @@ +package org.betterx.ui.layout.components.input; + +public enum MouseEvent { + DOWN, UP, MOVE +} diff --git a/src/main/java/org/betterx/ui/layout/components/render/ComponentRenderer.java b/src/main/java/org/betterx/ui/layout/components/render/ComponentRenderer.java new file mode 100644 index 00000000..283084d3 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/render/ComponentRenderer.java @@ -0,0 +1,7 @@ +package org.betterx.ui.layout.components.render; + +import org.betterx.ui.layout.values.Rectangle; + +public interface ComponentRenderer { + void renderInBounds(Rectangle bounds, Rectangle clipRect); +} diff --git a/src/main/java/org/betterx/ui/layout/components/render/ScrollerRenderer.java b/src/main/java/org/betterx/ui/layout/components/render/ScrollerRenderer.java new file mode 100644 index 00000000..32776a79 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/components/render/ScrollerRenderer.java @@ -0,0 +1,33 @@ +package org.betterx.ui.layout.components.render; + + +import org.betterx.ui.layout.values.Rectangle; + +public interface ScrollerRenderer { + default int scrollerHeight() { + return 16; + } + default int scrollerWidth() { + return 16; + } + + default Rectangle getScrollerBounds(Rectangle renderBounds) { + return new Rectangle( + renderBounds.right() - this.scrollerWidth(), + renderBounds.top, + this.scrollerWidth(), + renderBounds.height + ); + } + + default Rectangle getPickerBounds(Rectangle renderBounds, int pickerOffset, int pickerSize) { + return new Rectangle( + renderBounds.left, + renderBounds.top + pickerOffset, + renderBounds.width, + pickerSize + ); + } + + void renderScrollBar(Rectangle renderBounds, int pickerOffset, int pickerSize); +} diff --git a/src/main/java/org/betterx/ui/layout/values/DynamicSize.java b/src/main/java/org/betterx/ui/layout/values/DynamicSize.java new file mode 100644 index 00000000..e383a6f6 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/values/DynamicSize.java @@ -0,0 +1,102 @@ +package org.betterx.ui.layout.values; + +public class DynamicSize { + private SizeType sizeType; + private int calculatedSize; + + public DynamicSize(SizeType sizeType) { + this.sizeType = sizeType; + this.calculatedSize = 0; + } + + public static DynamicSize fixed(int size) { + return new DynamicSize(new SizeType.Fixed(size)); + } + + public static DynamicSize relative(double percentage) { + return new DynamicSize(new SizeType.Relative(percentage)); + } + + public static DynamicSize fill() { + return new DynamicSize(SizeType.FILL); + } + + public static DynamicSize fit() { + return new DynamicSize(SizeType.FIT_CONTENT); + } + + public int calculatedSize() { + return calculatedSize; + } + + public int setCalculatedSize(int value) { + calculatedSize = value; + return value; + } + + public DynamicSize attachComponent(SizeType.FitContent.ContentSizeSupplier c) { + if (sizeType instanceof SizeType.FitContent fit && fit.contentSize() == null) { + sizeType = fit.copyForSupplier(c); + } + return this; + } + + public int calculateFixed() { + return calculate(0); + } + + public double calculateRelative() { + if (sizeType instanceof SizeType.Relative rel) { + return rel.percentage(); + } + return 0; + } + + public int calculate(int parentSize) { + calculatedSize = 0; + if (sizeType instanceof SizeType.Fixed fixed) { + calculatedSize = fixed.size(); + } else if (sizeType instanceof SizeType.FitContent fit) { + calculatedSize = fit.contentSize().get(); + } else if (sizeType instanceof SizeType.Relative rel) { + calculatedSize = (int) (parentSize * rel.percentage()); + } + + return calculatedSize; + } + + public int calculateOrFill(int parentSize) { + calculatedSize = calculate(parentSize); + if (sizeType instanceof SizeType.Fill) { + calculatedSize = parentSize; + } + + return calculatedSize; + } + + public double fillWeight() { + if (sizeType instanceof SizeType.Fill fill) { + return 1; + } + return 0; + } + + public int fill(int fillSize) { + return fill(fillSize, fillWeight()); + } + + public int fill(int fillSize, double totalFillWeight) { + if (sizeType instanceof SizeType.Fill) { + calculatedSize = (int) Math.round(fillSize * (fillWeight() / totalFillWeight)); + } + return calculatedSize; + } + + @Override + public String toString() { + return "DynamicSize{" + + "sizeType=" + sizeType.getClass().getSimpleName() + + ", calculatedSize=" + calculatedSize + + '}'; + } +} diff --git a/src/main/java/org/betterx/ui/layout/values/Rectangle.java b/src/main/java/org/betterx/ui/layout/values/Rectangle.java new file mode 100644 index 00000000..ea301626 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/values/Rectangle.java @@ -0,0 +1,63 @@ +package org.betterx.ui.layout.values; + +public class Rectangle { + public static final Rectangle ZERO = new Rectangle(0, 0, 0, 0); + public final int left; + public final int top; + public final int width; + public final int height; + + public Rectangle(int left, int top, int width, int height) { + this.left = left; + this.top = top; + this.width = width; + this.height = height; + } + + public int right() { + return left + width; + } + + public int bottom() { + return top + height; + } + + public Rectangle movedBy(int left, int top) { + return new Rectangle(this.left + left, this.top + top, this.width, this.height); + } + + public Rectangle movedBy(int left, int top, int deltaWidth, int deltaHeight) { + return new Rectangle(this.left + left, this.top + top, this.width + deltaWidth, this.height + deltaHeight); + } + + public boolean overlaps(Rectangle r) { + return this.left < r.right() && this.right() > r.left && + this.top < r.bottom() && this.bottom() > r.top; + } + + public boolean contains(int x, int y) { + return x >= left && x <= right() && y >= top && y <= bottom(); + } + + public Rectangle intersect(Rectangle r) { + if (!overlaps(r)) return ZERO; + int left = Math.max(this.left, r.left); + int top = Math.max(this.top, r.top); + int right = Math.min(this.right(), r.right()); + int bottom = Math.min(this.bottom(), r.bottom()); + + return new Rectangle(left, top, right - left, bottom - top); + } + + @Override + public String toString() { + return "rectangle{" + + left + + "x" + top + + ", " + right() + + "x" + bottom() + + " [" + width + + "x" + height + + "]}"; + } +} diff --git a/src/main/java/org/betterx/ui/layout/values/SizeType.java b/src/main/java/org/betterx/ui/layout/values/SizeType.java new file mode 100644 index 00000000..1f0f9fe0 --- /dev/null +++ b/src/main/java/org/betterx/ui/layout/values/SizeType.java @@ -0,0 +1,38 @@ +package org.betterx.ui.layout.values; + +public interface SizeType { + FitContent FIT_CONTENT = new FitContent(); + Fill FILL = new Fill(); + + record Fill() implements SizeType { + } + + record FitContent(ContentSizeSupplier contentSize) implements SizeType { + @FunctionalInterface + public interface ContentSizeSupplier { + int get(); + } + + public FitContent() { + this(null); + } + + public FitContent copyForSupplier(ContentSizeSupplier component) { + return new FitContent(component); + } + } + + record Fixed(int size) implements SizeType { + @Override + public String toString() { + return getClass().getSimpleName() + "(" + size + ")"; + } + } + + record Relative(double percentage) implements SizeType { + @Override + public String toString() { + return getClass().getSimpleName() + "(" + percentage + ")"; + } + } +}