# Example: a dockable panel
URL: https://lsd.tools/docs/ex-panel
Updated: 2026-07-18

> A View with no kind is param() fields plus @action buttons (informally a panel when docked in the sidebar). This tool.py declares a GreetPanel with one text field and a Greet action, and @tool.view docks it in the sidebar. self.ctx is injected on mount, so the action calls self.ctx.ui.notify(...). No GTK, so it imports and tests headless.

A [`View`](/docs/ui-panels) with no kind is `param()` fields plus `@action` buttons (informally a *panel* when docked in the sidebar) — no GTK, so
it imports and tests headless. Save this as `tool.py`, point LSD at it
(["Add local package…"](/docs/pkg-develop)), and a **Greeter** panel docks in the sidebar.

## The code

```python
# tool.py
from lsdtools import Tool, Context, View, action, param

tool = Tool("greeter", label="Greeter")


class GreetPanel(View):
    """A View with no kind: a param() field plus an @action button."""

    name = param(str, default="world", label="Name")
    times = param(int, default=1, min=1, max=10, label="Repeat")

    @action(label="Greet", style="suggested")
    def greet(self):
        # self.ctx is injected when the panel mounts, so the host is one hop away.
        for _ in range(self.times):
            self.ctx.ui.notify(f"Hello, {self.name}!")


@tool.view("greeter.hello", title="Greeter", location="sidebar")
def panel(ctx: Context):
    return GreetPanel()
```

## What each line does

- **`class GreetPanel(View)`** — a panel *is* a `View`. Its fields are `param()` descriptors, so
 they get typing, coercion, and the min/max validation (`times` is clamped to 1–10) for free.
- **`@action(label="Greet", style="suggested")`** — a button. The handler reads the fields as typed
 attributes (`self.name`, `self.times`) — there is no values dict. `style` is `None`,
 `"suggested"`, or `"destructive"`.
- **`self.ctx.ui.notify(...)`** — `self.ctx` is the host [`Context`](/docs/api-context), injected
 automatically when the panel mounts, so an action can reach the host UI with no wiring.
- **`@tool.view("greeter.hello", title=…, location="sidebar")`** — docks the View in the sidebar.
 The decorated function is `(ctx) -> View`; return a `View` instance (or its class).

## Run it

You don't need the desktop to exercise the logic. `invoke` is the one dispatch entry for every
front-end, so a test drives the action directly — stub `ctx.ui` to capture the notifications:

```python
from lsdtools import Context

class FakeUI:
    def notify(self, message, level="info"):
        print(f"[{level}] {message}")

p = GreetPanel(name="Ada", times=2)
p.ctx = Context.testing(ui=FakeUI())
p.invoke("greet")
# → [info] Hello, Ada!
#   [info] Hello, Ada!
```

Or present the whole panel as text without a display — `show("text")` renders the fields and
actions:

```python
GreetPanel(name="Ada").show("text")
# ── GreetPanel ──
#   Name: 'Ada'
#   Repeat: 1
#   actions: Greet
```

In the desktop, loading the package puts a **Greeter** panel in the sidebar: type a name, set the
repeat count, and click **Greet** to fire the notifications.

## Try changing it

- Add a `param(bool, default=False, label="Shout")` and upper-case the greeting when it is set.
- Turn `name` into a dropdown with `param(str, default="world", choices=["world", "team"])`.
- Show a result instead of taking input — build a [data view](/docs/ui-widgets) (a `View` with a
 `kind`) and return it from `@tool.view`.

## Related

**Learn** — [Params & actions](/docs/ui-panels) · [Everything you show is a View](/docs/ui-overview) · [Commands & actions](/docs/tools-commands-actions)

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

**Examples** — [Your first tool](/docs/ex-hello-tool) · [A complete package](/docs/ex-full-package)
