Skip to content

Add pure-Python view API as alternative to cgx templates - #193

Merged
berendkleinhaneveld merged 9 commits into
masterfrom
pure-python-view-api
Jul 13, 2026
Merged

Add pure-Python view API as alternative to cgx templates#193
berendkleinhaneveld merged 9 commits into
masterfrom
pure-python-view-api

Conversation

@berendkleinhaneveld

Copy link
Copy Markdown
Collaborator

What

Adds a pure-Python way to describe component UIs — the view() method — as a fully supported alternative to .cgx templates, for users who prefer to stay in plain Python (better tooling, type checkers, no separate file format):

import collagraph as cg
from collagraph import h


class Counter(cg.Component):
    def init(self):
        self.state["count"] = 0

    def bump(self):
        self.state["count"] += 1

    def view(self):
        with h.widget():
            h.label(lambda: f"Count: {self.state['count']}")
            h.button("bump", on_clicked=self.bump)

Templates remain fully supported; the two coexist (a component defines either a template or a view method).

How it works

Like compiled cgx render functions, view() runs once per component instance: it's a setup function that builds the same Fragment tree the compiler produces, so all fine-grained reactivity lives in the existing Fragment layer — no new reactivity machinery. The one rule: a plain value is a static attribute, a zero-arg callable is a reactive bind.

The full template feature set is covered: static/bound attributes and bind= dicts, events (on_*), text content, when/elif_/otherwise, keyed and unkeyed each loops (loop variables as Solid-style getters), component usage with props/events, slots and fill, refs, and dynamic (:is) components. View build code additionally runs inside a temporary watcher that detects the eager-read footgun (reading reactive state without wrapping it in a lambda) and warns with a pointer to the fix.

Also in this PR

  • Hot reload for view components: plain .py modules defining view components are watched and reloaded, including scripts run directly (python my_app.py — the file is re-executed under a substitute module name so the __main__ guard doesn't re-fire). Also fixes a stale-bytecode edge where a same-size rewrite within the same mtime second would reload old code.
  • CLI support: collagraph -H examples/pyside/counter_view.py works, with file.py:ClassName to select a class when a module defines several.
  • Examples (counter_view.py, todo_view.py), a docs guide (Python Views, with a template→Python cheat sheet), and cross-links from the existing docs.
  • Drive-by fix: <window title=...> in the todo example was a silent no-op (the Qt property is windowTitle); now uses window_title.

Testing

  • 33 new tests (view DSL, hot reload of view modules, CLI resolution); full suite passes.
  • Examples verified end-to-end against an offscreen QApplication, including keyed list reconciliation and conditional switching.
  • Hot reload verified end-to-end offscreen with the watchdog enabled: edit on disk → reload → state preserved → UI still interactive, both programmatically and through collagraph -H.
  • mkdocs build --strict passes.

🤖 Generated with Claude Code

berendkleinhaneveld and others added 9 commits July 9, 2026 12:09
Components can now describe their UI in a `view` method using the
element builder `h` and control flow helpers, instead of (and alongside)
a .cgx template:

    class Counter(cg.Component):
        def view(self):
            with h.widget():
                h.label(text=lambda: f"Count: {self.state['count']}")
                h.button("bump", on_clicked=self.bump)

The view method runs once, like a compiled render function; fine-grained
reactivity is kept intact through one rule: a plain value is a static
attribute, a zero-arg callable is a reactive bind. The builder in
collagraph/dsl.py constructs the exact same Fragment tree that the cgx
compiler generates, so both frontends share the entire runtime:

* when/elif_/otherwise -> ControlFlowFragment (v-if/v-else-if/v-else)
* each decorator -> ListFragment, loop variables as getters (v-for/:key)
* slot/fill -> SlotFragment/slot content (v-slot)
* dynamic -> DynamicFragment (<component :is>)
* on_* kwargs -> events, bind= -> set_bind_dict, ref= -> template refs
* positional args -> text children ({{ interpolation }})

View code (including each() row builds) runs inside a temporary watcher:
any reactive read during the build is almost certainly a bug (the value
would be baked in statically), so a warning with the lambda fix is
emitted, and ambient watchers are shielded from picking up stray
dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* examples/pyside/counter_view.py: counter.cgx written with view()
* examples/pyside/todo_view.py: the todo_example.cgx/todo_list.cgx pair
  in a single file, additionally showing keyed each() and
  when()/otherwise() (an "All done!" message when the list is empty)
* docs/guide/python-views.md: full guide with a template-to-Python
  cheat sheet, linked from the nav, template syntax page, index and
  the PySide examples page

Both examples verified end-to-end against an offscreen QApplication
(adding items through the line edit, completing items, conditional
switch when the list empties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalize the hot reloader beyond .cgx files: plain Python modules that
define view components (Component subclasses overriding view()) used in
the component tree are now watched and reloaded on change.

- Watch .py modules of view components, including modules pulled in for
  dynamic (:is) components
- Reload script modules (__main__) by re-executing them from file under
  a substitute module name, so their `if __name__ == "__main__"` block
  does not run again
- Resolve the root component class by name when a reloaded module does
  not set __component_class (only cgx modules do)
- Clear stale bytecode caches before reimporting, so rewrites within
  the same mtime second are picked up

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `collagraph` command now also accepts .py modules containing view
components, with the same --hot-reload and --state options as .cgx files:

    collagraph -H examples/pyside/counter_view.py
    collagraph examples/pyside/todo_view.py:TodoApp

The root component is resolved from the module by finding the single
view component it defines. When a module defines multiple, select one
by appending :ClassName, or assign `__component_class` in the module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`title` is not a QMainWindow property, so `<window title=...>` was a
silent no-op; the Qt property is `windowTitle`, set via `window_title`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explain that templates accept dashes or underscores but Python views
require underscores (keyword-argument identifiers), and that accepted
attribute names depend on the renderer. Cross-link the PySide and Pygfx
attribute sections, and add dash/underscore examples to the PySide docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trailing underscore on elif_ (needed to avoid the elif keyword) read
awkwardly next to when and otherwise. or_when composes with them as plain
English -- "when A, or when B, otherwise C" -- and needs no underscore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The internals architecture page was written entirely around the .cgx
compiler as the sole way a Fragment tree is produced. Update it to
present the pure-Python view API as a second front-end that builds the
same Fragment tree directly, with no compile step:

- Broaden the intro and add a paragraph on the two front-ends
- Add the view() path to the overview and summary diagrams
- Add a "same tree from Python" subsection to the compiler walkthrough
- Reword the summary to be front-end agnostic

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@berendkleinhaneveld
berendkleinhaneveld merged commit 3882341 into master Jul 13, 2026
9 of 10 checks passed
@berendkleinhaneveld
berendkleinhaneveld deleted the pure-python-view-api branch July 13, 2026 08:28
berendkleinhaneveld added a commit that referenced this pull request Jul 21, 2026
Features:
- Add pure-Python view API as alternative to cgx templates (#193)
- Support text elements for PySide widgets that display text (#191)

Fixes & internals:
- Fragment parenting overhaul (#162)
- Fix PyInstaller hook for CGX files inside packages (#184)
- Write compiled AST to temp file when CGX_DEBUG is set (#175)

Performance:
- Speed up mount path: cheap arity check, reuse first(), leaner emit (#186)
- Cache Fragment._component_parent() lookups (#187)
- Avoid redundant anchor lookups in Fragment.anchor() and unkeyed v-for (#188)

Documentation:
- Add MkDocs documentation with GitHub Pages deployment (#176)
- Add internals architecture documentation page (#194)
- Add docs badge and links to README (#192)

Tooling & CI:
- Add benchmark suite and per-PR benchmark CI workflow (#185)
- Make benchmark CI guard robust against run-to-run noise (#196)
- Update GitHub actions from Node 20 to Node 24 (#190)
- Migrate from pre-commit to prek (#195)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant