Hi @hjohn — reviving this closed thread ( #1 ) only to show you where your feedback led, not to reopen anything. I went ahead and built the composable DSL we'd debated — luvjfx (https://github.com/luvml/luvjfx) — and I ended up adopting most of your objections as hard constraints rather than arguing them. The two libraries are fully independent and coexist happily; I mostly wanted to show you the parts that came straight from your comments, and how I lean on your reactive layer rather than reinventing it.
Things you were right about, now baked in:
- Discoverability is non-negotiable. I dropped the free-static
t()/value() idea entirely. Every property is a fluent method on a typed wrapper, so it's pure IDE autocomplete — you learn ~50 factory names (which mirror the JavaFX class names: VBox→vbox) and the IDE surfaces the rest. Your "t/onAction out of nowhere" smell is gone, because the target type is always visible at the call site.
- No forced
.build(). Factories return a thin wrapper holding the real node in a public .node field; .attr(n -> …) hands you the typed node for anything uncovered. Nothing is ever blocked.
- "Compose shared behaviour via default methods on interfaces" — that's literally the spine: a self-typed
FxNodeOps<S,N> base, one trait-interface per property, bundled per control, all default methods. And I don't hand-write them (your "it grows every release" worry): the whole surface is generated from JavaFX by reflection — 367 property-traits across 51 controls, re-runnable per release. What you write stays plain Java.
The one idea I'd offer back. Your sticking point was: "if I had a way to mark Node as one of the accepted vararg parameters, I'd be all for a type-safe solution." Typing the vararg as a functional interface — Consumer<? super N> — turns out to be that key. A bare configurator lambda and typed children then share one argument list, all checked against the node:
import static luvjfx.Fx.*;
var form = vbox().spacing(8).padding(16).nodes(
label("Name"),
textField().promptText("Your name"),
button("OK").defaultButton(true).onAction(e -> submit())
);
// config-lambda + children in one varargs; raw/foreign nodes wrap via fx(...):
vbox(v -> v.spacing(8), label("Name"), fx(existingJavaFxNode));
It reads like SwiftUI/Flutter — but stays plain Java. The same little form across three declarative UIs:
// SwiftUI
VStack(spacing: 8) {
Text("Name")
TextField("Your name", text: $name)
Button("OK") { submit() }
}
// Flutter (Dart)
Column(children: [
const Text('Name'),
TextField(onChanged: (v) => name = v),
ElevatedButton(onPressed: submit, child: const Text('OK')),
]);
// luvjfx (Java)
vbox().spacing(8).nodes(
label("Name"),
textField().promptText("Your name"),
button("OK").onAction(e -> submit())
);
Same nested tree, same terseness (actually more terse than flutter) — but no new language, no compiler plugin, no build(), and .node is a real TextField you can do anything with. That was the whole motivation: JavaFX can read as cleanly as the declarative-UI crowd without anyone leaving Java.
Your reactive layer, used as-is. I deliberately didn't rebuild Observe/Domain/Model — that part of FX is genuinely well-designed, so luvjfx just binds to it. luvjfx builds the tree, fx-values owns value+validity, and they meet only at ObservableValue/Property:
var roundTrip = BooleanModel.of(false);
var departure = ObjectModel.of(today, Domain.bounded(today, today.plusYears(1)));
var returnDate = ObjectModel.<LocalDate>of(today.plusDays(7), Domain.any());
// the return date's *domain* depends on departure — your Observe + Domain:
Observe.values(roundTrip, departure)
.compute((rt, dep) -> Boolean.TRUE.equals(rt) && dep != null
? Domain.bounded(dep, dep.plusYears(1)) : Domain.inapplicable())
.subscribe(returnDate::setDomain);
datePicker().disable(roundTrip.map(rt -> !rt)) // generated ObservableValue binding overload
.attr(dp -> Models.edits(dp.valueProperty(), returnDate)); // edits = a 3-line control→model bridge
button("Book").disable(Observe.booleans(departure.valid(), returnDate.valid()).allTrue().map(v -> !v));
(fx-core/fx-values aren't on Central yet, so I clone + mvn install them locally — a release would make this an ordinary dependency.)
The model↔control bridge is still a hair verbose (.attr(dp -> Models.edits(...))); I think it can collapse into a fluent .model(returnDate) overload so the wiring disappears entirely — but that's a refinement for another day.
That's all — shared in the spirit of the original exchange. Both libraries stand on their own; if any of this is useful to FX, take it freely, and I'm indebted to your reactive design either way. Thanks for the original pushback — it shaped the whole thing.
Hi @hjohn — reviving this closed thread ( #1 ) only to show you where your feedback led, not to reopen anything. I went ahead and built the composable DSL we'd debated — luvjfx (https://github.com/luvml/luvjfx) — and I ended up adopting most of your objections as hard constraints rather than arguing them. The two libraries are fully independent and coexist happily; I mostly wanted to show you the parts that came straight from your comments, and how I lean on your reactive layer rather than reinventing it.
Things you were right about, now baked in:
t()/value()idea entirely. Every property is a fluent method on a typed wrapper, so it's pure IDE autocomplete — you learn ~50 factory names (which mirror the JavaFX class names:VBox→vbox) and the IDE surfaces the rest. Your "t/onActionout of nowhere" smell is gone, because the target type is always visible at the call site..build(). Factories return a thin wrapper holding the real node in a public.nodefield;.attr(n -> …)hands you the typed node for anything uncovered. Nothing is ever blocked.FxNodeOps<S,N>base, one trait-interface per property, bundled per control, all default methods. And I don't hand-write them (your "it grows every release" worry): the whole surface is generated from JavaFX by reflection — 367 property-traits across 51 controls, re-runnable per release. What you write stays plain Java.The one idea I'd offer back. Your sticking point was: "if I had a way to mark
Nodeas one of the accepted vararg parameters, I'd be all for a type-safe solution." Typing the vararg as a functional interface —Consumer<? super N>— turns out to be that key. A bare configurator lambda and typed children then share one argument list, all checked against the node:It reads like SwiftUI/Flutter — but stays plain Java. The same little form across three declarative UIs:
Same nested tree, same terseness (actually more terse than flutter) — but no new language, no compiler plugin, no
build(), and.nodeis a realTextFieldyou can do anything with. That was the whole motivation: JavaFX can read as cleanly as the declarative-UI crowd without anyone leaving Java.Your reactive layer, used as-is. I deliberately didn't rebuild
Observe/Domain/Model— that part of FX is genuinely well-designed, so luvjfx just binds to it. luvjfx builds the tree, fx-values owns value+validity, and they meet only atObservableValue/Property:(fx-core/fx-values aren't on Central yet, so I clone +
mvn installthem locally — a release would make this an ordinary dependency.)The model↔control bridge is still a hair verbose (
.attr(dp -> Models.edits(...))); I think it can collapse into a fluent.model(returnDate)overload so the wiring disappears entirely — but that's a refinement for another day.That's all — shared in the spirit of the original exchange. Both libraries stand on their own; if any of this is useful to FX, take it freely, and I'm indebted to your reactive design either way. Thanks for the original pushback — it shaped the whole thing.