Skip to content

Commit bfbd2e0

Browse files
committed
fix: use representative node metadata for nested container tabs
Replace container-tab fallback text (DockSplitPane/DockTabPane) with a summary derived from the representative DockNode title and icon (Title +N). Keep container-tab headers reactive by updating on DockGraph revisions, nested tab selection changes, and DockNode title/icon changes. Add DockLayoutEngine regression tests for representative summary rendering and selected-inner-tab synchronization, and update STATUS/DONE/ROADMAP/CHANGELOG to reflect 191 passing tests.
1 parent 4af91e3 commit bfbd2e0

6 files changed

Lines changed: 217 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The changelog is grouped by release tags (`vX.Y.Z`) and includes an `Unreleased`
1010
- ✅ Removed obsolete JavaFX test JVM module flags that caused classpath/module warning noise (`24ac0bc`).
1111
- ✅ Stabilized JavaFX module-path test runtime without FXML assumptions (`e8197a9`).
1212
- ✅ Migrated plugin/dependency versions to `gradle/libs.versions.toml` and reduced duplicated build configuration (`517b5db`).
13-
- ✅ Expanded shortcut, context-menu, and demo-accelerator coverage in unit tests; full suite now runs with 189 tests.
13+
- ✅ Expanded shortcut, context-menu, and demo-accelerator coverage in unit tests; full suite now runs with 191 tests.
1414

1515
### Framework and UI
1616
- ✅ Added configurable framework keyboard shortcut API in `SnapFX` (`setShortcut`, `clearShortcut`, `resetShortcutsToDefaults`, `getShortcuts`).
@@ -24,6 +24,7 @@ The changelog is grouped by release tags (`vX.Y.Z`) and includes an `Unreleased`
2424
- ✅ Reworked DockNode icon model to image-based rendering so each view creates its own icon node, preventing parent-sharing losses in floating title bars.
2525
- ✅ Floating title-bar icon now tracks active tab selection in floating tab layouts.
2626
- ✅ Hardened drop-zone edge-size clamping for tiny bounds so drag hover no longer throws `Math.clamp` min/max-order exceptions.
27+
- ✅ Tab headers for nested container layouts now use representative DockNode title/icon summaries (`Title +N`) instead of container class names.
2728

2829
### Documentation
2930
- ✅ Split repository workflow content out of `README.md` into dedicated `CONTRIBUTING.md` and `RELEASING.md` (`6f93d0a`).

DONE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ SnapFX has been fully implemented with core functionality and is production-read
5959
- ✅ Full JPMS support with proper exports and opens
6060
- ✅ Compatible with Java 21 module system
6161

62-
### Testing (12 test classes, 189 tests)
62+
### Testing (12 test classes, 191 tests)
6363
-`DockGraphTest` (56 tests) - Tree manipulation and algorithms
6464
- **+11 regression tests** for critical bug fixes
6565
- Tests for empty container prevention
@@ -70,7 +70,7 @@ SnapFX has been fully implemented with core functionality and is production-read
7070
- Edge case tests for null/no-op/detached-target handling
7171
-`DockLayoutSerializerTest` (9 tests) - Persistence functionality
7272
- **+1 regression test** for locked state synchronization (2026-02-10)
73-
-`DockLayoutEngineTest` (29 tests) - View creation with TestFX, context-menu interaction coverage, float-availability policy checks, and tiny-bounds drop-zone clamp regression coverage
73+
-`DockLayoutEngineTest` (31 tests) - View creation with TestFX, context-menu interaction coverage, representative container-tab title/icon behavior, float-availability policy checks, and tiny-bounds drop-zone clamp regression coverage
7474
- Memory cleanup tests for cache boundedness and undock/rebuild cycles
7575
- Layout optimization tests for empty/single-child roots
7676
-`SnapFXTest` (44 tests) - Hide/Restore + Floating Window API behavior plus configurable shortcut behavior
@@ -146,6 +146,7 @@ SnapFX has been fully implemented with core functionality and is production-read
146146
-**Property Bindings**: Reactive UI updates via JavaFX properties
147147
-**CSS Styling**: Native Modena theme integration
148148
-**Tab Overflow Dropdown**: Menu shows titles when using custom tab graphics
149+
-**Container tab summary headers**: Tabs hosting nested layouts now show representative DockNode title/icon with `+N` suffix instead of container class names
149150
-**Tab Auto-Hide**: In locked mode, tabs only visible when >1
150151
-**Close Button Options**: Toggle tab/title close buttons, keep tab close always visible, align styling, and hide the title bar when desired
151152
-**CSS-based Control Glyphs**: Dock/floating control icons are stylesheet-defined; title close glyph is aligned with tab close styling

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ This roadmap lists planned work only; completed/fixed history is tracked in `CHA
7070
### 1.5 UI Bug Fixes
7171
**Priority**: 🟡 High
7272

73+
-**Container tab title/icon UX**: Tabs containing nested split/tab layouts now derive title/icon from a representative DockNode (`Title +N`) instead of showing container class names
7374
- 📋 No planned items; see `CHANGELOG.md` for completed fixes.
7475

7576
---

STATUS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
## Build Status
66

77
**Build**: `BUILD SUCCESSFUL`
8-
**Tests**: All 189 tests passing (latest full suite)
8+
**Tests**: All 191 tests passing (latest full suite)
99
**Module System**: Fully implemented (JPMS)
1010
**Demo App**: Running successfully
1111
**CI Automation**: GitHub Actions workflows added for push/PR tests and tag-triggered releases
@@ -37,6 +37,7 @@
3737
- ✅ CSS styling (Modena theme)
3838
- ✅ Context menus for tab headers, dock-node headers, and split panes (with float-action availability policy support and close/float icon parity)
3939
- ✅ DockNode icons render as independent per-view image nodes (no shared-node icon loss across headers/tabs/floating title bars)
40+
- ✅ Container tabs now use representative DockNode title/icon summaries (`Title +N`) instead of internal container class names
4041

4142
### Drag & Drop (100% ✅)
4243
- ✅ DockDragService implementation
@@ -128,7 +129,7 @@
128129
### Testing (100% ✅)
129130
- ✅ DockGraphTest (56 tests, +11 regression tests)
130131
- ✅ DockLayoutSerializerTest (9 tests, +1 regression test)
131-
- ✅ DockLayoutEngineTest (29 tests) - Includes tab/header/splitter context-menu coverage, float-availability policy checks, and tiny-bounds drop-zone clamp regression coverage
132+
- ✅ DockLayoutEngineTest (31 tests) - Includes tab/header/splitter context-menu coverage, representative container-tab title/icon behavior, float-availability policy checks, and tiny-bounds drop-zone clamp regression coverage
132133
-**SnapFXTest (44 tests)** - Hide/Restore + Floating Window API tests plus configurable shortcut behavior
133134
- ✅ DockGraphSplitTargetDockingTest (1 test)
134135
- ✅ DockDragServiceTest (8 tests) - D&D visibility, tab-hover activation, float-detach callback behavior, and ESC drag-cancel handling
@@ -138,7 +139,7 @@
138139
- ✅ SimpleExampleTest (2 tests) - Stylesheet resource resolution behavior
139140
- ✅ MarkdownDocumentationConsistencyTest (12 tests) - Markdown consistency guardrails
140141
- ✅ AboutDialogTest (2 tests) - About dialog branding resources and credit link targets
141-
-**189/189 tests passing**
142+
-**191/191 tests passing**
142143
-**Performance tests for large layouts** (50+ nodes with stress move/cleanup operations)
143144
-**Memory leak cleanup tests** (cache boundedness, undock cleanup, large-layout detach/attach cycles)
144145
-**Edge case tests** (null inputs, detached nodes, invalid move targets, no-op revision checks)

src/main/java/com/github/beowolve/snapfx/view/DockLayoutEngine.java

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,8 @@ private void clearCacheForElement(DockElement element) {
305305

306306
/**
307307
* Creates a Tab for the given DockElement. For DockNode, the tab header contains icon and title.
308-
* For containers, a generic label is used. Handles drag & drop and close events.
308+
* For containers, the tab label/icon is derived from the representative DockNode.
309+
* Handles drag & drop and close events.
309310
* @param element The DockElement to represent as a Tab
310311
* @return The created Tab
311312
*/
@@ -329,7 +330,10 @@ private Tab createTab(DockElement element, DockTabPane ownerTabPane) {
329330
event.consume();
330331
});
331332
} else {
332-
tab.setText(element.getClass().getSimpleName());
333+
TabHeader tabHeader = createContainerTabHeader(tab, element);
334+
tab.setGraphic(tabHeader.node());
335+
tab.getProperties().put(TAB_CLEANUP_KEY, tabHeader.cleanup());
336+
tab.getStyleClass().add("dock-tab-graphic");
333337
if (!shouldShowTabCloseButton()) {
334338
tab.setClosable(false);
335339
} else {
@@ -344,6 +348,139 @@ private Tab createTab(DockElement element, DockTabPane ownerTabPane) {
344348
return tab;
345349
}
346350

351+
private TabHeader createContainerTabHeader(Tab tab, DockElement element) {
352+
HBox tabHeader = new HBox(5);
353+
tabHeader.setAlignment(Pos.CENTER_LEFT);
354+
355+
StackPane iconPane = new StackPane();
356+
iconPane.setPrefSize(16, 16);
357+
iconPane.setMaxSize(16, 16);
358+
iconPane.setMinSize(16, 16);
359+
360+
Label tabLabel = new Label();
361+
List<Runnable> subtreeCleanup = new ArrayList<>();
362+
363+
Runnable refreshHeader = () -> updateContainerTabHeader(tab, element, tabLabel, iconPane);
364+
Runnable refreshListeners = () -> {
365+
clearContainerTabHeaderListeners(subtreeCleanup);
366+
registerContainerTabHeaderListeners(element, refreshHeader, subtreeCleanup);
367+
refreshHeader.run();
368+
};
369+
ChangeListener<Number> revisionListener = (obs, oldValue, newValue) -> refreshListeners.run();
370+
dockGraph.revisionProperty().addListener(revisionListener);
371+
refreshListeners.run();
372+
373+
tabHeader.getChildren().addAll(iconPane, tabLabel);
374+
375+
Runnable cleanup = () -> {
376+
dockGraph.revisionProperty().removeListener(revisionListener);
377+
clearContainerTabHeaderListeners(subtreeCleanup);
378+
iconPane.getChildren().clear();
379+
iconPane.setVisible(true);
380+
iconPane.setManaged(true);
381+
tabLabel.setText("");
382+
};
383+
return new TabHeader(tabHeader, cleanup);
384+
}
385+
386+
private void updateContainerTabHeader(Tab tab, DockElement element, Label tabLabel, StackPane iconPane) {
387+
DockNode representativeNode = resolveRepresentativeNode(element);
388+
int nodeCount = countDockNodes(element);
389+
String title = buildRepresentativeTitle(representativeNode, nodeCount);
390+
tab.setText(title);
391+
tabLabel.setText(title);
392+
393+
iconPane.getChildren().clear();
394+
if (representativeNode != null) {
395+
ImageView icon = createDockNodeIcon(representativeNode.getIcon());
396+
if (icon != null) {
397+
iconPane.getChildren().add(icon);
398+
}
399+
}
400+
boolean hasIcon = !iconPane.getChildren().isEmpty();
401+
iconPane.setVisible(hasIcon);
402+
iconPane.setManaged(hasIcon);
403+
}
404+
405+
private void registerContainerTabHeaderListeners(DockElement element, Runnable onUpdate, List<Runnable> cleanup) {
406+
if (element == null || onUpdate == null || cleanup == null) {
407+
return;
408+
}
409+
if (element instanceof DockNode dockNode) {
410+
ChangeListener<String> titleListener = (obs, oldValue, newValue) -> onUpdate.run();
411+
ChangeListener<Image> iconListener = (obs, oldValue, newValue) -> onUpdate.run();
412+
dockNode.titleProperty().addListener(titleListener);
413+
dockNode.iconProperty().addListener(iconListener);
414+
cleanup.add(() -> dockNode.titleProperty().removeListener(titleListener));
415+
cleanup.add(() -> dockNode.iconProperty().removeListener(iconListener));
416+
}
417+
if (element instanceof DockTabPane tabPane) {
418+
ChangeListener<Number> selectedIndexListener = (obs, oldValue, newValue) -> onUpdate.run();
419+
tabPane.selectedIndexProperty().addListener(selectedIndexListener);
420+
cleanup.add(() -> tabPane.selectedIndexProperty().removeListener(selectedIndexListener));
421+
}
422+
if (element instanceof DockContainer container) {
423+
for (DockElement child : container.getChildren()) {
424+
registerContainerTabHeaderListeners(child, onUpdate, cleanup);
425+
}
426+
}
427+
}
428+
429+
private void clearContainerTabHeaderListeners(List<Runnable> cleanup) {
430+
if (cleanup == null || cleanup.isEmpty()) {
431+
return;
432+
}
433+
for (Runnable cleanupTask : new ArrayList<>(cleanup)) {
434+
cleanupTask.run();
435+
}
436+
cleanup.clear();
437+
}
438+
439+
private String buildRepresentativeTitle(DockNode representativeNode, int nodeCount) {
440+
String baseTitle = representativeNode == null ? "Layout" : representativeNode.getTitle();
441+
if (baseTitle == null || baseTitle.isBlank()) {
442+
baseTitle = "Layout";
443+
}
444+
if (nodeCount <= 1) {
445+
return baseTitle;
446+
}
447+
return baseTitle + " +" + (nodeCount - 1);
448+
}
449+
450+
private DockNode resolveRepresentativeNode(DockElement element) {
451+
if (element == null) {
452+
return null;
453+
}
454+
if (element instanceof DockNode dockNode) {
455+
return dockNode;
456+
}
457+
if (element instanceof DockTabPane tabPane && !tabPane.getChildren().isEmpty()) {
458+
int selectedIndex = Math.clamp(tabPane.getSelectedIndex(), 0, tabPane.getChildren().size() - 1);
459+
return resolveRepresentativeNode(tabPane.getChildren().get(selectedIndex));
460+
}
461+
if (element instanceof DockContainer container && !container.getChildren().isEmpty()) {
462+
return resolveRepresentativeNode(container.getChildren().getFirst());
463+
}
464+
return null;
465+
}
466+
467+
private int countDockNodes(DockElement element) {
468+
if (element == null) {
469+
return 0;
470+
}
471+
if (element instanceof DockNode) {
472+
return 1;
473+
}
474+
if (element instanceof DockContainer container) {
475+
int count = 0;
476+
for (DockElement child : container.getChildren()) {
477+
count += countDockNodes(child);
478+
}
479+
return count;
480+
}
481+
return 0;
482+
}
483+
347484
private ContextMenu createHeaderContextMenu(DockNode dockNode) {
348485
ContextMenu contextMenu = new ContextMenu();
349486

src/test/java/com/github/beowolve/snapfx/view/DockLayoutEngineTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import javafx.scene.input.MouseButton;
1919
import javafx.scene.input.MouseEvent;
2020
import javafx.scene.input.PickResult;
21+
import javafx.scene.image.ImageView;
22+
import javafx.scene.image.WritableImage;
2123
import javafx.scene.layout.StackPane;
2224
import javafx.scene.layout.HBox;
2325
import javafx.scene.control.Tab;
@@ -126,6 +128,72 @@ void testDockNodeTabTextUsesTitle() {
126128
assertEquals("Renamed", tabPane.getTabs().get(0).getText());
127129
}
128130

131+
@Test
132+
void testContainerTabUsesRepresentativeNodeTitleAndIconSummary() {
133+
DockNode plainNode = new DockNode(new Label("Plain"), "Plain");
134+
DockNode splitNode1 = new DockNode(new Label("Split 1"), "Split 1");
135+
DockNode splitNode2 = new DockNode(new Label("Split 2"), "Split 2");
136+
WritableImage splitIcon = new WritableImage(16, 16);
137+
splitNode1.setIcon(splitIcon);
138+
DockSplitPane splitPane = new DockSplitPane(Orientation.HORIZONTAL);
139+
splitPane.addChild(splitNode1);
140+
splitPane.addChild(splitNode2);
141+
142+
DockTabPane rootTabPane = new DockTabPane();
143+
rootTabPane.addChild(plainNode);
144+
rootTabPane.addChild(splitPane);
145+
dockGraph.setRoot(rootTabPane);
146+
147+
TabPane tabPane = assertInstanceOf(TabPane.class, layoutEngine.buildSceneGraph());
148+
Tab containerTab = tabPane.getTabs().get(1);
149+
150+
assertEquals("Split 1 +1", containerTab.getText());
151+
assertNotEquals("DockSplitPane", containerTab.getText());
152+
153+
HBox header = assertInstanceOf(HBox.class, containerTab.getGraphic());
154+
StackPane iconPane = assertInstanceOf(StackPane.class, header.getChildren().getFirst());
155+
ImageView iconView = assertInstanceOf(ImageView.class, iconPane.getChildren().getFirst());
156+
assertEquals(splitIcon, iconView.getImage());
157+
}
158+
159+
@Test
160+
void testContainerTabFollowsSelectedInnerTabForRepresentativeTitleAndIcon() {
161+
DockNode plainNode = new DockNode(new Label("Plain"), "Plain");
162+
DockNode innerNode1 = new DockNode(new Label("One"), "One");
163+
DockNode innerNode2 = new DockNode(new Label("Two"), "Two");
164+
DockNode splitSiblingNode = new DockNode(new Label("Split Sibling"), "Split Sibling");
165+
WritableImage icon1 = new WritableImage(16, 16);
166+
WritableImage icon2 = new WritableImage(16, 16);
167+
innerNode1.setIcon(icon1);
168+
innerNode2.setIcon(icon2);
169+
170+
DockTabPane innerTabPane = new DockTabPane();
171+
innerTabPane.addChild(innerNode1);
172+
innerTabPane.addChild(innerNode2);
173+
innerTabPane.setSelectedIndex(0);
174+
175+
DockSplitPane splitPane = new DockSplitPane(Orientation.HORIZONTAL);
176+
splitPane.addChild(innerTabPane);
177+
splitPane.addChild(splitSiblingNode);
178+
179+
DockTabPane rootTabPane = new DockTabPane();
180+
rootTabPane.addChild(plainNode);
181+
rootTabPane.addChild(splitPane);
182+
dockGraph.setRoot(rootTabPane);
183+
184+
TabPane tabPane = assertInstanceOf(TabPane.class, layoutEngine.buildSceneGraph());
185+
Tab containerTab = tabPane.getTabs().get(1);
186+
assertEquals("One +2", containerTab.getText());
187+
188+
innerTabPane.setSelectedIndex(1);
189+
assertEquals("Two +2", containerTab.getText());
190+
191+
HBox header = assertInstanceOf(HBox.class, containerTab.getGraphic());
192+
StackPane iconPane = assertInstanceOf(StackPane.class, header.getChildren().getFirst());
193+
ImageView iconView = assertInstanceOf(ImageView.class, iconPane.getChildren().getFirst());
194+
assertEquals(icon2, iconView.getImage());
195+
}
196+
129197
/**
130198
* Regression test: Tab close must use the same close handler as the title bar.
131199
* Bug: Closing a tab bypassed onNodeCloseRequest, so hidden nodes were not tracked.

0 commit comments

Comments
 (0)