Getting Started with PyLay
Understand the layout model, state system, and spring animation — then build a complete settings screen from scratch.
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. anchordetermines which corner/edge the element is positioned from:"top_left","center","bottom_right", etc.xandyare 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).
| Mode | When to use | How |
|---|---|---|
| Managed | Building a standalone PyLay app | App(managed_display=True), call app.run() |
| Embedded | Adding 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()
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
| Pitfall | Cause | Fix |
|---|---|---|
| Widget shows in wrong position | anchor/x/y not matching intent | Check: anchor is the reference corner, x/y are offsets from it |
| State change doesn't update label | Passed a string literal instead of a State object | Pass State("initial"), not "initial" |
| VStack children overflow but no scroll | height spec is 0 or not set | Give the VStack an explicit height (1.0 for full parent) |
| Button click fires in embedded mode but nothing happens | process_events() not called each frame | Call app.process_events(pg.event.get()) every frame |
| FilePicker does nothing | Not added to the widget tree | Call root.add_child(picker) even though it is invisible |