Getting Started with PyLay

Understand the layout model, state system, and spring animation — then build a complete settings screen from scratch.

PyLay — a running app with controls tab
A complete PyLay app — bottom navigation, sidebar drawer, spring physics, dark mode.

Installation

Copy the PyLay/pylay/ directory into your project. Then install the dependency:

pip install pygame

Optionally install Pillow for emoji rendering:

pip install Pillow

Import everything:

from pylay import *
# or selectively:
from pylay import App, VStack, Button, Label, Switch, State

Core concepts

The widget tree

PyLay builds UIs as a tree of UIElement objects. Every widget — buttons, labels, sliders — is a UIElement with a parent and zero or more children. The tree is rooted at app.root.

app.root                   # FloatLayout (root)
├── VStack                 # Layout container
│   ├── Label("Title")     # Widget
│   ├── Slider(...)        # Widget
│   └── Button("Save")     # Widget
└── BottomNavigationBar    # Navigation widget

The layout model

Each UIElement has five layout parameters: anchor, x, y, width, and height. These resolve against the parent's bounds.

  • Integers are pixel values: width=200 = 200 pixels wide.
  • Floats (0.0–1.0) are fractions of the parent: width=1.0 = fill the parent, width=0.5 = half the parent.
  • anchor determines which corner/edge the element is positioned from: "top_left", "center", "bottom_right", etc.
  • x and y are offsets from the anchor point.
# Full-screen overlay
FloatLayout(anchor="top_left", x=0, y=0, width=1.0, height=1.0)

# Fixed 200×50 button in the bottom-right corner, 20px from the edge
Button("Save", anchor="bottom_right", x=20, y=20, width=200, height=50)

# Label that is 80% of the parent width, centered horizontally
Label("Title", anchor="top_center", x=0, y=32, width=0.8, height=40)

The State model

PyLay uses a reactive state system. Instead of manually updating widgets when data changes, you create a State object and pass it where a widget expects a value. The widget subscribes to the state and redraws when the value changes — no callbacks needed for display.

from pylay import State, Label, Button

score = State(0)

# Label updates automatically when score.value changes
label = Label(score, font_size=32, ...)

# Change the value from anywhere
score.value += 1       # label re-renders on next frame
score.set(100)         # same thing

Managed vs embedded mode

PyLay can own the pygame window (managed mode) or be embedded in an existing loop (embedded mode).

ModeWhen to useHow
ManagedBuilding a standalone PyLay appApp(managed_display=True), call app.run()
EmbeddedAdding PyLay UI to an existing pygame loop (e.g. Pianove)App(managed_display=False), call app.attach(surface), then app.process_events(events) and app.frame(dt) each tick

Recommended learning path

PyLay has a lot of surface area. Work through these steps in order — each one introduces the next concept naturally.

Step 1 — Hello World: App, Label, Button

The simplest PyLay app. Learn how App, VStack, and add_child() work.

from pylay import App, VStack, Label, Button

app = App("Hello", 800, 600, dark_mode=True)

root = VStack(spacing=16, padding=32,
              anchor="top_left", x=0, y=0, width=1.0, height=1.0)

root.add_child(Label("Hello, PyLay!", font_size=28, bold=True,
                     anchor="top_left", x=0, y=0, width=1.0, height=40))
root.add_child(Button("Click me", on_click=lambda: print("clicked"),
                      anchor="top_left", x=0, y=0, width=160, height=44))

app.set_root(root)
app.run()

Step 2 — State: reactive labels and counters

Add reactive state so the UI updates without manual refresh calls.

from pylay import App, VStack, Label, Button, State

app = App("Counter", 600, 400)
count = State(0)

root = VStack(spacing=12, padding=24,
              anchor="top_left", x=0, y=0, width=1.0, height=1.0)
root.add_child(Label(count, font_size=48, bold=True,
                     anchor="top_left", x=0, y=0, width=1.0, height=72))
root.add_child(Button("+ 1", on_click=lambda: count.set(count.value + 1),
                      anchor="top_left", x=0, y=0, width=120, height=44))
root.add_child(Button("Reset", on_click=lambda: count.set(0),
                      anchor="top_left", x=0, y=0, width=120, height=44))

app.set_root(root)
app.run()

Step 3 — Controls: Switch, Slider, TextField

Add interactive input widgets backed by State.

from pylay import App, VStack, Label, Switch, Slider, State, iOSTheme

app = App("Settings", 900, 700)

dark = State(True)
volume = State(0.75)

def on_dark_change(v):
    dark.set(v)
    app.apply_theme(v)

root = VStack(spacing=20, padding=32,
              anchor="top_left", x=0, y=0, width=1.0, height=1.0)

root.add_child(Label("Dark Mode", font_size=16,
                     anchor="top_left", x=0, y=0, width=1.0, height=28))
root.add_child(Switch(value=dark, on_change=on_dark_change,
                      anchor="top_left", x=0, y=0))

root.add_child(Label("Volume", font_size=16,
                     anchor="top_left", x=0, y=0, width=1.0, height=28))
root.add_child(Slider(value=volume, min_val=0.0, max_val=1.0,
                      anchor="top_left", x=0, y=0, width=1.0, height=30))

app.set_root(root)
app.run()

Step 4 — PageRouter: multiple screens

Use PageRouter for a multi-screen application. Page builder functions return a fresh widget tree each time they are called.

from pylay import App, PageRouter, VStack, Label, Button

app = App("Multi-Screen", 1024, 768)

def home():
    root = VStack(padding=32, anchor="top_left", x=0, y=0, width=1.0, height=1.0)
    root.add_child(Label("Home", font_size=28, bold=True,
                         anchor="top_left", x=0, y=0, width=1.0, height=48))
    root.add_child(Button("Go to Settings",
                          on_click=lambda: router.navigate("settings"),
                          anchor="top_left", x=0, y=0, width=200, height=44))
    return root

def settings():
    root = VStack(padding=32, anchor="top_left", x=0, y=0, width=1.0, height=1.0)
    root.add_child(Label("Settings", font_size=28, bold=True,
                         anchor="top_left", x=0, y=0, width=1.0, height=48))
    root.add_child(Button("← Back",
                          on_click=lambda: router.navigate("home"),
                          anchor="top_left", x=0, y=0, width=120, height=44))
    return root

router = PageRouter(
    pages={"home": home, "settings": settings},
    initial_page="home",
    anchor="top_left", x=0, y=0, width=1.0, height=1.0,
)
app.set_root(router)
router.navigate("home")
app.run()
Screenshot: Multi-screen app with PageRouter slide transition Add docs/img/pylay-router-demo.png

Step 5 — Theme switching

Add a dark/light toggle. All widgets automatically re-render when app.apply_theme() is called.

from pylay import App, VStack, Switch, Label, iOSTheme, State

app = App("Theme", 800, 600, dark_mode=True)
dark = State(True)

root = VStack(padding=24, anchor="top_left", x=0, y=0, width=1.0, height=1.0)
root.add_child(Label("Dark mode", font_size=15,
                     anchor="top_left", x=0, y=0, width=1.0, height=26))
root.add_child(Switch(
    value=dark,
    on_change=lambda v: (dark.set(v), app.apply_theme(v)),
    anchor="top_left", x=0, y=0,
))

app.set_root(root)
app.run()

Common pitfalls

PitfallCauseFix
Widget shows in wrong positionanchor/x/y not matching intentCheck: anchor is the reference corner, x/y are offsets from it
State change doesn't update labelPassed a string literal instead of a State objectPass State("initial"), not "initial"
VStack children overflow but no scrollheight spec is 0 or not setGive the VStack an explicit height (1.0 for full parent)
Button click fires in embedded mode but nothing happensprocess_events() not called each frameCall app.process_events(pg.event.get()) every frame
FilePicker does nothingNot added to the widget treeCall root.add_child(picker) even though it is invisible