Components

Component Tree / Forest

When every component created, we need to assign where it should generate. The root will be Main Container or Sidebar Container. Hence the relation between components is trees.

For example, if a page function implements as:

tgcomp.Text(p.Main, "Text")
tgcomp.Button(p.Main, "Button")
box := tgcomp.Box(p.Main)
tgcomp.Text(box, "Text1")
tgcomp.Text(box, "Text2")

Then the Component Tree will be:

---
config:
  look: handDrawn
---
graph TD
    Main --> Text1[Text]
    Main --> Button
    Main --> Box
    Box --> Text2[Text]
    Box --> Text3[Text]

One shape for every component

Every component reads the same way: the container first, then whatever the component is for, then an optional conf.

func Text(c *tgframe.Container, text string, conf ...*TextConf)
func Button(c *tgframe.Container, label string, conf ...*ButtonConf) bool
func Select(c *tgframe.Container, label string, items []string, conf ...*SelectConf) *int

The conf is variadic so that the common call carries nothing:

tgcomp.Button(p.Main, "Save")
tgcomp.Button(p.Main, "Save", &tgcomp.ButtonConf{ID: "save_all", Disabled: true})

Zero or one conf; two is a mistake rather than something to merge, and panics with the component's name in the message. An explicit nil means the same as none.

Every conf embeds tgframe.Base, which is where its ID comes from — the field is not declared per component, and the framework reads it through the embed rather than through a getter per conf.

type Base struct{ ID string }

type ButtonConf struct {
	tgframe.Base
	Color    tcutil.Color
	Disabled bool
}

Go version

Writing &ButtonConf{ID: "save_all"} — a promoted field as a key in a composite literal — needs the calling file to be at language version Go 1.27 or above. That is the only version-dependent thing a user of toolgui touches, and toolgui's own go.mod says go 1.27.1, so a module that depends on it is already above that line.

What a component hands back

Most components hand back what the user did — Button a bool, Textbox a *string — or nothing at all. The few that hand back something the page function operates later follow one rule, so that a caller wrapping toolgui in an abstraction of its own knows what to expect and can name the type:

  • A place the page can write and write over is a slot, *XxxSlot, and is written through With and emptied through Clear.
  • Something whose only follow-up is one teardown is a func(): call it, and the component is gone.
  • Anything else is a handle, *XxxHandle, with named methods for what it can do. The one that takes it off the page, where there is one, is Remove.

Every one of these types is exported, so a handle can be declared as a variable, kept in a struct field, passed to a function, and named in an interface.

ComponentHands backFinished with
Empty*EmptySlotWith / Clear
Spinnerfunc()call it
Status*StatusHandleComplete / Fail
ProgressBar*ProgressBarHandleRemove

Container is not one of these words. Box, Column, Form and Expand hand out a *tgframe.Container, which is a place components are added to, as many as the page function likes. A slot is written whole and rewritten whole, and a handle is neither — which is why EmptyContainer is now EmptySlot and StatusContainer is now StatusHandle. The old names stay as deprecated type aliases, so code that uses them still compiles; new code should not use them.

Status.Error is likewise now Status.Fail, with Error kept and deprecated: the old name reads like the error interface, which a status does not implement. Both take at most one closing label — two or more is a mistake rather than something to join, and panics, the way passing two confs does.

Identity: position and id

A page function runs again from the top on every interaction and writes every component again. Two questions follow from that, and they have different answers.

Which component is this, across runs? Its position. A container counts the components written into it, so the second component in the main container is container_component_container_main/1 on every run, whatever it contains. Nothing compares content, so writing the same thing twice is fine:

tgcomp.Text(p.Main, "same")
tgcomp.Text(p.Main, "same")

Both render. A component that keeps its position keeps its identity, so its props are updated in place instead of it being torn down and rebuilt.

Where is this component's state stored? Its id, derived from the component's type and its label: Button(c, "Save") is button_component_Save. A component claims one when it holds state, whether that state lives in Go — every input component — or only in the browser: Tab remembers which tab is open, Expand whether it is open, JSON which nodes are collapsed, and Iframe sends events back.

Components that only display something — Text, Markdown, Divider, Title, Table and the rest — have no id at all unless you give them one.

An id is a name, so two components cannot share one. Two identical buttons have the same id and the page fails with duplicated component id:

tgcomp.Button(p.Main, "Save")
tgcomp.Button(p.Main, "Save")   // error

Give one of them its own id to tell them apart. There is one way to do that, and every component takes it: Conf.ID.

tgcomp.Button(p.Main, "Save")
tgcomp.Button(p.Main, "Save", &tgcomp.ButtonConf{ID: "save_all"})

tgcomp.Expand(p.Main, "Details", false)
tgcomp.Expand(p.Main, "Details", false, &tgcomp.ExpandConf{ID: "second_details"})

tgcomp.Text(p.Main, "Value: 3", &tgcomp.TextConf{ID: "count_result"})

The display components take it the same way, which is what to reach for when a test or a stylesheet needs to name one in particular. So do the layout components: Box, Column, Form and the rest are configured through their conf like everything else, and the containers they hand out derive their ids from theirs.

An id given this way is also the element's id in the DOM.

The name in front of the id

A component does not take the conf id as-is: it prefixes it with its own name, so &tgcomp.ButtonConf{ID: "save"} is the component button_component_save. That is what keeps a Button and an Expand given the same "save" apart.

It matters as soon as Go code asks about a component by id, because the prefixed value — not the one written in the conf — is what the state is keyed by and what State.GetClickID() returns:

// Never true: GetClickID returns "button_component_save".
if p.State.GetClickID() == "save" {

Each component package hands out the getter that adds the prefix for you:

if tgcomp.ButtonClicked(p.Main, "Save") {

These getters read the run's state rather than the component, so they can be asked before the component is drawn. That is what a page needs when the button sits below the content it changes: handle the click first, then write the new content once, instead of sending the old content out and rewriting the whole slot.

They all name the component the same way: the container, plus the same arguments and conf the draw call gets. The getter derives the id from those, exactly as the draw call does, so the id is never written twice:

GetterNames the component by
ButtonClickedthe container, plus the same label and conf the Button call got
DownloadButtonClickedthe container, plus the same text and conf the DownloadButton call got
DownloadFileClickedthe container, plus the same text and conf the DownloadFile call got
IframeValuethe container, plus the same html and conf the Iframe call got
PluginValuethe container, plus the same src and conf the Plugin call got

The two download getters leave out the body the draw call takes: those ids come from the text, not from the file. IframeValue keeps its html, whose hash is the iframe's default id.

None of them draw anything — a getter reads, only the draw call adds a component — so it is fine to ask and then draw, in that order, in the same run.

The click id comes from the client, and a getter asked before the page has written anything has nothing of this run to check it against. So the Clicked getters check it against the ids the last run drew: a click naming a button that was not on the screen is not a click. Button needs no such check — it reports a click only where the button is actually written — which is why asking by hand, with GetClickID() and a prefix of your own, is not the same thing.

Writing one place more than once

A page function normally writes each place once per run. A slot — what Empty hands out, and what Spinner and Status are built on — is the exception: it can be written, cleared and written again while the run is still going, so the page can show "querying…" and then replace it with the result.

A slot keeps one key across every write. Clearing it sends a delete for that key, and the client drops the node and the subtree under it; the next write creates a node there again. The alternative — a fresh key per write — would save the delete, but it would leave the client holding a node per write until the run ended, and it would move the slot's contents in the tree every time, so nothing inside could keep anything across a redraw.

Keeping the key means an id written into the slot is claimed again on the next write, and an id is a name that only one component may hold. So clearing a slot gives its ids back: what was in it is off the screen, and the id is free for the next write to claim.

slot := tgcomp.Empty(p.Main)
for range names {
	// The same id every time, and no `duplicated component id`: each
	// textbox is gone before the next one is written.
	slot.With(func(c *tgframe.Container) {
		tgcomp.Textbox(c, "Name")
	})
}

An id that is given back and never claimed again names nothing on the page by the end of the run, so the state under it is dropped. That is the point: a widget cleared out of a slot should not hand its old value to whatever lands on its id next run. An id that is claimed again — the common case, where the slot is rewritten with the same widget — keeps its state, because the claim takes it back off the released list.

Two things follow for a page function. The container a slot's With hands over is only good until the next With or Clear, so take it in the callback rather than keeping it. And a slot starts empty on every run, whatever the last run left in it, so the first write of a run is not stacked on the last write of the one before.