Skip to content

Commit cd1883b

Browse files
committed
#160: Auto size sections in the KL-driven concept card based on volume of content
1 parent 8aacab0 commit cd1883b

5 files changed

Lines changed: 243 additions & 64 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package dev.ikm.komet.kview.controls;
2+
3+
import dev.ikm.komet.kview.fxutils.FXUtils;
4+
import javafx.event.EventTarget;
5+
import javafx.geometry.Orientation;
6+
import javafx.scene.Node;
7+
import javafx.scene.control.SplitPane;
8+
import javafx.scene.input.MouseEvent;
9+
import javafx.scene.layout.Region;
10+
11+
import java.util.List;
12+
13+
/**
14+
* A {@link SplitPane} that gives each of its items the size its content asks for, instead of
15+
* splitting its length evenly between them. An item holding a couple of rows takes the height
16+
* those rows need and no more; the space it doesn't use goes to the last item, which is also the
17+
* one the pane's own resizing grows and shrinks.
18+
*
19+
* <p>When the items' combined preferred size doesn't fit, each is capped at its fair share of what
20+
* there is (see {@link FXUtils#capAtFairShare(double[], double)}): the small items keep their full
21+
* preferred size, and what they leave over raises the share of the larger ones.
22+
*
23+
* <p>The sizing is re-applied on every layout pass, so an item that gains or loses content resizes
24+
* with it — until the user drags a divider. From that first drag on, this behaves as an ordinary
25+
* SplitPane and the items keep the sizes the user gives them.
26+
*/
27+
public class ContentSizedSplitPane extends SplitPane {
28+
29+
/** Style class the skin puts on its divider nodes. */
30+
private static final String DIVIDER_STYLE_CLASS = "split-pane-divider";
31+
32+
/**
33+
* Divider positions are fractions of the pane's length, so this is well under a pixel for any
34+
* realistic pane. Positions closer than this to the ones already set are left alone: setting a
35+
* position asks for another layout pass, which a rounding difference alone shouldn't do.
36+
*/
37+
private static final double POSITION_TOLERANCE = 0.0001;
38+
39+
private boolean sizeToContent = true;
40+
41+
public ContentSizedSplitPane() {
42+
// Grabbing a divider hands the sizing over to the user, for good.
43+
addEventFilter(MouseEvent.MOUSE_PRESSED, mouseEvent -> {
44+
if (isDivider(mouseEvent.getTarget())) {
45+
sizeToContent = false;
46+
}
47+
});
48+
}
49+
50+
@Override
51+
protected void layoutChildren() {
52+
if (sizeToContent) {
53+
sizeItemsToContent();
54+
}
55+
super.layoutChildren();
56+
}
57+
58+
/**
59+
* Places the dividers so every item gets its preferred size, with the last item taking whatever
60+
* the others didn't need.
61+
*/
62+
private void sizeItemsToContent() {
63+
List<Node> items = getItems();
64+
List<Divider> dividers = getDividers();
65+
if (dividers.isEmpty() || items.size() != dividers.size() + 1) {
66+
// The skin creates a divider per gap between items; until it has, there is nothing to
67+
// place (and nothing shown either).
68+
return;
69+
}
70+
71+
boolean vertical = getOrientation() == Orientation.VERTICAL;
72+
double length = vertical
73+
? getHeight() - snappedTopInset() - snappedBottomInset()
74+
: getWidth() - snappedLeftInset() - snappedRightInset();
75+
double breadth = vertical
76+
? getWidth() - snappedLeftInset() - snappedRightInset()
77+
: getHeight() - snappedTopInset() - snappedBottomInset();
78+
double dividerThickness = dividerThickness();
79+
double availableLength = length - dividerThickness * dividers.size();
80+
if (availableLength <= 0 || breadth <= 0) {
81+
return;
82+
}
83+
84+
double[] prefSizes = new double[items.size()];
85+
for (int i = 0; i < items.size(); i++) {
86+
Node item = items.get(i);
87+
prefSizes[i] = vertical ? item.prefHeight(breadth) : item.prefWidth(breadth);
88+
}
89+
90+
double[] sizes = FXUtils.capAtFairShare(prefSizes, availableLength);
91+
double allotted = 0;
92+
for (double size : sizes) {
93+
allotted += size;
94+
}
95+
sizes[sizes.length - 1] += availableLength - allotted;
96+
97+
// A divider's position is where its centre sits, as a fraction of the pane's length (see
98+
// SplitPaneSkin), so walk the items adding each one's size and the divider that follows it.
99+
double edge = 0;
100+
for (int i = 0; i < dividers.size(); i++) {
101+
edge += sizes[i];
102+
double position = (edge + dividerThickness / 2) / length;
103+
Divider divider = dividers.get(i);
104+
if (Math.abs(divider.getPosition() - position) > POSITION_TOLERANCE) {
105+
divider.setPosition(position);
106+
}
107+
edge += dividerThickness;
108+
}
109+
}
110+
111+
/**
112+
* How much of the pane's length a divider takes up. The skin sizes its dividers by their
113+
* preferred width whichever way the pane is oriented, so this reads the same measure.
114+
*/
115+
private double dividerThickness() {
116+
for (Node child : getChildrenUnmodifiable()) {
117+
if (child instanceof Region divider && divider.getStyleClass().contains(DIVIDER_STYLE_CLASS)) {
118+
return divider.prefWidth(-1);
119+
}
120+
}
121+
return 0;
122+
}
123+
124+
/** Whether the passed in event target is a divider, or a part of one (its grabber). */
125+
private boolean isDivider(EventTarget target) {
126+
for (Node node = target instanceof Node hit ? hit : null; node != null; node = node.getParent()) {
127+
if (node.getStyleClass().contains(DIVIDER_STYLE_CLASS)) {
128+
return true;
129+
}
130+
if (node == this) {
131+
return false;
132+
}
133+
}
134+
return false;
135+
}
136+
}

‎kview/src/main/java/dev/ikm/komet/kview/controls/skin/SectionTitledPaneSkin.java‎

Lines changed: 57 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.ikm.komet.kview.controls.skin;
22

33
import dev.ikm.komet.kview.controls.SectionTitledPane;
4+
import dev.ikm.komet.kview.fxutils.FXUtils;
45
import javafx.beans.binding.Bindings;
56
import javafx.collections.ObservableList;
67
import javafx.css.PseudoClass;
@@ -203,18 +204,32 @@ protected void layoutChildren() {
203204
super.layoutChildren();
204205
}
205206

207+
/**
208+
* The height the content wants, which is every row's preferred height stacked up. Measured
209+
* from the children rather than from the row constraints: those hold the heights the grid's
210+
* current height allowed (see {@link #applyRowConstraints()}), so a grid that was once
211+
* squeezed would go on asking for the squeezed height and never get its room back.
212+
*/
213+
@Override
214+
protected double computePrefHeight(double width) {
215+
double contentWidth = width < 0 ? -1 : width - snappedLeftInset() - snappedRightInset();
216+
double[] rowPrefHeights = rowPrefHeights(contentWidth);
217+
double prefHeight = Math.max(0, rowPrefHeights.length - 1) * getVgap();
218+
for (double rowPrefHeight : rowPrefHeights) {
219+
prefHeight += rowPrefHeight;
220+
}
221+
return snappedTopInset() + prefHeight + snappedBottomInset();
222+
}
223+
206224
/**
207225
* Recomputes the fixed per-row heights for the current size and content and installs them
208226
* as this grid's row constraints. Guarded so constraints are only touched when the heights
209227
* actually change — installing them re-requests layout, and the guard is what lets that
210228
* follow-up pass settle.
211229
*/
212230
private void applyRowConstraints() {
213-
List<Node> children = getManagedChildren();
214-
int rowCount = 0;
215-
for (Node child : children) {
216-
rowCount = Math.max(rowCount, rowIndexOf(child) + 1);
217-
}
231+
double[] rowPrefHeights = rowPrefHeights(getWidth() - snappedLeftInset() - snappedRightInset());
232+
int rowCount = rowPrefHeights.length;
218233
if (rowCount == 0) {
219234
if (appliedRowHeights.length > 0) {
220235
appliedRowHeights = new double[0];
@@ -223,25 +238,9 @@ private void applyRowConstraints() {
223238
return;
224239
}
225240

226-
// Column widths are uniform (see numberColumnsProperty subscription: every column
227-
// gets the same percentWidth), so each child's width — which its preferred height
228-
// may depend on — follows from its column span alone.
229-
int columnCount = Math.max(1, getColumnConstraints().size());
230-
double contentWidth = getWidth() - snappedLeftInset() - snappedRightInset();
231-
double columnWidth = (contentWidth - getHgap() * (columnCount - 1)) / columnCount;
232-
233-
double[] rowPrefHeights = new double[rowCount];
234-
for (Node child : children) {
235-
Integer span = GridPane.getColumnSpan(child);
236-
int columnSpan = span == null ? 1 : span;
237-
double cellWidth = columnWidth * columnSpan + getHgap() * (columnSpan - 1);
238-
int row = rowIndexOf(child);
239-
rowPrefHeights[row] = Math.max(rowPrefHeights[row], child.prefHeight(cellWidth));
240-
}
241-
242241
double availableHeight = getHeight() - snappedTopInset() - snappedBottomInset()
243242
- getVgap() * (rowCount - 1);
244-
double[] rowHeights = capAtFairShare(rowPrefHeights, availableHeight);
243+
double[] rowHeights = FXUtils.capAtFairShare(rowPrefHeights, availableHeight);
245244
for (int i = 0; i < rowHeights.length; i++) {
246245
rowHeights[i] = snapSizeY(rowHeights[i]);
247246
}
@@ -264,48 +263,46 @@ private void applyRowConstraints() {
264263
getRowConstraints().setAll(constraints);
265264
}
266265

267-
private static int rowIndexOf(Node child) {
268-
Integer rowIndex = GridPane.getRowIndex(child);
269-
return rowIndex == null ? 0 : rowIndex;
270-
}
271-
272266
/**
273-
* Allocates the available height among the rows: every row gets at most its preferred
274-
* height, and a row only gets less when the rows that need more than an equal split
275-
* cannot be satisfied — those tall rows then share what the short rows left over.
267+
* The preferred height of each row — the tallest preferred height among the children in it
268+
* — measured at the width the passed in content width leaves each cell. A negative content
269+
* width (the grid is being measured without one) leaves the children to their own
270+
* unconstrained preferred height.
276271
*/
277-
private static double[] capAtFairShare(double[] prefHeights, double availableHeight) {
278-
int rowCount = prefHeights.length;
279-
double[] heights = new double[rowCount];
280-
boolean[] keepsPref = new boolean[rowCount];
281-
double remaining = Math.max(0, availableHeight);
282-
int uncapped = rowCount;
283-
284-
// Settle the rows that fit within the current fair share; every row settled frees
285-
// up share for the rest, so iterate until a full pass settles nothing.
286-
boolean settledAny = true;
287-
while (settledAny && uncapped > 0) {
288-
settledAny = false;
289-
double fairShare = remaining / uncapped;
290-
for (int i = 0; i < rowCount; i++) {
291-
if (!keepsPref[i] && prefHeights[i] <= fairShare) {
292-
keepsPref[i] = true;
293-
heights[i] = prefHeights[i];
294-
remaining -= prefHeights[i];
295-
uncapped--;
296-
settledAny = true;
297-
}
298-
}
272+
private double[] rowPrefHeights(double contentWidth) {
273+
List<Node> children = getManagedChildren();
274+
int rowCount = 0;
275+
for (Node child : children) {
276+
rowCount = Math.max(rowCount, rowIndexOf(child) + 1);
299277
}
300-
if (uncapped > 0) {
301-
double fairShare = remaining / uncapped;
302-
for (int i = 0; i < rowCount; i++) {
303-
if (!keepsPref[i]) {
304-
heights[i] = fairShare;
305-
}
306-
}
278+
if (rowCount == 0) {
279+
return new double[0];
307280
}
308-
return heights;
281+
282+
// Column widths are uniform (see numberColumnsProperty subscription: every column
283+
// gets the same percentWidth), so each child's width — which its preferred height
284+
// may depend on — follows from its column span alone.
285+
int columnCount = Math.max(1, getColumnConstraints().size());
286+
double columnWidth = contentWidth < 0
287+
? -1
288+
: (contentWidth - getHgap() * (columnCount - 1)) / columnCount;
289+
290+
double[] rowPrefHeights = new double[rowCount];
291+
for (Node child : children) {
292+
Integer span = GridPane.getColumnSpan(child);
293+
int columnSpan = span == null ? 1 : span;
294+
double cellWidth = columnWidth < 0
295+
? -1
296+
: columnWidth * columnSpan + getHgap() * (columnSpan - 1);
297+
int row = rowIndexOf(child);
298+
rowPrefHeights[row] = Math.max(rowPrefHeights[row], child.prefHeight(cellWidth));
299+
}
300+
return rowPrefHeights;
301+
}
302+
303+
private static int rowIndexOf(Node child) {
304+
Integer rowIndex = GridPane.getRowIndex(child);
305+
return rowIndex == null ? 0 : rowIndex;
309306
}
310307

311308
private boolean heightsChanged(double[] rowHeights) {

‎kview/src/main/java/dev/ikm/komet/kview/fxutils/FXUtils.java‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,47 @@ private static void getTopmostPaneHierarchy(List<Pane> paneHierarchy, Node node)
310310
}
311311
}
312312

313+
/**
314+
* Distributes {@code availableSize} among items that ask for {@code prefSizes}: every item gets
315+
* at most the size it prefers, and an item only gets less than that when the items asking for
316+
* more than an equal share cannot all be satisfied — those tall items then share whatever the
317+
* shorter ones left over.
318+
*
319+
* @param prefSizes the size each item prefers, in item order
320+
* @param availableSize the size to distribute between them
321+
* @return the size allotted to each item, in the same order
322+
*/
323+
public static double[] capAtFairShare(double[] prefSizes, double availableSize) {
324+
int itemCount = prefSizes.length;
325+
double[] sizes = new double[itemCount];
326+
boolean[] keepsPref = new boolean[itemCount];
327+
double remaining = Math.max(0, availableSize);
328+
int uncapped = itemCount;
329+
330+
// Settle the items that fit within the current fair share; every item settled frees up
331+
// share for the rest, so iterate until a full pass settles nothing.
332+
boolean settledAny = true;
333+
while (settledAny && uncapped > 0) {
334+
settledAny = false;
335+
double fairShare = remaining / uncapped;
336+
for (int i = 0; i < itemCount; i++) {
337+
if (!keepsPref[i] && prefSizes[i] <= fairShare) {
338+
keepsPref[i] = true;
339+
sizes[i] = prefSizes[i];
340+
remaining -= prefSizes[i];
341+
uncapped--;
342+
settledAny = true;
343+
}
344+
}
345+
}
346+
if (uncapped > 0) {
347+
double fairShare = remaining / uncapped;
348+
for (int i = 0; i < itemCount; i++) {
349+
if (!keepsPref[i]) {
350+
sizes[i] = fairShare;
351+
}
352+
}
353+
}
354+
return sizes;
355+
}
313356
}

‎kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genpurpose/GenPurposeDetailsController.java‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import dev.ikm.komet.framework.observable.ObservableSemanticVersion;
4444
import dev.ikm.komet.framework.view.ViewProperties;
4545
import dev.ikm.komet.kview.common.ViewCalculatorUtils;
46+
import dev.ikm.komet.kview.controls.ContentSizedSplitPane;
4647
import dev.ikm.komet.kview.controls.ComponentItem;
4748
import dev.ikm.komet.kview.controls.KLWorkspace;
4849
import dev.ikm.komet.kview.controls.KlWindowControlToolbar;
@@ -201,7 +202,7 @@ public class GenPurposeDetailsController {
201202
@FXML
202203
StampViewControl stampViewControl;
203204
@FXML
204-
private SplitPane mainContent;
205+
private ContentSizedSplitPane mainContent;
205206
@FXML
206207
private BorderPane detailsOuterBorderPane;
207208
@FXML
@@ -269,7 +270,9 @@ private void initialize() {
269270

270271
// When the window resizes vertically only the bottom section should grow or shrink; the
271272
// SplitPane's default is to spread the delta across every section proportionally, so pin
272-
// all items except the last one.
273+
// all items except the last one. This takes over once the user has claimed the sections'
274+
// sizing by dragging a divider — until then ContentSizedSplitPane sizes every section to
275+
// its content and hands the leftover height to that same last section.
273276
mainContent.getItems().subscribe(() -> {
274277
List<Node> items = mainContent.getItems();
275278
items.forEach(item -> SplitPane.setResizableWithParent(item, item == items.getLast()));

‎kview/src/main/resources/dev/ikm/komet/kview/mvvm/view/genpurpose/genpurpose-details.fxml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<?import javafx.scene.layout.VBox?>
1818
<?import javafx.scene.shape.SVGPath?>
1919

20-
<?import javafx.scene.control.SplitPane?>
20+
<?import dev.ikm.komet.kview.controls.ContentSizedSplitPane?>
2121
<?import dev.ikm.komet.kview.controls.ComponentItemNode?>
2222
<BorderPane fx:id="detailsOuterBorderPane" maxHeight="Infinity" maxWidth="Infinity" prefWidth="727.0"
2323
stylesheets="@../kview.css" xmlns="http://javafx.com/javafx/24.0.1" xmlns:fx="http://javafx.com/fxml/1"
@@ -58,7 +58,7 @@
5858
<VBox focusTraversable="true" maxHeight="1.7976931348623157E308" prefWidth="762.0">
5959
<children>
6060
<HBox layoutX="14.0" layoutY="135.0" prefWidth="711.0" VBox.vgrow="ALWAYS">
61-
<SplitPane fx:id="mainContent" orientation="VERTICAL" HBox.hgrow="ALWAYS"/>
61+
<ContentSizedSplitPane fx:id="mainContent" orientation="VERTICAL" HBox.hgrow="ALWAYS"/>
6262
</HBox>
6363
</children>
6464
</VBox>

0 commit comments

Comments
 (0)