Core API
The application object, entry points, and window lifecycle. Import from neony.application.
NeonApplication
The application object — owns the windows, the theme, and shared state. Construct with a Config, build a Page, then run().
from neony.application import Config, NeonApplication, Page, Theme, WebViewConfig, WindowConfig
app = NeonApplication(
Config(
window=WindowConfig(title="Demo", width=480, height=360),
webview=WebViewConfig(devtools=True),
)
)
app.state.count = 0 # shared mutable state
app.theme = Theme.get("nightglow-light") # pick the initial preset before run()
def main() -> None:
app.run(page)Typed state: state defaults to a bare SimpleNamespace. Pass any object — a dataclass, pydantic model, or plain class — via the state= argument to get typed attribute access and IDE completion:
from dataclasses import dataclass
@dataclass
class AppState:
count: int = 0
user_name: str = ""
app = NeonApplication(Config(...), state=AppState())
app.state.count += 1 # typed as int
app.state.user_name = "Ada"All windows share the same state object, so this is the imperative counterpart to SharedSignal for cross-window data.
Attributes: config, state, theme, ready_handler, close_handler
Window methods (all async):
set_title(title), set_size(w, h), minimize(), toggle_maximize(), is_maximized(), set_fullscreen(f), start_dragging(), close(), apply_blur(color?), apply_acrylic(color?), apply_mica(), clear_effect(effect), eval_js(script), set_icon(icon).
transparent=True already applies the platform material automatically (Wayland blur on Linux where supported, Acrylic on Windows, Blur on macOS). The apply_* methods are manual overrides and platform-limited: apply_blur is macOS/Windows; acrylic / mica are Windows 11.
App methods: exit(code=0) — graceful app shutdown (sync). With close_to_tray=True window closes hide the app instead of quitting, so exit() is the way out — e.g. a tray "Quit" menu item.
Theme / rendering:
set_theme(theme), sync_theme(), set_background(url), render()
File dialogs (all async — system-native): open_file(...) -> str | None, open_files(...) -> list[str], save_file(...) -> str | None, select_folder(...) -> str | None.
Cancelling returns None (or [] for the multi-select); a dialog that can't be shown also returns None — never an exception.
path = await app.open_file(
title="Open image", default_dir="~/Pictures", filetypes=[("PNG images", "*.png"), ("All files", "*.*")]
)
if path is None:
return # cancelled
paths = await app.open_files(...) # [] on cancel
dest = await app.save_file(default_name="out.txt") # str | None
folder = await app.select_folder() # str | NoneThe dialogs are the platform's own — zenity on Linux (most desktops ship it), osascript on macOS, PowerShell on Windows, with a tkinter fallback. They open asynchronously, so the app's event loop keeps running while they're up, and nothing is drawn by Neony itself: the look, navigation and filters are exactly what the OS provides.
filetypes maps onto the native filter UI ([("PNG images", "*.png"), ("All files", "*.*")]); default_dir / default_name preselect the starting location.
launch()
One-liner entry point — builds a Config from keyword arguments.
from neony.application import Page, launch
launch(page, title="Demo", width=480, height=360, devtools=True)Accepts all WindowConfig / WebViewConfig fields plus mount_selector, auto_render, and state (a custom state object — see NeonApplication) — and protocols (see Custom protocols).
Custom protocols
Serve Python-generated content to the page through neony://<key>/… URLs. Handlers are plain functions or methods declared with the @protocol decorator; sync handlers run on the app thread pool, async handlers on the app event loop.
from neony.application import Page, launch, local_files, local_url, protocol
from neony.application.protocols import Request, Response
@protocol("qr")
def qr_codes(request: Request) -> Response:
key = request.path.strip("/") # neony://qr/<key> → "<key>"
return Response(body=make_qr_png(key), headers={"Content-Type": "image/png"})
class Avatars:
def __init__(self, db) -> None:
self.db = db
@protocol("avatar")
async def handle(self, request: Request) -> Response: # state via self
data = await self.db.fetch_avatar(request.path.strip("/"))
return Response(status=404) if data is None else Response(body=data, headers={"Content-Type": "image/jpeg"})
launch(page, title="Demo", protocols=[qr_codes, Avatars(db), local_files])Rules
- Declare everything before
launch()/run()— webview schemes are registered once at window creation and cannot be added afterwards. - Keys must match
^[a-z][a-z0-9-]*$(the key is the URL authority, which browsers lowercase). Duplicate keys raiseValueError. - The handler receives a frozen pydantic
Request:key,path(percent-decoded payload),method,url,query,headers, plus a case-insensitiverequest.header(name)helper. Return a frozenResponse(status, headers, body)(Response.text()/Response.json()for convenience). - URLs are built by
local_url(path)→neony://local/…andprotocol_url(key, value)→neony://<key>/…. - Requests to unregistered keys answer
404; handler exceptions log and answer500.
Built-in local_files serves any absolute filesystem path over neony://local/… — the custom-scheme twin of file_url(). WebViews block file:// subresources when the page is loaded from an HTML string, so this is the way to show local media:
from neony.application import Image, launch, local_files, local_url
launch(page, protocols=[local_files])
Image(local_url("~/Music/song.mp3"))It supports HTTP Range requests (206 Partial Content, 416 for an unsatisfiable range), answers HEAD, guesses MIME types, and sends ETag / Last-Modified / Accept-Ranges. There is no path allow-list: a Neony page is trusted application content. See demo_protocols.py.
Media playback — the managed Video / Audio components load neony://… sources automatically, so local media plays and seeking works where file:// subresources are blocked. Raw <audio> / <video> DOM elements are not handled this way — use the components (see Video / Audio). The whole file is held in memory while playing — ideal for voice clips and sound effects; mind the size for long videos.
Config, WindowConfig, WebViewConfig
Pydantic config models. WindowConfig covers geometry and appearance (title, width, height, decorations, transparent, always_on_top, resizable, icon, …). WebViewConfig covers runtime options (devtools, incognito, user_agent, javascript, …).
WindowConfig.icon — file path (PNG, ICO, …) or raw RGBA data (bytes, width, height), shown in the OS window chrome of decorated windows. Frameless windows have no OS chrome — see the TitleBar icon parameter for inline icons, and NeonApplication.set_icon() to swap at runtime.
WebViewConfig.default_context_menus — off by default: the app draws its own menus (the Menu component, contextmenu events) and the webview's native right-click menu would cover them. Set True for the platform default menu.
Page
Top-level flex-column container. Two layers: a full-viewport backdrop and a width-constrained, centered content column.
Page(gap="16px", padding="24px", max_width="720px")
Page(fill=True, radius="12px") # chrome layoutsOptions: direction, gap, padding (default "24px"), align, justify, width, max_width (default "600px"), glass, fill, radius
fill=True stretches to the full window height. radius rounds the window frame (for transparent frameless windows).
Methods: add(child) (chainable), on_close(fn) (chainable — see Lifecycle), on_focus(fn) / on_blur(fn) (chainable), on_keydown(fn) / on_keyup(fn) (chainable), on_shortcut(combo, fn) (chainable), build() → DOMElement
Lifecycle
Startup and teardown are declared as plain attributes — the framework owns the wiring to the native window events.
async def on_ready() -> None:
print("windows are up")
async def on_shutdown() -> None:
save_state(app.state) # runs after all windows close
app.ready_handler = on_ready
app.close_handler = on_shutdownclose_handler runs exactly once, after the last window closes and before the event loop stops — the last chance for async cleanup.
Per-window close — Page.on_close(fn) (sync or async, chainable, multiple handlers stack). Fires when that page's window is closing, before it actually closes; exceptions are logged and never block the close. For a confirm-before-close dialog, take over the titlebar close button instead — see TitleBar.override_close.
page = Page()
page.on_close(lambda: print("window closing"))Focus tracking — Page.on_focus(fn) / Page.on_blur(fn) (sync or async, chainable, multiple handlers stack) fire when the page's window gains / loses keyboard focus — useful for pausing timers, updating a status bar, or knowing which window is active in a multi-window app.
page = Page()
page.on_focus(lambda: print("active"))
page.on_blur(lambda: print("inactive"))Keyboard & shortcuts — Page.on_keydown(fn) / Page.on_keyup(fn) receive every key event, including keys typed while a child input has focus. Page.on_shortcut(combo, fn) registers an in-app shortcut; the combo is a string like "Ctrl+S" or a per-platform dict ({"darwin": "Meta+S", "default": "Ctrl+S"}). Shortcuts fire regardless of which element has focus.
page = Page()
page.on_keydown(lambda e: print(e.key, e.code, e.ctrl_key))
page.on_shortcut("Ctrl+S", save)
page.on_shortcut({"darwin": "Meta+K", "default": "Ctrl+K"}, open_search)Multi-window
run() accepts several pages — each opens its own window. All windows share one event loop and the app's state namespace; an event handler only re-renders the window it came from.
app = NeonApplication(Config(...))
app.run(page_one, page_two)
async def on_ready() -> None:
await app.set_title("Counter", window_index=0)
await app.set_title("Display", window_index=1)
app.ready_handler = on_readyEvery window-control method takes window_index (default 0).
launch([page_one, page_two], ...) accepts a list too.
Navigation policies
A link or redirect inside the page would otherwise navigate the webview away from your UI. Neony installs safe defaults on every window — navigation blocked, new-window requests denied, downloads cancelled — so nothing can escape without your say-so. Override them per-page.
Decision policies — a single handler, the last one registered wins (a decision can't be merged):
# Allow only your own site; everything else is blocked.
page.on_navigation(lambda url: url.startswith("https://myapp.example"))
# target="_blank" links and window.open(): "allow" or "deny".
page.on_new_window(lambda url: "deny")
# Return True to allow, False to cancel, or a path to redirect the
# download to a custom location.
page.on_download_started(lambda url, path: "/downloads/")Notifications — multiple handlers stack, all run:
# url, final path (or None if cancelled), success flag.
page.on_download_completed(lambda url, path, ok: print(f"downloaded {path}"))Tray & TrayItem — system tray (native menu)
A tray icon with a native context menu. Assign app.tray before run(); the icon materializes once the app is up.
from neony.application import Tray, TrayItem
app.tray = Tray(
icon="tray.png", # file path or raw RGBA (bytes, width, height)
tooltip="My App",
items=[
TrayItem("Show Window", id="show", on_activate=show_handler),
TrayItem.separator(),
TrayItem("Quit", id="quit", accelerator="CmdOrCtrl+Q", on_activate=quit_handler),
],
menu_on_left_click=False, # free the left button for on_left_click
on_left_click=toggle_handler, # sync or async
close_to_tray=True, # close hides the app instead of quitting
)TrayItem—text, optionalid(carried by activation callbacks),accelerator(shortcut syntax; Windows may not fire it from the keyboard),on_activate(sync or async, run asynchronously),checked=Truefor a check item;TrayItem.separator()for a divider.close_to_tray=True— every window's close request is prevented and the app hides (restore from the menu / tray click; on macOS a Dock click viaReopenEvent).Page.on_closehandlers still run.on_left_click— fires on a released left click whenmenu_on_left_click=False(typical use: toggle the window).Platform notes: Linux needs libayatana-appindicator; the tooltip is unsupported there and the menu cannot be replaced after creation.
See
demo_tray.py.