Appsudo Framework logo Appsudo Docs

Appsudo Framework Documentation

Introduction

Appsudo framework is a lightweight, cross-platform application development framework that allows developers to create beautiful, responsive mini apps with minimal effort. Using Python for logic and XML for layouts, Appsudo provides a simple yet powerful way to build applications.

Write Once, Run Anywhere

Build applications that run consistently across multiple platforms.

Intuitive Styling

Style components using an XML-like syntax that's easy to learn and use.

Powerful Components

Access a rich library of UI components to build sophisticated interfaces.

Development Environment Setup

Follow these steps to set up your development environment for Appsudo mini app development.

Step 1: Install Visual Studio Code

Download and install Visual Studio Code, the recommended IDE for Appsudo development.

Download VS Code

Step 2: Setup Android Development Environment

Windows Setup

  1. Download and install Android Studio.
  2. Ensure Android SDK and Android Emulator are included during installation.
  3. Set environment variables:
    ANDROID_SDK_ROOT=C:\Users\YourUsername\AppData\Local\Android\Sdk
    PATH=%PATH%;%ANDROID_SDK_ROOT%\platform-tools;%ANDROID_SDK_ROOT%\emulator
    Right-click 'This PC' > Properties > Advanced system settings > Environment Variables.
  4. Verify:
    adb --version
    emulator -help

Step 3: Install Appsudo Extension

  1. Open VS Code.
  2. Go to Extensions (Ctrl+Shift+X or Cmd+Shift+X).
  3. Search for "Appsudo App Runner" and click Install.
View Extension

Step 4: Running Your First App

  1. Open your Appsudo project in VS Code.
  2. Press Ctrl+Shift+P (or Cmd+Shift+P) for Command Palette.
  3. Type "Appsudo" and select "Appsudo App Runner".
  4. Select your target device.

Appsudo App Structure

Appsudo mini apps follow a specific directory structure. Understanding this is key for development.

PROJECT EXPLORER

myapp/
myapp.py
data/
config.json
view/
main_view.asxml
style/
styles.ascss
res/
images/
icons/
metadata.json
secrets.json

Key Components

  • myapp.py: Main application entry point.
  • data/: JSON files for data loading.
  • view/: ASXML files for UI layouts.
  • style/: ASCSS files for styling.
  • res/: Resource files (images, etc.).
  • metadata.json: App metadata.
  • secrets.json: App secret.

General Guidance & Naming Conventions

A few conventions are enforced by the Appsudo runtime when it loads and tracks your app. Follow these when creating a new app to avoid load-time errors and runtime crashes.

Folder name

Each Appsudo app is loaded as a Python module, so the app's folder name must be all lowercase and contain no spaces, hyphens, or other special characters. For an app named SportGame, the folder must be sportgame/.

App class name

Your main class must subclass App and follow the <Name>App convention. For SportGame:

class SportGameApp(App):
    ...

Constructor identifier

Inside __init__, the first argument to super().__init__(...) is the app's runtime identifier. It is used internally for tracking your app, so it must be passed as a single string with no spaces or extra wording. For SportGame:

def __init__(self, application_path):
    super().__init__("SportGame", application_path)

One render per app

self.render(...) may only be called once per app, and that single call must happen within the execution context of def run(self) — either directly inside run() or inside any helper method that run() invokes. Any additional call to self.render(...) will raise an exception and crash your app.

def run(self):
    self._show_main_view()

def _show_main_view(self):
    # Allowed: render() lives in a helper, but it is only reached via run().
    self.render(self.ui_view)

Data binding (recommended)

For apps that use the Appsudo UI layer (i.e. not pygame-driven apps), define a self.data object in __init__ and let the view inflater bind against it, as shown in the App Structure example. This keeps your view declarative and your state in one place.

def __init__(self, application_path):
    super().__init__("SportGame", application_path)
    self.data = SportGameData(application_path)

Components

Appsudo provides a rich library of UI components. Browse by category below or jump directly to one from the sidebar.

Layout

HorizontalView

layout

A container that arranges its children horizontally in a row. Supports proportional sizing via weight-sum / weight and can be made interactive on its own with clickable.

XML Usage

<HorizontalView width="fill" height="wrap" padding="10dp" background-color="#f5f5f5">
  <Button text="Button 1" margin-right="5dp" />
  <Button text="Button 2" margin-right="5dp" />
  <Button text="Button 3" />
</HorizontalView>

Proportional Layout (weight / weight-sum)

weight-sum on the parent defines the total proportional space. Each child sets weight="N" to claim N/total of the parent's main-axis size, and uses width="0dp" so the layout engine sizes it from the weight instead of a fixed width. Weights are relative, not percent-locked, but using a total of 100 makes them read like percentages.

<HorizontalView width="fill" height="wrap" weight-sum="100">
  <RelativeView width="0dp" weight="15" height="wrap">
    <Image src="{data.app_icon}" width="32dp" height="32dp" />
  </RelativeView>
  <Label text="{data.product_name}" weight="52" width="0dp" height="wrap" />
  <Label text="{data.product_price}" weight="33" width="0dp" height="wrap" alignment="right" />
</HorizontalView>

Clickable Container

Any view (including HorizontalView) can fire on-click itself when clickable="true" is set. The whole row becomes the hit area, and children that don't have their own on-click simply pass the click through to the container.

<HorizontalView width="fill" height="wrap"
                clickable="true" on-click="on_click_payment_option"
                weight-sum="100">
  <Image src="{data.payment_logo}" weight="10" width="0dp" height="30dp" />
  <Label text="{data.default_payment_method_name}" weight="80" width="0dp" />
  <Image src="{data.forward_icon}" weight="10" width="0dp" height="16dp" />
</HorizontalView>

Python Usage

from components.view import HorizontalView
from components.form import Button

horizontal_view = HorizontalView()

button1 = Button()
button1.set_text("Button 1")
button2 = Button()
button2.set_text("Button 2")

horizontal_view.add_element(button1)
horizontal_view.add_element(button2)
horizontal_view.refresh()

def on_click_payment_option(self, element):
    pass

Attributes

width"fill" | "wrap" | "Ndp" | "0dp"

Width of the view. Use 0dp together with weight to size proportionally inside a weighted parent.

height"fill" | "wrap" | "Ndp"

Height of the view.

weight-sumnumber

Total proportional space available to weighted children. Use 100 to make child weights read as percentages.

weightnumber

This view's share of its parent's weight-sum. Pair with width="0dp" (or height="0dp" inside a VerticalView) so the layout engine uses the weight.

clickable"true" | "false"

Makes the container itself interactive. When true, on-click fires for the whole view, not just inner Buttons.

on-clickcallback name

Python handler invoked on tap. Signature: def handler(self, element): .... Requires clickable="true".

alignment"left" | "right" | "center" | "center_vertical" | "center_horizontal"

Positions the children inside the view.

padding"Ndp"

Inner spacing on all sides. Also accepts side-specific padding-left, padding-right, padding-top, padding-bottom.

margin"Ndp"

Outer spacing on all sides. Side-specific margin-* variants supported.

background-color"#RRGGBB" | "#AARRGGBB"

Background fill color.

backgroundcolor string or resource path

Alternative background. Accepts a color or a resource path like res/images/bg.png.

border-color"#RRGGBB"

Border color.

border-width"Ndp"

Border thickness.

border-radius"Ndp"

Corner rounding. Side-specific variants such as border-radius-top-left available.

align-to"parent@bottom" | "parent@top" | "parent@left" | "parent@right" | "<id>@<edge>"

Anchors the view relative to its parent or a sibling. Mostly used inside a RelativeView.

idstring

Identifier for retrieval via find_element_by_id(id).

style"namespace.style_name"

Apply a style block from an external ASCSS file.

enabled"true" | "false"

If false the view (and its children) won't receive interactions.

VerticalView

layout

A container that arranges its children vertically in a column. Supports proportional sizing via weight-sum / weight and can be made interactive on its own with clickable.

XML Usage

<VerticalView width="fill" height="wrap" padding="10dp" background-color="#f5f5f5">
  <Button text="Button 1" margin-bottom="5dp" />
  <Button text="Button 2" margin-bottom="5dp" />
  <Button text="Button 3" />
</VerticalView>

Proportional Layout (weight / weight-sum)

Same model as HorizontalView, but children use height="0dp" so the layout engine sizes them from weight against the parent's weight-sum.

<VerticalView width="fill" height="fill" weight-sum="100">
  <Label text="Header" width="fill" height="0dp" weight="15" />
  <ScrollView width="fill" height="0dp" weight="70">
    <Label text="Body content" />
  </ScrollView>
  <HorizontalView width="fill" height="0dp" weight="15" />
</VerticalView>

Clickable Container

Set clickable="true" on any Layout-category view (VerticalView, HorizontalView, ScrollView, RelativeView, RepeatView, View, BottomView, DrawerView, PopupView) to turn it into a single tap target. Nested children that don't define their own on-click pass clicks up to the container.

<VerticalView width="fill" height="wrap"
              style="styles.card_item"
              clickable="true"
              on-click="on_question_item_click">
  <Label id="question_text_label" text="{data.text}" style="styles.text_question" />
  <Label id="question_author_label" text="{data.author}" style="styles.question_feed_item_author" />
</VerticalView>

Python Usage

from components.view import VerticalView
from components.form import Button

vertical_view = VerticalView()

button1 = Button()
button1.set_text("Button 1")
button2 = Button()
button2.set_text("Button 2")

vertical_view.add_element(button1)
vertical_view.add_element(button2)
vertical_view.refresh()

def on_question_item_click(self, element, item):
    pass

Attributes

width"fill" | "wrap" | "Ndp" | "0dp"

Width of the view.

height"fill" | "wrap" | "Ndp" | "0dp"

Height of the view. Use 0dp with weight inside a weighted VerticalView parent.

weight-sumnumber

Total proportional space available to weighted children on the Y axis.

weightnumber

This view's portion of its parent's weight-sum.

clickable"true" | "false"

Makes the container itself interactive so on-click fires when the whole column is tapped.

on-clickcallback name

Python handler invoked on tap. Signature: def handler(self, element): .... Inside a RepeatView template the handler receives (self, element, item).

alignment"left" | "right" | "center" | "center_vertical" | "center_horizontal"

Positions children inside the view.

padding / margin"Ndp"

Inner / outer spacing. Side-specific variants (padding-top, margin-bottom, ...) are supported.

background-color / backgroundcolor or resource path

Fill color or background image resource.

border-color / border-width / border-radiuscolor, dp, dp

Border styling, including side-specific border-radius-top-left, etc.

align-to"parent@bottom" | "parent@top" | "<id>@<edge>"

Anchors the view relative to its parent or a sibling inside a RelativeView.

idstring

Identifier for retrieval via find_element_by_id(id).

style"namespace.style_name"

Apply a style block from an external ASCSS file.

enabled"true" | "false"

If false the view (and its children) won't receive interactions.

ScrollView

layout

A scrollable container for content that exceeds the display area.

XML Usage

<ScrollView width="fill" height="300dp">
  <VerticalView width="fill" height="wrap">
    <Label text="Item 1" padding="10dp" />
    <Label text="Item 2" padding="10dp" />
    </VerticalView>
</ScrollView>

Python Usage

from components.view import ScrollView, VerticalView
from components.form import Label

# Create a scroll view
scroll_view = ScrollView()

# Create a vertical view as content
content = VerticalView()

# Add items to the content
for i in range(20):
    label = Label()
    label.set_text(f"Item {i+1}")
    content.add_element(label)

# Add the content to the scroll view
scroll_view.add_element(content)
scroll_view.refresh()

RelativeView

layout

A container that positions components relative to one another or to the parent container.

XML Usage

<RelativeView width="fill" height="300dp" background-color="#f5f5f5">
  <Button id="centerButton" text="Center" alignment="center" align-to="parent@center" />
  <Button id="topButton" text="Top" align-to="parent@top|parent@center_horizontal" />
  <Button id="bottomButton" text="Bottom" align-to="parent@bottom|parent@center_horizontal" />
</RelativeView>

Python Usage

from components.view import RelativeView
from components.form import Button

# Create a relative view
relative_view = RelativeView()

# Create buttons
center_button = Button()
center_button.set_text("Center")
center_button.set_id("centerButton")
center_button.set_align_to("parent@center")

top_button = Button()
top_button.set_text("Top")
top_button.set_id("topButton")
top_button.set_align_to("parent@top|parent@center_horizontal")

bottom_button = Button()
bottom_button.set_text("Bottom")
bottom_button.set_id("bottomButton")
bottom_button.set_align_to("parent@bottom|parent@center_horizontal")

# Add buttons to the relative view
relative_view.add_element(center_button)
relative_view.add_element(top_button)
relative_view.add_element(bottom_button)
relative_view.refresh()

RepeatView

layout

A container that repeats a custom view for each item in a data collection.

XML Usage

<RepeatView 
  on-create-view="on_create_view" 
  on-bind-view="on_bind_view" 
  layout-type="vertical" 
  items="{data.list}" 
  width="fill"
  height="wrap"
/>

Python Usage

from components.view import RepeatView

# In your App class:

# Assuming TextData class is defined elsewhere or is a simple placeholder
class TextData:
    def __init__(self, text):
        self.text = text

def setup(self):
    self.list = [TextData("Item 1"), TextData("Item 2"), TextData("Item 3")] 
    self.repeat_view = RepeatView()
    self.repeat_view.set_on_create_view(self.on_create_view)
    self.repeat_view.set_on_bind_view(self.on_bind_view)
    self.repeat_view.set_items(self.list)
    self.repeat_view.set_layout_type("vertical")

def on_create_view(self, parent):
    # Create and return a view for each item
    view = self.inflater.inflate("item_template", None) # Assuming item_template.asxml exists
    return view

def on_bind_view(self, view, item):
    # Bind data to the view
    label = view.find_element_by_id("item_label") # Assuming item_label ID in item_template.asxml
    label.set_text(item.text)

Methods

set_on_create_view(callback)

Sets the callback function that creates a view for each item

set_on_bind_view(callback)

Sets the callback function that binds data to each created view

set_items(items)

Sets the collection of items to display

set_layout_type(type)

Sets the layout type: 'horizontal', 'vertical', or 'grid'

View

layout

A base container for creating custom components.

XML Usage

<View width="fill" height="wrap" background-color="#f5f5f5" padding="10dp">
  <Label text="Custom View Content" />
</View>

Python Usage

from components.view import View
from components.form import Label

# Create a view
view = View()

# Add a label to it
label = Label()
label.set_text("Custom View Content")
view.add_element(label)
view.refresh()

Methods

add_element(child)

Adds a child component to the view

refresh()

Refresh the view with latest changes

find_element_by_id(id)

Finds a child component by its ID

BottomView

layout

A bottom-sheet container that slides up from the bottom of the screen. Toggled programmatically with show() / dismiss(). Useful for action sheets, confirmation prompts, or any contextual UI that should not take over the whole screen.

XML Usage

<BottomView id="bottom_view"
            width="fill" height="wrap"
            padding="20dp"
            background-color="white"
            border-width="1dp" border-color="gray"
            border-radius-top-left="16dp"
            border-radius-top-right="16dp">
  <Label text="Confirm payment" margin-bottom="10dp" />
  <HorizontalView width="fill" weight-sum="100">
    <Button text="Cancel"  weight="50" width="0dp" on-click="on_cancel" />
    <Button text="Confirm" weight="50" width="0dp" on-click="on_confirm" />
  </HorizontalView>
</BottomView>

Python Usage

from components.view import BottomView

bottom_view = self.ui_view.find_element_by_id("bottom_view")
bottom_view.show()

def on_cancel(self, element):
    bottom_view.dismiss()

def on_confirm(self, element):
    self.process_payment()
    bottom_view.dismiss()

Methods

show()

Slides the bottom sheet into view.

dismiss()

Hides the bottom sheet.

find_element_by_id(id)

Returns a child element by its id (inherited container method).

add_element(child)

Appends a child component programmatically.

Attributes

idstring

Identifier used to retrieve the view via find_element_by_id.

width / height"fill" | "wrap" | "Ndp"

Sizing of the sheet. height="wrap" keeps it as short as its content.

padding / margin"Ndp"

Inner and outer spacing. Side-specific variants are supported.

background-color"#RRGGBB" | named color

Background fill of the sheet.

border-width / border-color"Ndp", color

Outline of the sheet.

border-radius-top-left / -top-right"Ndp"

Rounded top corners. Typical for bottom sheets.

border-radius-bottom-left / -bottom-right"Ndp"

Rarely used since the bottom edge usually meets the screen.

weight / weight-sumnumber

Supports the proportional layout model when nested inside a weighted parent.

clickable"true" | "false"

When true the sheet itself responds to on-click taps.

on-clickcallback name

Optional handler for tapping inside the sheet itself.

align-to"parent@bottom" | "parent@top" | etc.

Anchor inside a RelativeView parent. BottomView typically docks to parent@bottom automatically.

DrawerView

layout

A slide-in side drawer that pairs a data-driven navigation menu with three named slot containers: DrawerToolbarView (top toolbar), DrawerHeaderView (above the menu list), and DrawerFooterView (pinned to the bottom). The drawer menu and toolbar are populated from Python data and re-rendered when you update them.

Menu Data Structure

The drawer-menu attribute is bound to a Python dict. Each key is a section header (use "" for ungrouped top-level items) and each value is a list of item dicts. Item dicts accept title (string, required), icon (full resource path, optional), and checked (bool, optional; used to render a checkmark / current-state indicator).

self.drawer_menu = {
    "": [
        {"title": "Sudo AI",   "icon": application_path + "/res/images/sudo_ai.png"},
        {"title": "New Chat",  "icon": application_path + "/res/images/new_chat.png"},
        {"title": "Settings",  "icon": application_path + "/res/images/setting_icon.png"}
    ],
    "Chats": [
        {"title": "Chat Title A", "checked": True},
        {"title": "Chat Title B", "checked": False}
    ]
}

XML Usage

<DrawerView on-navigate="on_navigate_click"
            drawer-menu="{data.drawer_menu}"
            toolbar-title="Sudo AI"
            width="fill" height="fill"
            padding="5dp" background-color="#1E1E1E">
  <DrawerToolbarView width="fill" height="wrap">
    <HorizontalView width="fill" height="wrap" weight-sum="100">
      <Label text="Sudo AI" style="app.chat_title" weight="50" width="0dp" />
      <HorizontalView width="0dp" height="fill" weight="50">
        <Image src="{data.chat_url}" on-click="on_saved_change"
               width="24dp" height="24dp" />
      </HorizontalView>
    </HorizontalView>
  </DrawerToolbarView>
  <DrawerHeaderView width="fill" height="wrap" margin="10dp">
    <Label text="Welcome" margin="8dp" />
  </DrawerHeaderView>
  <DrawerFooterView width="fill" height="wrap">
    <HorizontalView width="fill" height="wrap">
      <Image src="{data.profile_avatar}"
             width="40dp" height="40dp"
             scale-type="rounded" radius="20dp" margin="5dp" />
      <Label text="{data.profile_username}" margin="10dp" />
    </HorizontalView>
  </DrawerFooterView>
</DrawerView>

Python Usage

from components.view import DrawerView

def on_navigate_click(self, element, menu_name):
    if menu_name == "New Chat":
        self.reset_chat()
    elif menu_name == "Settings":
        self.open_settings()
    self.ui_view.close()

self.data.drawer_menu["Chats"].append({"title": "Chat Title C", "checked": False})
self.ui_view.set_drawer_menu(self.data.drawer_menu)

Methods

set_drawer_menu(menu_dict)

Replaces the drawer menu at runtime and re-renders the menu list.

close()

Closes the drawer.

find_element_by_id(id)

Locates a child element inside any slot.

Events

on_navigate_click(element, menu_name)

Fires when a drawer menu item is tapped. menu_name is the title of the selected item.

Attributes

drawer-menu{data.dict}

Bound dict describing the menu (see structure above).

toolbar-titlestring

Title rendered in the toolbar slot.

on-navigatecallback name

Python handler invoked when a menu entry is selected.

width / height"fill" | "wrap" | "Ndp"

Typically fill / fill so the drawer spans the screen height.

padding / margin"Ndp"

Inner / outer spacing.

background-color / backgroundcolor or resource path

Drawer surface fill or background image.

weight / weight-sum / clickable / on-clicksee Layout

All Layout-category capabilities apply. DrawerView can host weighted children and respond to its own taps.

idstring

Optional identifier.

Slot Elements

DrawerToolbarView

Top toolbar/title area. Accepts standard Layout attributes; most commonly width="fill", height="wrap".

DrawerHeaderView

Section rendered above the data-driven menu list. Good for greetings, search bars, etc.

DrawerFooterView

Pinned to the drawer's bottom edge. Typical for the signed-in user row.

PopupView

layout

A modal overlay container that floats above the rest of the UI. Toggled with show() / dismiss(); an optional on-dismiss callback fires when the user closes it.

XML Usage

<PopupView id="popup_view"
           width="fill" height="fill"
           padding="10dp"
           background-color="white"
           border-color="gray" border-width="1dp"
           border-radius="12dp"
           on-dismiss="on_dismiss_payment_option">
  <VerticalView width="fill" height="fill" weight-sum="100" padding="20dp">
    <Label text="Choose a payment option"
           height="0dp" weight="10" alignment="center" />
    <ScrollView width="fill" height="0dp" weight="80">
      <Label text="{data.payment_options_text}" />
    </ScrollView>
    <Button text="Close" height="0dp" weight="10" on-click="on_close_popup" />
  </VerticalView>
</PopupView>

Python Usage

from components.view import PopupView

popup_view = self.ui_view.find_element_by_id("popup_view")
popup_view.show()

def on_close_popup(self, element):
    popup_view.dismiss()

def on_dismiss_payment_option(self, element):
    bottom_view = self.ui_view.find_element_by_id("bottom_view")
    bottom_view.show()

Methods

show()

Reveals the overlay above all other content.

dismiss()

Closes the overlay and fires on-dismiss if registered.

find_element_by_id(id)

Returns a child element by its id.

add_element(child)

Appends a child programmatically.

Events

on_dismiss(element)

Invoked when the popup is closed (via dismiss() or system-level dismissal). Receives the PopupView element.

Attributes

idstring

Identifier used with find_element_by_id.

width / height"fill" | "wrap" | "Ndp"

Dimensions of the popup surface.

padding / margin"Ndp"

Inner and outer spacing, with side-specific variants.

background-color / backgroundcolor or resource path

Popup fill or background image.

border-color / border-width / border-radiuscolor, dp, dp

Outline + corner rounding. Side-specific border-radius-* variants supported.

on-dismisscallback name

Handler fired when the popup closes.

weight / weight-sum / clickable / on-clicksee Layout

PopupView is a Layout-category container, so it supports proportional layout and can be made tappable itself.

alignment"center" | "left" | "right" | etc.

Aligns child content inside the popup.

Input

Button

input

An interactive clickable element that triggers actions when pressed.

XML Usage

<Button 
  text="Click Me" 
  on-click="on_click" 
  background-color="#4a6cff" 
  font-color="white" 
  padding="10dp"
  border-radius="5px"
/>

Python Usage

from components.form import Button

# Create a button
button = Button()
button.set_text("Click Me")
# button.set_on_click(self.on_click) # Assuming self.on_click is defined in the app class

# Define the click handler in your app class
# def on_click(self, element):
#     # Handle button click
#     print("Button clicked!")

Methods

set_text(text)

Sets the button label text

set_on_click(callback)

Sets the callback function that is called when the button is clicked

TextBox

input

A text input field for user data entry.

XML Usage

<TextBox 
  text="{data.inputText}" 
  placeholder="Enter text..." 
  input-type="single" 
  on-change="on_text_change" 
  width="fill"
  padding="10dp"
  border-color="#ccc"
  border-width="1px"
  border-style="solid"
  font-size="16sp"
/>

Python Usage

from components.form import TextBox

# Create a text box
text_box = TextBox()
text_box.set_text("Initial text")
text_box.set_placeholder("Enter text...")
text_box.set_input_type("single")
# text_box.set_on_change(self.on_text_change) # Assuming self.on_text_change is defined

# Define the change handler in your app class
def on_text_change(self, element, value):
   print(f"Text changed: {value}")

Methods

set_text(text)

Sets the current text in the text box

get_text()

Returns the current text in the text box

set_input_type(type)

Sets the input type: 'single', 'multiline', 'password', 'phone', 'email', 'autocomplete', 'number'

set_placeholder(placeholder)

Sets the placeholder text displayed when the text box is empty

set_on_change(callback)

Sets the callback function that is called when the text changes

Checkbox

input

A toggle selection control that can be checked or unchecked.

XML Usage

<Checkbox 
  text="Enable feature" 
  checked="{data.featureEnabled}" 
  on-change="on_checkbox_change" 
  enabled="true"
/>

Python Usage

from components.form import Checkbox

# Create a checkbox
checkbox = Checkbox()
checkbox.set_text("Enable feature")
checkbox.set_checked(True)
checkbox.set_enabled(True)
# checkbox.set_on_change(self.on_checkbox_change) # Assuming self.on_checkbox_change is defined

# Define the change handler in your app class
# def on_checkbox_change(self, element, checked):
#     print(f"Checkbox changed: {checked}")

Methods

set_text(text)

Sets the label text for the checkbox

set_checked(checked)

Sets whether the checkbox is checked

set_enabled(enabled)

Sets whether the checkbox is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the checkbox state changes

get_checked()

Returns whether the checkbox is checked

CheckboxList

input

A group of checkboxes for selecting multiple options.

XML Usage

<CheckboxList 
  options="{data.options}" 
  checked-states="{data.checkedStates}" 
  on-change="on_checkbox_list_change"
  enabled="true"
/>

Python Usage

from components.form import CheckboxList

# Create a checkbox list
checkbox_list = CheckboxList()
checkbox_list.set_options(["Option 1", "Option 2", "Option 3"])
checkbox_list.set_checked_states([True, False, False])
checkbox_list.set_enabled(True)
# checkbox_list.set_on_change(self.on_checkbox_list_change) # Assuming self.on_checkbox_list_change

# Define the change handler in your app class
# def on_checkbox_list_change(self, index, element, is_checked):
#     print(f"Option {index} changed: {is_checked}")
    
# Get checked options
# checked_options = checkbox_list.get_checked_options()

Methods

set_options(options)

Sets the list of options

set_checked_states(states)

Sets the checked state for each option

set_enabled(enabled)

Sets whether the checkbox list is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when an option's state changes

get_checked_options()

Returns the list of checked options

Radio

input

A single radio button for selection.

XML Usage

<Radio 
  text="Select this option" 
  checked="{data.selected}" 
  on-change="on_radio_change" 
  enabled="true"
/>

Python Usage

from components.form import Radio

# Create a radio button
radio = Radio()
radio.set_text("Select this option")
radio.set_checked(True)
radio.set_enabled(True)
# radio.set_on_change(self.on_radio_change) # Assuming self.on_radio_change is defined

# Define the change handler in your app class
# def on_radio_change(self, element, checked):
#     print(f"Radio changed: {checked}")
    
# Check if radio is checked
# is_checked = radio.get_checked()

Methods

set_text(text)

Sets the label text for the radio button

set_checked(checked)

Sets whether the radio button is checked

set_enabled(enabled)

Sets whether the radio button is enabled or disabled

get_checked()

Returns whether the radio button is checked

set_on_change(callback)

Sets the callback function that is called when the radio button state changes

is_checked()

Returns whether the radio button is checked

RadioList

input

A group of radio buttons for selecting a single option.

XML Usage

<RadioList 
  options="{data.options}" 
  selected-index="{data.selectedIndex}" 
  on-change="on_radio_list_change"
  enabled="true"
/>

Python Usage

from components.form import RadioList

# Create a radio list
radio_list = RadioList()
radio_list.set_options(["Option 1", "Option 2", "Option 3"])
radio_list.set_selected_index(0)
radio_list.set_enabled(True)
# radio_list.set_on_change(self.on_radio_list_change) # Assuming self.on_radio_list_change

# Define the change handler in your app class
# def on_radio_list_change(self, element, checked_index):
#     print(f"Selected option: {checked_index}")

Methods

set_options(options)

Sets the list of options

set_selected_index(index)

Sets the selected option by index

set_enabled(enabled)

Sets whether the radio list is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the selection changes

DatePicker

input

A control for selecting dates and times.

XML Usage

<DatePicker 
  mode="date" 
  date="{data.selectedDate}" 
  on-date-change="on_date_change"
  show-calender-view="true"
/>

Python Usage

from components.form import DatePicker

# Create a date picker for date only
date_picker = DatePicker()
date_picker.set_mode("date")
date_picker.set_date((2023, 5, 15))  # Year, Month, Day
date_picker.set_show_calender_view(True)
# date_picker.set_on_date_change(self.on_date_change) # Assuming self.on_date_change

# Create a time picker
time_picker = DatePicker()
time_picker.set_mode("time")
time_picker.set_time((14, 30))  # Hours, Minutes
time_picker.set_24h_view(True)
# time_picker.set_on_time_change(self.on_time_change) # Assuming self.on_time_change

# Define change handlers in your app class
# def on_date_change(self, element, year, month, day):
#     print(f"Date changed: {year}-{month}-{day}")
    
# def on_time_change(self, element, hours, minutes):
#     print(f"Time changed: {hours}:{minutes}")
    
# Get selected date and time
# selected_date = date_picker.get_selected_date()
# selected_time = time_picker.get_selected_time()

Methods

set_mode(mode)

Sets the picker mode: 'date', 'time', or 'datetime'

set_date(date)

Sets the selected date as a tuple (year, month, day)

set_time(time)

Sets the selected time as a tuple (hours, minutes)

set_show_calender_view(show)

Sets whether to show the calendar view

set_on_date_change(callback)

Sets the callback function that is called when the date changes

set_on_time_change(callback)

Sets the callback function that is called when the time changes

set_24h_view(is_24h)

Sets whether to use 24-hour or 12-hour time format

get_selected_date()

Returns the selected date as {year, month, day}

get_selected_time()

Returns the selected time as {hour, minute}

Dropdown

input

A dropdown selection menu.

XML Usage

<Dropdown 
  options="{data.options}" 
  selected-index="{data.selectedIndex}" 
  prompt="Select an option" 
  on-change="on_dropdown_change"
  enabled="true"
/>

Python Usage

from components.form import Dropdown

# Create a dropdown
dropdown = Dropdown()
dropdown.set_options(["Option 1", "Option 2", "Option 3"])
dropdown.set_selected_index(0)
dropdown.set_prompt("Select an option")
dropdown.set_enabled(True)
# dropdown.set_on_change(self.on_dropdown_change) # Assuming self.on_dropdown_change

# Define the change handler in your app class
# def on_dropdown_change(self, element, index):
#     print(f"Selected option: {index}")
    
# Get selected option
# selected_option = dropdown.get_selected_option()
# selected_index = dropdown.get_selected_index()

Methods

set_options(options)

Sets the list of options

set_selected_index(index)

Sets the selected option by index

set_prompt(prompt)

Sets the prompt text displayed when no option is selected

set_enabled(enabled)

Sets whether the dropdown is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the selection changes

get_selected_option()

Returns the selected option text

get_selected_index()

Returns the selected option index

Slider

input

A control for selecting a value from a range.

XML Usage

<Slider 
  range="{data.sliderRange}" 
  value="{data.sliderValue}" 
  on-change="on_slider_change"
  on-start="on_slider_start"
  on-stop="on_slider_stop"
  enabled="true"
/>

Python Usage

from components.form import Slider

# Create a slider
slider = Slider()
slider.set_range((0, 100))
slider.set_value(50)
slider.set_enabled(True)
# slider.set_on_change(self.on_slider_change) # Assuming self.on_slider_change
# slider.set_on_start(self.on_slider_start)   # Assuming self.on_slider_start
# slider.set_on_stop(self.on_slider_stop)     # Assuming self.on_slider_stop

# Define handlers in your app class
# def on_slider_change(self, element, progress_value):
#     print(f"Slider value: {progress_value}")
    
# def on_slider_start(self, element):
#     print("Slider interaction started")
    
# def on_slider_stop(self, element):
#     print("Slider interaction stopped")

Methods

set_range(range)

Sets the range as a tuple (min, max)

set_value(value)

Sets the current value

set_enabled(enabled)

Sets whether the slider is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the value changes

set_on_start(callback)

Sets the callback function that is called when interaction starts

set_on_stop(callback)

Sets the callback function that is called when interaction stops

SpinBox

input

A control for selecting a numerical value with increment/decrement buttons.

XML Usage

<SpinBox 
  range="{data.spinBoxRange}" 
  value="{data.spinBoxValue}" 
  on-change="on_spin_box_change"
  enabled="true"
/>

Python Usage

from components.form import SpinBox

# Create a spin box
spin_box = SpinBox()
spin_box.set_range((0, 100))
spin_box.set_value(50)
spin_box.set_enabled(True)
# spin_box.set_on_change(self.on_spin_box_change) # Assuming self.on_spin_box_change

# Define the change handler in your app class
# def on_spin_box_change(self, element, old_value, new_value):
#     print(f"Value changed from {old_value} to {new_value}")
    
# Get current value
# current_value = spin_box.get_value()

Methods

set_range(range)

Sets the range as a tuple (min, max)

set_value(value)

Sets the current value

get_value()

Returns the current value

set_enabled(enabled)

Sets whether the spin box is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the value changes

Switch

input

A toggle switch control.

XML Usage

<Switch 
  text="Enable feature" 
  checked="{data.featureEnabled}" 
  on-change="on_switch_change" 
  enabled="true"
/>

Python Usage

from components.form import Switch

# Create a switch
switch_control = Switch() # Renamed to avoid conflict with keyword
switch_control.set_text("Enable feature")
switch_control.set_checked(True)
switch_control.set_enabled(True)
# switch_control.set_on_change(self.on_switch_change) # Assuming self.on_switch_change

# Define the change handler in your app class
# def on_switch_change(self, element, is_checked):
#     print(f"Switch changed: {is_checked}")
    
# Get switch state
# is_on = switch_control.get_checked()

Methods

set_text(text)

Sets the label text for the switch

set_checked(checked)

Sets whether the switch is checked/on

set_enabled(enabled)

Sets whether the switch is enabled or disabled

set_on_change(callback)

Sets the callback function that is called when the switch state changes

get_checked()

Returns whether the switch is checked/on

Display

Label

display

A component for displaying text.

XML Usage

<Label 
  text="Hello, World!" 
  font-size="16sp"
  font-color="#333333"
  alignment="left"
  on-click="on_label_click"
/>

Python Usage

from components.form import Label

# Create a label
label = Label()
label.set_text("Hello, World!")

# Optional: set click handler
# label.set_on_click(self.on_label_click) # Assuming self.on_label_click

# Get label text
# text = label.get_text()

# Define click handler in your app class
# def on_label_click(self, element):
#     print("Label clicked")

Methods

set_text(text)

Sets the text to display

get_text()

Returns the current text

set_on_click(callback)

Sets the callback function that is called when the label is clicked

Icon

display

A graphical icon element.

XML Usage

<Icon 
  src="{data.iconPath}" 
  color="#4a6cff" 
  size="24dp"
/>

Python Usage

from components.form import Icon
# Assuming application_path is defined in the context where this code runs
# For example, in an App class: self.application_path

# Create an icon
icon = Icon()
# icon.set_src(application_path + "/res/images/icon.png") 
icon.set_color("#4a6cff")
icon.set_size("24dp")

Methods

set_src(src)

Sets the icon source path

set_color(color)

Sets the icon color (supports color name and hex color)

set_size(size)

Sets the icon size (e.g., '24dp')

Progress

display

A progress indicator for showing loading or completion status.

XML Usage

<Progress 
  progress="{data.progressValue}" 
  max-progress="{data.maxProgress}" 
  progress-style="horizontal"
  indeterminate="{data.isIndeterminate}"
/>

Python Usage

from components.form import Progress

# Create a determinate progress bar
progress_bar = Progress()
progress_bar.set_progress(50)  # Current value
progress_bar.set_max_progress(100)  # Maximum value
progress_bar.set_progress_style("horizontal")

# Create an indeterminate progress indicator
loading_indicator = Progress()
loading_indicator.set_indeterminate(True)
loading_indicator.set_progress_style("default")

Methods

set_progress(progress)

Sets the current progress value

set_max_progress(max_progress)

Sets the maximum progress value

set_indeterminate(indeterminate)

Sets whether the progress indicator is indeterminate (ongoing activity without specific progress)

set_progress_style(style)

Sets the progress style: 'horizontal' or 'default'

BottomMenu

display

A navigation menu displayed at the bottom of the screen.

XML Usage

BottomMenu is primarily created and managed in Python code.

Python Usage

from components.basic import BottomMenu
# Assuming application_path is defined, e.g., self.application_path in an App class

# Create a bottom menu
bottom_menu = BottomMenu()
bottom_menu.set_icon_size(24)  # Set icon size to 24dp
# bottom_menu.set_menu_items([
#     ("Home", application_path + "/res/images/home_icon.png"), 
#     ("Search", application_path + "/res/images/search_icon.png"),
#     ("Profile", application_path + "/res/images/profile_icon.png")
# ])

# Set click handlers
# bottom_menu.set_click_handlers([
#     self.on_home_click,       # Assuming these methods are defined in the app class
#     self.on_search_click,
#     self.on_profile_click
# ])

# Define click handlers in your app class
# def on_home_click(self):
#     print("Home menu clicked")

# def on_search_click(self):
#     print("Search menu clicked")

# def on_profile_click(self):
#     print("Profile menu clicked")

# Show the bottom menu
# bottom_menu.show()

# Hide the bottom menu when needed
# bottom_menu.dismiss()

Methods

set_icon_size(size)

Sets the size of the menu item icons

set_menu_items(items)

Sets the menu items as a list of (name, icon_path) tuples

show()

Shows the bottom menu

dismiss()

Hides the bottom menu

set_click_handlers(handlers)

Sets the click handlers for menu items

BottomNavigation

display

A bottom-anchored navigation bar driven by a list of menu items. Each item shows its icon (and optionally its label) and fires a callback when tapped. Typical use is to switch between top-level screens of an app.

Menu Data Structure

The menu-items attribute is bound to a Python list of dicts. Each item dict supports title (string, required; also the value passed to the callback) and icon (full resource path to a PNG/SVG asset). The list order determines tab order from left to right.

self.menus = [
    {"title": "Home",   "icon": application_path + "/res/images/home.png"},
    {"title": "Search", "icon": application_path + "/res/images/search.png"},
    {"title": "Post",   "icon": application_path + "/res/images/post.png"},
    {"title": "Profile","icon": application_path + "/res/images/profile.png"}
]

XML Usage

<BottomNavigation
  menu-items="{data.menus}"
  width="fill"
  height="wrap"
  align-to="parent@bottom"
  on-item-selected="on_navigation_click"
  label-mode="labeled" />

Python Usage

from runtime.Logger import Logger

def on_navigation_click(self, element, menu):
    Logger.write(menu)
    if menu == "Home":
        self.load_home_view()
    elif menu == "Search":
        self.load_search_view()
    elif menu == "Post":
        self.load_post_view()
    elif menu == "Profile":
        self.load_profile_view()

Events

on_item_selected(element, menu_title)

Fires when a menu entry is tapped. menu_title is the title string of the selected item (not the full dict).

Attributes

menu-items{data.list}

Bound list of menu-item dicts (see structure above).

on-item-selectedcallback name

Python handler invoked when an item is selected.

label-mode"labeled" | "selected" | "unlabeled"

Controls whether icon labels are shown. "labeled" shows the label on every item; "selected" only on the active item; "unlabeled" hides them.

align-to"parent@bottom" | "parent@top" | "<id>@<edge>"

Anchor edge when placed inside a RelativeView. Almost always parent@bottom for a bottom nav bar.

width / height"fill" | "wrap" | "Ndp"

Sizing. Typically width="fill", height="wrap".

background-color"#RRGGBB" | named color

Background of the navigation bar.

padding / margin"Ndp"

Inner / outer spacing. Side-specific variants supported.

border-color / border-widthcolor, dp

Outline of the bar. Often a thin top border to separate from content.

idstring

Identifier for find_element_by_id lookup.

Divider

display

A thin line used to visually separate content within a layout. Visual-only. No instance methods or events. Typically dropped between sections inside a VerticalView/HorizontalView.

XML Usage

<VerticalView width="fill" height="wrap">
  <Label text="Account" />
  <Divider width="fill" thickness="1dp" color="#CCCCCC"
           margin-top="10dp" margin-bottom="10dp" />
  <Label text="Notifications" />
  <Divider width="fill" thickness="1dp" color="#CCCCCC"
           margin-top="10dp" margin-bottom="10dp" />
  <Label text="Privacy" />
</VerticalView>

Attributes

width"fill" | "wrap" | "Ndp"

Length of the line. Use fill for a full-width separator.

height"wrap" | "Ndp"

Reserved space around the line. The line itself is controlled by thickness.

thickness"Ndp"

Stroke thickness of the divider line.

color"#RRGGBB" | "#AARRGGBB"

Color of the divider line.

margin-top / -bottom / -left / -right"Ndp"

Spacing around the divider. margin shorthand applies to all four sides.

padding / padding-*"Ndp"

Inner spacing of the reserved area (less common, typically not needed).

background-color"#RRGGBB"

Fills the surrounding area; the line itself stays color.

align-to"parent@bottom" | "<id>@<edge>"

Anchors the divider inside a RelativeView.

idstring

Optional identifier.

Media

Image

media

A component for displaying images.

XML Usage

<Image 
  src="{data.imagePath}" 
  scale-type="fill_center"
  radius="10dp"
/>

Python Usage

from components.media import Image
from runtime.ImageHelper import ImageHelper
# Assuming application_path is defined, e.g., self.application_path in an App class

# Create an image from local path
image = Image()
# image.set_src(application_path + "/res/images/photo.jpg") 
image.set_scale_type("center_crop")

# Create a rounded image
rounded_image = Image()
# rounded_image.set_src(application_path + "/res/images/avatar.jpg")
rounded_image.set_scale_type("rounded")
rounded_image.set_radius("20dp")

# Load an image using ImageHelper (for remote URLs)
# bitmap = ImageHelper.get_bitmap("https://example.com/image.jpg")
# remote_image = Image()
# remote_image.set_src(bitmap)

Methods

set_src(src)

Sets the image source (path or bitmap)

set_scale_type(scale_type)

Sets how the image should be resized or positioned: 'fill', 'fill_center', 'center', 'center_crop', 'rounded'

set_radius(radius)

Sets the corner radius for rounded images (e.g., '10dp'). Only works when scale_type is 'rounded'

Video

media

A video player component.

XML Usage

<Video 
  uri="{data.videoUri}" 
  on-play-state-change="on_video_state_change"
  on-player-error="on_video_error"
/>

Python Usage

from components.media import Video
# Assuming application_path is defined for local files

# Create a video player
video = Video()
video.set_uri("https://example.com/video.mp4")  # HTTP location
# Or use local file:
# video.set_uri(application_path + "/res/videos/sample.mp4") 

# video.set_on_play_state_change(self.on_video_state_change) # Assuming self.on_video_state_change
# video.set_on_player_error(self.on_video_error)           # Assuming self.on_video_error

# Control playback
# video.play()
# video.pause()
# video.stop()
# video.seek_to(30000)  # Seek to 30 seconds (in milliseconds)

# Get video information
# duration = video.get_duration()
# current_position = video.get_current_position()

# Define event handlers in your app class
# def on_video_state_change(self, element, playback_state):
#     print(f"Playback state changed: {playback_state}")
    
# def on_video_error(self, element, error):
#     print(f"Video player error: {error}")

Methods

set_uri(uri)

Sets the video URI (URL or local file path)

set_on_play_state_change(callback)

Sets the callback function that is called when the playback state changes

set_on_player_error(callback)

Sets the callback function that is called when a player error occurs

play()

Starts or resumes playback

pause()

Pauses playback

stop()

Stops playback

seek_to(milliseconds)

Seeks to the specified position in milliseconds

get_duration()

Returns the total duration of the video in milliseconds

get_current_position()

Returns the current playback position in milliseconds

WebView

media

A component for displaying web content.

XML Usage

<WebView 
  url="{data.webUrl}" 
  javascript-enabled="true"
/>

Python Usage

from components.basic import WebView

# Create a web view with a URL
web_view = WebView()
web_view.set_url("https://example.com")
web_view.set_javascript_enabled(True)

# Alternative: Load HTML content
web_view_html = WebView()
web_view_html.set_html("<h1>Hello, World!</h1><p>This is HTML content.</p>")
web_view_html.set_base_url("https://example.com")  # Optional base URL for relative paths

# Navigation methods
# web_view.load_url("https://example.com/page2")
# web_view.load_html("<h1>New Content</h1>", "https://example.com")
# web_view.go_back()
# web_view.go_forward()
# web_view.reload()

Methods

set_url(url)

Sets the URL to load

set_html(html)

Sets HTML content to display

set_base_url(base_url)

Sets the base URL for resolving relative URLs in HTML content

set_javascript_enabled(enabled)

Sets whether JavaScript execution is enabled

load_url(url)

Loads the specified URL

load_html(html, base_url=None)

Loads the specified HTML content with an optional base URL

go_back()

Navigates back in the browsing history

go_forward()

Navigates forward in the browsing history

reload()

Reloads the current page

Map

media

An interactive map component.

XML Usage

<Map 
  location="{data.location}" 
/>

Python Usage

from components.basic import Map

# Create a map
map_view = Map()
map_view.set_location((40.7128, -74.0060, 12))  # Latitude, Longitude, Zoom level

# Add markers
map_view.add_marker(40.7128, -74.0060, "New York City")
map_view.add_marker(37.7749, -122.4194, "San Francisco")

# Update the map (after adding markers or changing location)
map_view.update_map()

Methods

set_location(location)

Sets the map location and zoom level as a tuple (latitude, longitude, zoom)

add_marker(latitude, longitude, title)

Adds a marker at the specified coordinates with a title

update_map()

Updates the map display after making changes

Control

Canvas

control

A drawing and rendering surface for custom graphics.

XML Usage

Canvas is primarily created and managed in Python code.

Python Usage

from components.canvas import Canvas
# Assuming application_path is defined, e.g., self.application_path in an App class

# Create a canvas
canvas = Canvas()

# Define the draw handler in your app class
# def on_draw(self, canvas_obj): # Renamed 'canvas' to 'canvas_obj' to avoid conflict
#     # Draw a rectangle
#     canvas_obj.draw_rect(10, 10, 100, 50, "#ff0000")
    
#     # Draw text
#     canvas_obj.draw_text("Hello, Canvas!", 20, 40, "#ffffff")
    
#     # Draw a circle
#     canvas_obj.draw_circle("#0000ff", 150, 100, 30)
    
#     # Draw a line
#     canvas_obj.draw_line(200, 10, 300, 50, "#00ff00", 2)
    
#     # Draw a polygon
#     points = [(50, 50), (100, 50), (75, 100)]
#     canvas_obj.draw_polygon("#ffff00", points)
    
#     # Draw an ellipse
#     canvas_obj.draw_ellipse("#ff00ff", (200, 200, 100, 50))
    
#     # Draw an arc
#     canvas_obj.draw_arc("#00ffff", (300, 300, 100, 100), 0, 180)
    
#     # Draw a bitmap
#     from runtime.ImageHelper import ImageHelper
#     bitmap = ImageHelper.get_bitmap(application_path + "/res/images/logo.png") 
#     canvas_obj.draw_bitmap(bitmap, 400, 50)

# Set the draw handler
# canvas.on_draw = self.on_draw

# Handle touch events in your app class
# def on_touch(self, element, action, x, y):
#     print(f"Touch at ({x}, {y}) with action {action}")
# canvas.on_touch = self.on_touch

# Handle key events in your app class
# def on_keyup(self, element, key_code, event):
#     print(f"Key pressed: {key_code}")
# canvas.on_keyup = self.on_keyup

# Get canvas dimensions
# width = canvas.get_width()
# height = canvas.get_height()

# Update and clear
# canvas.update()  # Refresh the display
# canvas.clear()   # Clear all drawings

Methods

get_width()

Returns the canvas width

get_height()

Returns the canvas height

on_touch(element, action, x, y)

Handler for touch events on the canvas

on_keyup(element, keyCode, event)

Handler for key up events

close()

Closes the canvas

is_disposed()

Checks if the canvas is disposed

update()

Updates the canvas display

clear()

Clears the canvas

on_draw(canvas)

Handler for drawing on the canvas

draw_text(text, x, y, color)

Draws text at the specified position

draw_rect(left, top, right, bottom, color, width=None)

Draws a rectangle

draw_circle(color, cx, cy, radius)

Draws a circle

draw_line(startx, starty, stopx, stopy, color, width=None, anti_alias=True)

Draws a line

draw_color(color)

Sets the background color

draw_ellipse(color, rect, width=0)

Draws an ellipse

draw_arc(color, rect, start_angle, stop_angle, width=1)

Draws an arc

draw_polygon(color, points, width=0)

Draws a polygon

draw_colors(colors, x, y, width, height, size)

Draws a color gradient

draw_bitmap(bitmap, x, y, width=None, height=None)

Draws a bitmap image

Custom Components

Custom Components let you package reusable UI plus logic into a standalone unit (a subclass of Component) and embed it as a custom XML tag inside any app. This section walks through authoring the component, declaring it with components.yaml, embedding it in .asxml, and testing it standalone, using a fictional StarRating widget consumed by a BookReviews app as the running example.

Overview

A Custom Component is a self-contained, reusable unit that bundles its own ASXML layout, Python logic, and configurable attributes. Once authored, any app can drop it into a view as if it were a built-in component.

The running example is StarRating, a small star-based rating widget. The BookReviews app declares it in components.yaml, then uses it inline:

<StarRating id="rating" max="5" current-value="3" label="Rate this book" />

The same widget works in any other app that adds star_rating to its components.yaml. No copy-pasting, no shared code, no rebuilding.

Folder Layout

Custom Components live under a global components directory (alongside your apps directory). The folder structure mirrors a normal app, with one important difference: the entry class subclasses Component, and the metadata.json has scope: "system".

star_rating/
├── star_rating.py       Entry: defines StarRatingComponent(Component)
├── icon.png             Icon shown in component registries
├── metadata.json        name, scope, displayName, description
├── data/
│   └── data.json
├── res/
│   └── images/
│       ├── star_filled.png
│       └── star_empty.png
└── view/
    └── star_rating.asxml

A minimal metadata.json for the component:

{
  "icon": "icon.png",
  "scope": "system",
  "displayName": "Star Rating",
  "description": "Reusable star-based rating widget."
}

Subclassing Component

The component's entry file defines a subclass of Component (imported from appsudo). The class is paired with a plain Python data holder, inflates its own ASXML view, and exposes configurable instance attributes that become the component's public XML surface.

from appsudo import Component
from components.view import ViewInflater


class StarRatingData:
    def __init__(self, component_path):
        self.component_path = component_path
        self.stars = []


class StarRatingComponent(Component):
    def __init__(self, component_path):
        Component.__init__(self, "StarRating", component_path)
        self.inflater = ViewInflater(self)
        self.data = StarRatingData(component_path)
        self.max = 5
        self.current_value = 0
        self.label = ""
        self.on_rate = None
        self.ui_view = self.inflater.inflate("star_rating", self.data)

    def on_star_click(self, element, star):
        self.current_value = star.get("index")
        if callable(self.on_rate):
            self.on_rate(self, self.current_value)

The Component Contract

Subclass Component

Import Component from appsudo and extend it.

Call Component.__init__(self, "TagName", component_path)

The first argument is the XML tag name consumers use to embed the component (<StarRating /> in this case).

Inflate a view

Use ViewInflater(self).inflate("view_name", data) to load the component's own ASXML layout from its view/ folder.

Expose configurable attributes

Instance attributes set in __init__ become the component's public XML attributes. Consumers can set them via XML or programmatically after find_element_by_id.

Define event handlers

Methods like on_star_click are referenced from the component's ASXML via on-click="on_star_click", the same way apps wire handlers.

Use a Data class

Pair the component with a plain Python data class (e.g., StarRatingData) that the view binds against via {data.field_name}.

components.yaml

To use a Custom Component, the consumer app declares it in a top-level components.yaml file. The framework reads this manifest at startup, locates each named component in the components registry, and makes its XML tag available to every .asxml view in the app.

The consumer app's components.yaml:

components:
  - name: star_rating
    version: 1.0.0

Manifest Fields

componentslist

Top-level array. Each entry declares one Custom Component dependency.

namestring

Folder name of the component under the global components directory. Resolves to /appsudo-components/<name>/.

versionstring

Version requirement. Pin to a known-good version for stability.

Embedding

Once the component is declared in components.yaml, embed it like any other view. Set its attributes inline, bind to data fields with {data.field}, and give it an id so you can retrieve the instance from Python.

A consumer's review_form.asxml mixing the custom StarRating with built-in views:

<VerticalView width="fill" height="fill" padding="15dp">
  <Label text="How was the book?" margin-bottom="10dp" />

  <StarRating id="rating"
              max="5"
              current-value="{data.last_rating}"
              label="Rate this book" />

  <TextBox id="review_text" placeholder="Write your review..." />
  <Button text="Submit" on-click="on_submit_review" />
</VerticalView>

Public Attributes

idstring

Identifier for find_element_by_id lookup.

maxnumber

Total number of stars to render.

current-valuenumber | {data.field}

Initial selected rating (between 0 and max). Accepts a data binding.

labelstring

Optional caption rendered above the stars.

on-ratecallback name

Python handler invoked when the user taps a star. Signature: def handler(component, value): ....

Programmatic Access

find_element_by_id returns the live component instance, so you can set callbacks or read state after inflation:

def setup(self):
    self.ui_view = self.inflater.inflate("review_form", self.data)
    rating = self.ui_view.find_element_by_id("rating")
    rating.on_rate = self.handle_rating

def handle_rating(self, component, value):
    self.data.last_rating = value

Testing

While developing a Custom Component you usually want to run it on its own without writing a full host app. The convention is to ship a thin App wrapper inside the component folder that instantiates the component, configures test values, and renders it. You then launch that wrapper through the existing VS Code Appsudo App Runner command (see Setup > Step 4).

A minimal star_rating_app.py placed next to star_rating.py:

from appsudo import App
from star_rating import StarRatingComponent


class StarRatingApp(App):
    def __init__(self, application_path):
        App.__init__(self, "StarRatingApp", application_path)
        self.component = StarRatingComponent(application_path)
        self.component.max = 5
        self.component.current_value = 3
        self.component.label = "Try the widget"

    def setup(self):
        self.ui_view = self.component.ui_view

    def run(self):
        super().run()
        self.render(self.ui_view.render())

Workflow

1. Open the component folder in VS Code

Treat the component folder as if it were an app project.

2. Press Cmd/Ctrl + Shift + P

Open the command palette and run Appsudo App Runner.

3. Select a device

Pick the target Android device or emulator.

4. Tweak values in the App wrapper

Update the test attributes (max, current_value, label) and re-run to iterate.

Pygame to Mini game

Bringing an existing pure-pygame program into Appsudo as a mini-game is largely mechanical: most of your code keeps working as-is. Appsudo ships a near-complete pygame subset under components.extras.game, exposed as the regular pygame module. The migration is mostly about wrapping the game in an App, replacing the standalone main() loop with a GameWindow subclass that draws to an Appsudo Canvas, and adjusting input handling for the overlay game controller used on mobile.

Overview

A mini-game is a pygame-driven Appsudo app whose frame loop is owned by GameWindow and whose drawing surface is an Appsudo Canvas. Game logic (sprites, physics, collisions, particles) does not change; only the outer scaffolding does.

Most pygame code works unchanged. import pygame works as in any pygame project, and every pygame class and function you are used to is available. Calls that are not fully wired (for example, some pygame.mixer controls) no-op rather than raise, so games can be ported incrementally without first stubbing out unsupported APIs.

Migration Checklist

Every mechanical change at a glance. The sections that follow expand on each row with a small before/after.

Pure pygame Appsudo mini game Why
import pygame import pygame (unchanged) Appsudo's pygame subset is exposed as the regular pygame module.
pygame.init(), pygame.mixer.init() Safe to call but unnecessary GameWindow initialises display; pygame.mixer.init() is a no-op because Appsudo manages audio on demand.
Standalone def main(): while running: class MyGame(GameWindow): def on_draw(self, canvas): GameWindow owns the frame loop; it calls on_draw per frame.
Module-level globals (SCREEN_WIDTH, colours…) Instance attributes on the GameWindow subclass (self.SCREEN_WIDTH, …) Mini-games are instantiated by the App; instance state is cleaner than module-level state.
screen = pygame.display.set_mode(...) drawn to globally canvas parameter passed into on_draw The Appsudo Canvas component owns the surface; you draw onto whatever canvas is handed to you.
K_UP/DOWN/LEFT/RIGHT, K_p, K_r K_DPAD_UP/DOWN/LEFT/RIGHT, K_BUTTON_START (keyboard keys still fire during desktop dev) Mobile users press an overlay game controller, not a keyboard.
running = False self.set_running(False) GameWindow exposes the loop flag.
pygame.quit() at end of main() (remove) The App framework tears down via on_close().
(new) App subclass wrapping the game class MyGameApp(App) with setup(), run(), play(), get_shareable(), on_close() Every mini-game ships as an App so the platform can launch, render, and share it.
File I/O for high-score Optionally DataStore from runtime.DataStore Persistent, Appsudo-managed storage. See DataStore.

1. Imports

Keep your existing pygame imports untouched. Add only the Appsudo modules you actually use: the App base class, the Canvas component, and the GameWindow base class for the game loop.

Before

import pygame
import random
import math

After

from appsudo import App
from components.canvas import Canvas
from components.extras.game.window import GameWindow

import pygame
import random
import math

2. Wrap the game in an App

Every mini-game ships as an App subclass that owns a Canvas view and the GameWindow subclass. The wrapper is a small, predictable skeleton.

class MyGameApp(App):
    def __init__(self, application_path):
        App.__init__(self, "MyGame", application_path)
        self.setup()

    def setup(self):
        self.ui_view = Canvas()
        self.game = MyGame(self.ui_view)

    def play(self):
        self.render(self.ui_view.render())

    def run(self):
        App.run(self)
        self.play()

    def get_shareable(self):
        return True   # flag: allow this app on feed / DM (search discoverability is unaffected)

    def on_close(self):
        pass

3. Subclass GameWindow instead of writing main()

Move your main() body into a subclass of GameWindow. GameWindow owns the frame loop and calls your on_draw(canvas) method every frame. The canvas argument is what you previously called screen.

Before

screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
clock = pygame.time.Clock()

def main():
    running = True
    while running:
        for event in pygame.event.get():
            ...
        screen.fill(COLOR_BACKGROUND)
        snake.draw(screen)
        pygame.display.flip()
        clock.tick(game_speed)

After

class MyGame(GameWindow):
    def __init__(self, canvas_view=None):
        super().__init__(canvas_view)
        self.GRID_SIZE = 20
        self.SCREEN_WIDTH = 600
        self.SCREEN_HEIGHT = 760
        self.COLOR_BACKGROUND = (13, 17, 23)
        self.clock = pygame.time.Clock()
        self.screen = pygame.display.set_mode((self.SCREEN_WIDTH, self.SCREEN_HEIGHT))
        self.game_speed = 10
        self.reset_game()

    def on_draw(self, canvas):
        self.handle_input()
        self.update_game_logic()
        canvas.fill(self.COLOR_BACKGROUND)
        self.snake.draw(canvas, self.GRID_SIZE)
        pygame.display.flip()
        self.clock.tick(self.game_speed)

4. Compose regular UI alongside the Canvas (optional)

This step is optional. Most mini-games render their entire UI inside the Canvas itself using pygame draw calls — score, HUD, pause menu, game-over screen — exactly as the original pygame program did. The skeleton in step 2, which simply renders Canvas() as the app's view, is enough for the majority of migrated games and is the recommended starting point.

If you do want to combine the canvas with native Appsudo UI components (e.g. a system Button for a settings sheet that lives outside the game surface), the standard ViewInflater + .asxml workflow works the same way it does for any other app. See the Components reference and the App Structure example. Do not feel obligated to introduce native UI just because it is possible.

5. Input: gamepad on mobile, keyboard for local dev

On mobile, players interact through an overlay game controller: either one of the built-in controller components or one your app provides itself. Desktop machines running the Appsudo Runner locally additionally forward WASD and arrow keys, but that path is for development only; production input on mobile flows through the overlay controller.

Pure pygame Mini game Purpose
K_UP / K_wK_DPAD_UP (+ K_w during local dev)Up
K_DOWN / K_sK_DPAD_DOWN (+ K_s during local dev)Down
K_LEFT / K_aK_DPAD_LEFT (+ K_a during local dev)Left
K_RIGHT / K_dK_DPAD_RIGHT (+ K_d during local dev)Right
K_pK_BUTTON_STARTPause / resume
K_r (game-over restart)K_BUTTON_STARTRestart
running = Falseself.set_running(False)Quit

Before

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    if event.type == pygame.KEYDOWN:
        if event.key == pygame.K_ESCAPE:
            running = False
        elif event.key == pygame.K_p:
            paused = not paused
        elif event.key in [pygame.K_UP, pygame.K_w]:
            snake.turn((0, -1))

After

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        self.set_running(False)
    if event.type == pygame.KEYDOWN:
        if event.key == pygame.K_ESCAPE:
            self.set_running(False)
        elif event.key == pygame.K_BUTTON_START:
            self.toggle_pause()
        elif event.key in [pygame.K_DPAD_UP, pygame.K_w]:
            self.snake.turn((0, -1))

6. Sound

pygame.mixer.Sound works like normal pygame. Build sounds the same way you would in pygame. No Appsudo-specific changes needed.

def generate_sound(frequency=440, duration=0.1):
    sample_rate = pygame.mixer.get_init()[0]
    max_amplitude = 2 ** (pygame.mixer.get_init()[2] - 1) - 1
    n_samples = int(round(duration * sample_rate))
    buf = bytearray([0] * (n_samples * 2))
    for i in range(n_samples):
        value = int(round(max_amplitude * math.sin(2 * math.pi * frequency * i / sample_rate)))
        buf[i*2] = value & 0xFF
        buf[i*2+1] = (value >> 8) & 0xFF
    return pygame.mixer.Sound(buf)

sound_eat = generate_sound(660, 0.08)
sound_eat.play()

7. Persistence

Plain file I/O works, but the Appsudo idiom for app-scoped persistence is DataStore.

Before

try:
    with open("highscore.txt", "r") as f:
        self.high_score = int(f.read())
except (FileNotFoundError, ValueError):
    self.high_score = 0

# later
with open("highscore.txt", "w") as f:
    f.write(str(self.high_score))

After

self.high_score = DataStore.get("highscore", default=0)

# later
DataStore.put("highscore", self.high_score)

8. Conventions to follow

These rules apply to mini-games like any other Appsudo app.

  • One render per app: self.render(...) exactly once, inside run() or a helper it invokes. See App Conventions.
  • App-Structure layout: same metadata.json / data/ / res/ / view/ folder shape as any other app. See App Structure.
  • Canvas is the game surface: never bind to pygame.display.set_mode results directly; draw onto the canvas passed into on_draw. See Canvas.
  • Frame end: call pygame.display.flip() and self.clock.tick(speed) at the bottom of on_draw.
  • Mobile input via overlay controller; WASD and arrows are dev-only. Use InputManager to ship a custom on-screen controller.
  • get_shareable() only gates posting to the feed and DM; the app remains searchable and usable regardless.
  • Compose regular UI alongside the Canvas for scoreboards, menus, settings. See Components.
  • Reference: the Tetris Game example shows all of the above in one place.

HTML based mini app

An HTML based mini app is an Appsudo app whose entire user interface is rendered by a single WebView component that points at an HTML file shipped inside the app folder. The app does not need a webserver. All HTML, CSS and JavaScript files live next to the Python entry point. The framework converts each local file path into a secured URL the WebView will load, and JavaScript inside the page runs in the same realm as the rest of the app so the on screen game controller can deliver key events directly to it.

1. Overview

If you already know how to write an HTML page, you can ship it as a mini app. The Python side stays small: build a view tree with one WebView, compute a secured URL for the first HTML page, and call set_url. Everything interactive lives in the HTML, the CSS and the JavaScript.

Games declare "type": "Game" in metadata.json. That tells the platform to render the on screen game controller below the WebView. Pressing a controller button fires a real keydown event on window inside the page; releasing fires a keyup. Regular apps declare "type": "App" and no overlay is shown.

2. Folder layout

Every HTML based mini app follows the same shape. The Python file at the top, a single view in view/, every HTML page and asset under static/, and the usual metadata.json plus logo.png.

myhtmlapp/
  myhtmlapp.py           # App subclass, entry point
  metadata.json          # icon, type, tags, author, displayName, scope
  logo.png               # app icon
  view/
    webview.asxml        # one WebView, fill x fill
  static/
    index.html           # first page loaded by the WebView
    page_two.html        # any extra pages the app ships
    style.css            # optional shared assets
    script.js

The Python class always extends App, owns the view tree built from view/webview.asxml, looks up the webview element by id, and sets its first URL once during post_setup.

3. Working with URLs and get_secured_url

A raw file:// URL such as file:///home/.../myhtmlapp/static/index.html is not loaded directly by the WebView. The view only accepts URLs that have been wrapped by get_secured_url. The framework hands back a token bound URL that the view will honour. Passing the raw path results in a load failure, so always wrap the path first.

from appsudo import App
from components.view import ViewInflater

class MyHtmlApp(App):
    def __init__(self, application_path):
        App.__init__(self, "MyHtmlApp", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        index_url = webview.get_secured_url(
            f"file://{self.application_path}/static/index.html"
        )
        webview.set_url(index_url)

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

Four things to remember

  • Always interpolate self.application_path. A hard coded path breaks once the app is installed because the install location is not known ahead of time.
  • The string has to start with file:// before being passed to get_secured_url. The wrapper expects a URL, not a bare filesystem path.
  • Call webview.set_url(secured_url) from post_setup, after render() has been called in run(). That guarantees the WebView is attached to the view tree before it tries to load the first page.
  • Each page the HTML can navigate to needs its own secured URL computed by get_secured_url. There is no way for the HTML to ask the framework for one at runtime, so the Python file pre computes them and hands them to the HTML before the page boots (see the multi page article below).

4. The view file

Every HTML based mini app uses the same one line view. A single WebView inside a fill by fill container.

<VerticalView width="fill" height="fill" background-color="#000000">
    <WebView
        id="webview"
        width="fill"
        height="fill"
    />
</VerticalView>

The background colour only shows while the first HTML page is loading. Pick a colour that matches your first frame: black for a dark themed game, white for a light themed app, or any brand colour for a custom splash feel.

5. The Python wrapper

The Python template from article 3 is the whole wrapper. Four methods do all the work and every HTML app implements them the same way.

__init__(self, application_path)

Pass the app name and application_path up to App.__init__, build the ViewInflater, then call self.setup().

setup(self)

Inflate the webview view and store the result in self.ui_view.

post_setup(self)

Look the WebView up by id with find_element_by_id("webview"), compute the secured URL for the first page, call set_url. If the app has additional pages, push their secured URLs into window.localStorage via webview.eval(...) in the same method.

run(self)

Call App.run(self), render the view tree once with self.render(self.ui_view.render()), then call self.post_setup(). Render before post_setup so the WebView exists when set_url fires.

6. Multi page apps without a webserver

For apps that ship more than one HTML file, the Python wrapper pre computes a secured URL for every page in static/ and hands them to the page by writing them to window.localStorage. The HTML reads the keys back whenever the user clicks a link. The page never has to know the on disk path or the secured token. It only has to know the names you chose.

Python side

def post_setup(self):
    webview = self.ui_view.find_element_by_id("webview")
    home    = webview.get_secured_url(f"file://{self.application_path}/static/index.html")
    about   = webview.get_secured_url(f"file://{self.application_path}/static/about.html")
    contact = webview.get_secured_url(f"file://{self.application_path}/static/contact.html")
    webview.set_url(home)
    webview.eval(f"""
        window.localStorage.setItem('home', '{home}');
        window.localStorage.setItem('about', '{about}');
        window.localStorage.setItem('contact', '{contact}');
    """)

HTML side

<a href="#" onclick="window.location = localStorage.getItem('about'); return false;">About</a>

7. Game example, single HTML file

A tiny canvas game that ships as one HTML file. A coloured square moves with the directional pad and changes colour when the user presses BUTTON_A. The page listens for both e.key and e.keyCode so it works with a hardware keyboard during desktop development and with the on screen controller on a real device.

arcadedot.py

from appsudo import App
from components.view import ViewInflater

class ArcadeDotApp(App):
    def __init__(self, application_path):
        App.__init__(self, "ArcadeDot", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        index_url = webview.get_secured_url(
            f"file://{self.application_path}/static/index.html"
        )
        webview.set_url(index_url)

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

static/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Arcade Dot</title>
<style>
  html, body { margin: 0; height: 100%; background: #0b0b1f; overflow: hidden; }
  canvas { display: block; width: 100vw; height: 100vh; touch-action: none; }
</style>
</head>
<body>
<canvas id="stage" width="600" height="400"></canvas>
<script>
  const canvas = document.getElementById('stage');
  const ctx = canvas.getContext('2d');
  const player = { x: 300, y: 200, size: 24, color: '#00ffd1' };
  const palette = ['#00ffd1', '#ff5e7a', '#ffd166', '#9b5de5'];
  let colorIndex = 0;
  const speed = 6;

  function draw() {
    ctx.fillStyle = '#0b0b1f';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = player.color;
    ctx.fillRect(player.x - player.size / 2, player.y - player.size / 2, player.size, player.size);
  }

  function move(dx, dy) {
    player.x = Math.max(player.size, Math.min(canvas.width  - player.size, player.x + dx * speed));
    player.y = Math.max(player.size, Math.min(canvas.height - player.size, player.y + dy * speed));
    draw();
  }

  function cycleColor() {
    colorIndex = (colorIndex + 1) % palette.length;
    player.color = palette[colorIndex];
    draw();
  }

  window.addEventListener('keydown', function (e) {
    switch (e.key) {
      case 'ArrowUp':    return move(0, -1);
      case 'ArrowDown':  return move(0,  1);
      case 'ArrowLeft':  return move(-1, 0);
      case 'ArrowRight': return move( 1, 0);
      case 'Enter':
      case 'a':          return cycleColor();
    }
    switch (e.keyCode) {
      case 19: return move(0, -1);
      case 20: return move(0,  1);
      case 21: return move(-1, 0);
      case 22: return move( 1, 0);
      case 96: return cycleColor();
    }
  });

  draw();
</script>
</body>
</html>

metadata.json

{
  "icon": "logo.png",
  "type": "Game",
  "tags": "Arcade",
  "author": "You",
  "displayName": "Arcade Dot",
  "scope": "system"
}

8. Game example, multi HTML files

A multi page game that uses the localStorage URL handoff from article 6. The app ships a menu and two tiny games. Clicking a menu entry swaps the WebView to that game. Pressing BUTTON_SELECT inside any game returns the user to the menu.

miniarcade.py

from appsudo import App
from components.view import ViewInflater

class MiniArcadeApp(App):
    def __init__(self, application_path):
        App.__init__(self, "MiniArcade", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        menu  = webview.get_secured_url(f"file://{self.application_path}/static/menu.html")
        tap   = webview.get_secured_url(f"file://{self.application_path}/static/coin_tap.html")
        dodge = webview.get_secured_url(f"file://{self.application_path}/static/dodge.html")
        webview.set_url(menu)
        webview.eval(f"""
            window.localStorage.setItem('menu', '{menu}');
            window.localStorage.setItem('tap', '{tap}');
            window.localStorage.setItem('dodge', '{dodge}');
        """)

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

static/menu.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Mini Arcade</title>
<style>
  body { margin: 0; height: 100vh; background: #0c0c1a; color: #fff; font-family: system-ui, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; }
  button { font: 600 18px system-ui; padding: 12px 24px; border: 0; border-radius: 12px; cursor: pointer; background: #4f46e5; color: #fff; }
  button:focus { outline: 3px solid #fbbf24; }
</style>
</head>
<body>
<h1>Mini Arcade</h1>
<button data-go="tap" autofocus>Coin Tap</button>
<button data-go="dodge">Dodge It</button>
<script>
  document.querySelectorAll('button[data-go]').forEach(function (b) {
    b.addEventListener('click', function () { window.location = localStorage.getItem(b.dataset.go); });
  });
  window.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.keyCode === 96) document.activeElement.click();
  });
</script>
</body>
</html>

static/coin_tap.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><title>Coin Tap</title>
<style>
  body { margin: 0; height: 100vh; background: #11172e; color: #fde047; font-family: system-ui, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; }
  h1 { font-size: 48px; margin: 0; }
  p  { color: #cbd5f5; }
</style>
</head>
<body>
<p>Press A to collect a coin. SELECT to go back.</p>
<h1 id="score">0</h1>
<script>
  let score = 0;
  const out = document.getElementById('score');
  window.addEventListener('keydown', function (e) {
    if (e.keyCode === 96 || e.key === 'Enter' || e.key === 'a') { score++; out.textContent = score; }
    if (e.keyCode === 109) window.location = localStorage.getItem('menu');
  });
</script>
</body>
</html>

static/dodge.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><title>Dodge It</title>
<style>
  body { margin: 0; background: #050714; overflow: hidden; }
  canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="stage" width="320" height="480"></canvas>
<script>
  const c = document.getElementById('stage');
  const g = c.getContext('2d');
  const player = { x: 160, y: 440 };
  const block  = { x: 160, y: 0, dy: 4 };
  let alive = true;

  function frame() {
    g.fillStyle = '#050714'; g.fillRect(0, 0, c.width, c.height);
    g.fillStyle = '#22d3ee'; g.fillRect(player.x - 14, player.y - 14, 28, 28);
    g.fillStyle = '#f87171'; g.fillRect(block.x - 12, block.y - 12, 24, 24);
    if (!alive) {
      g.fillStyle = '#fde047';
      g.font = '20px system-ui';
      g.fillText('You lose. SELECT to quit.', 40, 240);
      return;
    }
    block.y += block.dy;
    if (block.y > c.height) { block.y = 0; block.x = 20 + Math.random() * (c.width - 40); }
    if (Math.abs(block.x - player.x) < 24 && Math.abs(block.y - player.y) < 24) alive = false;
    requestAnimationFrame(frame);
  }

  window.addEventListener('keydown', function (e) {
    if (e.keyCode === 21 || e.key === 'ArrowLeft')  player.x = Math.max(14, player.x - 12);
    if (e.keyCode === 22 || e.key === 'ArrowRight') player.x = Math.min(c.width - 14, player.x + 12);
    if (e.keyCode === 109) window.location = localStorage.getItem('menu');
  });

  frame();
</script>
</body>
</html>

Going from one HTML to many HTML files only changes two things. The Python wrapper pre computes one secured URL per file and writes them to localStorage inside post_setup. Every page knows the keys it needs to read out and where to redirect when the user picks something.

9. Regular HTML app example, single HTML file

The smallest non game shape. One HTML file that the WebView loads, no localStorage handoff, no extra pages. The example is a tip calculator with two sliders for the bill amount and the tip percent so the input works cleanly on touch without a soft keyboard.

tipcalc.py

from appsudo import App
from components.view import ViewInflater

class TipCalcApp(App):
    def __init__(self, application_path):
        App.__init__(self, "TipCalc", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        index_url = webview.get_secured_url(
            f"file://{self.application_path}/static/index.html"
        )
        webview.set_url(index_url)

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

static/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tip Calculator</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #f7f7fb; color: #1f2937; }
  label { display: block; margin-top: 16px; font-weight: 600; }
  input[type="range"] { width: 100%; }
  .row { display: flex; justify-content: space-between; margin-top: 4px; font-variant-numeric: tabular-nums; }
  .total { margin-top: 24px; padding: 16px; background: #4f46e5; color: #fff; border-radius: 12px; font-size: 28px; text-align: center; }
</style>
</head>
<body>
<h1>Tip Calculator</h1>

<label for="bill">Bill amount</label>
<input id="bill" type="range" min="0" max="200" value="40" step="1">
<div class="row"><span>$0</span><span id="bill-out">$40</span><span>$200</span></div>

<label for="tip">Tip percent</label>
<input id="tip" type="range" min="0" max="30" value="18" step="1">
<div class="row"><span>0%</span><span id="tip-out">18%</span><span>30%</span></div>

<div class="total">Total: <span id="total">$47.20</span></div>

<script>
  const bill = document.getElementById('bill');
  const tip  = document.getElementById('tip');

  function recompute() {
    const b = Number(bill.value);
    const t = Number(tip.value);
    document.getElementById('bill-out').textContent = '$' + b;
    document.getElementById('tip-out').textContent  = t + '%';
    document.getElementById('total').textContent    = '$' + (b * (1 + t / 100)).toFixed(2);
  }
  bill.addEventListener('input', recompute);
  tip.addEventListener('input', recompute);
  recompute();
</script>
</body>
</html>

metadata.json

{
  "icon": "logo.png",
  "type": "App",
  "tags": "Utility",
  "author": "You",
  "displayName": "Tip Calculator",
  "scope": "system"
}

10. Regular HTML app example, multi HTML files

A small three page info viewer with a home, an about and a contact page. It reuses the localStorage URL handoff from article 6, applied to a non game.

recipebook.py

from appsudo import App
from components.view import ViewInflater

class RecipeBookApp(App):
    def __init__(self, application_path):
        App.__init__(self, "RecipeBook", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        home    = webview.get_secured_url(f"file://{self.application_path}/static/index.html")
        about   = webview.get_secured_url(f"file://{self.application_path}/static/about.html")
        contact = webview.get_secured_url(f"file://{self.application_path}/static/contact.html")
        webview.set_url(home)
        webview.eval(f"""
            window.localStorage.setItem('home', '{home}');
            window.localStorage.setItem('about', '{about}');
            window.localStorage.setItem('contact', '{contact}');
        """)

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

static/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Recipe Book</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #fafafa; color: #222; }
  nav a { margin-right: 16px; color: #4f46e5; text-decoration: none; }
  h1 { margin-top: 0; }
</style>
</head>
<body>
<nav>
  <a href="#" data-go="home">Home</a>
  <a href="#" data-go="about">About</a>
  <a href="#" data-go="contact">Contact</a>
</nav>
<h1>Recipe Book</h1>
<p>Welcome. Pick a recipe from the menu or read about this app.</p>
<script>
  document.querySelectorAll('nav a[data-go]').forEach(function (link) {
    link.addEventListener('click', function (e) {
      e.preventDefault();
      const target = localStorage.getItem(link.dataset.go);
      if (target) window.location = target;
    });
  });
</script>
</body>
</html>

The about.html and contact.html files repeat the same <nav> block so the user can move between any two pages from anywhere. Anything specific to that page goes below the nav.

metadata.json

{
  "icon": "logo.png",
  "type": "App",
  "tags": "Reference",
  "author": "You",
  "displayName": "Recipe Book",
  "scope": "system"
}

11. Key mapping table for the game controller

When the app metadata.json declares "type": "Game", an on screen controller appears below the WebView. Pressing a controller button fires a keydown event on window inside the HTML and releasing fires a keyup. The table lists the e.keyCode and e.key values your code can match against.

Controller button e.keyCode e.key
DPAD_UP 19 "ArrowUp"
DPAD_DOWN 20 "ArrowDown"
DPAD_LEFT 21 "ArrowLeft"
DPAD_RIGHT 22 "ArrowRight"
BUTTON_A 96 "Enter" or "a"
BUTTON_B 97 " " or "b"
BUTTON_X 99 "x"
BUTTON_Y 100 "y"
BUTTON_L1 102 no e.key
BUTTON_R1 103 no e.key
BUTTON_START108 no e.key
BUTTON_SELECT 109 no e.key

Examples

Learn Appsudo through practical, complete examples.

Todo List App

A simple todo list application that allows adding, completing, and removing tasks.

todo_app.py

from appsudo import App, AppRuntime
        
from components.view import ViewInflater
from components.form import Button, TextBox
from runtime.Helper import Helper
from runtime.DataStore import DataStore

class TodoItem:
  def __init__(self, text, completed=False):
      self.text = text
      self.completed = completed

class TodoListData:
  def __init__(self, application_path):
      self.application_path = application_path
      self.new_task = ""
      self.task_placeholder = "Enter a new task..."
      self.add_button_text = "Add Task"
      self.title = "My Todo List"
      self.list = []  # For storing the tasks

class TodoListApp(App):
  def __init__(self, application_path):
      App.__init__(self, "TodoList", application_path)
      self.inflater = ViewInflater(self)
      self.ui_view = None
      self.tasks = []
      self.data = TodoListData(application_path)
      self.setup()

  def setup(self):
      # Load saved tasks from DataStore
      DataStore.get("todo_tasks", self.load_tasks)
  
  def load_tasks(self, saved_tasks):
      if saved_tasks:
          try:
              import json
              task_data = json.loads(saved_tasks)
              for item in task_data:
                  self.tasks.append(TodoItem(item["text"], item["completed"]))
          except Exception as e:
              Helper.log(f"Error loading tasks: {e}")
      
      # Create UI
      self.ui_view = self.inflater.inflate("todo_list", self.data)
      self.refresh_task_list()
      
  def save_tasks(self):
      # Save tasks to DataStore
      try:
          import json
          task_data = []
          for task in self.tasks:
              task_data.append({
                  "text": task.text,
                  "completed": task.completed
              })
          DataStore.set("todo_tasks", json.dumps(task_data), lambda r: None)
      except Exception as e:
          Helper.log(f"Error saving tasks: {e}")
  
  def on_task_input_change(self, element):
      self.data.new_task = element.get_text()
  
  def on_add_task(self, element):
      if self.data.new_task.strip():
          # Add new task
          self.tasks.append(TodoItem(self.data.new_task))
          
          # Clear input
          input_box = self.ui_view.find_element_by_id("task_input")
          input_box.set_text("")
          self.data.new_task = ""
          
          # Refresh list and save
          self.refresh_task_list()
          self.save_tasks()
  
  def on_create_task_view(self, parent):
      # Create a view for each task
      return self.inflater.inflate("todo_item", None)
  
  def on_bind_task_view(self, view, task):
      # Update task item view with data
      checkbox = view.find_element_by_id("task_checkbox")
      checkbox.set_text(task.text)
      checkbox.set_checked(task.completed)
      
      # Set delete button handler
      delete_btn = view.find_element_by_id("delete_button")
      delete_btn.set_tag(self.tasks.index(task))  # Store task index in button tag
      
      # Set checkbox change handler
      checkbox.set_tag(self.tasks.index(task))  # Store task index in checkbox tag
  
  def on_task_status_change(self, element, checked):
      # Update task completion status
      task_index = element.get_tag()
      if 0 <= task_index < len(self.tasks):
          self.tasks[task_index].completed = checked
          self.save_tasks()
  
  def on_delete_task(self, element):
      # Delete task
      task_index = element.get_tag()
      if 0 <= task_index < len(self.tasks):
          del self.tasks[task_index]
          self.refresh_task_list()
          self.save_tasks()
  
  def refresh_task_list(self):
      # Update task list view
      task_list_scroll_view = self.ui_view.find_element_by_id("task_list")
      if task_list_scroll_view:
          repeat_view_container = task_list_scroll_view.get_child_at(0) # Assuming RepeatView is the direct child
          if repeat_view_container: # This might be the RepeatView itself or a wrapper
               # If RepeatView is not the direct child, find it
              tasks_repeat_view = repeat_view_container.find_element_by_id("tasks_repeat_view") or repeat_view_container 
              if tasks_repeat_view and hasattr(tasks_repeat_view, 'clear'):
                   tasks_repeat_view.clear() # Clear items from RepeatView if it has clear method
              elif tasks_repeat_view and hasattr(tasks_repeat_view, 'set_items'): # Or reset items
                   tasks_repeat_view.set_items([])


      # Update data
      self.data.list = self.tasks
      
      # Rebind data to RepeatView
      repeat_view = self.ui_view.find_element_by_id("tasks_repeat_view")
      if repeat_view:
          repeat_view.set_items(self.tasks)
  
  def run(self):
      App.run(self)
      self.render(self.ui_view.render())

todo_list.asxml

<VerticalView width="fill" height="fill" padding="15dp" background-color="#f5f5f5">
  <Label text="{data.title}" font-size="24sp" font-color="#333" margin-bottom="15dp" alignment="center" />
  
  <HorizontalView width="fill" height="wrap" margin-bottom="15dp">
    <TextBox 
      id="task_input"
      width="fill" 
      height="wrap" 
      text="{data.new_task}" 
      placeholder="{data.task_placeholder}"
      on-change="on_task_input_change"
      padding="10dp"
      border-color="#ccc"
      border-width="1px"
      border-style="solid"
      border-radius="5px"
      margin-right="5dp"
    />
    
    <Button 
      text="{data.add_button_text}" 
      on-click="on_add_task"
      background-color="#4a6cff"
      font-color="white"
      padding="10dp"
      border-radius="5px"
    />
  </HorizontalView>
  
  <ScrollView id="task_list" width="fill" height="fill" background-color="white" border-radius="5px">
    <RepeatView 
      id="tasks_repeat_view"
      width="fill" 
      height="wrap" 
      items="{data.list}" 
      on-create-view="on_create_task_view" 
      on-bind-view="on_bind_task_view" 
      layout-type="vertical" 
    />
  </ScrollView>
</VerticalView>

todo_item.asxml

<HorizontalView width="fill" height="wrap" padding="10dp" border-color="#eee" border-width="1px" border-style="solid">
  <Checkbox 
    id="task_checkbox" 
    width="fill" 
    text="" 
    on-change="on_task_status_change" 
  />
  
  <Button 
    id="delete_button" 
    text="X" 
    on-click="on_delete_task"
    background-color="#ff5252"
    font-color="white"
    padding="5dp"
    border-radius="3px"
    width="30dp"
    height="30dp"
  />
</HorizontalView>

Weather App

A weather application that fetches and displays weather data for a specified location.

weather_app.py

from appsudo import App, AppRuntime
        
from components.view import ViewInflater
from components.form import Button, TextBox, Label, Progress
from runtime import HttpClient
from runtime.Helper import Helper
from runtime.DataStore import DataStore

class WeatherData:
  def __init__(self, application_path):
      self.application_path = application_path
      self.city = ""
      self.temperature = "-- °C"
      self.description = "Enter a city to get weather information"
      self.humidity = "Humidity: -- %"
      self.wind_speed = "Wind: -- km/h"
      self.placeholder = "Enter city name..."
      self.button_text = "Get Weather"
      self.title = "Weather App"
      self.loading = False
      self.error = ""

class WeatherApp(App):
  def __init__(self, application_path):
      App.__init__(self, "WeatherApp", application_path)
      self.inflater = ViewInflater(self)
      self.ui_view = None
      self.data = WeatherData(application_path)
      
      from runtime.DataLoader import DataLoader
      config = DataLoader.load_json(application_path, "config")
      self.api_key = config.get("weather_api_key", "YOUR_API_KEY_HERE") # Ensure this key is valid
      
      self.setup()
  
  def setup(self):
      DataStore.get("last_city", self.load_last_city)
  
  def load_last_city(self, city):
      if city:
          self.data.city = city
      self.ui_view = self.inflater.inflate("weather_view", self.data)
      if self.data.city:
          self.fetch_weather()
      else: # Ensure UI is updated even if no last city
          self.update_ui()

  def on_city_input_change(self, element):
      self.data.city = element.get_text()
  
  def on_get_weather(self, element):
      if not self.data.city.strip():
          self.data.error = "Please enter a city name"
          self.update_ui()
          return
      self.fetch_weather()
  
  def fetch_weather(self):
      self.data.loading = True
      self.data.error = ""
      self.update_ui()
      
      url = f"https://api.openweathermap.org/data/2.5/weather?q={self.data.city}&units=metric&appid={self.api_key}"
      HttpClient.current().get(url, {}, self.process_weather_response)
  
  def process_weather_response(self, response):
      self.data.loading = False
      try:
          import json
          weather_data = json.loads(response)
          if weather_data.get("cod") != 200:
              self.data.error = weather_data.get("message", "Error fetching weather data")
              # Clear previous weather data on error
              self.data.temperature = "-- °C"
              self.data.description = "Could not fetch weather."
              self.data.humidity = "Humidity: -- %"
              self.data.wind_speed = "Wind: -- km/h"
          else:
              self.data.temperature = f"{round(weather_data['main']['temp'])} °C"
              self.data.description = weather_data['weather'][0]['description'].capitalize()
              self.data.humidity = f"Humidity: {weather_data['main']['humidity']}%"
              self.data.wind_speed = f"Wind: {round(weather_data['wind']['speed'] * 3.6)} km/h" # Convert m/s to km/h
              self.data.error = "" # Clear error on success
              DataStore.set("last_city", self.data.city, lambda r: None)
      except Exception as e:
          self.data.error = f"Error processing data: {str(e)}"
          Helper.log(f"Weather API error: {e}")
           # Clear previous weather data on error
          self.data.temperature = "-- °C"
          self.data.description = "Error processing data."
          self.data.humidity = "Humidity: -- %"
          self.data.wind_speed = "Wind: -- km/h"
      self.update_ui()
  
  def update_ui(self):
      if not self.ui_view: return 

      elements_to_update = {
          "temperature_label": self.data.temperature,
          "description_label": self.data.description,
          "humidity_label": self.data.humidity,
          "wind_label": self.data.wind_speed,
          "error_label": self.data.error
      }
      for el_id, text_val in elements_to_update.items():
          element = self.ui_view.find_element_by_id(el_id)
          if element: element.set_text(text_val)

      loading_indicator = self.ui_view.find_element_by_id("loading_indicator")
      if loading_indicator:
          loading_indicator.set_indeterminate(self.data.loading)
          if not self.data.loading : loading_indicator.set_progress(0) 
  
  def run(self):
      App.run(self)
      if self.ui_view: 
          self.render(self.ui_view.render())

weather_view.asxml

<VerticalView width="fill" height="fill" padding="15dp" background-color="#f5f5f5">
  <Label text="{data.title}" font-size="24sp" font-color="#333" margin-bottom="15dp" alignment="center" />
  
  <HorizontalView width="fill" height="wrap" margin-bottom="15dp">
    <TextBox 
      id="city_input"
      width="fill" 
      height="wrap" 
      text="{data.city}" 
      placeholder="{data.placeholder}"
      on-change="on_city_input_change"
      padding="10dp"
      border-color="#ccc"
      border-width="1px"
      border-style="solid"
      border-radius="5px"
      margin-right="5dp"
    />
    
    <Button 
      text="{data.button_text}" 
      on-click="on_get_weather"
      background-color="#4a6cff"
      font-color="white"
      padding="10dp"
      border-radius="5px"
    />
  </HorizontalView>
  
  <Progress id="loading_indicator" width="fill" height="wrap" margin-bottom="10dp" progress-style="horizontal" />
  
  <Label id="error_label" text="{data.error}" font-color="#f44336" font-size="14sp" margin-bottom="10dp" alignment="center"/>
  
  <VerticalView width="fill" height="wrap" padding="20dp" background-color="white" border-radius="10px">
    <Label id="temperature_label" text="{data.temperature}" font-size="36sp" font-color="#333" alignment="center" margin-bottom="10dp" />
    <Label id="description_label" text="{data.description}" font-size="18sp" font-color="#555" alignment="center" margin-bottom="20dp" />
    <HorizontalView width="fill" height="wrap" alignment="center">
      <Label id="humidity_label" text="{data.humidity}" font-size="16sp" font-color="#666" margin-right="20dp"/>
      <Label id="wind_label" text="{data.wind_speed}" font-size="16sp" font-color="#666" />
    </HorizontalView>
  </VerticalView>
</VerticalView>

config.json

{
  "weather_api_key": "YOUR_OPENWEATHERMAP_API_KEY"
}

Tetris Game

A classic Tetris game implementation using the Canvas component for rendering. This example demonstrates how to create a game using the GameWindow class and PyGame.

tetris_app.py

from appsudo import App 
from components.canvas import Canvas
from components.extras.game.window import GameWindow 

import pygame
import random
import sys

class Colors: 
  DARK_BLUE = (44, 62, 80)
  LIGHT_BLUE = (52, 152, 219)
  WHITE = (255, 255, 255)
  GREEN = (46, 204, 113)
  PURPLE = (142, 68, 173)
  ORANGE = (230, 126, 34)
  YELLOW = (241, 196, 15)
  RED = (231, 76, 60)
  CYAN = (0, 255, 255)
  
  BLOCK_COLORS = [CYAN, YELLOW, PURPLE, GREEN, RED, ORANGE, LIGHT_BLUE]

  @classmethod
  def get_color_tuple(cls, idx):
      return cls.BLOCK_COLORS[idx % len(cls.BLOCK_COLORS)]

SHAPES = [
  [[1, 1, 1, 1]],  # I
  [[1, 1], [1, 1]],  # O
  [[0, 1, 0], [1, 1, 1]],  # T
  [[1, 0, 0], [1, 1, 1]],  # L
  [[0, 0, 1], [1, 1, 1]],  # J
  [[0, 1, 1], [1, 1, 0]],  # S
  [[1, 1, 0], [0, 1, 1]]   # Z
]

class Piece:
  def __init__(self, x, y, shape_idx):
      self.x = x
      self.y = y
      self.shape_idx = shape_idx
      self.shape = SHAPES[shape_idx]
      self.color_idx = shape_idx 

  def rotate(self):
      self.shape = [list(row) for row in zip(*self.shape[::-1])]

class TetrisGameLogic:
  def __init__(self, cols=10, rows=20):
      self.cols = cols
      self.rows = rows
      self.grid = [[0 for _ in range(cols)] for _ in range(rows)]
      self.current_piece = self.new_piece()
      self.next_piece = self.new_piece()
      self.score = 0
      self.level = 1
      self.lines_cleared_total = 0
      self.game_over = False
      self.fall_speed_initial = 500 
      self.fall_speed = self.fall_speed_initial
      self.fall_time = 0

  def new_piece(self):
      shape_idx = random.randint(0, len(SHAPES) - 1)
      start_x = self.cols // 2 - len(SHAPES[shape_idx][0]) // 2
      return Piece(start_x, 0, shape_idx)

  def is_valid_position(self, piece, offset_x=0, offset_y=0):
      for r_idx, row in enumerate(piece.shape):
          for c_idx, cell in enumerate(row):
              if cell:
                  x = piece.x + c_idx + offset_x
                  y = piece.y + r_idx + offset_y
                  if not (0 <= x < self.cols and 0 <= y < self.rows and self.grid[y][x] == 0):
                      return False
      return True

  def lock_piece(self):
      for r_idx, row in enumerate(self.current_piece.shape):
          for c_idx, cell in enumerate(row):
              if cell:
                  # Ensure y+r_idx is within grid bounds before assignment
                  if 0 <= self.current_piece.y + r_idx < self.rows and                      0 <= self.current_piece.x + c_idx < self.cols:
                      self.grid[self.current_piece.y + r_idx][self.current_piece.x + c_idx] = self.current_piece.color_idx + 1
      self.clear_lines()
      self.current_piece = self.next_piece
      self.next_piece = self.new_piece()
      if not self.is_valid_position(self.current_piece):
          self.game_over = True
  
  def clear_lines(self):
      lines_cleared_this_turn = 0
      new_grid = [row for row in self.grid if not all(cell > 0 for cell in row)] # Check for filled cells
      lines_cleared_this_turn = self.rows - len(new_grid)
      
      if lines_cleared_this_turn > 0:
          self.lines_cleared_total += lines_cleared_this_turn
          for _ in range(lines_cleared_this_turn):
              new_grid.insert(0, [0 for _ in range(self.cols)])
          self.grid = new_grid
          
          score_map = {1: 40, 2: 100, 3: 300, 4: 1200}
          self.score += score_map.get(lines_cleared_this_turn, 0) * self.level
          
          self.level = (self.lines_cleared_total // 10) + 1
          self.fall_speed = max(100, self.fall_speed_initial - (self.level - 1) * 20)

  def move(self, dx, dy):
      if not self.game_over and self.is_valid_position(self.current_piece, dx, dy):
          self.current_piece.x += dx
          self.current_piece.y += dy
          return True
      return False

  def drop(self):
      if not self.game_over:
          # Keep moving down as long as it's a valid move
          while self.is_valid_position(self.current_piece, 0, 1):
              self.current_piece.y += 1
              self.score += 1 
          self.lock_piece()


  def rotate_piece(self):
      if not self.game_over:
          original_shape = [list(row) for row in self.current_piece.shape] 
          self.current_piece.rotate()
          if not self.is_valid_position(self.current_piece):
              self.current_piece.shape = original_shape 

  def update(self, dt_ms):
      if self.game_over:
          return
      self.fall_time += dt_ms
      if self.fall_time >= self.fall_speed:
          self.fall_time = 0
          if not self.move(0, 1):
              self.lock_piece()
  
  def reset_game(self):
      self.grid = [[0 for _ in range(self.cols)] for _ in range(self.rows)]
      self.current_piece = self.new_piece()
      self.next_piece = self.new_piece()
      self.score = 0
      self.level = 1
      self.lines_cleared_total = 0
      self.game_over = False
      self.fall_speed = self.fall_speed_initial
      self.fall_time = 0


class TetrisGamePyGame(GameWindow):
  def __init__(self, application_path, canvas_view):
      super().__init__(canvas_view) 
      pygame.init()
      
      self.cell_size = 25 
      self.game_logic = TetrisGameLogic()

      self.grid_width_px = self.game_logic.cols * self.cell_size
      self.grid_height_px = self.game_logic.rows * self.cell_size
      
      self.info_panel_width = 150
      self.screen_width = self.grid_width_px + self.info_panel_width + 30 
      self.screen_height = self.grid_height_px + 40 
      
      # This surface is managed by GameWindow, do not re-set_mode here
      # self.screen = pygame.display.set_mode((self.screen_width, self.screen_height)) 
      # pygame.display.set_caption("AppSudo Tetris") # Caption is also handled by GameWindow

      self.font_small = pygame.font.Font(None, 24)
      self.font_large = pygame.font.Font(None, 36)
      self.font_game_over = pygame.font.Font(None, 48)

      self.clock = pygame.time.Clock()
      # self.last_fall_time = pygame.time.get_ticks() # Replaced by game_logic.fall_time and dt

  def on_draw(self, surface): 
      dt = self.clock.tick(60) # Get delta time in milliseconds

      for event in pygame.event.get():
          if event.type == pygame.QUIT:
              self.running = False # Signal GameWindow to stop
              return # Exit on_draw early
          if event.type == pygame.KEYDOWN:
              if self.game_logic.game_over:
                  if event.key == pygame.K_r: 
                      self.game_logic.reset_game()
              else:
                  if event.key == pygame.K_LEFT:
                      self.game_logic.move(-1, 0)
                  elif event.key == pygame.K_RIGHT:
                      self.game_logic.move(1, 0)
                  elif event.key == pygame.K_DOWN:
                      if self.game_logic.move(0, 1):
                           self.game_logic.score +=1 
                      else: # If cannot move down, lock piece
                          self.game_logic.lock_piece()
                      self.game_logic.fall_time = 0 # Reset fall timer on manual down
                  elif event.key == pygame.K_UP:
                      self.game_logic.rotate_piece()
                  elif event.key == pygame.K_SPACE:
                      self.game_logic.drop()
      
      if not self.game_logic.game_over:
          self.game_logic.update(dt) 

      surface.fill(Colors.DARK_BLUE)
      
      grid_origin_x = 15
      grid_origin_y = 20

      pygame.draw.rect(surface, Colors.LIGHT_BLUE, 
                       (grid_origin_x - 2, grid_origin_y - 2, 
                        self.grid_width_px + 4, self.grid_height_px + 4), 2)

      for r_idx, row in enumerate(self.game_logic.grid):
          for c_idx, cell_color_idx in enumerate(row):
              if cell_color_idx > 0:
                  color = Colors.get_color_tuple(cell_color_idx - 1)
                  pygame.draw.rect(surface, color,
                                   (grid_origin_x + c_idx * self.cell_size, 
                                    grid_origin_y + r_idx * self.cell_size,
                                    self.cell_size -1, self.cell_size -1))

      if not self.game_logic.game_over and self.game_logic.current_piece:
          piece = self.game_logic.current_piece
          for r_idx, row_data in enumerate(piece.shape):
              for c_idx, cell in enumerate(row_data):
                  if cell:
                      color = Colors.get_color_tuple(piece.color_idx)
                      pygame.draw.rect(surface, color,
                                       (grid_origin_x + (piece.x + c_idx) * self.cell_size,
                                        grid_origin_y + (piece.y + r_idx) * self.cell_size,
                                        self.cell_size -1, self.cell_size-1))
      
      info_x = grid_origin_x + self.grid_width_px + 20
      
      score_text = self.font_large.render(f"Score: {self.game_logic.score}", True, Colors.WHITE)
      surface.blit(score_text, (info_x, grid_origin_y + 20))
      
      level_text = self.font_small.render(f"Level: {self.game_logic.level}", True, Colors.WHITE)
      surface.blit(level_text, (info_x, grid_origin_y + 70))

      lines_text = self.font_small.render(f"Lines: {self.game_logic.lines_cleared_total}", True, Colors.WHITE)
      surface.blit(lines_text, (info_x, grid_origin_y + 100))

      next_text = self.font_small.render("Next:", True, Colors.WHITE)
      surface.blit(next_text, (info_x, grid_origin_y + 150))

      if self.game_logic.next_piece:
          next_p = self.game_logic.next_piece
          for r_idx, row_data in enumerate(next_p.shape):
              for c_idx, cell in enumerate(row_data):
                  if cell:
                      color = Colors.get_color_tuple(next_p.color_idx)
                      pygame.draw.rect(surface, color,
                                       (info_x + c_idx * self.cell_size,
                                        grid_origin_y + 180 + r_idx * self.cell_size,
                                        self.cell_size -1, self.cell_size -1))
      
      if self.game_logic.game_over:
          game_over_text = self.font_game_over.render("GAME OVER", True, Colors.RED)
          text_rect = game_over_text.get_rect(center=(self.screen_width // 2, self.screen_height // 2 - 50))
          surface.blit(game_over_text, text_rect)
          
          restart_text = self.font_small.render("Press 'R' to Restart", True, Colors.WHITE)
          restart_rect = restart_text.get_rect(center=(self.screen_width // 2, self.screen_height // 2))
          surface.blit(restart_text, restart_rect)

      # pygame.display.flip() # GameWindow handles this via canvas update

class TetrisApp(App):
  def __init__(self, application_path):
      super().__init__("TetrisGameApp", application_path) 
      self.application_path = application_path 
      self.ui_view = None
      self.game_instance = None
      self.setup()

  def setup(self):
      self.ui_view = Canvas() 
      self.game_instance = TetrisGamePyGame(self.application_path, self.ui_view)
      # GameWindow's on_draw is automatically called by the Canvas component
      # when Appsudo renders it.

  def run(self):
      super().run() 
      if self.ui_view:
           self.render(self.ui_view.render()) 
  
  def on_key_event(self, key_code, is_pressed): # Appsudo key event hook
      if self.game_instance and hasattr(self.game_instance, 'handle_key_event'):
          self.game_instance.handle_key_event(key_code, is_pressed)
      return True # Indicate event was handled (optional)

  def on_close(self):
      if self.game_instance:
          self.game_instance.running = False 
      pygame.quit()
      super().on_close()

Appsudo CSS Framework

Style components easily with an XML-like syntax or separate ASCSS files.

Supported Attributes

background

Sets the background of an element (e.g., color string like "#FF0000" or a resource path like "res/images/bg.png").

background-color

Sets the background color of an element (e.g., "#RRGGBB", "#AARRGGBB").

border-radius

Defines the radius of element corners (e.g., "5dp").

border-color

Sets the color of element borders (e.g., "#0000FF").

border-width

Defines the width of element borders (e.g., "1dp").

border-style

Sets the style of element borders (e.g., "solid", "dash", "line").

font-color

Defines the color of text within an element (e.g., "#333333").

font-size

Sets the size of text within an element (e.g., "16sp", "14dp").

alignment

Positions content within an element (e.g., "center", "left", "right", "top", "bottom", "center_vertical", "center_horizontal").

width

Sets the width of an element (e.g., "fill", "wrap", "100dp", "50%").

height

Sets the height of an element (e.g., "fill", "wrap", "200dp", "25%").

margin

Sets the outer spacing on all sides (e.g., "10dp").

margin-left / -right / -top / -bottom

Sets specific margin for one side (e.g., "5dp").

padding

Sets the inner spacing on all sides (e.g., "15dp").

padding-left / -right / -top / -bottom

Sets specific padding for one side (e.g., "8dp").

enabled

Determines if an element is interactive ("true" or "false").

Core Features

Powerful built-in services that handle threading, networking, persistence, and more.

ThreadExecutor

Provides a way to run tasks in background threads to avoid blocking the main UI thread.

Usage

from core import ThreadExecutor, ThreadRunnable
        
# Create a runnable task
class MyTask(ThreadRunnable):
  def __init__(self, data):
      self.data = data
      
  def run(self):
      # Perform time-consuming operation
      import time
      time.sleep(2)
      print(f"Processed {self.data}")

# Create and start the executor
def process_data(self): # Assuming this is part of a class
  task = MyTask("sample data")
  executor = ThreadExecutor(task)
  executor.start()
  
  # Optionally, you can check thread state or control execution
  print(f"Thread state: {executor.get_state()}")
  print(f"Thread ID: {executor.get_id()}")
  print(f"Thread is alive: {executor.is_alive()}")
  
  # Wait for thread to complete (if needed)
  executor.join()

Methods

__init__(runnable: ThreadRunnable)

Initializes a new executor with the given runnable task

start()

Starts the thread execution

get_state()

Returns the current state of the thread

get_id()

Returns the thread ID

get_name()

Returns the thread name

is_alive()

Checks if the thread is still running

interrupt()

Interrupts the thread execution

join(millis=None)

Waits for the thread to complete, optionally with a timeout in milliseconds

sleep(millis)

Causes the thread to sleep for the specified milliseconds

yield_()

Causes the thread to yield execution to other threads

HttpClient

Provides methods for making HTTP requests to web services and APIs.

Usage

from runtime import HttpClient
        
# GET request
def fetch_data(self): # Assuming this is part of a class
  url = "https://api.example.com/data"
  headers = {
      "Authorization": f"Bearer {self.api_key}", # Assuming self.api_key is defined
      "Content-Type": "application/json"
  }
  HttpClient.current().get(url, headers, self.handle_response) # Assuming self.handle_response
  
# POST request
def submit_data(self): # Assuming this is part of a class
  url = "https://api.example.com/submit"
  payload = {
      "name": "John Doe",
      "email": "john@example.com",
      "message": "Hello, world!"
  }
  headers = {
      "Content-Type": "application/json"
  }
  HttpClient.current().post(url, payload, headers, self.handle_response) # Assuming self.handle_response
  
# PUT request
def update_data(self): # Assuming this is part of a class
  url = "https://api.example.com/data/123"
  payload = {
      "name": "Updated Name",
      "email": "updated@example.com"
  }
  headers = {
      "Content-Type": "application/json"
  }
  HttpClient.current().put(url, payload, headers, self.handle_response) # Assuming self.handle_response
  
# DELETE request
def delete_data(self): # Assuming this is part of a class
  url = "https://api.example.com/data/123"
  headers = {
      "Authorization": f"Bearer {self.api_key}" # Assuming self.api_key is defined
  }
  HttpClient.current().delete(url, headers, self.handle_response) # Assuming self.handle_response
  
# Response handler
def handle_response(self, response): # Assuming this is part of a class
  import json
  try:
      data = json.loads(response)
      print(f"Response data: {data}")
  except Exception as e:
      print(f"Error parsing response: {e}")

Methods

current()

Returns the current HttpClient instance

get(url, headers, callback)

Performs an HTTP GET request

post(url, payload, headers, callback)

Performs an HTTP POST request

put(url, payload, headers, callback)

Performs an HTTP PUT request

delete(url, headers, callback)

Performs an HTTP DELETE request

Helper

Provides utility functions and helpers for common operations.

Usage

from runtime.Helper import Helper
        
# Log a message for debugging
Helper.log("Debug message")

# Perform other utility operations as needed in your app

Methods

log(message)

Logs a message for debugging purposes

Logger

Provides logging capabilities for debugging and monitoring.

Usage

from core import Logger # Assuming 'core' is the correct module
        
# Write a log message
Logger.write("User logged in")
Logger.write("Error occurred: " + str(exception)) # Assuming 'exception' is defined

Methods

write(message)

Writes a message to the log

DataStore

A persistent key-value storage system for saving and retrieving data.

Usage

from runtime.DataStore import DataStore
        
# Save data
def save_settings(self): # Assuming this is part of a class
    DataStore.set("username", self.username, lambda r: None) # Assuming self.username
    DataStore.set("theme", self.theme, self.on_theme_saved) # Assuming self.theme and self.on_theme_saved
    
def on_theme_saved(self, result): # Assuming this is part of a class
    print("Theme saved successfully")

# Retrieve data
def load_settings(self): # Assuming this is part of a class
    DataStore.get("username", self.on_username_loaded) # Assuming self.on_username_loaded
    DataStore.get("theme", self.on_theme_loaded)     # Assuming self.on_theme_loaded
    
def on_username_loaded(self, value): # Assuming this is part of a class
    if value:
        self.username = value
        # self.update_ui() # Assuming self.update_ui
    
def on_theme_loaded(self, value): # Assuming this is part of a class
    if value:
        self.theme = value
        # self.apply_theme() # Assuming self.apply_theme
        
# Increment a counter
def increment_counter(self): # Assuming this is part of a class
    DataStore.increment("counter", self.on_counter_updated) # Assuming self.on_counter_updated
    
def on_counter_updated(self, new_value): # Assuming this is part of a class
    self.counter = new_value
    # self.update_counter_display() # Assuming self.update_counter_display

Methods

set(key, value, callback)

Stores a value with the given key

get(key, callback)

Retrieves the value for the given key

increment(key, callback)

Increments a numeric value stored with the given key

DataLoader

Utilities for loading data from files.

Usage

from runtime.DataLoader import DataLoader
from core import Logger
      
# Load JSON configuration (assuming self.application_path is defined)
config = DataLoader.load_json(self.application_path, "config") 
api_key = config.get("api_key", "default_key")
server_url = config.get("server_url", "https://default.example.com")

# Use the loaded configuration
Logger.write(f"Using API key: {api_key}")
Logger.write(f"Connecting to server: {server_url}")

Methods

load_json(application_path, filename)

Loads and parses a JSON file from the specified path

ImageHelper

Utilities for working with images.

Usage

from runtime.ImageHelper import ImageHelper
from components.media import Image

# Load an image from a URL or local path
bitmap_remote = ImageHelper.get_bitmap("https://example.com/image.jpg")
# Assuming self.application_path is defined:
bitmap_local = ImageHelper.get_bitmap(self.application_path + "/res/images/logo.png")

# Use the bitmap with an Image component
image_component = Image()
image_component.set_src(bitmap_remote) # or bitmap_local

Methods

get_bitmap(url)

Loads a bitmap from a URL or local file path

InputManager

Simulates gamepad, D-pad, and button input events, and triggers device haptic feedback. Use InputManager.create() to obtain an instance you can trigger() on, and call InputManager.vibrate() directly for haptics.

Usage

from runtime.InputManager import InputManager
from runtime.InputManager import (
    K_DPAD_UP, K_DPAD_DOWN, K_DPAD_LEFT, K_DPAD_RIGHT,
    K_BUTTON_A, K_BUTTON_B, K_BUTTON_X, K_BUTTON_Y,
    K_BUTTON_START, K_BUTTON_SELECT,
    K_BUTTON_L1, K_BUTTON_R1, K_BUTTON_L2, K_BUTTON_R2,
)

input_manager = InputManager.create()

input_manager.trigger(K_BUTTON_A)
input_manager.trigger(K_DPAD_UP)

input_manager.trigger(K_BUTTON_L2, 0.75)
input_manager.trigger(K_BUTTON_R2, 1.0)

InputManager.vibrate()
InputManager.vibrate(500)

Methods

InputManager.create()

Returns an InputManager instance you can call trigger() on. Raises RuntimeError if it cannot be created.

trigger(key_constant, value=1.0)

Sends a synthetic input event for the given key constant. Digital keys ignore value; analog triggers (K_BUTTON_L2, K_BUTTON_R2) use value in the 0.0–1.0 range as the trigger pressure. Call on the instance returned by InputManager.create().

InputManager.vibrate(in_millisecond=200)

Fires a haptic pulse for the given duration in milliseconds. Silently no-ops if vibration is unavailable.

Key Constants

K_DPAD_UP

D-pad up direction.

K_DPAD_DOWN

D-pad down direction.

K_DPAD_LEFT

D-pad left direction.

K_DPAD_RIGHT

D-pad right direction.

K_BUTTON_A

Primary face button A (typically the “confirm” action).

K_BUTTON_B

Primary face button B (typically the “back/cancel” action).

K_BUTTON_X

Primary face button X.

K_BUTTON_Y

Primary face button Y.

K_BUTTON_START

Start / menu button.

K_BUTTON_SELECT

Select / back / share button.

K_BUTTON_L1

Left shoulder button (digital).

K_BUTTON_R1

Right shoulder button (digital).

K_BUTTON_L2

Left analog trigger. Pass a value in the 0.0–1.0 range to express pressure.

K_BUTTON_R2

Right analog trigger. Pass a value in the 0.0–1.0 range to express pressure.

ANALOG_TRIGGERS

Set containing {K_BUTTON_L2, K_BUTTON_R2}. Use it to check whether a key constant should be treated as analog and accepts a value magnitude.

Dialog

Dialog is a static helper for showing modal dialogs. It exposes four shapes: a confirm only dialog (show), a confirm with description (confirm), a single line text input (input), and a custom view dialog (showView). Each shape takes a positive button label and callback plus a negative button label and callback.

Usage

from appsudo import Dialog
from components.view import ViewInflater

Dialog.show(
    "OK", lambda: print("ok"),
    "Cancel", lambda: print("cancelled"),
    title="Delete this item?",
)

Dialog.confirm(
    "Yes", lambda: do_purge(),
    "No",  lambda: print("kept"),
    title="Empty trash",
    description="All 14 items will be removed. This cannot be undone.",
)

def renamed(new_name):
    print("renamed to", new_name)

Dialog.input(
    "Save",   renamed,
    "Cancel", lambda: print("cancelled"),
    title="Rename file",
    hint="New file name",
)

inflater = ViewInflater(self)
form_view = inflater.inflate("rename_form", None)
Dialog.showView(
    form_view,
    "Apply",  lambda: self.commit_form(),
    "Cancel", lambda: self.discard_form(),
    title="Edit preferences",
)

Methods

Dialog.show(positive_text, positive_callback, negative_text, negative_callback, title=None)

Shows a two button confirm dialog. positive_callback and negative_callback are called with no arguments when the user taps the matching button. title is optional.

Dialog.confirm(positive_text, positive_callback, negative_text, negative_callback, title=None, description=None)

Like show but adds an optional description line below the title. Either title or description may be None.

Dialog.input(positive_text, positive_callback, negative_text, negative_callback, title=None, hint=None)

Shows a dialog with a single line text field. positive_callback receives the typed string as its only argument; negative_callback is called with no arguments. hint is the placeholder text shown when the field is empty. Pressing Enter inside the field triggers the positive callback.

Dialog.showView(view, positive_text, positive_callback, negative_text, negative_callback, title=None)

Embeds an inflated view between the title and the action buttons, useful for forms, checklists, or any layout built with ViewInflater. The view's render() output is mounted inside the dialog body. Title is optional.

Notification

Notification covers two related capabilities. Notification.toast shows a short in app message that auto dismisses. The remaining four methods route through AppContext.current() to deliver and schedule real system notifications and to cancel them by id.

Usage

from appsudo import Notification
import time

Notification.toast("Saved")

notif_id = Notification.sendNotification(
    "Build complete",
    "Your project finished in 12.4s",
    {"projectId": 42},
)

Notification.cancelNotification(notif_id)

ten_minutes_from_now = int(time.time() * 1000) + 10 * 60 * 1000
reminder_id = Notification.scheduleNotification(
    "Stretch break",
    "Stand up and walk for five minutes.",
    ten_minutes_from_now,
    {"category": "wellness"},
)

Notification.cancelScheduleNotification(reminder_id)

Methods

Notification.toast(message)

Shows a transient message centered in the app, then fades after a short timer. Returns nothing. Calling toast again while one is visible replaces the previous one.

Notification.sendNotification(title, content, data=dict())

Posts a system notification immediately. data is an arbitrary mapping carried alongside the notification and delivered to your handler when the user taps it. Returns the notification id assigned by the platform; keep it if you may want to cancel later.

Notification.cancelNotification(notification_id)

Removes a posted notification by the id returned from sendNotification. Safe to call with an id that no longer exists.

Notification.scheduleNotification(title, content, triggerMs, data=dict())

Schedules a notification to fire later. triggerMs is an absolute Unix epoch time in milliseconds. Returns a notification id. Compute the value with int(time.time() * 1000) + delay_ms when you want a relative delay.

Notification.cancelScheduleNotification(notification_id)

Cancels a still pending scheduled notification by the id returned from scheduleNotification. Calling it after the notification has already fired is safe but has no effect on the delivered notification; use cancelNotification for that.

AppOAuthManager

AppOAuthManager lets an app receive the redirect URL after the user finishes an OAuth flow with an external provider. The app calls register_callback(fn) once, and the framework invokes the registered callback with the full redirect URL when the provider sends the user back to the app. The pattern works for any OAuth provider that supports a redirect URI.

The framework only intercepts one redirect URI: com.appsudo.app.miniapp://callback. Configure this exact value as the redirect URI in your OAuth provider console (or in your own auth server's allowed redirect list). Any other redirect URI will not be intercepted and the registered callback will not be invoked. Query string parameters and fragments are preserved on the redirect, so the provider can append ?code=...&state=... to the redirect URI and your callback receives the full string.

Usage

A fictional Google Calendar app illustrates both flow shapes. The first version forwards the redirect URL into a WebView so JavaScript can finish the token exchange. The second version skips the WebView and does the exchange in Python.

WebView forwarding flow

from appsudo import App, AppOAuthManager
from components.view import ViewInflater

CLIENT_ID    = "<your-google-client-id>"
REDIRECT_URI = "com.appsudo.app.miniapp://callback"
AUTH_URL     = "https://accounts.google.com/o/oauth2/v2/auth"
SCOPES       = "https://www.googleapis.com/auth/calendar.readonly"


class GcalApp(App):
    def __init__(self, application_path):
        App.__init__(self, "Gcal", application_path)
        self.inflater = ViewInflater(self)
        self.ui_view = None
        self.setup()

    def setup(self):
        self.ui_view = self.inflater.inflate("webview", None)

    def post_setup(self):
        webview = self.ui_view.find_element_by_id("webview")
        secured_url = webview.get_secured_url(
            f"file://{self.application_path}/static/index.html?clientId={CLIENT_ID}"
        )
        webview.set_url(secured_url)
        AppOAuthManager.register_callback(self.on_oauth_redirect)

    def on_oauth_redirect(self, url):
        webview = self.ui_view.find_element_by_id("webview")
        webview.eval(f"window.handleGoogleCallback({url!r});")

    def run(self):
        App.run(self)
        self.render(self.ui_view.render())
        self.post_setup()

The HTML at static/index.html is the place that opens the Google sign in page (for example by setting window.location to the Google auth URL with the redirect URI above). When Google redirects the user back, the framework invokes the callback registered in post_setup. The callback hands the full URL to the page's own handleGoogleCallback JavaScript function so the page can read the code query parameter and trade it for an access token.

Pure Python flow

from appsudo import App, AppOAuthManager
from runtime.HttpClient import HttpClient
from runtime.DataStore import DataStore
import urllib.parse

CLIENT_ID    = "<your-google-client-id>"
REDIRECT_URI = "com.appsudo.app.miniapp://callback"
TOKEN_URL    = "https://oauth2.googleapis.com/token"


class GcalApp(App):
    def post_setup(self):
        AppOAuthManager.register_callback(self.on_oauth_redirect)

    def on_oauth_redirect(self, url):
        parsed = urllib.parse.urlparse(url)
        params = urllib.parse.parse_qs(parsed.query)
        code = (params.get("code") or [None])[0]
        if not code:
            return

        client = HttpClient()
        response = client.post(
            TOKEN_URL,
            {
                "code": code,
                "client_id": CLIENT_ID,
                "redirect_uri": REDIRECT_URI,
                "grant_type": "authorization_code",
            },
        )
        token = response.json()
        DataStore.put("gcal_access_token",  token["access_token"])
        DataStore.put("gcal_refresh_token", token.get("refresh_token", ""))

Methods

AppOAuthManager.register_callback(callback)

Stores callback on the class. The framework will call it with one string argument (the redirect URL) the next time the OAuth provider sends the user back. Only one callback is registered at a time, so calling register_callback again replaces the previous registration. Pass None to unregister.