# Custom views
URL: https://lsd.tools/docs/ui-custom-views
Updated: 2026-07-18

> When the built-in table/chart/flowchart views aren't enough, subclass View. Set a class-level kind (its registry key), implement feed(data) to store it, and to_text()/to_image() to render it. Defining the subclass registers it by kind. The GTK drawing shell lives separately: your view's gtk module calls set_view_shell(kind, factory) — the one sanctioned place a package writes GTK, imported only when a display is present. Everything else stays headless.

The three built-in [data views](/docs/ui-widgets) cover most cases, but you can render anything by
subclassing [`View`](/docs/ui-overview). A custom view keeps the split that makes every view
testable: the **representation** (data + text/image) is headless, and the **GTK shell** that draws
it lives apart, in the one place a package is allowed to write GTK.

## Subclass View

Set a class-level `kind` (its registry key), implement `feed(data)` to take the data, and
`to_text()` / `to_image()` to render it. Config that changes presentation stays in `param()`
fields. Defining the subclass registers it by `kind` (redefining a kind overrides it):

```python
# grade_band/view.py
from lsdtools import View, param

class GradeBandView(View):
    kind = "grade_band"                        # its registry key

    cutoff = param(float, default=0.5, min=0.0, max=1.0, label="Cut-off")

    def feed(self, data):
        """Store the data (a Table of {name, grade}) and notify any shell to redraw."""
        self._rows = data.to_dicts()
        self._data_changed()

    def to_text(self):
        above = [r for r in self._rows if r["grade"] >= self.cutoff]
        return f"{len(above)}/{len(self._rows)} rows at or above {self.cutoff}"
```

`_data_changed()` fires a view event that a shell subscribes to (redraw). Add `to_image(self,
path=None, *, width=1024, height=768)` when you want a PNG rendering; the base raises
`NotImplementedError` for whichever you don't provide.

## Run it headless

The view imports no GTK, so it renders anywhere — feed it a [`Table`](/docs/tools-tables) and read
`to_text()`, or attach it to an [`Engine`](/docs/ui-overview):

```python
from lsdtools import Table
from grade_band.view import GradeBandView

w = GradeBandView(cutoff=0.6)
w.feed(Table({"name": ["ann", "bob", "cy"], "grade": [0.8, 0.4, 0.65]}))

print(w.to_text())
# → 2/3 rows at or above 0.6

w.show("text")
```

## Register the GTK shell

The drawing code lives in your view's **`gtk` module** — the one sanctioned place a package writes
GTK. It calls `set_view_shell(kind, factory)`, where the factory turns a view instance into a
control. Import GTK *inside* the factory so the module stays safe to load headless:

```python
# grade_band/gtk.py — loaded by the desktop when a display is present
from lsdtools.views import set_view_shell

def build_grade_band(view):
    import gi                                  # GTK stays inside the factory
    gi.require_version("Gtk", "4.0")
    from gi.repository import Gtk
    area = Gtk.DrawingArea()
    # draw view._rows against view.cutoff; subscribe to redraw:
    view.on_view_event("data-changed", area.queue_draw)
    return area

def register():
    set_view_shell("grade_band", build_grade_band)
```

The desktop calls `register()` when it loads your view package. Until a shell exists for a kind,
headless consumers still work through `to_text()` / `to_image()`; only building the live control
needs the shell. That is the design: check and ship the data half first, add pixels later.

## Related

**Learn** — [Data views](/docs/ui-widgets) · [Everything you show is a View](/docs/ui-overview) · [Params & actions](/docs/ui-panels)

**Start** — [How an LSD package fits together](/docs/start-architecture) · [Naming: which is which](/docs/tools-glossary)

**Examples** — [A dockable panel](/docs/ex-panel) · [A complete package](/docs/ex-full-package)
</content>

## FAQ
### Why is the GTK shell registered separately from the view?

So the view stays headless. The View subclass — its data, its to_text()/to_image() — imports no GTK, so it runs in the CLI, in CI, and from an agent. The drawing shell is a separate concern registered with set_view_shell in the view's gtk module, the one sanctioned place a package writes GTK; the desktop loads it only when a display is present.

### What happens if no shell is registered for my kind?

Headless consumers still work — they call your to_text() / to_image(). Only building the live GTK control needs the shell; without it that path raises a clear error telling you to install the view package (whose gtk module registers the shell). Data and text first, pixels later.
