AppShell#

A single-tier application scaffold: a stack of toolbars across the top, one navigation sidebar on the left, and a content area that swaps as you navigate. A full-width status band can run along the bottom. For a multi-section app with a VS Code-style workspace rail, use Workbench instead.

AppShell demo — light theme AppShell demo — dark theme

Usage#

An AppShell wires up the application chrome — toolbars, a sidebar, a content area that swaps as you navigate, and a status band — so you fill each piece (shell.add_toolbar(), the sidebar provider, shell.statusbar) rather than assembling the layout yourself.

Higher-emphasis selection#

By default the selected item gets a subtle accent wash (variant="ghost"). Pass variant="solid" to page_nav() for a filled-accent item with on-accent (white) text — the higher-emphasis look. (It needs nav_accent; with nav_accent=None it falls back to a neutral wash.)

with shell.page_nav(variant="solid") as nav:
    ...

"ghost" (default)

"solid"

Ghost selection wash — light theme Ghost selection wash — dark theme Solid selection fill — light theme Solid selection fill — dark theme

Scrollable pages#

Pass scrollable=True to wrap a page’s content in a vertical scroll area.

with nav.add_page("log", text="Log", icon="list", scrollable=True, padding=16):
    for i in range(100):
        bs.Label(f"Log entry {i}")

Data-bound sidebar (master–detail)#

Instead of authored pages, fill the sidebar straight from a data source with list_nav() (a flat list) or tree_nav() (a hierarchy). Decorate a builder with @shell.detail to render the body for the selected record — it receives the record as a dict. The first item is selected automatically.

from bootstack.data import MemoryDataSource

devices = MemoryDataSource().load([
    {"id": 1, "title": "Sensor A", "text": "online"},
    {"id": 2, "title": "Sensor B", "text": "offline"},
])

with bs.AppShell(title="Devices") as shell:
    shell.list_nav(devices)

    @shell.detail
    def show(record):
        with bs.Column(grow=True, horizontal="stretch", gap=12, padding=24):
            bs.Label(record["title"], font="heading-lg")
            bs.Label(record["text"])
shell.run()

Custom sidebar#

custom_nav() claims the sidebar as a blank container you fill yourself — the escape hatch when none of the providers fit. Drive the content region with shell.content.

with shell.custom_nav():
    bs.Label("Filters", font="heading-md")
    with bs.Accordion():
        ...

Toolbars#

The shell’s top region is a stack of Toolbar bands, added with shell.add_toolbar() — the same chrome as on App. A toolbar holds buttons, labels, widgets, and menus (toolbar.add_menu(...)); each add_toolbar() call stacks a new full-width band above the sidebar / content body. On macOS a toolbar’s menus bridge to the native global menu bar.

The shell always draws a quiet hairline between the chrome stack and the workspace below it, so a single toolbar needs no divider of its own. Reserve add_toolbar(divider=True) for separating one stacked band from the next.

with bs.AppShell(title="My App") as shell:
    with shell.add_toolbar() as bar:
        with bar.add_menu("File") as file:
            file.add_action("Quit", shortcut="Mod+Q", on_click=shell.close)
        bar.add_spacer()
        bar.add_theme_toggle()
        bar.add_button(label="Save", icon="save", on_click=save)

Status bar#

shell.statusbar is a full-width band along the bottom, intended for passive status — counts, sync state, a ready message. Interactive controls (buttons, a search box) belong on a toolbar by convention; the status bar reads best as a quiet display strip. It renders only once a segment is added, or when the shell is built with show_statusbar=True. add_spacer() (or side="right") pushes following segments to the right cluster.

shell.statusbar.add_text("Ready")
shell.statusbar.add_spacer()
shell.statusbar.add_text("v1.0", side="right")

Pass textsignal= to make a segment reactive — bind it to a Signal and it updates live as the value changes:

selected = bs.Signal("0 selected")
shell.statusbar.add_text(textsignal=selected)
...
selected.set("3 selected")   # the status updates automatically

The StatusBar handle also supports add_widget() (a custom passive widget), add_spacer(), and clear(); or use it as a container — with shell.statusbar: parents widgets into the left cluster.

Styling#

Each region’s background is a surface token you can override; the defaults give the shell its layered look (the status band sits on the elevated chrome surface, the sidebar a step below). The dividers and the nav-item selection wash blend against these automatically.

Kwarg

Default

Region

sidebar_surface

'raised'

The navigation sidebar.

statusbar_surface

'chrome'

The bottom status band.

The selected nav item is neutral by default. Set nav_accent to tint the selection with an accent (None keeps it neutral); the per-sidebar page_nav(variant=...) then chooses how the accent reads — a subtle 'ghost' wash (default) or a filled 'solid' item.

bs.AppShell(nav_accent="primary")   # accent the selection; ghost wash by default

Events#

All shorthands take a handler (returns a cancellable Subscription) or no argument (returns a composable Stream).

Shorthand

Handler receives

on_page_change

PageChangeEvent

on_sidebar_toggle

PaneToggleEvent

on_sidebar_mode_change

DisplayModeEvent

shell.on_page_change(lambda e: print("now on:", e.page))

Theme, locale, and configuration#

Like App, an AppShell is configured through flat constructor keyword arguments, and the same options are read and changed at runtime through shell.* properties. Assigning shell.theme or shell.locale takes effect live.

shell = bs.AppShell(
    title="My App",
    theme="bootstrap-dark",
    light_theme="nord-light",
    dark_theme="nord-dark",
    locale="de_DE",
)

shell.theme = "bootstrap-light"     # switch the theme now

React to changes and persist them across launches with a Storefrom_store() restores configuration and tolerates version skew, and the change events write each value back:

from bootstack.store import Store

store = Store("settings")
shell = bs.AppShell.from_store(store, title="My App")
shell.on_theme_change(lambda theme: store.update(theme=theme))

See App Configuration for the full configuration reference — every option, the locale-derived read-only properties, and window-state persistence.

Window options#

bs.AppShell(
    title="My App",
    icon="assets/app.ico",        # icon file, an Image, or an AppIcon
    size=(1024, 768),
    min_size=(640, 480),
    resizable=(True, True),
)

# Custom chrome (no OS title bar; draws a themed border instead)
bs.AppShell(undecorated=True)

See also#

Workbench — the two-tier shell: a workspace rail plus per-workspace sidebars.

PageStack — page navigation without a built-in sidebar.

Tabs — tab-strip navigation.

Toolbar — the standalone toolbar widget.

API#

The complete reference for AppShell lives on the Application API page. At a glance:

AppShell

Single-tier application window: one navigation sidebar plus content.

Full Example#

 1
 2
 3def metric_card(label, value):
 4    """A reusable builder — paints one metric card into the active container."""
 5    with bs.Card(padding=16, gap=4):
 6        bs.Label(label, font="caption")
 7        bs.Label(value, font="heading-md")
 8
 9
10with bs.AppShell(title="My App", size=(800, 540)) as shell:
11
12    # ── Toolbar ───────────────────────────────────────────────────────────────
13    with shell.add_toolbar() as bar:
14        bar.add_spacer()
15        bar.add_theme_toggle()
16
17    # ── Pages ─────────────────────────────────────────────────────────────────
18    # Each page IS a column — set padding/gap on add_page; no inner wrapper.
19    with shell.page_nav() as nav:
20        with nav.add_page("dashboard", text="Dashboard", icon="speedometer2", padding=24, gap=12):
21            bs.Label("Dashboard", font="heading-lg")
22            bs.Label("Welcome back. Here is your overview.")
23            with bs.Grid(columns=3, gap=12, horizontal="stretch"):
24                metric_card("Revenue", "$12,400")
25                metric_card("Users", "1,280")
26                metric_card("Orders", "340")
27
28        with nav.add_page("inbox", text="Inbox", icon="inbox", padding=24, gap=8):
29            bs.Label("Inbox", font="heading-lg")
30            bs.Label("No new messages.")
31
32        nav.add_divider()
33        nav.add_header("Documents")
34
35        with nav.add_page("files", text="Files", icon="folder", padding=24, gap=8):
36            bs.Label("Files", font="heading-lg")
37            bs.Label("Your documents will appear here.")
38
39        with nav.add_page("images", text="Images", icon="image", padding=24, gap=8):
40            bs.Label("Images", font="heading-lg")
41            bs.Label("Your images will appear here.")
42
43        with nav.add_page("settings", text="Settings", icon="gear", pin_to_footer=True, padding=24, gap=8):
44            bs.Label("Settings", font="heading-lg")
45            bs.Label("Adjust your preferences.")
46
47    shell.navigate("dashboard")
48
49shell.run()