Replies: 6 comments 8 replies
-
Proposal: Hierarchical ConfigThis is a proposal to achieve the best of both worlds, based on the following elements. Diff-based
|
Beta Was this translation helpful? Give feedback.
-
Improved semantic terminologyThe process of constructing the overall GUI is composed of two processes, analogous to how almost every other complex entity is made.
Keeping these two processes logically separate in your mind is key for understanding what goes where, including the critical point that the Plan can be continuously updated, but the actual built elements remain in place unless the plans for that element change. Customization can happen at both stages of the process: the Plan can be updated by additional |
Beta Was this translation helpful? Give feedback.
-
Proposal: structurally nested widget planningThe key limitation of the existing proposal is that you can not make automatically named children, since you have to specify the nesting using paths, which are not possible with automatic naming. My proposal addresses this by using a similar system to the existing tree nesting paradigm we use. In my proposal, you can write this: splits := core.Add(p, func(w *core.Splits) {})
core.Add(splits, func(w *core.Frame) {}) Instead of: core.AddAt(p, "splits", func(w *core.Splits) {})
core.AddAt(p, "splits/nav", func(w *core.Frame) {}) In my proposed code above, |
Beta Was this translation helpful? Give feedback.
-
App-level declarative paradigm?Here are two possible ways to write the basic example, both seven lines of code: Existing way: package main
import "cogentcore.org/core/core"
func main() {
b := core.NewBody("Hello")
core.NewButton(b).SetText("Hello, World!")
b.RunMainWindow()
} Potential new declarative way: package main
import "cogentcore.org/core/core"
func main() { core.Run(NewApp()) }
type App struct { core.Frame }
func (a *App) Make(p *core.Plan) {
core.Add(p, func(w *Button) { w.SetText("Hello, World!") })
} |
Beta Was this translation helpful? Give feedback.
-
Making plans and building widgets, with dynamic updating(Note: This is provisional documentation for the current state of the new Plan / Build architecture.) Perhaps the most important challenge for an advanced GUI framework is providing an efficient, logical, and flexible way to construct and dynamically update the GUI contents. One popular "brute force" technique, the declarative approach, is to use exactly the same code for construction and updating, rebuilding the current state every time, and letting the framework figure out what has changed (using a diff-based algorithm). This is not very computationally or memory efficient, to say the least: our benchmark tests showed factors in the 100x to 1000x range of both computation and memory cost relative to our more optimized approach. It also typically makes it difficult or impossible to use standard programming logic during construction, because everything ends up being written in the form of a giant static literal expression. Furthermore, it introduces significant complexity regarding the status of the active state of the widgets when they are updated. Our "Plan & Build" alternative approach improves upon the standard declarative approach by using unique name identifiers as keys (like unique map keys) for determining the elements of a Plan for the GUI structure, and putting all the construction code inside a closure that is only executed when a new widget element is actually needed. Thus, all the logic is expressed using standard programming constructs ( A key general principle behind the
|
Beta Was this translation helpful? Give feedback.
-
Disclaimer: I have only spent about an hour investigating cogentcore, and all of my GUI experience is from the 90s, before more modern trends became popular. The whole project strikes me as an interesting and impressive achievement. In particular, it is a GUI library that comes with multiple non-trivial applications, which surely provides invaluable experience. My comments here are merely first impressions. Overall, I find the claims, made here and elsewhere in cogentcore documentation, against the declarative approach to be over-stated and under-substantiated. I read the claims and remain unconvinced. These concerns are closer to a matter of programmer taste and familiarity than a matter of being obvious or factual. Often declarative approaches are held up as being easier to maintain precisely because they are a more constrained, less expressive, DSL, and so easier to learn and use correctly. This is in large part why declarative approaches like HTML have succeeded. Personally, I usually find nested declarations easier to reason about than a set of tightly woven imperative APIs. That said, the efficiency arguments presented here hold obvious weight, in my opinion. I noticed that the pathed ID approach talked about here bears some resemblance to the one used by Xilem, I presume for similar reasons. See "Identity and id paths" at https://raphlinus.github.io/rust/gui/2022/05/07/ui-architecture.html. That architecture sets the lofty goal of being both declarative and fast. But, it is written in Rust, and no Go solution is likely to be able to touch it in terms of performance.
It isn't clear to me how these things are stacks. Stacks usually involve both push and pop operations, and often only the top element is accessed. Aren't these just lists? After reading all the above posts I remained confused about the distinction between Planning, Building, Rebuilding, Restyling, etc. I think the planning phase is effectively constructing bespoke "objects" of what amounts to a single type that behaves in a fixed way according to "the plan". The other phases are effectively methods on that object type (or "class"). The final |
Beta Was this translation helpful? Give feedback.
-
The Declarative Paradigm
Many standard GUI frameworks use the declarative paradigm, where you construct a new composite object that represents the desired state of a widget, and the framework figures out from that what needs to be updated relative to the current state (i.e., basically performing a
diff
and applying the resultingpatch
to the current state). This is done every single time the state changes!The primary advantage of this paradigm is that the user doesn't need to worry about all the possible ways something might change : you can just create the desired state.
The primary disadvantage is that it ends up putting large chunks of GUI logic into single huge monolithic functions that are deeply nested and complex, with potentially significant amounts of embedded logic. In Go terms, it is basically writing your entire GUI logic within a struct literal expression, which are arguably the most unpleasant and unwieldy expressions in the language.
These declarative constructor expressions need to be massively nested because otherwise you would have to deal with complex logic of finding a particular place to insert a new element, and dealing with the state updating logic that you're trying to avoid in the first place. This means that you cannot use standard programming logic like
if
andfor
to flexibly build your GUI elements.For example, here is an example from the main Flutter sample app:
The code ends up being full of inline ternary logic expressions and other ways of replacing the standard procedural / imperative programming control flow constructs. The excessive boilerplate elements of the Dart language are not doing this code any favors, but even within Go, the equivalent declarative constructors end up being similarly horrible, here in the case of go-app:
Note the use of
app.If
to implement conditional logic, and there are similarapp.Range
constructs.Fyne uses an imperative mode of updating, but ends up making large nested structures like the declarative paradigm for the initial configuration, as in this example from their tutorial, where the
container.NewVBox
function takes a list of widgets to add:Clearly, there is room for improvement here. There are ways of breaking these functions into smaller components, but the basic issues remain.
Another critical issue with the declarative paradigm is that the final resulting state can end up being a random mashup of old and new, updated elements, which can create problems with coordinating across the different parts of the overall state. This typically requires additional complexity and logic to distinguish between the "state" that might be overwritten by updates.
The Imperative Paradigm
In the imperative paradigm, one calls functions to directly construct the GUI structure, so that standard control flow logic can be used. For example, here's a sample from the Cogent Core demo:
The
NewX
constructor functions take a parentWidget
and automatically add themselves to the parent's list of children.This results in simple, logical code, but what happens if you want to update the configuration based on some kind of state change?
Simple updates to state, such as hover or changing text etc can all be managed by changing state of individual widgets, and are straightforward to handle. Typically, widgets handle these updates themselves. But what happens if you want to make changes across different levels of a set of nested widgets? This logic can become a bit complex, in terms of finding and updating the specific widgets in question. And this update logic ends up being physically separated from the original configuration code, so it is harder to keep the two synchronized.
Furthermore if you want to change the overall structure of a set of nested widgets, it can become even more complicated. Often you end up just resetting everything and rebuilding from scratch, which is obviously less efficient than a declarative mode "diff" update.
The Best of Both Worlds
To get the best of both worlds, we need the following:
Beta Was this translation helpful? Give feedback.
All reactions