Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a single base class for all app types #2244

Closed
wants to merge 12 commits into from
Closed
Prev Previous commit
Next Next commit
Add Android and iOS implementations of new app/window APIs.
  • Loading branch information
freakboy3742 committed Mar 16, 2024
commit babcc3b864680b1d70881b6ec30a9bc0b12ae2aa
45 changes: 27 additions & 18 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@
from java import dynamic_proxy
from org.beeware.android import IPythonApp, MainActivity

import toga
from toga.command import Command, Group, Separator

from .libs import events
from .screens import Screen as ScreenImpl
from .window import Window


class MainWindow(Window):
_is_main_window = True


class TogaApp(dynamic_proxy(IPythonApp)):
Expand Down Expand Up @@ -95,6 +91,11 @@ def onOptionsItemSelected(self, menuitem):
return True

def onPrepareOptionsMenu(self, menu):
# If the main window doesn't have a toolbar, there's no preparation required;
# this is a simple main window, which can't have commands.
if not hasattr(self._impl.interface.main_window, "toolbar"):
return False

menu.clear()
itemid = 1 # 0 is the same as Menu.NONE.
groupid = 1
Expand Down Expand Up @@ -179,6 +180,9 @@ def onPrepareOptionsMenu(self, menu):


class App:
# Android apps exit when the last window is closed
CLOSE_ON_LAST_WINDOW = True

def __init__(self, interface):
self.interface = interface
self.interface._impl = self
Expand All @@ -190,14 +194,6 @@ def __init__(self, interface):
def native(self):
return self._listener.native if self._listener else None

def create(self):
# The `_listener` listens for activity event callbacks. For simplicity,
# the app's `.native` is the listener's native Java class.
self._listener = TogaApp(self)
# Call user code to populate the main window
self.interface._startup()
self.create_app_commands()

######################################################################
# Commands and menus
######################################################################
Expand All @@ -213,7 +209,8 @@ def create_app_commands(self):
)

def create_menus(self):
self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu
# On Android, menus and toolbars are populated using the same mechanism.
self.interface.main_window._impl.create_toolbar()

######################################################################
# App lifecycle
Expand All @@ -222,16 +219,28 @@ def create_menus(self):
def exit(self):
pass # pragma: no cover

def finalize(self):
self.create_app_commands()
self.create_menus()

def main_loop(self):
# In order to support user asyncio code, start the Python/Android cooperative event loop.
self.loop.run_forever_cooperatively()

# On Android, Toga UI integrates automatically into the main Android event loop by virtue
# of the Android Activity system.
self.create()
# On Android, Toga UI integrates automatically into the main Android event loop
# by virtue of the Android Activity system. The `_listener` listens for activity
# event callbacks. For simplicity, the app's `.native` is the listener's native
# Java class.
self._listener = TogaApp(self)

# Call user code to populate the main window
self.interface._startup()

def set_main_window(self, window):
pass
if window is None:
raise RuntimeError("Session-based apps are not supported on Android")
elif window == toga.App.BACKGROUND:
raise RuntimeError("Background apps are not supported on Android")

######################################################################
# App resources
Expand Down
7 changes: 4 additions & 3 deletions android/src/toga_android/factory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from toga import NotImplementedWarning

from . import dialogs
from .app import App, MainWindow
from .app import App
from .command import Command
from .fonts import Font
from .hardware.camera import Camera
Expand Down Expand Up @@ -30,7 +30,7 @@
from .widgets.textinput import TextInput
from .widgets.timeinput import TimeInput
from .widgets.webview import WebView
from .window import Window
from .window import MainWindow, Window


def not_implemented(feature):
Expand All @@ -40,7 +40,6 @@ def not_implemented(feature):
__all__ = [
"App",
"Command",
"MainWindow",
"not_implemented",
# Resources
"dialogs",
Expand Down Expand Up @@ -76,7 +75,9 @@ def not_implemented(feature):
"TimeInput",
# "Tree",
"WebView",
# Windows
"Window",
"MainWindow",
]


Expand Down
30 changes: 21 additions & 9 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,12 @@ def onGlobalLayout(self):


class Window(Container):
_is_main_window = False

def __init__(self, interface, title, position, size):
super().__init__()
self.interface = interface
self.interface._impl = self
self._initial_title = title

if not self._is_main_window:
raise RuntimeError(
"Secondary windows cannot be created on mobile platforms"
)

######################################################################
# Window properties
######################################################################
Expand All @@ -60,17 +53,27 @@ def close(self):
pass

def create_toolbar(self):
self.app.native.invalidateOptionsMenu()
# Simple windows don't have a titlebar
pass

def _configure_titlebar(self):
# Simple windows hide the titlebar.
self.app.native.getSupportActionBar().hide()

def set_app(self, app):
if len(app.interface.windows) > 1:
raise RuntimeError("Secondary windows cannot be created on Android")

self.app = app
native_parent = app.native.findViewById(R.id.content)
native_parent = self.app.native.findViewById(R.id.content)
self.init_container(native_parent)
native_parent.getViewTreeObserver().addOnGlobalLayoutListener(
LayoutListener(self)
)
self.set_title(self._initial_title)

self._configure_titlebar()

def show(self):
pass

Expand Down Expand Up @@ -155,3 +158,12 @@ def get_image_data(self):
stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
return bytes(stream.toByteArray())


class MainWindow(Window):
def _configure_titlebar(self):
# The titlebar will be visible by default.
pass

def create_toolbar(self):
self.app.native.invalidateOptionsMenu()
12 changes: 6 additions & 6 deletions cocoa/src/toga_cocoa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def applicationSupportsSecureRestorableState_(self, app) -> bool:
@objc_method
def applicationOpenUntitledFile_(self, sender) -> bool:
if self.interface.document_types and self.interface.main_window is None:
self.impl.select_file()
self.impl._select_file()
return True
return False

Expand Down Expand Up @@ -166,7 +166,7 @@ def new_file_handler(app, **kwargs):
return new_file_handler

def _menu_open_file(self, app, **kwargs):
self.select_file()
self._select_file()

def _menu_quit(self, command, **kwargs):
self.interface.on_exit()
Expand Down Expand Up @@ -582,10 +582,7 @@ def get_screens(self):
# App capabilities
######################################################################

def beep(self):
NSBeep()

def select_file(self, **kwargs):
def _select_file(self, **kwargs):
# FIXME This should be all we need; but for some reason, application types
# aren't being registered correctly..
# NSDocumentController.sharedDocumentController().openDocument_(None)
Expand All @@ -605,6 +602,9 @@ def select_file(self, **kwargs):
# print("Untitled File opened?", panel.URLs)
self.appDelegate.application(None, openFiles=panel.URLs)

def beep(self):
NSBeep()

def show_about_dialog(self):
options = NSMutableDictionary.alloc().init()

Expand Down
28 changes: 17 additions & 11 deletions iOS/src/toga_iOS/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@
from rubicon.objc import objc_method
from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle

import toga
from toga_iOS.libs import UIResponder, UIScreen, av_foundation
from toga_iOS.window import Window

from .screens import Screen as ScreenImpl


class MainWindow(Window):
_is_main_window = True


class PythonAppDelegate(UIResponder):
@objc_method
def applicationDidBecomeActive_(self, application) -> None:
Expand Down Expand Up @@ -53,6 +49,9 @@ def application_didChangeStatusBarOrientation_(


class App:
# iOS apps exit when the last window is closed
CLOSE_ON_LAST_WINDOW = True

def __init__(self, interface):
self.interface = interface
self.interface._impl = self
Expand All @@ -66,13 +65,17 @@ def __init__(self, interface):
self.loop = asyncio.new_event_loop()

def create(self):
"""Calls the startup method on the interface."""
# Call the app's startup method
self.interface._startup()

######################################################################
# Commands and menus
######################################################################

def create_app_commands(self):
# No commands on an iOS app (for now)
pass

def create_menus(self):
# No menus on an iOS app (for now)
pass
Expand All @@ -85,6 +88,10 @@ def exit(self): # pragma: no cover
# Mobile apps can't be exited, but the entry point needs to exist
pass

def finalize(self):
self.create_app_commands()
self.create_menus()

def main_loop(self):
# Main loop is non-blocking on iOS. The app loop is integrated with the
# main iOS event loop, so this call will return; however, it will leave
Expand All @@ -93,7 +100,10 @@ def main_loop(self):
self.loop.run_forever_cooperatively(lifecycle=iOSLifecycle())

def set_main_window(self, window):
pass
if window is None:
raise RuntimeError("Session-based apps are not supported on iOS")
elif window == toga.App.BACKGROUND:
raise RuntimeError("Background apps are not supported on iOS")

######################################################################
# App resources
Expand All @@ -111,10 +121,6 @@ def beep(self):
# sounding like a single strike of a bell.
av_foundation.AudioServicesPlayAlertSound(1013)

def open_document(self, fileURL): # pragma: no cover
"""Add a new document to this app."""
pass

def show_about_dialog(self):
self.interface.factory.not_implemented("App.show_about_dialog()")

Expand Down
47 changes: 47 additions & 0 deletions iOS/src/toga_iOS/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,54 @@ def __init__(
layout_native=None,
on_refresh=None,
):
"""A bare content container.

:param content: The widget impl that is the container's initial content.
:param layout_native: The native widget that should be used to provide
size hints to the layout. This will usually be the container widget
itself; however, for widgets like ScrollContainer where the layout
needs to be computed based on a different size to what will be
rendered, the source of the size can be different.
:param on_refresh: The callback to be notified when this container's layout is
refreshed.
"""
super().__init__(
content=content,
layout_native=layout_native,
on_refresh=on_refresh,
)

# Construct a UIViewController to hold the root content
self.controller = UIViewController.alloc().init()

# Set the controller's view to be the root content widget
self.controller.view = self.native

@property
def height(self):
return self.layout_native.bounds.size.height - self.top_offset

@property
def top_offset(self):
return UIApplication.sharedApplication.statusBarFrame.size.height

@property
def title(self):
return self._title

@title.setter
def title(self, value):
self._title = value


class NavigationContainer(Container):
def __init__(
self,
content=None,
layout_native=None,
on_refresh=None,
):
"""A container that provides a navigation bar.
:param content: The widget impl that is the container's initial content.
:param layout_native: The native widget that should be used to provide
size hints to the layout. This will usually be the container widget
Expand Down
Loading