diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef23f26fc..ffd3d0686b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,9 +91,8 @@ jobs: # The $(ls ...) shell expansion is done in the Github environment; # the value of TOGA_INSTALL_COMMAND will be a literal string, # without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py-core - cd core - mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} + TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl)" tox -e py + mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} - name: Store coverage data uses: actions/upload-artifact@v3.1.3 with: @@ -128,7 +127,7 @@ jobs: cd core python -m coverage combine python -m coverage html --skip-covered --skip-empty - python -m coverage report --rcfile ../pyproject.toml # --fail-under=100 + python -m coverage report --rcfile ../pyproject.toml --fail-under=100 - name: Upload HTML report if check failed. uses: actions/upload-artifact@v3.1.3 with: @@ -136,56 +135,6 @@ jobs: path: core/htmlcov if: ${{ failure() }} - backend: - runs-on: ${{ matrix.runs-on }} - needs: [package, core] - strategy: - matrix: - backend: [ "android", "cocoa", "gtk", "iOS", "web", "winforms" ] - include: - - runs-on: ubuntu-latest - - python-version: "3.8" # Should be env.min_python_version (https://github.com/actions/runner/issues/480) - - pre-command: - - - backend: cocoa - runs-on: macos-latest - - - backend: gtk - pre-command: | - sudo apt update -y - sudo apt install -y pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-gtk-3.0 - - - backend: iOS - runs-on: macos-latest - - - backend: winforms - runs-on: windows-latest - # Py3.9 is the first Python version for which - # a wheel of pythonnet isn't available on PyPI. - python-version: "3.9" - steps: - - uses: actions/checkout@v4.1.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 - with: - python-version: ${{ matrix.python-version }} - - name: Get packages - uses: actions/download-artifact@v3.0.2 - with: - name: ${{ needs.package.outputs.artifact-name }} - - name: Install dev dependencies - run: | - ${{ matrix.pre-command }} - # We don't actually want to install toga-core; - # we just want the dev extras so we have a known version of tox - python -m pip install ./core[dev] - - name: Test - run: | - # The $(ls ...) shell expansion is done in the Github environment; - # the value of TOGA_INSTALL_COMMAND will be a literal string, - # without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls core/dist/toga_core-*.whl)[dev] ../$(ls dummy/dist/toga_dummy-*.whl) ../$(ls ${{ matrix.backend }}/dist/toga_${{ matrix.backend }}-*.whl)" tox -e py-${{ matrix.backend }} - testbed: runs-on: ${{ matrix.runs-on }} needs: core @@ -288,3 +237,11 @@ jobs: with: name: testbed-failure-app-data-${{ matrix.backend }} path: testbed/app_data/* + # This step is only needed if you're trying to diagnose test failures that + # only occur in CI, and can't be reproduced locally. When it runs, it will + # open an SSH server (URL reported in the logs) so you can ssh into the CI + # machine. + # - uses: actions/checkout@v3 + # - name: Setup tmate session + # uses: mxschmitt/action-tmate@v3 + # if: failure() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cfba1e5e9..4d32924938 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,17 @@ jobs: ci: uses: ./.github/workflows/ci.yml + docs: + name: Verify Docs Build + uses: beeware/.github/.github/workflows/docs-build-verify.yml@main + secrets: inherit + with: + project-name: "toga" + project-version: ${{ github.ref_name }} + release: name: Create GitHub release - needs: ci + needs: [ ci, docs ] runs-on: ubuntu-latest permissions: contents: write @@ -68,4 +76,4 @@ jobs: - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - repository_url: https://test.pypi.org/legacy/ + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index 822fd730c6..95f5bd0ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage.xml dist build +logs _build distribute-* docs/env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d1d7d6e4f..4331e241bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 23.10.0 + rev: 23.10.1 hooks: - id: black - repo: https://github.com/PyCQA/flake8 diff --git a/android/setup.cfg b/android/setup.cfg index eb812857a0..e3ef915b3c 100644 --- a/android/setup.cfg +++ b/android/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index dd7ed4aa78..2c572a642a 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,13 +1,13 @@ import asyncio +import sys +from android.graphics.drawable import BitmapDrawable +from android.media import RingtoneManager +from android.view import Menu, MenuItem from java import dynamic_proxy from org.beeware.android import IPythonApp, MainActivity -import toga -from android.graphics.drawable import Drawable -from android.media import RingtoneManager -from android.view import Menu, MenuItem -from toga.command import Group +from toga.command import GROUP_BREAK, SECTION_BREAK, Command, Group from .libs import events from .window import Window @@ -41,18 +41,19 @@ def onResume(self): print("Toga app: onResume") def onPause(self): - print("Toga app: onPause") + print("Toga app: onPause") # pragma: no cover def onStop(self): - print("Toga app: onStop") + print("Toga app: onStop") # pragma: no cover def onDestroy(self): - print("Toga app: onDestroy") + print("Toga app: onDestroy") # pragma: no cover def onRestart(self): - print("Toga app: onRestart") + print("Toga app: onRestart") # pragma: no cover - def onActivityResult(self, requestCode, resultCode, resultData): + # TODO #1798: document and test this somehow + def onActivityResult(self, requestCode, resultCode, resultData): # pragma: no cover """Callback method, called from MainActivity when an Intent ends. :param int requestCode: The integer request code originally supplied to startActivityForResult(), @@ -75,94 +76,86 @@ def onActivityResult(self, requestCode, resultCode, resultData): print("No intent matching request code {requestCode}") def onConfigurationChanged(self, new_config): - pass + pass # pragma: no cover def onOptionsItemSelected(self, menuitem): - consumed = False - try: - cmd = self.menuitem_mapping[menuitem.getItemId()] - consumed = True - if cmd.action is not None: - cmd.action(menuitem) - except KeyError: - print("menu item id not found in menuitem_mapping dictionary!") - return consumed + itemid = menuitem.getItemId() + if itemid == Menu.NONE: + # This method also fires when opening submenus + return False + else: + self.menuitem_mapping[itemid].action() + return True def onPrepareOptionsMenu(self, menu): menu.clear() - itemid = 0 + itemid = 1 # 0 is the same as Menu.NONE. + groupid = 1 menulist = {} # dictionary with all menus self.menuitem_mapping.clear() # create option menu for cmd in self._impl.interface.commands: - if cmd == toga.SECTION_BREAK or cmd == toga.GROUP_BREAK: + if cmd == SECTION_BREAK or cmd == GROUP_BREAK: + groupid += 1 continue - if cmd in self._impl.interface.main_window.toolbar: - continue # do not show toolbar commands in the option menu (except when overflowing) - grouppath = cmd.group.path - if grouppath[0] != Group.COMMANDS: - # only the Commands group (and its subgroups) are supported - # other groups should eventually go into the navigation drawer + # Toolbar commands are added below. + if cmd in self._impl.interface.main_window.toolbar: continue + if cmd.group.key in menulist: menugroup = menulist[cmd.group.key] else: # create all missing submenus parentmenu = menu - for group in grouppath: - groupkey = group.key + groupkey = () + for section, order, text in cmd.group.key: + groupkey += ((section, order, text),) if groupkey in menulist: menugroup = menulist[groupkey] else: - if group.text == toga.Group.COMMANDS.text: + if len(groupkey) == 1 and text == Group.COMMANDS.text: + # Add this group directly to the top-level menu menulist[groupkey] = menu menugroup = menu else: - itemid += 1 - order = Menu.NONE if group.order is None else group.order + # Add all other groups as submenus menugroup = parentmenu.addSubMenu( - Menu.NONE, itemid, order, group.text - ) # groupId, itemId, order, title + groupid, Menu.NONE, Menu.NONE, text + ) menulist[groupkey] = menugroup parentmenu = menugroup + # create menu item - itemid += 1 - order = Menu.NONE if cmd.order is None else cmd.order - menuitem = menugroup.add( - Menu.NONE, itemid, order, cmd.text - ) # groupId, itemId, order, title + menuitem = menugroup.add(groupid, itemid, Menu.NONE, cmd.text) menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_NEVER) menuitem.setEnabled(cmd.enabled) - self.menuitem_mapping[ - itemid - ] = cmd # store itemid for use in onOptionsItemSelected + self.menuitem_mapping[itemid] = cmd + itemid += 1 # create toolbar actions - if self._impl.interface.main_window: + if self._impl.interface.main_window: # pragma: no branch for cmd in self._impl.interface.main_window.toolbar: - if cmd == toga.SECTION_BREAK or cmd == toga.GROUP_BREAK: + if cmd == SECTION_BREAK or cmd == GROUP_BREAK: + groupid += 1 continue - itemid += 1 - order = Menu.NONE if cmd.order is None else cmd.order - menuitem = menu.add( - Menu.NONE, itemid, order, cmd.text - ) # groupId, itemId, order, title - menuitem.setShowAsActionFlags( - MenuItem.SHOW_AS_ACTION_IF_ROOM - ) # toolbar button / item in options menu on overflow + + menuitem = menu.add(groupid, itemid, Menu.NONE, cmd.text) + # SHOW_AS_ACTION_IF_ROOM is too conservative, showing only 2 items on + # a medium-size screen in portrait. + menuitem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menuitem.setEnabled(cmd.enabled) if cmd.icon: - icon = Drawable.createFromPath(str(cmd.icon._impl.path)) - if icon: - menuitem.setIcon(icon) - else: - print("Could not create icon: " + str(cmd.icon._impl.path)) - self.menuitem_mapping[ - itemid - ] = cmd # store itemid for use in onOptionsItemSelected + menuitem.setIcon( + BitmapDrawable( + self.native.getResources(), cmd.icon._impl.native + ) + ) + self.menuitem_mapping[itemid] = cmd + itemid += 1 + # Display the menu. return True @@ -184,9 +177,20 @@ def create(self): self._listener = TogaApp(self) # Call user code to populate the main window self.interface._startup() - - def open_document(self, fileURL): - print("Can't open document %s (yet)" % fileURL) + self._create_app_commands() + + def create_menus(self): + self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu + + def _create_app_commands(self): + self.interface.commands.add( + # About should be the last item in the menu, in a section on its own. + Command( + lambda _: self.interface.about(), + f"About {self.interface.formal_name}", + section=sys.maxsize, + ), + ) def main_loop(self): # In order to support user asyncio code, start the Python/Android cooperative event loop. @@ -200,7 +204,21 @@ def set_main_window(self, window): pass def show_about_dialog(self): - self.interface.factory.not_implemented("App.show_about_dialog()") + message_parts = [] + if self.interface.version is not None: + message_parts.append( + f"{self.interface.formal_name} v{self.interface.version}" + ) + else: + message_parts.append(self.interface.formal_name) + + if self.interface.author is not None: + message_parts.append(f"Author: {self.interface.author}") + if self.interface.description is not None: + message_parts.append(f"\n{self.interface.description}") + self.interface.main_window.info_dialog( + f"About {self.interface.formal_name}", "\n".join(message_parts) + ) def beep(self): uri = RingtoneManager.getActualDefaultRingtoneUri( @@ -210,9 +228,16 @@ def beep(self): ringtone.play() def exit(self): + pass # pragma: no cover + + def get_current_window(self): + return self.interface.main_window._impl + + def set_current_window(self, window): pass - async def intent_result(self, intent): + # TODO #1798: document and test this somehow + async def intent_result(self, intent): # pragma: no cover """Calls an Intent and waits for its result. A RuntimeError will be raised when the Intent cannot be invoked. @@ -234,6 +259,12 @@ async def intent_result(self, intent): except AttributeError: raise RuntimeError("No appropriate Activity found to handle this intent.") + def enter_full_screen(self, windows): + pass + + def exit_full_screen(self, windows): + pass + def hide_cursor(self): pass diff --git a/android/src/toga_android/colors.py b/android/src/toga_android/colors.py index 971ef9ea1b..ffd8a71e30 100644 --- a/android/src/toga_android/colors.py +++ b/android/src/toga_android/colors.py @@ -1,6 +1,5 @@ -from travertino.colors import NAMED_COLOR, TRANSPARENT - from android.graphics import Color +from travertino.colors import TRANSPARENT CACHE = {TRANSPARENT: Color.TRANSPARENT} @@ -9,8 +8,6 @@ def native_color(c): try: color = CACHE[c] except KeyError: - if isinstance(c, str): - c = NAMED_COLOR[c] color = Color.argb( int(c.rgba.a * 255), int(c.rgba.r), int(c.rgba.g), int(c.rgba.b) ) diff --git a/android/src/toga_android/command.py b/android/src/toga_android/command.py index 8575d29e27..1cec2474cc 100644 --- a/android/src/toga_android/command.py +++ b/android/src/toga_android/command.py @@ -1,7 +1,10 @@ +from org.beeware.android import MainActivity + + class Command: def __init__(self, interface): self.interface = interface self.native = [] def set_enabled(self, value): - pass + MainActivity.singletonThis.invalidateOptionsMenu() diff --git a/android/src/toga_android/dialogs.py b/android/src/toga_android/dialogs.py index ab8c0abdf1..83f044ba21 100644 --- a/android/src/toga_android/dialogs.py +++ b/android/src/toga_android/dialogs.py @@ -1,10 +1,9 @@ from abc import ABC -from java import dynamic_proxy - from android import R from android.app import AlertDialog from android.content import DialogInterface +from java import dynamic_proxy class OnClickListener(dynamic_proxy(DialogInterface.OnClickListener)): @@ -58,7 +57,7 @@ def __init__( self.native.show() def completion_handler(self, return_value: bool) -> None: - self.on_result(self, return_value) + self.on_result(return_value) self.interface.future.set_result(return_value) diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index fd6a84014f..9171ea4766 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -10,6 +10,7 @@ from .widgets.canvas import Canvas from .widgets.dateinput import DateInput from .widgets.detailedlist import DetailedList +from .widgets.divider import Divider from .widgets.imageview import ImageView from .widgets.label import Label from .widgets.multilinetextinput import MultilineTextInput @@ -33,34 +34,41 @@ def not_implemented(feature): __all__ = [ "App", - "Box", - "Button", - "Canvas", "Command", - "DateInput", + "MainWindow", + "not_implemented", + # Resources + "dialogs", "Font", "Icon", "Image", + "Paths", + # Widgets + # ActivityIndicator + "Box", + "Button", + "Canvas", + "DateInput", + "DetailedList", + "Divider", "ImageView", "Label", - "MainWindow", "MultilineTextInput", "NumberInput", + # "OptionContainer", "PasswordInput", "ProgressBar", + "ScrollContainer", + # "SplitContainer", "Selection", "Slider", - "ScrollContainer", "Switch", "Table", "TextInput", "TimeInput", + # "Tree", "WebView", "Window", - "DetailedList", - "not_implemented", - "Paths", - "dialogs", ] diff --git a/android/src/toga_android/fonts.py b/android/src/toga_android/fonts.py index 677343115f..f3b7018564 100644 --- a/android/src/toga_android/fonts.py +++ b/android/src/toga_android/fonts.py @@ -1,10 +1,10 @@ from pathlib import Path -from org.beeware.android import MainActivity - from android import R from android.graphics import Typeface from android.util import TypedValue +from org.beeware.android import MainActivity + from toga.fonts import ( _REGISTERED_FONT_CACHE, BOLD, diff --git a/android/src/toga_android/images.py b/android/src/toga_android/images.py index e3a357e57f..8ed5ededda 100644 --- a/android/src/toga_android/images.py +++ b/android/src/toga_android/images.py @@ -1,8 +1,7 @@ from pathlib import Path -from java.io import FileOutputStream - from android.graphics import Bitmap, BitmapFactory +from java.io import ByteArrayOutputStream, FileOutputStream class Image: @@ -24,6 +23,11 @@ def get_width(self): def get_height(self): return self.native.getHeight() + def get_data(self): + stream = ByteArrayOutputStream() + self.native.compress(Bitmap.CompressFormat.PNG, 90, stream) + return bytes(stream.toByteArray()) + def save(self, path): path = Path(path) try: diff --git a/android/src/toga_android/keys.py b/android/src/toga_android/keys.py index db1407f57b..59e8cad444 100644 --- a/android/src/toga_android/keys.py +++ b/android/src/toga_android/keys.py @@ -1,4 +1,5 @@ from android.view import KeyEvent + from toga.keys import Key KEYEVENT_KEYS = { @@ -197,13 +198,13 @@ def toga_key(event): # TODO: Confirm the mapping of Control, Meta and Hyper are correct. if event.isCapsLockOn(): - modifiers.add(Key.CAPSLOCK) + modifiers.add(Key.CAPSLOCK) # pragma: no cover if event.isShiftPressed(): - modifiers.add(Key.SHIFT) + modifiers.add(Key.SHIFT) # pragma: no cover if event.isCtrlPressed(): - modifiers.add(Key.MOD_1) + modifiers.add(Key.MOD_1) # pragma: no cover if event.isAltPressed(): - modifiers.add(Key.MOD_2) + modifiers.add(Key.MOD_2) # pragma: no cover return {"key": key, "modifiers": modifiers} except KeyError: # pragma: nocover diff --git a/android/src/toga_android/libs/events.py b/android/src/toga_android/libs/events.py index 2e1a3c16f9..77cdde3bc2 100644 --- a/android/src/toga_android/libs/events.py +++ b/android/src/toga_android/libs/events.py @@ -7,12 +7,11 @@ import sys import threading +from android.os import Handler, Looper, MessageQueue from java import dynamic_proxy from java.io import FileDescriptor from java.lang import Runnable -from android.os import Handler, Looper, MessageQueue - # Some methods in this file are based on CPython's implementation. # Per https://github.com/python/cpython/blob/master/LICENSE , re-use is permitted # via the Python Software Foundation License Version 2, which includes inclusion @@ -54,7 +53,7 @@ def __init__(self): # `executor` thread that typically exists in event loops. The event loop itself relies # on `run_in_executor()` for DNS lookups. In the future, we can restore `run_in_executor()`. async def run_in_executor(self, executor, func, *args): - return func(*args) + return func(*args) # pragma: no cover # Override parent `_call_soon()` to ensure Android wakes us up to do the delayed task. def _call_soon(self, callback, args, context): @@ -74,9 +73,13 @@ def run_forever_cooperatively(self): event loop interop is not paid by apps that don't use the event loop.""" # Based on `BaseEventLoop.run_forever()` in CPython. if self.is_running(): - raise RuntimeError("Refusing to start since loop is already running.") + raise RuntimeError( + "Refusing to start since loop is already running." + ) # pragma: no cover if self._closed: - raise RuntimeError("Event loop is closed. Create a new object.") + raise RuntimeError( + "Event loop is closed. Create a new object." + ) # pragma: no cover self._set_coroutine_origin_tracking(self._debug) self._thread_id = threading.get_ident() @@ -97,7 +100,7 @@ def enqueue_android_wakeup_for_delayed_tasks(self): loop. """ # If we are supposed to stop, actually stop. - if self._stopping: + if self._stopping: # pragma: no cover self._stopping = False self._thread_id = None asyncio.events._set_running_loop(None) @@ -124,7 +127,7 @@ def enqueue_android_wakeup_for_delayed_tasks(self): def _set_coroutine_origin_tracking(self, debug): # If running on Python 3.7 or 3.8, integrate with upstream event loop's debug feature, allowing # unawaited coroutines to have some useful info logged. See https://bugs.python.org/issue32591 - if hasattr(super(), "_set_coroutine_origin_tracking"): + if hasattr(super(), "_set_coroutine_origin_tracking"): # pragma: no cover super()._set_coroutine_origin_tracking(debug) def _get_next_delayed_task_wakeup(self): @@ -140,7 +143,7 @@ def _get_next_delayed_task_wakeup(self): sched_count > _MIN_SCHEDULED_TIMER_HANDLES and self._timer_cancelled_count / sched_count > _MIN_CANCELLED_TIMER_HANDLES_FRACTION - ): + ): # pragma: no cover # Remove delayed calls that were cancelled if their number # is too high new_scheduled = [] @@ -162,7 +165,7 @@ def _get_next_delayed_task_wakeup(self): timeout = None if self._ready or self._stopping: - if self._debug: + if self._debug: # pragma: no cover print("AndroidEventLoop: self.ready is", self._ready) timeout = 0 elif self._scheduled: @@ -199,8 +202,8 @@ def run_delayed_tasks(self): for i in range(ntodo): handle = self._ready.popleft() if handle._cancelled: - continue - if self._debug: + continue # pragma: no cover + if self._debug: # pragma: no cover try: self._current_handle = handle t0 = self.time() @@ -293,7 +296,7 @@ def message_queue(self): # unregister() and register(), so we rely on that as well. def register(self, fileobj, events, data=None): - if self._debug: + if self._debug: # pragma: no cover print( "register() fileobj={fileobj} events={events} data={data}".format( fileobj=fileobj, events=events, data=data @@ -303,7 +306,7 @@ def register(self, fileobj, events, data=None): self.register_with_android(fileobj, events) return ret - def unregister(self, fileobj): + def unregister(self, fileobj): # pragma: no cover self.message_queue.removeOnFileDescriptorEventListener(_create_java_fd(fileobj)) return super().unregister(fileobj) @@ -311,13 +314,13 @@ def reregister_with_android_soon(self, fileobj): def _reregister(): # If the fileobj got unregistered, exit early. key = self._key_from_fd(fileobj) - if key is None: + if key is None: # pragma: no cover if self._debug: print( "reregister_with_android_soon reregister_temporarily_ignored_fd exiting early; key=None" ) return - if self._debug: + if self._debug: # pragma: no cover print( "reregister_with_android_soon reregistering key={key}".format( key=key @@ -329,7 +332,7 @@ def _reregister(): self.loop.call_later(0, _reregister) def register_with_android(self, fileobj, events): - if self._debug: + if self._debug: # pragma: no cover print( "register_with_android() fileobj={fileobj} events={events}".format( fileobj=fileobj, events=events @@ -348,7 +351,7 @@ def handle_fd_wakeup(self, fd, events): Filter the events to just those that are registered, then notify the loop.""" key = self._key_from_fd(fd) - if key is None: + if key is None: # pragma: no cover print( "Warning: handle_fd_wakeup: wakeup for unregistered fd={fd}".format( fd=fd @@ -361,7 +364,7 @@ def handle_fd_wakeup(self, fd, events): if events & event_type and key.events & event_type: key_event_pairs.append((key, event_type)) if key_event_pairs: - if self._debug: + if self._debug: # pragma: no cover print( "handle_fd_wakeup() calling parent for key_event_pairs={key_event_pairs}".format( key_event_pairs=key_event_pairs @@ -369,7 +372,7 @@ def handle_fd_wakeup(self, fd, events): ) # Call superclass private method to notify. self.loop._process_events(key_event_pairs) - else: + else: # pragma: no cover print( "Warning: handle_fd_wakeup(): unnecessary wakeup fd={fd} events={events} key={key}".format( fd=fd, events=events, key=key @@ -407,7 +410,7 @@ def onFileDescriptorEvents(self, fd_obj, events): selectors.EVENT_WRITE have the same value (2).""" # Call hidden (non-private) method to get the numeric FD, so we can pass that to Python. fd = getattr(fd_obj, "getInt$")() - if self._debug: + if self._debug: # pragma: no cover print( "onFileDescriptorEvents woke up for fd={fd} events={events}".format( fd=fd, events=events diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 9fae28706f..5acba9a5fb 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod -from decimal import ROUND_HALF_EVEN, ROUND_UP, Decimal - -from org.beeware.android import MainActivity -from travertino.size import at_least +from decimal import ROUND_HALF_EVEN, Decimal from android.graphics import PorterDuff, PorterDuffColorFilter, Rect from android.graphics.drawable import ColorDrawable, InsetDrawable from android.view import Gravity, View from android.widget import RelativeLayout +from org.beeware.android import MainActivity +from travertino.size import at_least + from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT, TRANSPARENT from ..colors import native_color @@ -33,7 +33,7 @@ def scale_out(self, value, rounding=SCALE_DEFAULT_ROUNDING): return self.scale_round(value / self.dpi_scale, rounding) def scale_round(self, value, rounding): - if rounding is None: + if rounding is None: # pragma: no cover return value return int(Decimal(value).to_integral(rounding)) @@ -182,18 +182,13 @@ def remove_child(self, child): # TODO: consider calling requestLayout or forceLayout here # (https://github.com/beeware/toga/issues/1289#issuecomment-1453096034) def refresh(self): - intrinsic = self.interface.intrinsic - intrinsic.width = intrinsic.height = None + # Default values; may be overwritten by rehint(). + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) self.rehint() - assert intrinsic.width is not None, self - assert intrinsic.height is not None, self - - intrinsic.width = self.scale_out(intrinsic.width, ROUND_UP) - intrinsic.height = self.scale_out(intrinsic.height, ROUND_UP) - @abstractmethod def rehint(self): - ... + pass def align(value): diff --git a/android/src/toga_android/widgets/box.py b/android/src/toga_android/widgets/box.py index 2a584a130f..c4ad8dbe18 100644 --- a/android/src/toga_android/widgets/box.py +++ b/android/src/toga_android/widgets/box.py @@ -1,5 +1,3 @@ -from travertino.size import at_least - from android.widget import RelativeLayout from .base import Widget @@ -11,7 +9,3 @@ def create(self): def set_background_color(self, value): self.set_background_simple(value) - - def rehint(self): - self.interface.intrinsic.width = at_least(0) - self.interface.intrinsic.height = at_least(0) diff --git a/android/src/toga_android/widgets/button.py b/android/src/toga_android/widgets/button.py index ccc167fc5c..6fb4340b5f 100644 --- a/android/src/toga_android/widgets/button.py +++ b/android/src/toga_android/widgets/button.py @@ -1,8 +1,9 @@ -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP from android.view import View from android.widget import Button as A_Button +from java import dynamic_proxy +from travertino.size import at_least from .label import TextViewWidget @@ -13,7 +14,7 @@ def __init__(self, button_impl): self.button_impl = button_impl def onClick(self, _view): - self.button_impl.interface.on_press(None) + self.button_impl.interface.on_press() class Button(TextViewWidget): @@ -41,5 +42,9 @@ def rehint(self): View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED, ) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.getMeasuredWidth()), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 47cbb4508d..a2be9aaace 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -1,9 +1,5 @@ -from math import degrees, pi - -from java import dynamic_proxy, jint -from java.io import ByteArrayOutputStream -from org.beeware.android import DrawHandlerView, IDrawHandler -from travertino.size import at_least +import itertools +from math import degrees from android.graphics import ( Bitmap, @@ -14,7 +10,11 @@ Path, ) from android.view import MotionEvent, View -from toga.widgets.canvas import Baseline, FillRule +from java import dynamic_proxy, jint +from java.io import ByteArrayOutputStream +from org.beeware.android import DrawHandlerView, IDrawHandler + +from toga.widgets.canvas import Baseline, FillRule, arc_to_bezier, sweepangle from ..colors import native_color from .base import Widget @@ -40,11 +40,11 @@ def __init__(self, impl): def onTouch(self, canvas, event): x, y = map(self.impl.scale_out, (event.getX(), event.getY())) if (action := event.getAction()) == MotionEvent.ACTION_DOWN: - self.interface.on_press(None, x, y) + self.interface.on_press(x, y) elif action == MotionEvent.ACTION_MOVE: - self.interface.on_drag(None, x, y) + self.interface.on_drag(x, y) elif action == MotionEvent.ACTION_UP: - self.interface.on_release(None, x, y) + self.interface.on_release(x, y) else: # pragma: no cover return False return True @@ -58,7 +58,7 @@ def create(self): def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) - self.interface.on_resize(None, width=width, height=height) + self.interface.on_resize(width=width, height=height) def redraw(self): self.native.invalidate() @@ -101,25 +101,8 @@ def quadratic_curve_to(self, cpx, cpy, x, y, path, **kwargs): path.quadTo(cpx, cpy, x, y) def arc(self, x, y, radius, startangle, endangle, anticlockwise, path, **kwargs): - sweepangle = endangle - startangle - if anticlockwise: - if sweepangle > 0: - sweepangle -= 2 * pi - else: - if sweepangle < 0: - sweepangle += 2 * pi - - # HTML says sweep angles should be clamped at +/- 360 degrees, but Android uses - # mod 360 instead, so 360 would cause the circle to completely disappear. - limit = 359.999 # Must be less than 360 in 32-bit floating point. - path.arcTo( - x - radius, - y - radius, - x + radius, - y + radius, - degrees(startangle), - max(-limit, min(degrees(sweepangle), limit)), - False, # forceMoveTo + self.ellipse( + x, y, radius, radius, 0, startangle, endangle, anticlockwise, path, **kwargs ) def ellipse( @@ -136,19 +119,23 @@ def ellipse( **kwargs, ): matrix = Matrix() - matrix.postScale(radiusx, radiusy) - matrix.postRotate(degrees(rotation)) - matrix.postTranslate(x, y) - - # Creating the ellipse as a separate path and then using addPath would make it a - # disconnected contour. And there's no way to extract the segments from a path - # until getPathIterator in API level 34. So this is the simplest solution I - # could find. - inverse = Matrix() - matrix.invert(inverse) - path.transform(inverse) - self.arc(0, 0, 1, startangle, endangle, anticlockwise, path) - path.transform(matrix) + matrix.preTranslate(x, y) + matrix.preRotate(degrees(rotation)) + matrix.preScale(radiusx, radiusy) + matrix.preRotate(degrees(startangle)) + + coords = list( + itertools.chain( + *arc_to_bezier(sweepangle(startangle, endangle, anticlockwise)) + ) + ) + matrix.mapPoints(coords) + + self.line_to(coords[0], coords[1], path, **kwargs) + i = 2 + while i < len(coords): + self.bezier_curve_to(*coords[i : i + 6], path, **kwargs) + i += 6 def rect(self, x, y, width, height, path, **kwargs): path.addRect(x, y, x + width, y + height, Path.Direction.CW) @@ -265,7 +252,3 @@ def get_image_data(self): def set_background_color(self, value): self.set_background_simple(value) - - def rehint(self): - self.interface.intrinsic.width = at_least(0) - self.interface.intrinsic.height = at_least(0) diff --git a/android/src/toga_android/widgets/dateinput.py b/android/src/toga_android/widgets/dateinput.py index 2f3e000c4e..a2398e687c 100644 --- a/android/src/toga_android/widgets/dateinput.py +++ b/android/src/toga_android/widgets/dateinput.py @@ -1,9 +1,8 @@ from datetime import date, datetime, time -from java import dynamic_proxy - from android import R from android.app import DatePickerDialog +from java import dynamic_proxy from .internal.pickers import PickerBase @@ -43,7 +42,7 @@ def get_value(self): def set_value(self, value): self.native.setText(value.isoformat()) self._dialog.updateDate(value.year, value.month - 1, value.day) - self.interface.on_change(None) + self.interface.on_change() def get_min_date(self): return py_date(self._picker.getMinDate()) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index bdcc487eb8..ac439159b7 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -1,15 +1,13 @@ from dataclasses import dataclass -from androidx.swiperefreshlayout.widget import SwipeRefreshLayout -from java import dynamic_proxy -from travertino.size import at_least - from android import R from android.app import AlertDialog from android.content import DialogInterface from android.graphics import Rect from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView +from androidx.swiperefreshlayout.widget import SwipeRefreshLayout +from java import dynamic_proxy from .base import Widget @@ -22,7 +20,7 @@ def __init__(self, impl, row_number): def onClick(self, _view): self.impl._set_selection(self.row_number) - self.impl.interface.on_select(None) + self.impl.interface.on_select() @dataclass @@ -41,7 +39,7 @@ def __init__(self, impl, row_number): def onLongClick(self, _view): self.impl._set_selection(self.row_number) - self.impl.interface.on_select(None) + self.impl.interface.on_select() actions = [ action @@ -77,7 +75,7 @@ def __init__(self, actions, row): self.row = row def onClick(self, dialog, which): - self.actions[which].handler(None, row=self.row) + self.actions[which].handler(row=self.row) class OnRefreshListener(dynamic_proxy(SwipeRefreshLayout.OnRefreshListener)): @@ -86,7 +84,7 @@ def __init__(self, interface): self._interface = interface def onRefresh(self): - self._interface.on_refresh(None) + self._interface.on_refresh() class DetailedList(Widget): @@ -244,7 +242,3 @@ def scroll_to_row(self, row): hit_rect, True, # Immediate, not animated ) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/src/toga_android/widgets/divider.py b/android/src/toga_android/widgets/divider.py new file mode 100644 index 0000000000..3c856abac2 --- /dev/null +++ b/android/src/toga_android/widgets/divider.py @@ -0,0 +1,42 @@ +from android.graphics import Color +from android.view import View +from android.widget import LinearLayout +from travertino.size import at_least + +from .base import Widget + + +class Divider(Widget): + def create(self): + self.native = View(self._native_activity) + + # Background color needs to be set or else divider will not be visible. + self.native.setBackgroundColor(Color.LTGRAY) + + self._direction = self.interface.HORIZONTAL + + def set_background_color(self, value): + self.set_background_simple(value) + + def get_direction(self): + return self._direction + + def set_direction(self, value): + self._direction = value + + if value == self.interface.VERTICAL: + # Set the height for a vertical divider + params = LinearLayout.LayoutParams(1, self.interface._MIN_HEIGHT) + else: + # Set the width for a horizontal divider + params = LinearLayout.LayoutParams(self.interface._MIN_WIDTH, 1) + + self.native.setLayoutParams(params) + + def rehint(self): + if self.get_direction() == self.interface.VERTICAL: + self.interface.intrinsic.width = 1 + self.interface.intrinsic.height = at_least(self.native.getHeight()) + else: + self.interface.intrinsic.width = at_least(self.native.getWidth()) + self.interface.intrinsic.height = 1 diff --git a/android/src/toga_android/widgets/imageview.py b/android/src/toga_android/widgets/imageview.py index e70bd38392..6f92541793 100644 --- a/android/src/toga_android/widgets/imageview.py +++ b/android/src/toga_android/widgets/imageview.py @@ -1,4 +1,7 @@ +from decimal import ROUND_UP + from android.widget import ImageView as A_ImageView + from toga.widgets.imageview import rehint_imageview from .base import Widget @@ -20,7 +23,7 @@ def set_image(self, image): def rehint(self): # User specified sizes are in "pixels", which is DP; - # we need to convert all sizes into SP. + # we need to convert all sizes into physical pixels. dpi = self.native.getContext().getResources().getDisplayMetrics().densityDpi # Toga needs to know how the current DPI compares to the platform default, # which is 160: https://developer.android.com/training/multiscreen/screendensities @@ -29,8 +32,8 @@ def rehint(self): width, height, aspect_ratio = rehint_imageview( image=self.interface.image, style=self.interface.style, scale=scale ) - self.interface.intrinsic.width = width - self.interface.intrinsic.height = height + self.interface.intrinsic.width = self.scale_out(width, ROUND_UP) + self.interface.intrinsic.height = self.scale_out(height, ROUND_UP) if aspect_ratio is not None: self.native.setScaleType(A_ImageView.ScaleType.FIT_CENTER) else: diff --git a/android/src/toga_android/widgets/internal/pickers.py b/android/src/toga_android/widgets/internal/pickers.py index 470f1b05e5..61e00a1d46 100644 --- a/android/src/toga_android/widgets/internal/pickers.py +++ b/android/src/toga_android/widgets/internal/pickers.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod - -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP from android.view import View from android.widget import EditText +from java import dynamic_proxy +from travertino.size import at_least from ..label import TextViewWidget @@ -43,4 +43,6 @@ def create(self): def rehint(self): self.interface.intrinsic.width = at_least(300) self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/label.py b/android/src/toga_android/widgets/label.py index 756fa51e0c..5737514ae6 100644 --- a/android/src/toga_android/widgets/label.py +++ b/android/src/toga_android/widgets/label.py @@ -1,10 +1,12 @@ -from travertino.size import at_least +from decimal import ROUND_UP from android.os import Build from android.text import Layout from android.util import TypedValue from android.view import Gravity, View from android.widget import TextView +from travertino.size import at_least + from toga.constants import JUSTIFY from toga_android.colors import native_color @@ -68,13 +70,15 @@ def rehint(self): # This is the height with word-wrapping disabled. self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) min_height = self.native.getMeasuredHeight() - self.interface.intrinsic.height = min_height + self.interface.intrinsic.height = self.scale_out(min_height, ROUND_UP) # Ask it how wide it would be if it had to be the minimum height. self.native.measure( View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.makeMeasureSpec(min_height, View.MeasureSpec.AT_MOST), ) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.getMeasuredWidth()), ROUND_UP + ) def set_alignment(self, value): self.set_textview_alignment(value, Gravity.TOP) diff --git a/android/src/toga_android/widgets/multilinetextinput.py b/android/src/toga_android/widgets/multilinetextinput.py index a3ee4a1591..0f876c6136 100644 --- a/android/src/toga_android/widgets/multilinetextinput.py +++ b/android/src/toga_android/widgets/multilinetextinput.py @@ -1,7 +1,6 @@ -from travertino.size import at_least - from android.text import InputType from android.view import Gravity +from travertino.size import at_least from .textinput import TextInput @@ -13,7 +12,7 @@ def create(self): ) def _on_change(self): - self.interface.on_change(None) + self.interface.on_change() def _on_confirm(self): # pragma: nocover pass # The interface doesn't support this event. @@ -27,6 +26,7 @@ def _on_lose_focus(self): def set_alignment(self, value): self.set_textview_alignment(value, Gravity.TOP) + # This method is necessary to override the TextInput base class. def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/src/toga_android/widgets/numberinput.py b/android/src/toga_android/widgets/numberinput.py index e91050e8e2..8ff2ce79c9 100644 --- a/android/src/toga_android/widgets/numberinput.py +++ b/android/src/toga_android/widgets/numberinput.py @@ -1,6 +1,7 @@ from decimal import InvalidOperation from android.text import InputType + from toga.widgets.numberinput import _clean_decimal from .textinput import TextInput @@ -33,7 +34,7 @@ def set_min_value(self, value): pass # This backend doesn't support stepped increments. def _on_change(self): - self.interface.on_change(None) + self.interface.on_change() def _on_confirm(self): # pragma: nocover pass # The interface doesn't support this event. diff --git a/android/src/toga_android/widgets/progressbar.py b/android/src/toga_android/widgets/progressbar.py index 539f1ce1d6..2567537be3 100644 --- a/android/src/toga_android/widgets/progressbar.py +++ b/android/src/toga_android/widgets/progressbar.py @@ -1,4 +1,4 @@ -from travertino.size import at_least +from decimal import ROUND_UP from android import R from android.view import View @@ -93,5 +93,6 @@ def rehint(self): View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED, ) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index 1e727774f4..193f29a247 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -1,10 +1,8 @@ from decimal import ROUND_DOWN -from java import dynamic_proxy -from travertino.size import at_least - from android.view import Gravity, View from android.widget import HorizontalScrollView, LinearLayout, ScrollView +from java import dynamic_proxy from ..container import Container from .base import Widget @@ -28,7 +26,7 @@ def __init__(self, impl): self.impl = impl def onScrollChange(self, view, new_x, new_y, old_x, old_y): - self.impl.interface.on_scroll(None) + self.impl.interface.on_scroll() class ScrollContainer(Widget, Container): @@ -104,7 +102,3 @@ def set_position(self, horizontal_position, vertical_position): def set_background_color(self, value): self.set_background_simple(value) - - def rehint(self): - self.interface.intrinsic.width = at_least(0) - self.interface.intrinsic.height = at_least(0) diff --git a/android/src/toga_android/widgets/selection.py b/android/src/toga_android/widgets/selection.py index 2e79e93382..cddd649f5f 100644 --- a/android/src/toga_android/widgets/selection.py +++ b/android/src/toga_android/widgets/selection.py @@ -1,9 +1,9 @@ -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP from android import R from android.view import View from android.widget import AdapterView, ArrayAdapter, Spinner +from java import dynamic_proxy from .base import Widget @@ -36,7 +36,7 @@ def create(self): # the change, and use self.last_selection to prevent duplication. def on_change(self, index): if index != self.last_selection: - self.interface.on_change(None) + self.interface.on_change() self.last_selection = index def insert(self, index, item): @@ -69,7 +69,7 @@ def remove(self, index, item=None): self.select_item(self.last_selection) if removed_selection: - self.interface.on_change(None) + self.interface.on_change() def select_item(self, index, item=None): self.native.setSelection(index) @@ -85,5 +85,6 @@ def clear(self): def rehint(self): self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/slider.py b/android/src/toga_android/widgets/slider.py index 6f75168977..12a263b152 100644 --- a/android/src/toga_android/widgets/slider.py +++ b/android/src/toga_android/widgets/slider.py @@ -1,10 +1,11 @@ -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP -import toga from android import R from android.view import View from android.widget import SeekBar +from java import dynamic_proxy + +import toga from .base import Widget @@ -24,10 +25,10 @@ def onProgressChanged(self, _view, _progress, _from_user): self.impl.on_change() def onStartTrackingTouch(self, native_seekbar): - self.impl.interface.on_press(None) + self.impl.interface.on_press() def onStopTrackingTouch(self, native_seekbar): - self.impl.interface.on_release(None) + self.impl.interface.on_release() class Slider(Widget, toga.widgets.slider.IntSliderImpl): @@ -67,5 +68,6 @@ def _load_tick_drawable(self): def rehint(self): self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/switch.py b/android/src/toga_android/widgets/switch.py index e843cd94bd..d1202171e1 100644 --- a/android/src/toga_android/widgets/switch.py +++ b/android/src/toga_android/widgets/switch.py @@ -1,8 +1,9 @@ -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP from android.view import View from android.widget import CompoundButton, Switch as A_Switch +from java import dynamic_proxy +from travertino.size import at_least from .label import TextViewWidget @@ -13,7 +14,7 @@ def __init__(self, impl): self._impl = impl def onCheckedChanged(self, _button, _checked): - self._impl.interface.on_change(None) + self._impl.interface.on_change() class Switch(TextViewWidget): @@ -44,5 +45,9 @@ def set_value(self, value): def rehint(self): self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.getMeasuredWidth()), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 849addeff0..72cdfd724f 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -1,13 +1,12 @@ from warnings import warn -from java import dynamic_proxy -from travertino.size import at_least - -import toga from android import R from android.graphics import Rect, Typeface from android.view import Gravity, View from android.widget import LinearLayout, ScrollView, TableLayout, TableRow, TextView +from java import dynamic_proxy + +import toga from .base import Widget from .label import set_textview_font @@ -28,7 +27,7 @@ def onClick(self, view): else: self.impl.clear_selection() self.impl.add_selection(tr_id, view) - self.impl.interface.on_select(None) + self.impl.interface.on_select() class TogaOnLongClickListener(dynamic_proxy(View.OnLongClickListener)): @@ -40,8 +39,8 @@ def onLongClick(self, view): self.impl.clear_selection() index = view.getId() self.impl.add_selection(index, view) - self.impl.interface.on_select(None) - self.impl.interface.on_activate(None, row=self.impl.interface.data[index]) + self.impl.interface.on_select() + self.impl.interface.on_activate(row=self.impl.interface.data[index]) return True @@ -221,7 +220,3 @@ def set_background_color(self, value): def set_font(self, font): self._font_impl = font._impl self.change_source(self.interface.data) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/src/toga_android/widgets/textinput.py b/android/src/toga_android/widgets/textinput.py index 24d1529026..b6c1c90c56 100644 --- a/android/src/toga_android/widgets/textinput.py +++ b/android/src/toga_android/widgets/textinput.py @@ -1,9 +1,10 @@ -from java import dynamic_proxy -from travertino.size import at_least +from decimal import ROUND_UP from android.text import InputType, TextWatcher from android.view import Gravity, View from android.widget import EditText +from java import dynamic_proxy + from toga_android.keys import toga_key from .label import TextViewWidget @@ -110,19 +111,20 @@ def is_valid(self): return self.native.getError() is None def _on_change(self): - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() def _on_confirm(self): - self.interface.on_confirm(None) + self.interface.on_confirm() def _on_gain_focus(self): - self.interface.on_gain_focus(None) + self.interface.on_gain_focus() def _on_lose_focus(self): - self.interface.on_lose_focus(None) + self.interface.on_lose_focus() def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.native.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - self.interface.intrinsic.height = self.native.getMeasuredHeight() + self.interface.intrinsic.height = self.scale_out( + self.native.getMeasuredHeight(), ROUND_UP + ) diff --git a/android/src/toga_android/widgets/timeinput.py b/android/src/toga_android/widgets/timeinput.py index 05d9744ae9..7c065c0d8b 100644 --- a/android/src/toga_android/widgets/timeinput.py +++ b/android/src/toga_android/widgets/timeinput.py @@ -1,9 +1,8 @@ from datetime import time -from java import dynamic_proxy - from android import R from android.app import TimePickerDialog +from java import dynamic_proxy from .internal.pickers import PickerBase @@ -39,7 +38,7 @@ def get_value(self): def set_value(self, value): self.native.setText(value.isoformat(timespec="minutes")) self._dialog.updateTime(value.hour, value.minute) - self.interface.on_change(None) + self.interface.on_change() def get_min_time(self): return self._min_time diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 343fadd954..25dcbda30d 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,9 +1,8 @@ import json +from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy -from travertino.size import at_least -from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from toga.widgets.webview import JavaScriptResult from .base import Widget @@ -81,7 +80,3 @@ def evaluate_javascript(self, javascript, on_result=None): javascript, ReceiveString(result.future, on_result) ) return result - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 88fc0fd77b..ffd523a7d1 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,9 +1,13 @@ from decimal import ROUND_UP -from java import dynamic_proxy - from android import R +from android.graphics import ( + Bitmap, + Canvas as A_Canvas, +) from android.view import ViewTreeObserver +from java import dynamic_proxy +from java.io import ByteArrayOutputStream from .container import Container @@ -66,7 +70,7 @@ def set_size(self, size): pass def create_toolbar(self): - pass + self.app.native.invalidateOptionsMenu() def show(self): pass @@ -98,3 +102,17 @@ def close(self): def set_full_screen(self, is_full_screen): self.interface.factory.not_implemented("Window.set_full_screen()") + + def get_image_data(self): + bitmap = Bitmap.createBitmap( + self.native_content.getWidth(), + self.native_content.getHeight(), + Bitmap.Config.ARGB_8888, + ) + canvas = A_Canvas(bitmap) + # TODO: Need to draw window background as well as the content. + self.native_content.draw(canvas) + + stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream) + return bytes(stream.toByteArray()) diff --git a/android/tests/test_implementation.py b/android/tests/test_implementation.py deleted file mode 100644 index 7c3f93dfb9..0000000000 --- a/android/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_android" - ) - ) - ) -) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 30bed800b1..76365fcc4d 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -1,17 +1,26 @@ from pathlib import Path +from android import R from org.beeware.android import MainActivity +from pytest import xfail + +from toga import Group from .probe import BaseProbe +from .window import WindowProbe class AppProbe(BaseProbe): + supports_key = False + def __init__(self, app): super().__init__(app) - assert isinstance(self.app._impl.native, MainActivity) + self.native = self.app._impl.native + self.main_window_probe = WindowProbe(self.app, app.main_window) + assert isinstance(self.native, MainActivity) def get_app_context(self): - return self.app._impl.native.getApplicationContext() + return self.native.getApplicationContext() @property def config_path(self): @@ -28,3 +37,72 @@ def cache_path(self): @property def logs_path(self): return Path(self.get_app_context().getFilesDir().getPath()) / "log" + + def _menu_item(self, path): + menu = self.main_window_probe._native_menu() + for i_path, label in enumerate(path): + if i_path == 0 and label == Group.COMMANDS.text: + continue + + for i_item in range(menu.size()): + item = menu.getItem(i_item) + assert not item.requestsActionButton() + if item.getTitle() == label and not item.requiresActionButton(): + break + else: + raise AssertionError(f"no item named {path[:i_path+1]}") + + if i_path < len(path) - 1: + # Simulate opening the submenu. + assert self.native.onOptionsItemSelected(item) is False + menu = item.getSubMenu() + assert menu is not None + + return item + + def _activate_menu_item(self, path): + assert self.native.onOptionsItemSelected(self._menu_item(path)) + + def activate_menu_exit(self): + xfail("This backend doesn't have an exit command") + + def activate_menu_about(self): + self._activate_menu_item(["About Toga Testbed"]) + + async def close_about_dialog(self): + await self.main_window_probe.close_info_dialog(None) + + def activate_menu_visit_homepage(self): + xfail("This backend doesn't have a visit homepage command") + + def assert_menu_item(self, path, *, enabled=True): + assert self._menu_item(path).isEnabled() == enabled + + def assert_system_menus(self): + self.assert_menu_item(["About Toga Testbed"]) + + def activate_menu_close_window(self): + xfail("This backend doesn't have a window management menu") + + def activate_menu_close_all_windows(self): + xfail("This backend doesn't have a window management menu") + + def activate_menu_minimize(self): + xfail("This backend doesn't have a window management menu") + + def enter_background(self): + xfail( + "This is possible (https://stackoverflow.com/a/7071289), but there's no " + "easy way to bring it to the foreground again" + ) + + def enter_foreground(self): + xfail("See enter_background") + + def terminate(self): + xfail("Can't simulate this action without killing the app") + + def rotate(self): + self.native.findViewById( + R.id.content + ).getViewTreeObserver().dispatchOnGlobalLayout() diff --git a/android/tests_backend/fonts.py b/android/tests_backend/fonts.py index 600444add0..6a0ddddf02 100644 --- a/android/tests_backend/fonts.py +++ b/android/tests_backend/fonts.py @@ -1,12 +1,12 @@ from concurrent.futures import ThreadPoolExecutor +from android.graphics import Typeface +from android.graphics.fonts import FontFamily +from android.util import TypedValue from fontTools.ttLib import TTFont from java import jint from java.lang import Integer, Long -from android.graphics import Typeface -from android.graphics.fonts import FontFamily -from android.util import TypedValue from toga.fonts import ( BOLD, ITALIC, diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index 7e09968550..c436051a80 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -1,5 +1,4 @@ import pytest - from android.graphics import Bitmap from .probe import BaseProbe diff --git a/android/tests_backend/probe.py b/android/tests_backend/probe.py index fc8c458e79..d21380ee43 100644 --- a/android/tests_backend/probe.py +++ b/android/tests_backend/probe.py @@ -1,11 +1,11 @@ import asyncio -from java import dynamic_proxy -from org.beeware.android import MainActivity - from android import R from android.view import View, ViewTreeObserver, WindowManagerGlobal from android.widget import Button +from java import dynamic_proxy +from org.beeware.android import MainActivity +from pytest import approx class LayoutListener(dynamic_proxy(ViewTreeObserver.OnGlobalLayoutListener)): @@ -89,7 +89,14 @@ async def redraw(self, message=None, delay=0): print("Redraw timed out") if self.app.run_slow: - delay = min(delay, 1) + delay = max(delay, 1) if delay: print("Waiting for redraw" if message is None else message) await asyncio.sleep(delay) + + def assert_image_size(self, image_size, size): + # Sizes are approximate because of scaling inconsistencies. + assert image_size == ( + approx(size[0] * self.scale_factor, abs=2), + approx(size[1] * self.scale_factor, abs=2), + ) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 67193ec22f..123c7b8d1c 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -1,8 +1,6 @@ import asyncio import pytest -from pytest import approx - from android.graphics.drawable import ( ColorDrawable, DrawableContainer, @@ -11,6 +9,7 @@ ) from android.os import Build, SystemClock from android.view import MotionEvent, View, ViewGroup + from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT @@ -86,12 +85,12 @@ def assert_layout(self, size, position): # Size and position is as expected. Values must be scaled from DP, and # compared inexactly due to pixel scaling assert ( - approx(self.native.getWidth() / self.scale_factor, rel=0.01), - approx(self.native.getHeight() / self.scale_factor, rel=0.01), + pytest.approx(self.native.getWidth() / self.scale_factor, rel=0.01), + pytest.approx(self.native.getHeight() / self.scale_factor, rel=0.01), ) == size assert ( - approx(self.native.getLeft() / self.scale_factor, rel=0.01), - approx(self.native.getTop() / self.scale_factor, rel=0.01), + pytest.approx(self.native.getLeft() / self.scale_factor, rel=0.01), + pytest.approx(self.native.getTop() / self.scale_factor, rel=0.01), ) == position @property diff --git a/android/tests_backend/widgets/canvas.py b/android/tests_backend/widgets/canvas.py index 380bc9c3f2..55fc16f15d 100644 --- a/android/tests_backend/widgets/canvas.py +++ b/android/tests_backend/widgets/canvas.py @@ -1,11 +1,10 @@ from io import BytesIO import pytest -from org.beeware.android import DrawHandlerView -from PIL import Image - from android.os import SystemClock from android.view import MotionEvent +from org.beeware.android import DrawHandlerView +from PIL import Image from .base import SimpleProbe @@ -21,10 +20,6 @@ def reference_variant(self, reference): def get_image(self): return Image.open(BytesIO(self.impl.get_image_data())) - def assert_image_size(self, image, width, height): - assert image.width == width * self.scale_factor - assert image.height == height * self.scale_factor - def motion_event(self, action, x, y): time = SystemClock.uptimeMillis() super().motion_event( diff --git a/android/tests_backend/widgets/detailedlist.py b/android/tests_backend/widgets/detailedlist.py index 2921e14be2..2e42c7b28e 100644 --- a/android/tests_backend/widgets/detailedlist.py +++ b/android/tests_backend/widgets/detailedlist.py @@ -1,7 +1,5 @@ import asyncio -from androidx.swiperefreshlayout.widget import SwipeRefreshLayout - from android.os import SystemClock from android.view import KeyEvent from android.widget import ( @@ -12,6 +10,7 @@ ScrollView, TextView, ) +from androidx.swiperefreshlayout.widget import SwipeRefreshLayout from .base import SimpleProbe, find_view_by_type diff --git a/android/tests_backend/widgets/divider.py b/android/tests_backend/widgets/divider.py new file mode 100644 index 0000000000..1526ba2849 --- /dev/null +++ b/android/tests_backend/widgets/divider.py @@ -0,0 +1,7 @@ +from java import jclass + +from .base import SimpleProbe + + +class DividerProbe(SimpleProbe): + native_class = jclass("android.view.View") diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py index 93c5145f33..f74ccb6562 100644 --- a/android/tests_backend/widgets/label.py +++ b/android/tests_backend/widgets/label.py @@ -1,6 +1,5 @@ -from java import jclass - from android.os import Build +from java import jclass from .base import SimpleProbe from .properties import toga_alignment, toga_color diff --git a/android/tests_backend/widgets/properties.py b/android/tests_backend/widgets/properties.py index c202caecbe..356fb78552 100644 --- a/android/tests_backend/widgets/properties.py +++ b/android/tests_backend/widgets/properties.py @@ -1,9 +1,9 @@ -from java import jint - from android.graphics import Color from android.os import Build from android.text import Layout from android.view import Gravity +from java import jint + from toga.colors import TRANSPARENT, rgba from toga.constants import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP diff --git a/android/tests_backend/widgets/selection.py b/android/tests_backend/widgets/selection.py index 8925cf272f..390cd90004 100644 --- a/android/tests_backend/widgets/selection.py +++ b/android/tests_backend/widgets/selection.py @@ -1,6 +1,5 @@ -from pytest import xfail - from android.widget import Spinner +from pytest import xfail from .base import SimpleProbe diff --git a/android/tests_backend/widgets/slider.py b/android/tests_backend/widgets/slider.py index 32f24ea085..4847e2fd6d 100644 --- a/android/tests_backend/widgets/slider.py +++ b/android/tests_backend/widgets/slider.py @@ -1,7 +1,6 @@ -from java import jclass - from android.os import Build, SystemClock from android.view import MotionEvent +from java import jclass from .base import SimpleProbe diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index d27084314f..aea21ed8a0 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -1,5 +1,4 @@ import pytest - from android.widget import ScrollView, TableLayout, TextView from .base import SimpleProbe diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py index 149765bbea..2bbc62c4f9 100644 --- a/android/tests_backend/widgets/textinput.py +++ b/android/tests_backend/widgets/textinput.py @@ -1,10 +1,9 @@ import pytest -from java import jclass - from android.os import SystemClock from android.text import InputType from android.view import KeyEvent from android.view.inputmethod import EditorInfo +from java import jclass from .label import LabelProbe diff --git a/android/tests_backend/widgets/timeinput.py b/android/tests_backend/widgets/timeinput.py index 4c784e480f..0edd7f6044 100644 --- a/android/tests_backend/widgets/timeinput.py +++ b/android/tests_backend/widgets/timeinput.py @@ -1,7 +1,7 @@ import re from datetime import time -from android import R as android_R +from android import R from android.widget import TimePicker from .base import find_view_by_type @@ -34,9 +34,7 @@ async def _change_dialog_value(self, delta): @property def _picker(self): - picker = find_view_by_type( - self._dialog.findViewById(android_R.id.content), TimePicker - ) + picker = find_view_by_type(self._dialog.findViewById(R.id.content), TimePicker) assert picker is not None assert picker.is24HourView() return picker diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 20a9747e01..bc283f43a3 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -1,4 +1,5 @@ import pytest +from androidx.appcompat import R as appcompat_R from .probe import BaseProbe @@ -6,6 +7,7 @@ class WindowProbe(BaseProbe): def __init__(self, app, window): super().__init__(app) + self.native = self.app._impl.native async def wait_for_window(self, message, minimize=False, full_screen=False): await self.redraw(message) @@ -48,3 +50,40 @@ async def close_open_file_dialog(self, dialog, result, multiple_select): async def close_select_folder_dialog(self, dialog, result, multiple_select): pytest.skip("Select Folder dialog not implemented on Android") + + def _native_menu(self): + return self.native.findViewById(appcompat_R.id.action_bar).getMenu() + + def _toolbar_items(self): + result = [] + prev_group = None + menu = self._native_menu() + for i_item in range(menu.size()): + item = menu.getItem(i_item) + assert not item.requestsActionButton() + + if item.requiresActionButton(): + if prev_group and prev_group != item.getGroupId(): + # The separator doesn't actually appear, but it keeps the indices + # correct for the tests. + result.append(None) + prev_group = item.getGroupId() + result.append(item) + + return result + + def has_toolbar(self): + return bool(self._toolbar_items()) + + def assert_is_toolbar_separator(self, index, section=False): + assert self._toolbar_items()[index] is None + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self._toolbar_items()[index] + assert item.getTitle() == label + # Tooltips are not implemented + assert (item.getIcon() is not None) == has_icon + assert item.isEnabled() == enabled + + def press_toolbar_button(self, index): + self.native.onOptionsItemSelected(self._toolbar_items()[index]) diff --git a/changes/1215.bugfix.rst b/changes/1215.bugfix.rst deleted file mode 100644 index e4bc5ad084..0000000000 --- a/changes/1215.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Widgets are now removed from windows when the window is closed, preventing a memory leak on window closure. diff --git a/changes/1235.bugfix.rst b/changes/1235.bugfix.rst deleted file mode 100644 index ec4c06a7d3..0000000000 --- a/changes/1235.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Android and iOS apps no longer crash if you invoke ``App.hide_cursor()`` or ``App.show_cursor()``. diff --git a/changes/1723.bugfix.rst b/changes/1723.bugfix.rst deleted file mode 100644 index a3ca28204a..0000000000 --- a/changes/1723.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -A Selection widget with no items now consistently returns a current value of ``None`` on all platforms. diff --git a/changes/1767.feature.txt b/changes/1767.feature.txt deleted file mode 100644 index f53e314286..0000000000 --- a/changes/1767.feature.txt +++ /dev/null @@ -1,2 +0,0 @@ -Headings are no longer mandatory for Tree widgets. -If headings are not provided, the widget will not display its header bar. diff --git a/changes/1779.bugfix.rst b/changes/1779.bugfix.rst deleted file mode 100644 index 7008e135e7..0000000000 --- a/changes/1779.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -macOS widget methods that return strings are now guaranteed to return strings, rather than native Objective C string objects. diff --git a/changes/1837.feature.rst b/changes/1837.feature.rst deleted file mode 100644 index e9b4941620..0000000000 --- a/changes/1837.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Support for custom font loading was added to the GTK, Cocoa and iOS backends. diff --git a/changes/1847.feature.rst b/changes/1847.feature.rst deleted file mode 100644 index 8cc68e68cf..0000000000 --- a/changes/1847.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The testbed app has better diagnostic output when running in test mode. diff --git a/changes/1849.doc.rst b/changes/1849.doc.rst deleted file mode 100644 index 6b8dd7b08b..0000000000 --- a/changes/1849.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Documentation for application paths was added. diff --git a/changes/1855.bugfix.rst b/changes/1855.bugfix.rst deleted file mode 100644 index 46db31d18b..0000000000 --- a/changes/1855.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Webviews on Windows no longer have a black background when they are resized. diff --git a/changes/1865.misc.rst b/changes/1865.misc.rst deleted file mode 100644 index a15dd7ff1a..0000000000 --- a/changes/1865.misc.rst +++ /dev/null @@ -1 +0,0 @@ -PyPI package names were removed as valid spelling words. diff --git a/changes/1866.bugfix.rst b/changes/1866.bugfix.rst deleted file mode 100644 index c3abc9d0dc..0000000000 --- a/changes/1866.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Ensure correct value is set for `MultilineTextInput.readonly` for iOS diff --git a/changes/1867.feature.rst b/changes/1867.feature.rst deleted file mode 100644 index 98c16c6c7f..0000000000 --- a/changes/1867.feature.rst +++ /dev/null @@ -1 +0,0 @@ -A new Textual backend was added to support terminal applications. diff --git a/changes/1868.doc.rst b/changes/1868.doc.rst deleted file mode 100644 index 469cd25ed5..0000000000 --- a/changes/1868.doc.rst +++ /dev/null @@ -1 +0,0 @@ -The contribution guide was expanded to include more suggestions for potential projects, and to explain how the backend tests work. diff --git a/changes/1872.bugfix.rst b/changes/1872.bugfix.rst deleted file mode 100644 index 5c00193447..0000000000 --- a/changes/1872.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -A window without an ``on_close`` handler can now be closed using the window frame close button. diff --git a/changes/1872.feature.rst b/changes/1872.feature.rst deleted file mode 100644 index cbdbd0eb5e..0000000000 --- a/changes/1872.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Support for determining the currently active window was added to Winforms. diff --git a/changes/1875.misc.rst b/changes/1875.misc.rst deleted file mode 100644 index 190c84554d..0000000000 --- a/changes/1875.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 3.5.0 to 3.5.2. diff --git a/changes/1876.feature.rst b/changes/1876.feature.rst deleted file mode 100644 index 5f567757f0..0000000000 --- a/changes/1876.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Programmatically scrolling to top and bottom in MultilineTextInput is now possible on iOS diff --git a/changes/1878.bugfix.rst b/changes/1878.bugfix.rst deleted file mode 100644 index f6a4d57117..0000000000 --- a/changes/1878.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a startup crash on Android devices older than API level 29 (Android 10). diff --git a/changes/1879.bugfix.rst b/changes/1879.bugfix.rst deleted file mode 100644 index c47ecfff95..0000000000 --- a/changes/1879.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Missing value handling on Tables was fixed on Android and Linux. diff --git a/changes/1880.feature.rst b/changes/1880.feature.rst deleted file mode 100644 index 1bc205820a..0000000000 --- a/changes/1880.feature.rst +++ /dev/null @@ -1 +0,0 @@ -A handler has been added for users confirming the contents of a TextInput by pressing Enter/Return. diff --git a/changes/1881.bugfix.rst b/changes/1881.bugfix.rst deleted file mode 100644 index d0b1244e14..0000000000 --- a/changes/1881.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Changed spelling to prevent Sphinx spelling error. diff --git a/changes/1882.misc.rst b/changes/1882.misc.rst deleted file mode 100644 index 24dd6618eb..0000000000 --- a/changes/1882.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``tox`` commands for building docs now use multi-processing for faster builds. diff --git a/changes/1886.misc.rst b/changes/1886.misc.rst deleted file mode 100644 index 98a4438747..0000000000 --- a/changes/1886.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The latest version of ``docformatter``, i.e. v1.6.1, was run to lint all docstrings in Toga. diff --git a/changes/1887.feature.rst b/changes/1887.feature.rst deleted file mode 100644 index 9a6030b167..0000000000 --- a/changes/1887.feature.rst +++ /dev/null @@ -1 +0,0 @@ -An API for setting a window into active focus was added. diff --git a/changes/1889.misc.rst b/changes/1889.misc.rst deleted file mode 100644 index 71b65af880..0000000000 --- a/changes/1889.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/setup-python from 4.5.0 to 4.6.0. diff --git a/changes/1892.bugfix.rst b/changes/1892.bugfix.rst deleted file mode 100644 index 14168889f6..0000000000 --- a/changes/1892.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -A bug in the gtk backend for `current_window` property was fixed. diff --git a/changes/1893.feature.rst b/changes/1893.feature.rst deleted file mode 100644 index a3d87b4ea9..0000000000 --- a/changes/1893.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Widgets now have a `.clear()` method to remove all child widgets. diff --git a/changes/1894.feature.rst b/changes/1894.feature.rst deleted file mode 100644 index 63eaef269a..0000000000 --- a/changes/1894.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Winforms now supports hiding and re-showing the app cursor. diff --git a/changes/1895.misc.rst b/changes/1895.misc.rst deleted file mode 100644 index 3785d48e9d..0000000000 --- a/changes/1895.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The implementation of ``current_window`` on Winforms was corrected. diff --git a/changes/1897.doc.rst b/changes/1897.doc.rst deleted file mode 100644 index 5367252d9a..0000000000 --- a/changes/1897.doc.rst +++ /dev/null @@ -1 +0,0 @@ -All code blocks were updated to add a button to copy the relevant contents on to the user's clipboard. diff --git a/changes/1900.misc.rst b/changes/1900.misc.rst deleted file mode 100644 index d616e6fe32..0000000000 --- a/changes/1900.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Release processes were changed to use Trusted Publishing. diff --git a/changes/1901.feature.rst b/changes/1901.feature.rst deleted file mode 100644 index df25c72d92..0000000000 --- a/changes/1901.feature.rst +++ /dev/null @@ -1 +0,0 @@ -New ProgressBar and Switch widgets were added to the web platform implementation. diff --git a/changes/1902.misc.rst b/changes/1902.misc.rst deleted file mode 100644 index 5679e35d9c..0000000000 --- a/changes/1902.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Moved font measuring logic for GTK from Font class to Canvas widget diff --git a/changes/1903.feature.rst b/changes/1903.feature.rst deleted file mode 100644 index a448645944..0000000000 --- a/changes/1903.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Font APIs now have 100% test coverage. diff --git a/changes/1903.removal.rst b/changes/1903.removal.rst deleted file mode 100644 index 9deb0249da..0000000000 --- a/changes/1903.removal.rst +++ /dev/null @@ -1 +0,0 @@ -The ``weight``, ``style`` and ``variant`` arguments for ``Font`` and ``Font.register`` are now keyword-only. diff --git a/changes/1906.misc.rst b/changes/1906.misc.rst deleted file mode 100644 index 671ef25abf..0000000000 --- a/changes/1906.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The root user prompt ``#`` has been removed as a prompt match for copying commands from code blocks. diff --git a/changes/1909.bugfix.rst b/changes/1909.bugfix.rst deleted file mode 100644 index f6a633067c..0000000000 --- a/changes/1909.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Error handling associated with the creation of creation of Android Intents has been improved. diff --git a/changes/1913.feature.rst b/changes/1913.feature.rst deleted file mode 100644 index 4c4ed18a0a..0000000000 --- a/changes/1913.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Missing value handling was added to Trees. diff --git a/changes/1916.misc.rst b/changes/1916.misc.rst deleted file mode 100644 index 6aa9d82606..0000000000 --- a/changes/1916.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Installation ordering in tox environment was altered to ensure correct resolution of dependencies. diff --git a/changes/1917.misc.rst b/changes/1917.misc.rst deleted file mode 100644 index 248da6ab25..0000000000 --- a/changes/1917.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated ``pre-commit`` hooks to the latest version. diff --git a/changes/1918.misc.rst b/changes/1918.misc.rst deleted file mode 100644 index bdedcccfa5..0000000000 --- a/changes/1918.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated dsaltares/fetch-gh-release-asset from 1.1.0 to 1.1.1. diff --git a/changes/1920.bugfix.rst b/changes/1920.bugfix.rst deleted file mode 100644 index 547ecc89ae..0000000000 --- a/changes/1920.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -The DetailedList widget on GTK now provides an accurate size hint during layout. diff --git a/changes/1921.bugfix.rst b/changes/1921.bugfix.rst deleted file mode 100644 index 52c86fb701..0000000000 --- a/changes/1921.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Apps on Linux no longer segfault if an X Windows display cannot be identified. diff --git a/changes/1926.misc.rst b/changes/1926.misc.rst deleted file mode 100644 index 75768c042d..0000000000 --- a/changes/1926.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Dependencies in attic projects were unpinned to avoid spurious dependabot security alerts. diff --git a/changes/1933.misc.rst b/changes/1933.misc.rst deleted file mode 100644 index 248da6ab25..0000000000 --- a/changes/1933.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated ``pre-commit`` hooks to the latest version. diff --git a/changes/1938.feature.rst b/changes/1938.feature.rst deleted file mode 100644 index 1b4b1de04d..0000000000 --- a/changes/1938.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The MultilineTextInput widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1938.removal.rst b/changes/1938.removal.rst deleted file mode 100644 index 6460bbbea3..0000000000 --- a/changes/1938.removal.rst +++ /dev/null @@ -1 +0,0 @@ -The ``clear()`` method for resetting the value of a MultilineTextInput, TextInput and PasswordInput has been removed. This method was an ambiguous override of the ``clear()`` method on Widget that removed all child nodes. To remove all content from a text input widget, use ``widget.value = ""``. diff --git a/changes/1944.feature.rst b/changes/1944.feature.rst deleted file mode 100644 index cedc7c9b58..0000000000 --- a/changes/1944.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The TextInput and PasswordInput widgets now have 100% test coverage, and complete API documentation. diff --git a/changes/1944.removal.1.rst b/changes/1944.removal.1.rst deleted file mode 100644 index fd7f331eba..0000000000 --- a/changes/1944.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The argument names used to construct validators have changed. Error message arguments now all end with ``_message``; ``compare_count`` has been renamed ``count``; and ``min_value`` and ``max_value`` have been renamed ``min_length`` and ``max_length``, respectively. diff --git a/changes/1944.removal.2.rst b/changes/1944.removal.2.rst deleted file mode 100644 index edb06c6a2e..0000000000 --- a/changes/1944.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -The ability to perform multiple substring matches in a ``Contains`` validator has been removed. diff --git a/changes/1944.removal.3.rst b/changes/1944.removal.3.rst deleted file mode 100644 index 4c5055fae3..0000000000 --- a/changes/1944.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -The ``TextInput.validate`` method has been removed. Validation now happens automatically whenever the ``value`` or ``validators`` properties are changed. diff --git a/changes/1946.feature.rst b/changes/1946.feature.rst deleted file mode 100644 index 0d19d9dd40..0000000000 --- a/changes/1946.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The NumberInput widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1947.bugfix.rst b/changes/1947.bugfix.rst deleted file mode 100644 index 3a7016b732..0000000000 --- a/changes/1947.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -The ``on_result`` handler is now used by Cocoa file dialogs. diff --git a/changes/1948.misc.rst b/changes/1948.misc.rst deleted file mode 100644 index f59c7571f8..0000000000 --- a/changes/1948.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Dialogs were refactored to provide reuse for asynchronous results, and provide full interface/impl separation. diff --git a/changes/1949.feature.rst b/changes/1949.feature.rst deleted file mode 100644 index e6f7f1134e..0000000000 --- a/changes/1949.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The WebView widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1949.removal.1.rst b/changes/1949.removal.1.rst deleted file mode 100644 index 70f1e709de..0000000000 --- a/changes/1949.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The ``on_key_down`` handler has been removed from WebView. If you need to catch user input, either use a handler in the embedded Javascript, or create a ``Command`` with a key shortcut. diff --git a/changes/1949.removal.2.rst b/changes/1949.removal.2.rst deleted file mode 100644 index 607bdada1a..0000000000 --- a/changes/1949.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -The ``get_dom()`` method on WebView has been removed. This method wasn't implemented on most platforms, and wasn't working on any of the platforms where it *was* implemented, as modern web view implementations don't provide a synchronous API for accessing web content in this way. diff --git a/changes/1949.removal.3.rst b/changes/1949.removal.3.rst deleted file mode 100644 index 88ee8ad077..0000000000 --- a/changes/1949.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -The ``evaluate_javascript()`` method on WebView has been modified to work in both synchronous and asynchronous contexts. In a synchronous context you can invoke the method and use a functional ``on_result`` callback to be notified when evaluation is complete. In an asynchronous context, you can await the result. diff --git a/changes/1949.removal.4.rst b/changes/1949.removal.4.rst deleted file mode 100644 index 04b5c24a0d..0000000000 --- a/changes/1949.removal.4.rst +++ /dev/null @@ -1 +0,0 @@ -The ``invoke_javascript()`` method has been removed. All usage of ``invoke_javascript()`` can be replaced with ``evaluate_javascript()``. diff --git a/changes/1949.removal.5.rst b/changes/1949.removal.5.rst deleted file mode 100644 index 4840d9beb1..0000000000 --- a/changes/1949.removal.5.rst +++ /dev/null @@ -1 +0,0 @@ -The usage of local ``file://`` URLs has been explicitly prohibited. ``file://`` URLs have not been reliable for some time; their usage is now explicitly prohibited. diff --git a/changes/1950.misc.rst b/changes/1950.misc.rst deleted file mode 100644 index 9fa62a8b94..0000000000 --- a/changes/1950.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The NavigationView was removed; it wasn't exposed in public API. diff --git a/changes/1951.feature.rst b/changes/1951.feature.rst deleted file mode 100644 index a9fe48f668..0000000000 --- a/changes/1951.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The DateInput and TimeInput widgets now have 100% test coverage, and complete API documentation. diff --git a/changes/1951.removal.1.rst b/changes/1951.removal.1.rst deleted file mode 100644 index 1ae323e047..0000000000 --- a/changes/1951.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -``DatePicker`` has been renamed ``DateInput``. diff --git a/changes/1951.removal.2.rst b/changes/1951.removal.2.rst deleted file mode 100644 index 697d6e1f85..0000000000 --- a/changes/1951.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -``TimePicker`` has been renamed ``TimeInput``. diff --git a/changes/1952.misc.rst b/changes/1952.misc.rst deleted file mode 100644 index a6ba419dee..0000000000 --- a/changes/1952.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Type annotations were updated for all widgets that have been audited. diff --git a/changes/1954.misc.rst b/changes/1954.misc.rst deleted file mode 100644 index 13f8fc2a61..0000000000 --- a/changes/1954.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/setup-python from 4.6.0 to 4.6.1. diff --git a/changes/1955.feature.rst b/changes/1955.feature.rst deleted file mode 100644 index cce0a75039..0000000000 --- a/changes/1955.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The Selection widget now has 100% test coverage and complete API documentation. diff --git a/changes/1955.removal.1.rst b/changes/1955.removal.1.rst deleted file mode 100644 index 9064fdce96..0000000000 --- a/changes/1955.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The ``on_select`` handler on the Selection widget has been renamed ``on_change`` for consistency with other widgets. diff --git a/changes/1955.removal.2.rst b/changes/1955.removal.2.rst deleted file mode 100644 index 37dc673205..0000000000 --- a/changes/1955.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -The ``_notify()`` method on data sources has been renamed ``notify()``, reflecting its status as a public API. diff --git a/changes/1955.removal.3.rst b/changes/1955.removal.3.rst deleted file mode 100644 index 862c0cdbca..0000000000 --- a/changes/1955.removal.3.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``prepend()`` method was removed from the ``ListSource`` and ``TreeSource`` APIs. -Calls to ``prepend(...)`` can be replaced with ``insert(0, ...)``. diff --git a/changes/1955.removal.4.rst b/changes/1955.removal.4.rst deleted file mode 100644 index ec2d1e50d0..0000000000 --- a/changes/1955.removal.4.rst +++ /dev/null @@ -1,5 +0,0 @@ -The ``insert`` and ``append`` APIs on ``ListSource`` and ``TreeSource`` have been -modified to provide an interface that is closer to that ``list`` API. These methods -previously accepted a variable list of positional and keyword arguments; these arguments -should be combined into a single tuple or dictionary. This matches the API provided by -``__setitem__()``. diff --git a/changes/1956.feature.rst b/changes/1956.feature.rst deleted file mode 100644 index 59de21d71f..0000000000 --- a/changes/1956.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The ImageView widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1956.removal.rst b/changes/1956.removal.rst deleted file mode 100644 index 49c9bce655..0000000000 --- a/changes/1956.removal.rst +++ /dev/null @@ -1 +0,0 @@ -Images and ImageViews no longer support loading images from URLs. If you need to display an image from a URL, use a background task to obtain the image data asynchronously, then create the Image and/or set the ImageView ``image`` property on the completion of the asynchronous load. diff --git a/changes/1958.bugfix.rst b/changes/1958.bugfix.rst deleted file mode 100644 index a50af370ca..0000000000 --- a/changes/1958.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Pack layout now honors an explicit width/height setting of 0. diff --git a/changes/1958.removal.1.rst b/changes/1958.removal.1.rst deleted file mode 100644 index 0a92873e23..0000000000 --- a/changes/1958.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -A row box contained inside a row box will now expand to the full height of its parent, rather than collapsing to the maximum height of the inner box's child content. diff --git a/changes/1958.removal.2.rst b/changes/1958.removal.2.rst deleted file mode 100644 index e37636b8ae..0000000000 --- a/changes/1958.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -A column box contained inside a column box will now expand to the full width of its parent, rather than collapsing to the maximum width of the inner box's child content. diff --git a/changes/1963.misc.rst b/changes/1963.misc.rst deleted file mode 100644 index f37035e948..0000000000 --- a/changes/1963.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The iOS handler for focus changes was corrected to correctly resign focus. diff --git a/changes/1964.feature.1.rst b/changes/1964.feature.1.rst deleted file mode 100644 index ecdf5b3422..0000000000 --- a/changes/1964.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -The Paths property of apps now has 100% test coverage, and complete API documentation. diff --git a/changes/1964.feature.2.rst b/changes/1964.feature.2.rst deleted file mode 100644 index 1f4d50f228..0000000000 --- a/changes/1964.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -The app paths now include a ``config`` path for storing config files. diff --git a/changes/1964.removal.1.rst b/changes/1964.removal.1.rst deleted file mode 100644 index 2945eb7b2f..0000000000 --- a/changes/1964.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The location returned by ``toga.App.paths.app`` is now the folder that contains the Python source file that defines the app class used by the app. If you are using a ``toga.App`` instance directly, this may alter the path that is returned. diff --git a/changes/1964.removal.2.rst b/changes/1964.removal.2.rst deleted file mode 100644 index 3dbe9045e2..0000000000 --- a/changes/1964.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -On Winforms, if an application doesn't define an author, an author of ``Unknown`` is now used in application data paths, rather than ``Toga``. diff --git a/changes/1964.removal.3.rst b/changes/1964.removal.3.rst deleted file mode 100644 index 22bcae6c19..0000000000 --- a/changes/1964.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -Winforms now returns ``AppData/Local///Data`` as the user data file location, rather than ``AppData/Local//``. diff --git a/changes/1964.removal.4.rst b/changes/1964.removal.4.rst deleted file mode 100644 index b22fe6ab97..0000000000 --- a/changes/1964.removal.4.rst +++ /dev/null @@ -1 +0,0 @@ -On Android, the user data folder is now a ``data`` subdirectory of the location returned by ``context.getFilesDir()``, rather than the bare ``context.getFilesDir()`` location. diff --git a/changes/1964.removal.5.rst b/changes/1964.removal.5.rst deleted file mode 100644 index debf6198aa..0000000000 --- a/changes/1964.removal.5.rst +++ /dev/null @@ -1 +0,0 @@ -GTK now returns ``~/.local/state/appname/log`` as the log file location, rather than ``~/.cache/appname/log``. diff --git a/changes/1969.feature.rst b/changes/1969.feature.rst deleted file mode 100644 index c0261629de..0000000000 --- a/changes/1969.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The ScrollContainer widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1972.misc.rst b/changes/1972.misc.rst deleted file mode 100644 index 05105d7c6e..0000000000 --- a/changes/1972.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 3.5.2 to 3.5.3. diff --git a/changes/1977.misc.rst b/changes/1977.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/1977.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/1980.misc.rst b/changes/1980.misc.rst deleted file mode 100644 index 36887818c7..0000000000 --- a/changes/1980.misc.rst +++ /dev/null @@ -1 +0,0 @@ -References to Android system libraries have been modified to use Chaquopy native syntax, rather than Rubicon Java. diff --git a/changes/1984.feature.rst b/changes/1984.feature.rst deleted file mode 100644 index caadeee2d1..0000000000 --- a/changes/1984.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The SplitContainer widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1984.removal.1.rst b/changes/1984.removal.1.rst deleted file mode 100644 index 8b3e8f37a9..0000000000 --- a/changes/1984.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -Support for SplitContainers with more than 2 panels of content has been removed. diff --git a/changes/1984.removal.2.rst b/changes/1984.removal.2.rst deleted file mode 100644 index 7d194c8aa1..0000000000 --- a/changes/1984.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -Support for 3-tuple form of specifying SplitContainer items, used to prevent panels from resizing, has been removed. diff --git a/changes/1988.misc.rst b/changes/1988.misc.rst deleted file mode 100644 index 0ac6a013de..0000000000 --- a/changes/1988.misc.rst +++ /dev/null @@ -1 +0,0 @@ -An error in path instantiation by the web backend was corrected. diff --git a/changes/1989.misc.rst b/changes/1989.misc.rst deleted file mode 100644 index 9480ad6111..0000000000 --- a/changes/1989.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The on_webview_load signal when setting the URL to None was deferred to the event loop, rather than being invoked immediately. diff --git a/changes/1992.feature.rst b/changes/1992.feature.rst deleted file mode 100644 index 803d761478..0000000000 --- a/changes/1992.feature.rst +++ /dev/null @@ -1 +0,0 @@ -A more informative error message is returned when a platform backend doesn't support a widget. diff --git a/changes/1995.feature.rst b/changes/1995.feature.rst deleted file mode 100644 index 77d89cf247..0000000000 --- a/changes/1995.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The example apps were updated to support being run with ``briefcase run`` on all platforms. diff --git a/changes/1996.feature.rst b/changes/1996.feature.rst deleted file mode 100644 index 405bb99826..0000000000 --- a/changes/1996.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The OptionContainer widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1996.removal.1.rst b/changes/1996.removal.1.rst deleted file mode 100644 index 119d0bd069..0000000000 --- a/changes/1996.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The ability to increment and decrement the current OptionContainer tab was removed. Instead of `container.current_tab += 1`, use `container.current_tab = container.current_tab.index + 1` diff --git a/changes/1996.removal.2.rst b/changes/1996.removal.2.rst deleted file mode 100644 index 7b1b2a7c64..0000000000 --- a/changes/1996.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -``OptionContainer.add()``, ``OptionContainer.remove()`` and ``OptionContainer.insert()`` have been removed, due to being ambiguous with base widget methods of the same name. Use the ``OptionContainer.content.append()``, ``OptionContainer.content.remove()`` and ``OptionContainer.content.insert()`` APIs instead. diff --git a/changes/1996.removal.3.rst b/changes/1996.removal.3.rst deleted file mode 100644 index cd32b910ab..0000000000 --- a/changes/1996.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -The ``on_select`` handler for OptionContainer no longer receives the ``option`` argument providing the selected tab. Use ``current_tab`` to obtain the currently selected tab. diff --git a/changes/1998.misc.rst b/changes/1998.misc.rst deleted file mode 100644 index 977eddaf66..0000000000 --- a/changes/1998.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The reference to ``toga.paths.Paths`` in API Reference was corrected to resolve correctly. diff --git a/changes/1999.removal.1.rst b/changes/1999.removal.1.rst deleted file mode 100644 index 8c5f27b7e7..0000000000 --- a/changes/1999.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -``Slider.range`` has been replaced by ``Slider.min`` and ``Slider.max``. diff --git a/changes/1999.removal.2.rst b/changes/1999.removal.2.rst deleted file mode 100644 index 3b102fe4ff..0000000000 --- a/changes/1999.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -``NumberInput.min_value`` and ``NumberInput.max_value`` have been renamed ``NumberInput.min`` and ``NumberInput.max``, respectively. diff --git a/changes/1999.removal.3.rst b/changes/1999.removal.3.rst deleted file mode 100644 index 8e47bd7180..0000000000 --- a/changes/1999.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -``DatePicker.min_date`` and ``DatePicker.max_date`` has been renamed ``DateInput.min`` and ``DateInput.max``, respectively. diff --git a/changes/1999.removal.4.rst b/changes/1999.removal.4.rst deleted file mode 100644 index 3b2264aa6b..0000000000 --- a/changes/1999.removal.4.rst +++ /dev/null @@ -1 +0,0 @@ -``TimePicker.min_time`` and ``TimePicker.max_time`` has been renamed ``TimeInput.min`` and ``TimeInput.max``, respectively. diff --git a/changes/2001.doc.rst b/changes/2001.doc.rst deleted file mode 100644 index a0b6cbcf3a..0000000000 --- a/changes/2001.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Class references were updated to reflect their prefererred import location, rather than location where they are defined in code. diff --git a/changes/2008.misc.rst b/changes/2008.misc.rst deleted file mode 100644 index 59d9323c51..0000000000 --- a/changes/2008.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The import for Pyodide's ``create_proxy`` function was updated to import from the ``ffi`` submodule. diff --git a/changes/2011.feature.1.rst b/changes/2011.feature.1.rst deleted file mode 100644 index 476813d4d7..0000000000 --- a/changes/2011.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -The Table widget now has 100% test coverage and complete API documentation. diff --git a/changes/2011.feature.2.rst b/changes/2011.feature.2.rst deleted file mode 100644 index e48283fa2b..0000000000 --- a/changes/2011.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -Tables can now omit the header row by specifying ``headings=None`` and providing accessors. diff --git a/changes/2011.removal.1.rst b/changes/2011.removal.1.rst deleted file mode 100644 index 28d434d518..0000000000 --- a/changes/2011.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -``Table.on_double_click`` has been renamed ``Table.on_activate``. diff --git a/changes/2011.removal.2.rst b/changes/2011.removal.2.rst deleted file mode 100644 index 950ee3c64c..0000000000 --- a/changes/2011.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -Tables now use an empty string for the default missing value, rather than warning about missing values. diff --git a/changes/2011.removal.3.rst b/changes/2011.removal.3.rst deleted file mode 100644 index 0f8d7c80be..0000000000 --- a/changes/2011.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -``Table.add_column()`` has been deprecated in favor of ``Table.append_column()`` and ``Table.insert_column()`` diff --git a/changes/2014.misc.rst b/changes/2014.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2014.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2017.feature.1.rst b/changes/2017.feature.1.rst deleted file mode 100644 index 9e581442ce..0000000000 --- a/changes/2017.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -The Tree widget now has 100% test coverage and complete API documentation. diff --git a/changes/2017.feature.2.rst b/changes/2017.feature.2.rst deleted file mode 100644 index 65351dd337..0000000000 --- a/changes/2017.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -Columns can now be added and removed from a Tree. diff --git a/changes/2017.removal.1.rst b/changes/2017.removal.1.rst deleted file mode 100644 index 882b3b9d01..0000000000 --- a/changes/2017.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -The ``parent`` argument has been removed from the ``insert`` and ``append`` calls on ``TreeSource``. This improves consistency between the API for ``TreeSource`` and the API for ``list``. To insert or append a row in to a descendent of a TreeSource root, use ``insert`` and ``append`` on the parent node itself - i.e., ``source.insert(parent, index, ...)`` becomes ``parent.insert(index, ...)``, and ``source.insert(None, index, ...)`` becomes ``source.insert(index, ...)``. diff --git a/changes/2017.removal.2.rst b/changes/2017.removal.2.rst deleted file mode 100644 index 150389a5c1..0000000000 --- a/changes/2017.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -Trees now use an empty string for the default missing value, rather than warning about missing values. diff --git a/changes/2017.removal.3.rst b/changes/2017.removal.3.rst deleted file mode 100644 index 28d434d518..0000000000 --- a/changes/2017.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -``Table.on_double_click`` has been renamed ``Table.on_activate``. diff --git a/changes/2018.feature.rst b/changes/2018.feature.rst deleted file mode 100644 index de95a2461a..0000000000 --- a/changes/2018.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The default system notification sound can be played via app.beep() diff --git a/changes/2019.misc.rst b/changes/2019.misc.rst deleted file mode 100644 index 113d1d37c0..0000000000 --- a/changes/2019.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The Toga repository checkout for Read the Docs builds is now made unshallow to ensure accurate SCM versioning. diff --git a/changes/2020.bugfix.rst b/changes/2020.bugfix.rst deleted file mode 100644 index 24040c2fd3..0000000000 --- a/changes/2020.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -The minimum window size is now correctly recomputed and enforced if window content changes. diff --git a/changes/2021.doc.rst b/changes/2021.doc.rst deleted file mode 100644 index f4462e2bcb..0000000000 --- a/changes/2021.doc.rst +++ /dev/null @@ -1 +0,0 @@ -The Linux system dependencies were updated to reflect current requirements for developing and using Toga. diff --git a/changes/2022.misc.rst b/changes/2022.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2022.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2025.feature.1.rst b/changes/2025.feature.1.rst deleted file mode 100644 index 1f27d4f773..0000000000 --- a/changes/2025.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -The DetailedList widget now has 100% test coverage and complete API documentation. diff --git a/changes/2025.feature.2.rst b/changes/2025.feature.2.rst deleted file mode 100644 index acfbf94e1a..0000000000 --- a/changes/2025.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -The accessors used to populate a DetailedList can now be customised. diff --git a/changes/2025.feature.3.rst b/changes/2025.feature.3.rst deleted file mode 100644 index c08ba6a011..0000000000 --- a/changes/2025.feature.3.rst +++ /dev/null @@ -1 +0,0 @@ -A DetailedList can now provide a value to use when a row doesn't provide the required data. diff --git a/changes/2025.feature.4.rst b/changes/2025.feature.4.rst deleted file mode 100644 index c20370c7b3..0000000000 --- a/changes/2025.feature.4.rst +++ /dev/null @@ -1 +0,0 @@ -DetailedList can now respond to "primary" and "secondary" user actions. These may be implemented as left and right swipe respectively, or using any other platform-appropriate mechanism. diff --git a/changes/2025.removal.1.rst b/changes/2025.removal.1.rst deleted file mode 100644 index 14d19c204e..0000000000 --- a/changes/2025.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -When constructing a DetailedList from a list of tuples, or a list of lists, the required order of values has changed from (icon, title, subtitle) to (title, subtitle, icon). diff --git a/changes/2025.removal.2.rst b/changes/2025.removal.2.rst deleted file mode 100644 index 7b3dc14033..0000000000 --- a/changes/2025.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -The ``on_select`` handler for DetailedList no longer receives the selected row as an argument. diff --git a/changes/2025.removal.3.rst b/changes/2025.removal.3.rst deleted file mode 100644 index 14e44ed1af..0000000000 --- a/changes/2025.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -The handling of row deletion in DetailedList widgets has been significantly altered. The ``on_delete`` event handler has been renamed ``on_primary_action``, and is now *only* a notification that a "swipe left" event (or platform equivalent) has been confirmed. This was previously inconsistent across platforms. Some platforms would update the data source to remove the row; some treated ``on_delete`` as a notification event and expected the application to handle the deletion. It is now the application's responsibility to perform the data deletion. diff --git a/changes/2027.removal.rst b/changes/2027.removal.rst deleted file mode 100644 index d591714633..0000000000 --- a/changes/2027.removal.rst +++ /dev/null @@ -1 +0,0 @@ -Support for Python 3.7 was removed. diff --git a/changes/2028.misc.rst b/changes/2028.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2028.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2029.feature.1.rst b/changes/2029.feature.1.rst deleted file mode 100644 index 7f58ef70cd..0000000000 --- a/changes/2029.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -Canvas now has 100% test coverage, and complete API documentation. diff --git a/changes/2029.feature.2.rst b/changes/2029.feature.2.rst deleted file mode 100644 index cdd27cda72..0000000000 --- a/changes/2029.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -Transformations can now be applied to *any* canvas context, not just the root context. diff --git a/changes/2029.feature.3.rst b/changes/2029.feature.3.rst deleted file mode 100644 index c6208565ae..0000000000 --- a/changes/2029.feature.3.rst +++ /dev/null @@ -1 +0,0 @@ -Canvas now provides more ``list``-like methods for manipulating drawing objects in a context. diff --git a/changes/2029.feature.4.rst b/changes/2029.feature.4.rst deleted file mode 100644 index 4021d671db..0000000000 --- a/changes/2029.feature.4.rst +++ /dev/null @@ -1 +0,0 @@ -On Windows, the default font now follows the system theme. On most devices, this means it has changed from Microsoft Sans Serif 8pt to Segoe UI 9pt. diff --git a/changes/2029.feature.5.rst b/changes/2029.feature.5.rst deleted file mode 100644 index c8c2e1146e..0000000000 --- a/changes/2029.feature.5.rst +++ /dev/null @@ -1 +0,0 @@ -Font sizes are now consistently interpreted as CSS points. On Android, iOS and macOS, this means any numeric font sizes will appear 33% larger than before. The default font size on these platforms is unchanged. diff --git a/changes/2029.removal.1.rst b/changes/2029.removal.1.rst deleted file mode 100644 index d1eb9f64ad..0000000000 --- a/changes/2029.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -A Canvas is no longer implicitly a context object. The ``Canvas.context`` property now returns the root context of the canvas. If you were previously using ``Canvas.context()`` to generate an empty context, it should be replaced with ``Canvas.Context()``. Any operations to ``remove()`` drawing objects from the canvas or ``clear()`` the canvas of drawing objects should be made on ``Canvas.context``. Invoking these methods on ``Canvas`` will now call the base ``Widget`` implementations, which will throw an exception because ``Canvas`` widgets cannot have children. diff --git a/changes/2029.removal.2.rst b/changes/2029.removal.2.rst deleted file mode 100644 index 3c46c50b0a..0000000000 --- a/changes/2029.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -Drawing operations (e.g., ``arc``, ``line_to``, etc) can no longer be invoked directly on a Canvas. Instead, they should be invoked on the root context of the canvas, retrieved with via the `canvas` property. Context creating operations (``Fill``, ``Stroke`` and ``ClosedPath``) are not affected. diff --git a/changes/2029.removal.3.rst b/changes/2029.removal.3.rst deleted file mode 100644 index f4ad827055..0000000000 --- a/changes/2029.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -The ``preserve`` option on ``Fill()`` operations has been deprecated. It was required for an internal optimization and can be safely removed without impact. diff --git a/changes/2029.removal.4.rst b/changes/2029.removal.4.rst deleted file mode 100644 index d72db3803d..0000000000 --- a/changes/2029.removal.4.rst +++ /dev/null @@ -1 +0,0 @@ -Methods that generate new contexts have been renamed: ``context()``, ``closed_path()``, ``fill()`` and ``stroke()`` have become ``Context()``, ``ClosedPath()``, ``Fill()`` and ``Stroke()`` respectively. This has been done to make it easier to differentiate between primitive drawing operations and context-generating operations. diff --git a/changes/2029.removal.5.rst b/changes/2029.removal.5.rst deleted file mode 100644 index ccf97a0fac..0000000000 --- a/changes/2029.removal.5.rst +++ /dev/null @@ -1 +0,0 @@ -The ``new_path`` operation has been renamed ``begin_path`` for consistency with the HTML5 Canvas API. diff --git a/changes/2029.removal.6.rst b/changes/2029.removal.6.rst deleted file mode 100644 index 556a37cf82..0000000000 --- a/changes/2029.removal.6.rst +++ /dev/null @@ -1 +0,0 @@ -``fill()`` and ``stroke()`` now return simple drawing operations, rather than context managers. If you attempt to use ``fill()`` or ``stroke()`` on a context as a context manager, an exception will be raised; using these methods on Canvas will raise a warning, but return the appropriate context manager. diff --git a/changes/2029.removal.7.rst b/changes/2029.removal.7.rst deleted file mode 100644 index 2508b97086..0000000000 --- a/changes/2029.removal.7.rst +++ /dev/null @@ -1 +0,0 @@ -The ``clicks`` argument to ``Canvas.on_press`` has been removed. Instead, to detect "double clicks", you should use ``Canvas.on_activate``. The ``clicks`` argument has also been removed from ``Canvas.on_release``, ``Canvas.on_drag``, ``Canvas.on_alt_press``, ``Canvas.on_alt_release``, and ``Canvas.on_alt_drag``. diff --git a/changes/2029.removal.8.rst b/changes/2029.removal.8.rst deleted file mode 100644 index c464565d38..0000000000 --- a/changes/2029.removal.8.rst +++ /dev/null @@ -1 +0,0 @@ -The ``tight`` argument to ``Canvas.measure_text()`` has been deprecated. It was a GTK implementation detail, and can be safely removed without impact. diff --git a/changes/2034.misc.rst b/changes/2034.misc.rst deleted file mode 100644 index 0be930004b..0000000000 --- a/changes/2034.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``black`` was updated to its latest version. diff --git a/changes/2035.misc.rst b/changes/2035.misc.rst deleted file mode 100644 index 37b0513f6f..0000000000 --- a/changes/2035.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/setup-python from 4.6.1 to 4.7.0. diff --git a/changes/2039.misc.rst b/changes/2039.misc.rst deleted file mode 100644 index 87b0c6d54d..0000000000 --- a/changes/2039.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Fixing a bug introduced in #1969 that clashes the dialog display on iOS diff --git a/changes/2044.feature.rst b/changes/2044.feature.rst deleted file mode 100644 index d0acf985ec..0000000000 --- a/changes/2044.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Toga now has significantly improved type annotations for its public API diff --git a/changes/2047.feature.rst b/changes/2047.feature.rst deleted file mode 100644 index c5bd725787..0000000000 --- a/changes/2047.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Applications now verify that a main window has been created as part of the ``startup()`` method. diff --git a/changes/2050.feature.rst b/changes/2050.feature.rst deleted file mode 100644 index e1632062c5..0000000000 --- a/changes/2050.feature.rst +++ /dev/null @@ -1 +0,0 @@ -An implementation of ActivityIndicator was added to the Web backend. diff --git a/changes/2051.feature.rst b/changes/2051.feature.rst deleted file mode 100644 index 49248b82c0..0000000000 --- a/changes/2051.feature.rst +++ /dev/null @@ -1 +0,0 @@ -An implementation of Divider was added to the Web backend. diff --git a/changes/2052.misc.rst b/changes/2052.misc.rst deleted file mode 100644 index a4ee91fe66..0000000000 --- a/changes/2052.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The version pin for the prerelease pygobject has been updated, following the resolution of pygobject#119. diff --git a/changes/2053.misc.rst b/changes/2053.misc.rst deleted file mode 100644 index 44850f3029..0000000000 --- a/changes/2053.misc.rst +++ /dev/null @@ -1 +0,0 @@ -An edge case in SplitContainer having empty content was corrected on Cocoa. diff --git a/changes/2055.misc.rst b/changes/2055.misc.rst deleted file mode 100644 index c1a62a07d2..0000000000 --- a/changes/2055.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Full test coverage for handlers was added. diff --git a/changes/2056.misc.rst b/changes/2056.misc.rst deleted file mode 100644 index e1b6193e1c..0000000000 --- a/changes/2056.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``flake8`` was updated to its latest version. diff --git a/changes/2057.misc.rst b/changes/2057.misc.rst deleted file mode 100644 index 2b29b4a1c2..0000000000 --- a/changes/2057.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Codespell was added to the pre-commit configuration. diff --git a/changes/2058.feature.rst b/changes/2058.feature.rst deleted file mode 100644 index e50fbe1a5f..0000000000 --- a/changes/2058.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Window and MainWindow now have 100% test coverage, and complete API documentation. diff --git a/changes/2058.removal.1.rst b/changes/2058.removal.1.rst deleted file mode 100644 index 402071ece3..0000000000 --- a/changes/2058.removal.1.rst +++ /dev/null @@ -1 +0,0 @@ -Windows no longer need to be explicitly added to the app's window list. When a window is created, it will be automatically added to the windows for the currently running app. diff --git a/changes/2058.removal.2.rst b/changes/2058.removal.2.rst deleted file mode 100644 index 3499758242..0000000000 --- a/changes/2058.removal.2.rst +++ /dev/null @@ -1 +0,0 @@ -The ``multiselect`` argument to Open File and Select Folder dialogs has been renamed ``multiple_select``, for consistency with other widgets that have multiple selection capability. diff --git a/changes/2058.removal.3.rst b/changes/2058.removal.3.rst deleted file mode 100644 index 4c6d52f106..0000000000 --- a/changes/2058.removal.3.rst +++ /dev/null @@ -1 +0,0 @@ -``Window.resizeable`` and ``Window.closeable`` have been renamed ``Window.resizable`` and ``Window.closable``, to adhere to US spelling conventions. diff --git a/changes/2059.misc.rst b/changes/2059.misc.rst deleted file mode 100644 index c3ec018f49..0000000000 --- a/changes/2059.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Changed spelling to prevent pre-commit(Codespell) error. diff --git a/changes/2067.misc.rst b/changes/2067.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2067.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2068.misc.rst b/changes/2068.misc.rst deleted file mode 100644 index 16e1a04a63..0000000000 --- a/changes/2068.misc.rst +++ /dev/null @@ -1 +0,0 @@ -App.beep() has been implemented for Android. diff --git a/changes/2069.misc.rst b/changes/2069.misc.rst deleted file mode 100644 index bb5a3c7699..0000000000 --- a/changes/2069.misc.rst +++ /dev/null @@ -1 +0,0 @@ -A backwards incompatibility issue in the date_and_time example was corrected. diff --git a/changes/2085.misc.rst b/changes/2085.misc.rst deleted file mode 100644 index fc40890351..0000000000 --- a/changes/2085.misc.rst +++ /dev/null @@ -1 +0,0 @@ -WebView no longer uses the deprecated ``run_javascript`` API on Gtk. diff --git a/changes/2089.feature.rst b/changes/2089.feature.rst deleted file mode 100644 index d8ec047366..0000000000 --- a/changes/2089.feature.rst +++ /dev/null @@ -1 +0,0 @@ -A PasswordInput widget was added to the Web backend. diff --git a/changes/2090.misc.rst b/changes/2090.misc.rst deleted file mode 100644 index 20cd09480d..0000000000 --- a/changes/2090.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The use of indirect imports on Winforms has been replaced with direct imports. diff --git a/changes/2092.misc.rst b/changes/2092.misc.rst deleted file mode 100644 index 16b6a7f30a..0000000000 --- a/changes/2092.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Dialogs were added to the textual backend. diff --git a/changes/2093.misc.rst b/changes/2093.misc.rst deleted file mode 100644 index a8aa22d02b..0000000000 --- a/changes/2093.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Repeated autodoc options were refactored into the Sphinx configuration. diff --git a/changes/2094.bugfix.rst b/changes/2094.bugfix.rst deleted file mode 100644 index ee4d11b975..0000000000 --- a/changes/2094.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixes `TypeError` when `toga.Window.title` is invoked on WinForms. diff --git a/changes/2095.bugfix.rst b/changes/2095.bugfix.rst deleted file mode 100644 index 9d741d8ac8..0000000000 --- a/changes/2095.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Modified WinForms backend to prevent error when window content is not set. diff --git a/changes/2101.misc.rst b/changes/2101.misc.rst deleted file mode 100644 index 13e36e26e1..0000000000 --- a/changes/2101.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated ncipollo/release-action from 1.12.0 to 1.13.0. diff --git a/changes/2102.misc.rst b/changes/2102.misc.rst deleted file mode 100644 index 53d6e44bab..0000000000 --- a/changes/2102.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 3.5.3 to 3.6.0. diff --git a/changes/2109.feature.rst b/changes/2109.feature.rst deleted file mode 100644 index 5d1a07e596..0000000000 --- a/changes/2109.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The WebKit inspector is automatically enabled on all webviews, provided you're using macOS 13.3 (Ventura) or iOS 16.4, or later. diff --git a/changes/2113.misc.rst b/changes/2113.misc.rst deleted file mode 100644 index 5ba9f597d6..0000000000 --- a/changes/2113.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 3.6.0 to 4.0.0. diff --git a/changes/2114.misc.rst b/changes/2114.misc.rst deleted file mode 100644 index 75ff7bec3d..0000000000 --- a/changes/2114.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/upload-artifact from 3.1.2 to 3.1.3. diff --git a/changes/2115.misc.rst b/changes/2115.misc.rst deleted file mode 100644 index 0be930004b..0000000000 --- a/changes/2115.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``black`` was updated to its latest version. diff --git a/changes/2116.misc.rst b/changes/2116.misc.rst deleted file mode 100644 index 05179231dd..0000000000 --- a/changes/2116.misc.rst +++ /dev/null @@ -1 +0,0 @@ -An error raised when displaying a GTK About dialog was resolved. diff --git a/changes/2118.misc.rst b/changes/2118.misc.rst deleted file mode 100644 index e10be4a164..0000000000 --- a/changes/2118.misc.rst +++ /dev/null @@ -1 +0,0 @@ -A stray symlink in the tutorials folder was purged. diff --git a/changes/2119.misc.rst b/changes/2119.misc.rst deleted file mode 100644 index 941f7e555a..0000000000 --- a/changes/2119.misc.rst +++ /dev/null @@ -1 +0,0 @@ -An issue with imageview scaling on GTK was resolved. diff --git a/changes/2123.misc.rst b/changes/2123.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2123.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2124.misc.rst b/changes/2124.misc.rst deleted file mode 100644 index 0be930004b..0000000000 --- a/changes/2124.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``black`` was updated to its latest version. diff --git a/changes/2127.misc.rst b/changes/2127.misc.rst deleted file mode 100644 index 96fd43c442..0000000000 --- a/changes/2127.misc.rst +++ /dev/null @@ -1 +0,0 @@ -A misspelled delegate assignment was removed from the iOS and Cocoa Webkit implementations. diff --git a/changes/2128.misc.rst b/changes/2128.misc.rst deleted file mode 100644 index 2ddf867e95..0000000000 --- a/changes/2128.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The copyright year was removed from the documentation footer. diff --git a/changes/2131.misc.rst b/changes/2131.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2131.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2132.misc.rst b/changes/2132.misc.rst deleted file mode 100644 index 74ff099c31..0000000000 --- a/changes/2132.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 4.0.0 to 4.1.0. diff --git a/changes/2136.bugfix.rst b/changes/2136.bugfix.rst deleted file mode 100644 index 4357ea961d..0000000000 --- a/changes/2136.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Multiline text inputs no longer show spelling suggestions when in readonly mode. diff --git a/changes/2146.misc.rst b/changes/2146.misc.rst deleted file mode 100644 index 6375be371e..0000000000 --- a/changes/2146.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pre-commit-hooks`` was updated to its latest version. diff --git a/changes/2147.misc.rst b/changes/2147.misc.rst deleted file mode 100644 index 9eb46067a6..0000000000 --- a/changes/2147.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version. diff --git a/changes/2148.misc.rst b/changes/2148.misc.rst deleted file mode 100644 index f2f6d8b238..0000000000 --- a/changes/2148.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``codespell`` was updated to its latest version. diff --git a/changes/2149.misc.rst b/changes/2149.misc.rst deleted file mode 100644 index 8b779af0e5..0000000000 --- a/changes/2149.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/setup-python from 4.7.0 to 4.7.1. diff --git a/changes/2150.misc.rst b/changes/2150.misc.rst deleted file mode 100644 index 75ff7bec3d..0000000000 --- a/changes/2150.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/upload-artifact from 3.1.2 to 3.1.3. diff --git a/changes/2151.feature.rst b/changes/2151.feature.rst deleted file mode 100644 index 9ec4d2524c..0000000000 --- a/changes/2151.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Text input widgets on macOS now support undo and redo. diff --git a/changes/2161.bugfix.rst b/changes/2161.bugfix.rst deleted file mode 100644 index 443b8aac2d..0000000000 --- a/changes/2161.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -iOS container views are now set to automatically resize with their parent view diff --git a/changes/2163.misc.rst b/changes/2163.misc.rst deleted file mode 100644 index 971a390ec9..0000000000 --- a/changes/2163.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Winforms command event handlers were made persistent objects, rather than on-demand closures. diff --git a/changes/2165.misc.rst b/changes/2165.misc.rst deleted file mode 100644 index 0be930004b..0000000000 --- a/changes/2165.misc.rst +++ /dev/null @@ -1 +0,0 @@ -The ``pre-commit`` hook for ``black`` was updated to its latest version. diff --git a/changes/2166.misc.rst b/changes/2166.misc.rst deleted file mode 100644 index 160e818feb..0000000000 --- a/changes/2166.misc.rst +++ /dev/null @@ -1 +0,0 @@ -Updated actions/checkout from 4.1.0 to 4.1.1. diff --git a/changes/2194.bugfix.rst b/changes/2194.bugfix.rst new file mode 100644 index 0000000000..5e064641e3 --- /dev/null +++ b/changes/2194.bugfix.rst @@ -0,0 +1 @@ +The web backend no longer generates a duplicate titlebar. diff --git a/changes/2195.bugfix.rst b/changes/2195.bugfix.rst new file mode 100644 index 0000000000..4fb4695357 --- /dev/null +++ b/changes/2195.bugfix.rst @@ -0,0 +1 @@ +An issue with the display of the About dialog on the web backend was corrected. diff --git a/changes/2198.feature.rst b/changes/2198.feature.rst new file mode 100644 index 0000000000..4b2d73689a --- /dev/null +++ b/changes/2198.feature.rst @@ -0,0 +1 @@ +A wider range of command shortcut keys are now supported on WinForms. diff --git a/changes/2198.removal.rst b/changes/2198.removal.rst new file mode 100644 index 0000000000..02e332e010 --- /dev/null +++ b/changes/2198.removal.rst @@ -0,0 +1 @@ +The use of Caps Lock as a keyboard modifier for commands was removed. diff --git a/changes/2199.doc.rst b/changes/2199.doc.rst new file mode 100644 index 0000000000..4ee080ce2d --- /dev/null +++ b/changes/2199.doc.rst @@ -0,0 +1 @@ +Documentation for ``toga.Key`` was added. diff --git a/changes/2200.feature.rst b/changes/2200.feature.rst new file mode 100644 index 0000000000..701f35111b --- /dev/null +++ b/changes/2200.feature.rst @@ -0,0 +1 @@ +Most widgets with flexible sizes now default to a minimum size of 100 CSS pixels. An explicit size will still override this value. diff --git a/changes/2201.misc.rst b/changes/2201.misc.rst new file mode 100644 index 0000000000..4d73995780 --- /dev/null +++ b/changes/2201.misc.rst @@ -0,0 +1 @@ +Minor fix: removed a repeated word in the docs for App Paths diff --git a/changes/2204.misc.rst b/changes/2204.misc.rst new file mode 100644 index 0000000000..1524aa29c5 --- /dev/null +++ b/changes/2204.misc.rst @@ -0,0 +1 @@ +Some inconsistencies in widget support documentation were corrected. diff --git a/changes/2214.bugfix.rst b/changes/2214.bugfix.rst new file mode 100644 index 0000000000..97e2873a7e --- /dev/null +++ b/changes/2214.bugfix.rst @@ -0,0 +1 @@ +Compliance with Apple's HIG regarding the naming and shortcuts for the Close and Close All menu items was improved. diff --git a/changes/2215.misc.rst b/changes/2215.misc.rst new file mode 100644 index 0000000000..918e180a5b --- /dev/null +++ b/changes/2215.misc.rst @@ -0,0 +1 @@ +On Android the creation of the app commands has been moved to the method _create_app_commands() diff --git a/changes/2216.misc.rst b/changes/2216.misc.rst new file mode 100644 index 0000000000..dd3c07c506 --- /dev/null +++ b/changes/2216.misc.rst @@ -0,0 +1 @@ +Add more tests for sliders with empty ranges diff --git a/changes/750.bugfix.rst b/changes/750.bugfix.rst deleted file mode 100644 index 15b2c1ae59..0000000000 --- a/changes/750.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -The WinForms event loop was decoupled from the main form, allowing background tasks to run without a main window being present. diff --git a/cocoa/setup.cfg b/cocoa/setup.cfg index 80743dbd64..7b9f2cc29f 100644 --- a/cocoa/setup.cfg +++ b/cocoa/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/cocoa/setup.py b/cocoa/setup.py index f9c2736edd..30217c2e40 100644 --- a/cocoa/setup.py +++ b/cocoa/setup.py @@ -7,7 +7,7 @@ version=version, install_requires=[ "fonttools >= 4.42.1, < 5.0.0", - "rubicon-objc >= 0.4.5rc1, < 0.5.0", + "rubicon-objc >= 0.4.7, < 0.5.0", f"toga-core == {version}", ], ) diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 54bbd2a2e3..d22135fdf0 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -2,11 +2,13 @@ import inspect import os import sys +from pathlib import Path from urllib.parse import unquote, urlparse from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga +from toga.command import GROUP_BREAK, SECTION_BREAK from toga.handlers import NativeHandler from .keys import cocoa_key @@ -16,6 +18,7 @@ NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, + NSAboutPanelOptionVersion, NSApplication, NSApplicationActivationPolicyRegular, NSBeep, @@ -40,11 +43,11 @@ class MainWindow(Window): def cocoa_windowShouldClose(self): # Main Window close is a proxy for "Exit app". - # Defer all handling to the app's exit method. + # Defer all handling to the app's on_exit handler. # As a result of calling that method, the app will either # exit, or the user will cancel the exit; in which case # the main window shouldn't close, either. - self.interface.app.exit() + self.interface.app.on_exit() return False @@ -57,21 +60,21 @@ def applicationDidFinishLaunching_(self, notification): self.native.activateIgnoringOtherApps(True) @objc_method - def applicationOpenUntitledFile_(self, sender) -> bool: + def applicationOpenUntitledFile_(self, sender) -> bool: # pragma: no cover self.impl.select_file() return True @objc_method - def addDocument_(self, document) -> None: + def addDocument_(self, document) -> None: # pragma: no cover # print("Add Document", document) super().addDocument_(document) @objc_method - def applicationShouldOpenUntitledFile_(self, sender) -> bool: + def applicationShouldOpenUntitledFile_(self, sender) -> bool: # pragma: no cover return True @objc_method - def application_openFiles_(self, app, filenames) -> None: + def application_openFiles_(self, app, filenames) -> None: # pragma: no cover for i in range(0, len(filenames)): filename = filenames[i] # If you start your Toga application as `python myapp.py` or @@ -98,8 +101,7 @@ def application_openFiles_(self, app, filenames) -> None: @objc_method def selectMenuItem_(self, sender) -> None: cmd = self.impl._menu_items[sender] - if cmd.action: - cmd.action(None) + cmd.action() @objc_method def validateMenuItem_(self, sender) -> bool: @@ -119,6 +121,9 @@ def __init__(self, interface): asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() + # Stimulate the build of the app + self.create() + def create(self): self.native = NSApplication.sharedApplication self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) @@ -142,6 +147,8 @@ def create(self): # Create the lookup table of menu items, # then force the creation of the menus. + self._menu_groups = {} + self._menu_items = {} self.create_menus() def _create_app_commands(self): @@ -155,7 +162,7 @@ def _create_app_commands(self): ), toga.Command( None, - "Preferences", + "Settings\u2026", shortcut=toga.Key.MOD_1 + ",", group=toga.Group.APP, section=20, @@ -185,31 +192,30 @@ def _create_app_commands(self): ), # Quit should always be the last item, in a section on its own toga.Command( - self._menu_exit, + self._menu_quit, "Quit " + formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, ), # ---- File menu ---------------------------------- - # This is a bit of an oddity. Safari has 2 distinct "Close Window" and - # "Close All Windows" menu items (partially to differentiate from "Close - # Tab"). Most other Apple HIG apps have a "Close" item that becomes - # "Close All" when you press Option (MOD_2). That behavior isn't something - # we're currently set up to implement, so we live with a separate menu item - # for now. + # This is a bit of an oddity. Apple HIG apps that don't have tabs as + # part of their interface (so, Preview and Numbers, but not Safari) + # have a "Close" item that becomes "Close All" when you press Option + # (MOD_2). That behavior isn't something we're currently set up to + # implement, so we live with a separate menu item for now. toga.Command( self._menu_close_window, - "Close Window", - shortcut=toga.Key.MOD_1 + "W", + "Close", + shortcut=toga.Key.MOD_1 + "w", group=toga.Group.FILE, order=1, section=50, ), toga.Command( self._menu_close_all_windows, - "Close All Windows", - shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "W", + "Close All", + shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "w", group=toga.Group.FILE, order=2, section=50, @@ -285,7 +291,7 @@ def _create_app_commands(self): ), # ---- Help menu ---------------------------------- toga.Command( - lambda _, **kwargs: self.interface.visit_homepage(), + self._menu_visit_homepage, "Visit homepage", enabled=self.interface.home_page is not None, group=toga.Group.HELP, @@ -295,32 +301,42 @@ def _create_app_commands(self): def _menu_about(self, app, **kwargs): self.interface.about() - def _menu_exit(self, app, **kwargs): - self.interface.exit() + def _menu_quit(self, app, **kwargs): + self.interface.on_exit() def _menu_close_window(self, app, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.performClose(None) def _menu_close_all_windows(self, app, **kwargs): - for window in self.interface.windows: + # Convert to a list to so that we're not altering a set while iterating + for window in list(self.interface.windows): window._impl.native.performClose(None) def _menu_minimize(self, app, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) + def _menu_visit_homepage(self, app, **kwargs): + self.interface.visit_homepage() + def create_menus(self): - # Recreate the menu - self._menu_items = {} - self._menu_groups = {} + # Recreate the menu. + # Remove any native references to the existing menu + for menu_item, cmd in self._menu_items.items(): + cmd._impl.native.remove(menu_item) + + # Create a clean menubar instance. menubar = NSMenu.alloc().initWithTitle("MainMenu") submenu = None + self._menu_groups = {} + self._menu_items = {} + for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: - submenu.addItem_(NSMenuItem.separatorItem()) + elif cmd == SECTION_BREAK: + submenu.addItem(NSMenuItem.separatorItem()) else: submenu = self._submenu(cmd.group, menubar) @@ -343,9 +359,17 @@ def create_menus(self): action=action, keyEquivalent=key, ) + if modifier is not None: item.keyEquivalentModifierMask = modifier + # Explicit set the initial enabled/disabled state on the menu item + item.setEnabled(cmd.enabled) + + # Associated the MenuItem with the command, so that future + # changes to enabled etc are reflected. + cmd._impl.native.add(item) + self._menu_items[item] = cmd submenu.addItem(item) @@ -379,9 +403,6 @@ def _submenu(self, group, menubar): return submenu def main_loop(self): - # Stimulate the build of the app - self.create() - self.loop.run_forever(lifecycle=CocoaLifecycle(self.native)) def set_main_window(self, window): @@ -391,28 +412,28 @@ def show_about_dialog(self): options = NSMutableDictionary.alloc().init() options[NSAboutPanelOptionApplicationIcon] = self.interface.icon._impl.native + options[NSAboutPanelOptionApplicationName] = self.interface.formal_name - if self.interface.name is not None: - options[NSAboutPanelOptionApplicationName] = self.interface.name - - if self.interface.version is not None: + if self.interface.version is None: + options[NSAboutPanelOptionApplicationVersion] = "0.0" + else: options[NSAboutPanelOptionApplicationVersion] = self.interface.version # The build number - # if self.interface.version is not None: - # options[NSAboutPanelOptionVersion] = "the build" + options[NSAboutPanelOptionVersion] = "1" - if self.interface.author is not None: - options["Copyright"] = "Copyright © {author}".format( - author=self.interface.author - ) + if self.interface.author is None: + options["Copyright"] = "" + else: + options["Copyright"] = f"Copyright © {self.interface.author}" self.native.orderFrontStandardAboutPanelWithOptions(options) def beep(self): NSBeep() - def exit(self): + # We can't call this under test conditions, because it would kill the test harness + def exit(self): # pragma: no cover self.loop.stop() def get_current_window(self): @@ -422,11 +443,6 @@ def set_current_window(self, window): window._impl.native.makeKeyAndOrderFront(window._impl.native) def enter_full_screen(self, windows): - # If we're already in full screen mode, exit so that - # we can re-assign windows to screens. - if self.interface.is_full_screen: - self.interface.exit_full_screen() - opts = NSMutableDictionary.alloc().init() opts.setObject( NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" @@ -470,19 +486,22 @@ def select_file(self, **kwargs): """No-op when the app is not a ``DocumentApp``.""" -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): super()._create_app_commands() self.interface.commands.add( toga.Command( - lambda _: self.select_file(), - text="Open...", + self._menu_open_file, + text="Open\u2026", shortcut=toga.Key.MOD_1 + "o", group=toga.Group.FILE, section=0, ), ) + def _menu_open_file(self, app, **kwargs): + self.select_file() + def select_file(self, **kwargs): # FIXME This should be all we need; but for some reason, application types # aren't being registered correctly.. @@ -504,20 +523,9 @@ def select_file(self, **kwargs): self.appDelegate.application_openFiles_(None, panel.URLs) def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ # Convert a cocoa fileURL to a file path. fileURL = fileURL.rstrip("/") - path = unquote(urlparse(fileURL).path) - extension = os.path.splitext(path)[1][1:] - - # Create the document instance - DocType = self.interface.document_types[extension] - document = DocType(path, app=self.interface) - self.interface._documents.append(document) + path = Path(unquote(urlparse(fileURL).path)) - # Show the document. - document.show() + # Create and show the document instance + self.interface._open(path) diff --git a/cocoa/src/toga_cocoa/command.py b/cocoa/src/toga_cocoa/command.py index 8575d29e27..876a7226d5 100644 --- a/cocoa/src/toga_cocoa/command.py +++ b/cocoa/src/toga_cocoa/command.py @@ -1,7 +1,17 @@ +from toga_cocoa.libs import NSMenuItem + + class Command: def __init__(self, interface): self.interface = interface - self.native = [] + self.native = set() def set_enabled(self, value): - pass + for item in self.native: + if isinstance(item, NSMenuItem): + # Menu item enabled status is determined by the app delegate + item.menu.update() + else: + # Otherwise, assume the native object has + # and explicit enabled property + item.setEnabled(value) diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index 352ac0c838..b0de131005 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -34,10 +34,15 @@ def __del__(self): # pragma: nocover def _remove_constraints(self): if self.container: # print(f"Remove constraints for {self.widget} in {self.container}") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) + # Due to the unpredictability of garbage collection, it's possible for + # the native object of the window's container to be deleted on the ObjC + # side before the constraints for the window have been removed. Protect + # against this possibility. + if self.container.native: + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) self.width_constraint.release() self.height_constraint.release() diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index 8cef990686..f612b291ac 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -67,7 +67,7 @@ def __init__( NSLayoutAttributeLeft, 1.0, min_width, - ) + ).retain() self.native.addConstraint(self._min_width_constraint) self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -78,9 +78,14 @@ def __init__( NSLayoutAttributeTop, 1.0, min_height, - ) + ).retain() self.native.addConstraint(self._min_height_constraint) + def __del__(self): + self._min_height_constraint.release() + self._min_width_constraint.release() + self.native = None + @property def content(self): """The Toga implementation widget that is the root content of this container. diff --git a/cocoa/src/toga_cocoa/dialogs.py b/cocoa/src/toga_cocoa/dialogs.py index d2e5fd70ed..31a4a8997e 100644 --- a/cocoa/src/toga_cocoa/dialogs.py +++ b/cocoa/src/toga_cocoa/dialogs.py @@ -54,14 +54,14 @@ def build_dialog(self): pass def completion_handler(self, return_value: int) -> None: - self.on_result(None, None) + self.on_result(None) self.interface.future.set_result(None) def bool_completion_handler(self, return_value: int) -> None: result = return_value == NSAlertFirstButtonReturn - self.on_result(None, result) + self.on_result(result) self.interface.future.set_result(result) @@ -216,7 +216,7 @@ def single_path_completion_handler(self, return_value: int) -> None: else: result = None - self.on_result(None, result) + self.on_result(result) self.interface.future.set_result(result) def multi_path_completion_handler(self, return_value: int) -> None: @@ -225,7 +225,7 @@ def multi_path_completion_handler(self, return_value: int) -> None: else: result = None - self.on_result(None, result) + self.on_result(result) self.interface.future.set_result(result) diff --git a/cocoa/src/toga_cocoa/documents.py b/cocoa/src/toga_cocoa/documents.py index 81166fb51c..efa7b91092 100644 --- a/cocoa/src/toga_cocoa/documents.py +++ b/cocoa/src/toga_cocoa/documents.py @@ -1,9 +1,10 @@ +import os from urllib.parse import quote from toga_cocoa.libs import NSURL, NSDocument, objc_method, objc_property -class TogaDocument(NSDocument): +class TogaDocument(NSDocument): # pragma: no cover interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -19,14 +20,17 @@ def readFromFileWrapper_ofType_error_( return True -class Document: +class Document: # pragma: no cover + # macOS has multiple documents in a single app instance. + SINGLE_DOCUMENT_APP = False + def __init__(self, interface): self.native = TogaDocument.alloc() self.native.interface = interface self.native.impl = self self.native.initWithContentsOfURL( - NSURL.URLWithString(f"file://{quote(interface.filename)}"), + NSURL.URLWithString(f"file://{quote(os.fsdecode(interface.path))}"), ofType=interface.document_type, error=None, ) diff --git a/cocoa/src/toga_cocoa/images.py b/cocoa/src/toga_cocoa/images.py index 466b9a0876..012ab55779 100644 --- a/cocoa/src/toga_cocoa/images.py +++ b/cocoa/src/toga_cocoa/images.py @@ -1,3 +1,4 @@ +from ctypes import POINTER, c_char, cast from pathlib import Path from toga_cocoa.libs import ( @@ -8,6 +9,15 @@ ) +def nsdata_to_bytes(data: NSData) -> bytes: + """Convert an NSData into a raw bytes representation""" + # data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to + # POINTER(c_char) to get an addressable array of bytes, and slice that array to + # the known length. We don't use c_char_p because it has handling of NUL + # termination, and POINTER(c_char) allows array subscripting. + return cast(data.bytes, POINTER(c_char))[: data.length] + + class Image: def __init__(self, interface, path=None, data=None): self.interface = interface @@ -40,6 +50,15 @@ def get_width(self): def get_height(self): return self.native.size.height + def get_data(self): + return nsdata_to_bytes( + NSBitmapImageRep.representationOfImageRepsInArray( + self.native.representations, + usingType=NSBitmapImageFileType.PNG, + properties=None, + ) + ) + def save(self, path): path = Path(path) try: diff --git a/cocoa/src/toga_cocoa/keys.py b/cocoa/src/toga_cocoa/keys.py index b027dae328..9648bba62b 100644 --- a/cocoa/src/toga_cocoa/keys.py +++ b/cocoa/src/toga_cocoa/keys.py @@ -2,122 +2,147 @@ from toga import Key from toga_cocoa.libs import ( - NSEventModifierFlagCapsLock, NSEventModifierFlagCommand, NSEventModifierFlagControl, NSEventModifierFlagOption, NSEventModifierFlagShift, ) +# A map of Cocoa keycodes to Toga key values, when no Shift is pressed +TOGA_KEYS = { + 0: Key.A, + 1: Key.S, + 2: Key.D, + 3: Key.F, + 4: Key.H, + 5: Key.G, + 6: Key.Z, + 7: Key.X, + 8: Key.C, + 9: Key.V, + 11: Key.B, + 12: Key.Q, + 13: Key.W, + 14: Key.E, + 15: Key.R, + 16: Key.Y, + 17: Key.T, + 18: Key._1, + 19: Key._2, + 20: Key._3, + 21: Key._4, + 22: Key._6, + 23: Key._5, + 24: Key.PLUS, + 25: Key._9, + 26: Key._7, + 27: Key.MINUS, + 28: Key._8, + 29: Key._0, + 30: Key.CLOSE_BRACKET, + 31: Key.O, + 32: Key.U, + 33: Key.OPEN_BRACKET, + 34: Key.I, + 35: Key.P, + 36: Key.ENTER, + 37: Key.L, + 38: Key.J, + 39: Key.QUOTE, + 40: Key.K, + 41: Key.COLON, + 42: Key.BACKSLASH, + 43: Key.COMMA, + 44: Key.SLASH, + 45: Key.N, + 46: Key.M, + 47: Key.FULL_STOP, + 48: Key.TAB, + 49: Key.SPACE, + 50: Key.BACK_QUOTE, + 51: Key.BACKSPACE, + 53: Key.ESCAPE, + 65: Key.NUMPAD_DECIMAL_POINT, + 67: Key.NUMPAD_MULTIPLY, + 69: Key.NUMPAD_PLUS, + 71: Key.NUMPAD_CLEAR, + 75: Key.NUMPAD_DIVIDE, + 76: Key.NUMPAD_ENTER, + 78: Key.NUMPAD_MINUS, + 81: Key.NUMPAD_EQUAL, + 82: Key.NUMPAD_0, + 83: Key.NUMPAD_1, + 84: Key.NUMPAD_2, + 85: Key.NUMPAD_3, + 86: Key.NUMPAD_4, + 87: Key.NUMPAD_5, + 88: Key.NUMPAD_6, + 89: Key.NUMPAD_7, + 91: Key.NUMPAD_8, + 92: Key.NUMPAD_9, + # : Key.F4, + 96: Key.F5, + 97: Key.F7, + 98: Key.F5, + 99: Key.F3, + 100: Key.F8, + 101: Key.F9, + 109: Key.F9, + 115: Key.HOME, + 116: Key.PAGE_UP, + 117: Key.DELETE, + 119: Key.END, + 120: Key.F2, + 121: Key.PAGE_DOWN, + 122: Key.F1, + 123: Key.LEFT, + 124: Key.RIGHT, + 125: Key.DOWN, + 126: Key.UP, +} -def modified_key(key, shift=None): - def mod_fn(modifierFlags): - if modifierFlags & NSEventModifierFlagShift: - return shift - return key - - return mod_fn +# Keys that have a different Toga key when Shift is pressed. +TOGA_SHIFT_MODIFIED = { + Key._1: Key.EXCLAMATION, + Key._2: Key.AT, + Key._3: Key.HASH, + Key._4: Key.DOLLAR, + Key._6: Key.CARET, + Key._5: Key.PERCENT, + Key.PLUS: Key.EQUAL, + Key._9: Key.OPEN_PARENTHESIS, + Key._7: Key.AMPERSAND, + Key.MINUS: Key.UNDERSCORE, + Key._8: Key.ASTERISK, + Key._0: Key.CLOSE_PARENTHESIS, + Key.CLOSE_BRACKET: Key.CLOSE_BRACKET, + Key.OPEN_BRACKET: Key.OPEN_BRACKET, + Key.ENTER: Key.ENTER, + Key.QUOTE: Key.DOUBLE_QUOTE, + Key.COLON: Key.SEMICOLON, + Key.COMMA: Key.LESS_THAN, + Key.SLASH: Key.QUESTION, + Key.FULL_STOP: Key.GREATER_THAN, + Key.BACK_QUOTE: Key.TILDE, +} def toga_key(event): """Convert a Cocoa NSKeyEvent into a Toga event.""" - key = { - 0: Key.A, - 1: Key.S, - 2: Key.D, - 3: Key.F, - 4: Key.H, - 5: Key.G, - 6: Key.Z, - 7: Key.X, - 8: Key.C, - 9: Key.V, - 11: Key.B, - 12: Key.Q, - 13: Key.W, - 14: Key.E, - 15: Key.R, - 16: Key.Y, - 17: Key.T, - 18: modified_key(Key._1, shift=Key.EXCLAMATION)(event.modifierFlags), - 19: modified_key(Key._2, shift=Key.AT)(event.modifierFlags), - 20: modified_key(Key._3, shift=Key.HASH)(event.modifierFlags), - 21: modified_key(Key._4, shift=Key.DOLLAR)(event.modifierFlags), - 22: modified_key(Key._6, shift=Key.CARET)(event.modifierFlags), - 23: modified_key(Key._5, shift=Key.PERCENT)(event.modifierFlags), - 24: modified_key(Key.PLUS, shift=Key.EQUAL)(event.modifierFlags), - 25: modified_key(Key._9, shift=Key.OPEN_PARENTHESIS)(event.modifierFlags), - 26: modified_key(Key._7, shift=Key.AMPERSAND)(event.modifierFlags), - 27: modified_key(Key.MINUS, shift=Key.UNDERSCORE)(event.modifierFlags), - 28: modified_key(Key._8, shift=Key.ASTERISK)(event.modifierFlags), - 29: modified_key(Key._0, shift=Key.CLOSE_PARENTHESIS)(event.modifierFlags), - 30: Key.CLOSE_BRACKET, - 31: Key.O, - 32: Key.U, - 33: Key.OPEN_BRACKET, - 34: Key.I, - 35: Key.P, - 36: Key.ENTER, - 37: Key.L, - 38: Key.J, - 39: modified_key(Key.QUOTE, shift=Key.DOUBLE_QUOTE)(event.modifierFlags), - 40: Key.K, - 41: modified_key(Key.COLON, shift=Key.SEMICOLON)(event.modifierFlags), - 42: Key.BACKSLASH, - 43: modified_key(Key.COMMA, shift=Key.LESS_THAN)(event.modifierFlags), - 44: modified_key(Key.SLASH, shift=Key.QUESTION)(event.modifierFlags), - 45: Key.N, - 46: Key.M, - 47: modified_key(Key.FULL_STOP, shift=Key.GREATER_THAN)(event.modifierFlags), - 48: Key.TAB, - 49: Key.SPACE, - 50: modified_key(Key.BACK_QUOTE, shift=Key.TILDE)(event.modifierFlags), - 51: Key.BACKSPACE, - 53: Key.ESCAPE, - 65: Key.NUMPAD_DECIMAL_POINT, - 67: Key.NUMPAD_MULTIPLY, - 69: Key.NUMPAD_PLUS, - 71: Key.NUMPAD_CLEAR, - 75: Key.NUMPAD_DIVIDE, - 76: Key.NUMPAD_ENTER, - 78: Key.NUMPAD_MINUS, - 81: Key.NUMPAD_EQUAL, - 82: Key.NUMPAD_0, - 83: Key.NUMPAD_1, - 84: Key.NUMPAD_2, - 85: Key.NUMPAD_3, - 86: Key.NUMPAD_4, - 87: Key.NUMPAD_5, - 88: Key.NUMPAD_6, - 89: Key.NUMPAD_7, - 91: Key.NUMPAD_8, - 92: Key.NUMPAD_9, - # : Key.F4, - 96: Key.F5, - 97: Key.F7, - 98: Key.F5, - 99: Key.F3, - 100: Key.F8, - 101: Key.F9, - 109: Key.F9, - 115: Key.HOME, - 116: Key.PAGE_UP, - 117: Key.DELETE, - 119: Key.END, - 120: Key.F2, - 121: Key.PAGE_DOWN, - 122: Key.F1, - 123: Key.LEFT, - 124: Key.RIGHT, - 125: Key.DOWN, - 126: Key.UP, - }.get(event.keyCode, None) + natural_key = TOGA_KEYS.get(event.keyCode, None) + if event.modifierFlags & NSEventModifierFlagShift: + try: + key = TOGA_SHIFT_MODIFIED[natural_key] + except KeyError: + key = natural_key + else: + key = natural_key modifiers = set() - if event.modifierFlags & NSEventModifierFlagCapsLock: - modifiers.add(Key.CAPSLOCK) - if event.modifierFlags & NSEventModifierFlagShift: + # Only apply a shift modifier for the a/A case. + # keys like ! that inherently need shift don't return as modified. + if event.modifierFlags & NSEventModifierFlagShift and key == natural_key: modifiers.add(Key.SHIFT) if event.modifierFlags & NSEventModifierFlagCommand: modifiers.add(Key.MOD_1) @@ -130,39 +155,39 @@ def toga_key(event): COCOA_KEY_CODES = { - Key.ESCAPE: "%c" % 0x001B, - Key.TAB: "%c" % 0x0009, - Key.BACKSPACE: "%c" % 0x0008, - Key.ENTER: "%c" % 0x000D, - Key.F1: "", # TODO - Key.F2: "", # TODO - Key.F3: "", # TODO - Key.F4: "", # TODO - Key.F5: "", # TODO - Key.F6: "", # TODO - Key.F7: "", # TODO - Key.F8: "", # TODO - Key.F9: "", # TODO - Key.F10: "", # TODO - Key.F11: "", # TODO - Key.F12: "", # TODO - Key.F13: "", # TODO - Key.F14: "", # TODO - Key.F15: "", # TODO - Key.F16: "", # TODO - Key.F17: "", # TODO - Key.F18: "", # TODO - Key.F19: "", # TODO + Key.ESCAPE: chr(0x001B), + Key.TAB: chr(0x0009), + Key.BACKSPACE: chr(0x0008), + Key.ENTER: chr(0x000D), + Key.F1: chr(0xF704), + Key.F2: chr(0xF705), + Key.F3: chr(0xF706), + Key.F4: chr(0xF707), + Key.F5: chr(0xF708), + Key.F6: chr(0xF709), + Key.F7: chr(0xF70A), + Key.F8: chr(0xF70B), + Key.F9: chr(0xF70C), + Key.F10: chr(0xF70D), + Key.F11: chr(0xF70E), + Key.F12: chr(0xF70F), + Key.F13: chr(0xF710), + Key.F14: chr(0xF711), + Key.F15: chr(0xF712), + Key.F16: chr(0xF713), + Key.F17: chr(0xF714), + Key.F18: chr(0xF715), + Key.F19: chr(0xF716), Key.EJECT: "", # TODO - Key.HOME: "%c" % 0x2196, - Key.END: "%c" % 0x2198, - Key.DELETE: "%c" % 0x007F, - Key.PAGE_UP: "%c" % 0x21DE, - Key.PAGE_DOWN: "%c" % 0x21DF, - Key.UP: "%c" % 0x001E, - Key.DOWN: "%c" % 0x001F, - Key.LEFT: "%c" % 0x001C, - Key.RIGHT: "%c" % 0x001D, + Key.HOME: chr(0x2196), + Key.END: chr(0x2198), + Key.DELETE: chr(0x007F), + Key.PAGE_UP: chr(0x21DE), + Key.PAGE_DOWN: chr(0x21DF), + Key.UP: chr(0x001E), + Key.DOWN: chr(0x001F), + Key.LEFT: chr(0x001C), + Key.RIGHT: chr(0x001D), Key.NUMPAD_0: "0", Key.NUMPAD_1: "1", Key.NUMPAD_2: "2", @@ -174,18 +199,17 @@ def toga_key(event): Key.NUMPAD_8: "8", Key.NUMPAD_9: "9", Key.NUMPAD_CLEAR: "", # TODO - Key.NUMPAD_DECIMAL_POINT: "", # TODO - Key.NUMPAD_DIVIDE: "", # TODO - Key.NUMPAD_ENTER: "", # TODO - Key.NUMPAD_EQUAL: "", # TODO - Key.NUMPAD_MINUS: "", # TODO - Key.NUMPAD_MULTIPLY: "", # TODO - Key.NUMPAD_PLUS: "", # TODO + Key.NUMPAD_DECIMAL_POINT: ".", + Key.NUMPAD_DIVIDE: "/", + Key.NUMPAD_ENTER: chr(0x000D), + Key.NUMPAD_EQUAL: "=", + Key.NUMPAD_MINUS: "-", + Key.NUMPAD_MULTIPLY: "*", + Key.NUMPAD_PLUS: "+", } COCOA_MODIFIERS = { Key.SHIFT: NSEventModifierFlagShift, - Key.CAPSLOCK: NSEventModifierFlagCapsLock, Key.MOD_1: NSEventModifierFlagCommand, Key.MOD_2: NSEventModifierFlagOption, Key.MOD_3: NSEventModifierFlagControl, @@ -213,4 +237,8 @@ def cocoa_key(shortcut): key = key.replace(mod.value, "") modifiers |= mask + # If the remaining key string is upper case, add a shift modifier. + if key.isupper(): + modifiers |= NSEventModifierFlagShift + return key, modifiers diff --git a/cocoa/src/toga_cocoa/widgets/button.py b/cocoa/src/toga_cocoa/widgets/button.py index 1eb11323de..75b4976b15 100644 --- a/cocoa/src/toga_cocoa/widgets/button.py +++ b/cocoa/src/toga_cocoa/widgets/button.py @@ -22,7 +22,7 @@ class TogaButton(NSButton): @objc_method def onPress_(self, obj) -> None: - self.interface.on_press(None) + self.interface.on_press() class Button(Widget): diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index 1df8900f8a..d8c8ca8305 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -1,4 +1,3 @@ -from ctypes import POINTER, c_char, cast from math import ceil from rubicon.objc import objc_method, objc_property @@ -7,6 +6,7 @@ from toga.colors import BLACK, TRANSPARENT, color from toga.widgets.canvas import Baseline, FillRule from toga_cocoa.colors import native_color +from toga_cocoa.images import nsdata_to_bytes from toga_cocoa.libs import ( CGFloat, CGPathDrawingMode, @@ -50,34 +50,34 @@ def isFlipped(self) -> bool: def mouseDown_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) if event.clickCount == 1: - self.interface.on_press(None, position.x, position.y) + self.interface.on_press(position.x, position.y) else: - self.interface.on_activate(None, position.x, position.y) + self.interface.on_activate(position.x, position.y) @objc_method def rightMouseDown_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) - self.interface.on_alt_press(None, position.x, position.y) + self.interface.on_alt_press(position.x, position.y) @objc_method def mouseUp_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) - self.interface.on_release(None, position.x, position.y) + self.interface.on_release(position.x, position.y) @objc_method def rightMouseUp_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) - self.interface.on_alt_release(None, position.x, position.y) + self.interface.on_alt_release(position.x, position.y) @objc_method def mouseDragged_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) - self.interface.on_drag(None, position.x, position.y) + self.interface.on_drag(position.x, position.y) @objc_method def rightMouseDragged_(self, event) -> None: position = self.convertPoint(event.locationInWindow, fromView=None) - self.interface.on_alt_drag(None, position.x, position.y) + self.interface.on_alt_drag(position.x, position.y) class Canvas(Widget): @@ -94,7 +94,7 @@ def redraw(self): def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) - self.interface.on_resize(None, width=width, height=height) + self.interface.on_resize(width=width, height=height) def set_background_color(self, color): if color is TRANSPARENT or color is None: @@ -325,15 +325,12 @@ def get_image_data(self): bitmap.setSize(self.native.bounds.size) self.native.cacheDisplayInRect(self.native.bounds, toBitmapImageRep=bitmap) - data = bitmap.representationUsingType( - NSBitmapImageFileType.PNG, - properties=None, + return nsdata_to_bytes( + bitmap.representationUsingType( + NSBitmapImageFileType.PNG, + properties=None, + ) ) - # data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to - # POINTER(c_char) to get an addressable array of bytes, and slice that array to - # the known length. We don't use c_char_p because it has handling of NUL - # termination, and POINTER(c_char) allows array subscripting. - return cast(data.bytes, POINTER(c_char))[: data.length] # Rehint def rehint(self): diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index b5566d19a2..6745cb694a 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -56,12 +56,12 @@ def menuForEvent_(self, event): @objc_method def primaryActionOnRow_(self, menuitem): row = self.interface.data[menuitem.tag] - self.interface.on_primary_action(self.interface, row=row) + self.interface.on_primary_action(row=row) @objc_method def secondaryActionOnRow_(self, menuitem): row = self.interface.data[menuitem.tag] - self.interface.on_secondary_action(self.interface, row=row) + self.interface.on_secondary_action(row=row) # TableDataSource methods @objc_method @@ -122,7 +122,7 @@ def selectionShouldChangeInTableView_(self, table) -> bool: @objc_method def tableViewSelectionDidChange_(self, notification) -> None: - self.interface.on_select(self.interface) + self.interface.on_select() class DetailedList(Widget): @@ -180,7 +180,7 @@ def remove(self, index, item): # After deletion, the selection changes, but Cocoa doesn't send # a tableViewSelectionDidChange: message. - self.interface.on_select(self.interface) + self.interface.on_select() def clear(self): self.native_detailedlist.reloadData() diff --git a/cocoa/src/toga_cocoa/widgets/internal/refresh.py b/cocoa/src/toga_cocoa/widgets/internal/refresh.py index 2c6ee33717..c4abaa2df9 100644 --- a/cocoa/src/toga_cocoa/widgets/internal/refresh.py +++ b/cocoa/src/toga_cocoa/widgets/internal/refresh.py @@ -273,7 +273,7 @@ def scrollWheel_(self, event) -> None: NSMakePoint(self.contentView.bounds.origin.x, -HEADER_HEIGHT) ) self.refresh_indicator.startAnimation(self) - self.interface.on_refresh(self.interface) + self.interface.on_refresh() send_super(__class__, self, "scrollWheel:", event, argtypes=[c_void_p]) diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index b08fa86c1f..c651fa8e47 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -21,7 +21,7 @@ class TogaTextView(NSTextView): @objc_method def textDidChange_(self, notification) -> None: - self.interface.on_change(None) + self.interface.on_change() class MultilineTextInput(Widget): @@ -78,7 +78,7 @@ def get_value(self): def set_value(self, value): self.native_text.string = value - self.interface.on_change(None) + self.interface.on_change() def set_color(self, value): self.native_text.textColor = native_color(value) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index e9b93e7812..906ebbbee2 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -47,7 +47,7 @@ def controlTextDidChange_(self, notification) -> None: # would make it invalid. self.impl.native_input.stringValue = _clean_decimal_str(value) - self.interface.on_change(self.interface) + self.interface.on_change() class TogaNumberInput(NSTextField): @@ -234,7 +234,7 @@ def set_value(self, value): else: self.native_stepper.floatValue = float(value) self.native_input.stringValue = str(value) - self.interface.on_change(None) + self.interface.on_change() def get_enabled(self): return self.native_input.isEnabled diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 66566470ea..1057afdff9 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -26,7 +26,7 @@ def tabView_didSelectTabViewItem_(self, view, item) -> None: container.content.interface.refresh() # Notify of the change in selection. - self.interface.on_select(None) + self.interface.on_select() @objc_method def refreshContent(self) -> None: diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 5c9226e22b..40721bdeee 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -22,7 +22,7 @@ class TogaScrollView(NSScrollView): @objc_method def didScroll_(self, note) -> None: - self.interface.on_scroll(None) + self.interface.on_scroll() @objc_method def refreshContent(self): @@ -109,7 +109,7 @@ def set_vertical(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() def get_horizontal(self): return self.native.hasHorizontalScroller @@ -123,7 +123,7 @@ def set_horizontal(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) @@ -161,4 +161,4 @@ def set_position(self, horizontal_position, vertical_position): new_position = NSMakePoint(horizontal_position, vertical_position) self.native.contentView.scrollToPoint(new_position) self.native.reflectScrolledClipView(self.native.contentView) - self.interface.on_scroll(None) + self.interface.on_scroll() diff --git a/cocoa/src/toga_cocoa/widgets/selection.py b/cocoa/src/toga_cocoa/widgets/selection.py index a846c32c99..e0a3bfd52e 100644 --- a/cocoa/src/toga_cocoa/widgets/selection.py +++ b/cocoa/src/toga_cocoa/widgets/selection.py @@ -11,7 +11,7 @@ class TogaPopupButton(NSPopUpButton): @objc_method def onSelect_(self, obj) -> None: - self.interface.on_change(None) + self.interface.on_change() class Selection(Widget): @@ -41,7 +41,7 @@ def insert(self, index, item): # If this is the first time item in the list, it will be automatically # selected; trigger a change event. if len(self.interface.items) == 1: - self.interface.on_change(None) + self.interface.on_change() def change(self, item): index = self.interface._items.index(item) @@ -56,15 +56,15 @@ def remove(self, index, item): self.native.removeItemAtIndex(index) if selection_change: - self.interface.on_change(None) + self.interface.on_change() def clear(self): self.native.removeAllItems() - self.interface.on_change(None) + self.interface.on_change() def select_item(self, index, item): self.native.selectItemAtIndex(index) - self.interface.on_change(None) + self.interface.on_change() def get_selected_index(self): index = self.native.indexOfSelectedItem diff --git a/cocoa/src/toga_cocoa/widgets/slider.py b/cocoa/src/toga_cocoa/widgets/slider.py index bf80d24a4f..98d86b9642 100644 --- a/cocoa/src/toga_cocoa/widgets/slider.py +++ b/cocoa/src/toga_cocoa/widgets/slider.py @@ -20,11 +20,11 @@ class TogaSlider(NSSlider): def onSlide_(self, sender) -> None: event_type = sender.window.currentEvent().type if event_type == NSEventType.LeftMouseDown: - self.interface.on_press(None) + self.interface.on_press() elif event_type == NSEventType.LeftMouseUp: - self.interface.on_release(None) + self.interface.on_release() - self.interface.on_change(None) + self.interface.on_change() class Slider(Widget, toga.widgets.slider.SliderImpl): diff --git a/cocoa/src/toga_cocoa/widgets/switch.py b/cocoa/src/toga_cocoa/widgets/switch.py index 666ec2393f..15f92bc6aa 100644 --- a/cocoa/src/toga_cocoa/widgets/switch.py +++ b/cocoa/src/toga_cocoa/widgets/switch.py @@ -20,7 +20,7 @@ class TogaSwitch(NSButton): @objc_method def onPress_(self, obj) -> None: - self.interface.on_change(None) + self.interface.on_change() class Switch(Widget): @@ -53,7 +53,7 @@ def set_value(self, value): old_value = self.native.state == NSOnState self.native.state = NSOnState if value else NSOffState if self.interface.on_change and value != old_value: - self.interface.on_change(self.interface) + self.interface.on_change() def rehint(self): content_size = self.native.intrinsicContentSize() diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 6e0c616432..d9c218e82d 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -1,11 +1,7 @@ -from ctypes import c_void_p - -from rubicon.objc import SEL, at, objc_method, objc_property, send_super +from rubicon.objc import SEL, at, objc_method, objc_property from travertino.size import at_least import toga -from toga.keys import Key -from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -83,16 +79,6 @@ def tableView_pasteboardWriterForRow_(self, table, row) -> None: # pragma: no c # this seems to be required to prevent issue 21562075 in AppKit return None - @objc_method - def keyDown_(self, event) -> None: - # any time this table is in focus and a key is pressed, this method will be called - if toga_key(event) == {"key": Key.A, "modifiers": {Key.MOD_1}}: - if self.interface.multiple_select: - self.selectAll(self) - else: - # forward call to super - send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p]) - # TableDelegate methods @objc_method def selectionShouldChangeInTableView_(self, table) -> bool: @@ -102,7 +88,7 @@ def selectionShouldChangeInTableView_(self, table) -> bool: @objc_method def tableViewSelectionDidChange_(self, notification) -> None: - self.interface.on_select(None) + self.interface.on_select() # 2021-09-04: Commented out this method because it appears to be a # source of significant slowdown when the table has a lot of data @@ -133,7 +119,7 @@ def tableViewSelectionDidChange_(self, notification) -> None: def onDoubleClick_(self, sender) -> None: clicked = self.interface.data[self.clickedRow] - self.interface.on_activate(None, row=clicked) + self.interface.on_activate(row=clicked) class Table(Widget): diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py index 2cf47cd812..ee7f762e98 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -38,17 +38,17 @@ class TogaTextFieldProxy: @staticmethod def textDidChange_(cls, self, notification) -> None: - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() @staticmethod def becomeFirstResponder(cls, self) -> bool: - self.interface.on_gain_focus(self.interface) + self.interface.on_gain_focus() return send_super(cls, self, "becomeFirstResponder") @staticmethod def textDidEndEditing_(cls, self, textObject) -> None: - self.interface.on_lose_focus(self.interface) + self.interface.on_lose_focus() send_super(cls, self, "textDidEndEditing:", textObject, argtypes=[c_void_p]) @staticmethod @@ -60,7 +60,7 @@ def control_textView_doCommandBySelector_( selector: SEL, ) -> bool: if selector.name == b"insertNewline:": - self.interface.on_confirm(None) + self.interface.on_confirm() return False @@ -213,7 +213,7 @@ def get_value(self): def set_value(self, value): self.native.stringValue = value - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() def rehint(self): diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 6af99f1774..fb206f7558 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -1,11 +1,7 @@ -from ctypes import c_void_p - -from rubicon.objc import SEL, at, objc_method, objc_property, send_super +from rubicon.objc import SEL, at, objc_method, objc_property from travertino.size import at_least import toga -from toga.keys import Key -from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -159,26 +155,16 @@ def outlineView_pasteboardWriterForItem_( # else: # self.reloadData() - @objc_method - def keyDown_(self, event) -> None: - # any time this table is in focus and a key is pressed, this method will be called - if toga_key(event) == {"key": Key.A, "modifiers": {Key.MOD_1}}: - if self.interface.multiple_select: - self.selectAll(self) - else: - # forward call to super - send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p]) - # OutlineViewDelegate methods @objc_method def outlineViewSelectionDidChange_(self, notification) -> None: - self.interface.on_select(self.interface) + self.interface.on_select() # target methods @objc_method def onDoubleClick_(self, sender) -> None: node = self.itemAtRow(self.clickedRow).attrs["node"] - self.interface.on_activate(self.interface, node=node) + self.interface.on_activate(node=node) class Tree(Widget): diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 63e51123f3..925164fa59 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -1,5 +1,4 @@ -from rubicon.objc import objc_method, objc_property, py_from_ns -from rubicon.objc.runtime import objc_id +from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least from toga.widgets.webview import JavaScriptResult @@ -31,7 +30,7 @@ class TogaWebView(WKWebView): @objc_method def webView_didFinishNavigation_(self, navigation) -> None: - self.interface.on_webview_load(self.interface) + self.interface.on_webview_load() if self.impl.loaded_future: self.impl.loaded_future.set_result(None) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 3ac7332313..e1d82e1cd6 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,8 +1,10 @@ -from toga.command import Command as BaseCommand +from toga.command import Command from toga_cocoa.container import Container +from toga_cocoa.images import nsdata_to_bytes from toga_cocoa.libs import ( SEL, NSBackingStoreBuffered, + NSBitmapImageFileType, NSMakeRect, NSMutableArray, NSPoint, @@ -18,7 +20,10 @@ def toolbar_identifier(cmd): - return "ToolbarItem-%s" % id(cmd) + if isinstance(cmd, Command): + return "ToolbarItem-%s" % id(cmd) + else: + return "ToolbarSeparator-%s" % id(cmd) class TogaWindow(NSWindow): @@ -40,10 +45,11 @@ def windowDidResize_(self, notification) -> None: ###################################################################### @objc_method - def toolbarAllowedItemIdentifiers_(self, toolbar): + def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover """Determine the list of available toolbar items.""" - # This method is required by the Cocoa API, but isn't actually invoked, - # because customizable toolbars are no longer a thing. + # This method is required by the Cocoa API, but it's only ever called if the + # toolbar allows user customization. We don't turn that option on so this method + # can't ever be invoked - but we need to provide an implementation. allowed = NSMutableArray.alloc().init() for item in self.interface.toolbar: allowed.addObject_(toolbar_identifier(item)) @@ -59,25 +65,27 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): @objc_method def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( - self, toolbar, identifier, insert: bool + self, + toolbar, + identifier, + insert: bool, ): """Create the requested toolbar button.""" native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) try: item = self.impl._toolbar_items[str(identifier)] - if item.text: - native.setLabel(item.text) - native.setPaletteLabel(item.text) + native.setLabel(item.text) + native.setPaletteLabel(item.text) if item.tooltip: native.setToolTip(item.tooltip) if item.icon: native.setImage(item.icon._impl.native) - item._impl.native.append(native) + item._impl.native.add(native) native.setTarget_(self) native.setAction_(SEL("onToolbarButtonPress:")) - except KeyError: + except KeyError: # Separator items pass # Prevent the toolbar item from being deallocated when @@ -89,7 +97,14 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( @objc_method def validateToolbarItem_(self, item) -> bool: """Confirm if the toolbar item should be enabled.""" - return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + try: + return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + except KeyError: # pragma: nocover + # This branch *shouldn't* ever happen; but there's an edge + # case where a toolbar redraw happens in the middle of deleting + # a toolbar item that can't be reliably reproduced, so it sometimes + # happens in testing. + return False ###################################################################### # Toolbar button press delegate methods @@ -99,7 +114,7 @@ def validateToolbarItem_(self, item) -> bool: def onToolbarButtonPress_(self, obj) -> None: """Invoke the action tied to the toolbar button.""" item = self.impl._toolbar_items[str(obj.itemIdentifier)] - item.action(obj) + item.action() class Window: @@ -130,7 +145,8 @@ def __init__(self, interface, title, position, size): # Cocoa releases windows when they are closed; this causes havoc with # Toga's widget cleanup because the ObjC runtime thinks there's no - # references to the object left. Add an explicit reference to the window. + # references to the object left. Add a reference that can be released + # in response to the close. self.native.retain() self.set_title(title) @@ -142,21 +158,60 @@ def __init__(self, interface, title, position, size): self.container = Container(on_refresh=self.content_refreshed) self.native.contentView = self.container.native + # Ensure that the container renders it's background in the same color as the window. + self.native.wantsLayer = True + self.container.native.backgroundColor = self.native.backgroundColor + + # By default, no toolbar + self._toolbar_items = {} + self.native_toolbar = None + def __del__(self): + self.purge_toolbar() self.native.release() + def purge_toolbar(self): + while self._toolbar_items: + dead_items = [] + _, cmd = self._toolbar_items.popitem() + # The command might have toolbar representations on multiple window + # toolbars, and may have other representations (at the very least, a menu + # item). Only clean up the representation pointing at *this* window. Do this + # in 2 passes so that we're not modifying the set of native objects while + # iterating over it. + for item_native in cmd._impl.native: + if ( + isinstance(item_native, NSToolbarItem) + and item_native.target == self.native + ): + dead_items.append(item_native) + + for item_native in dead_items: + cmd._impl.native.remove(item_native) + item_native.release() + def create_toolbar(self): - self._toolbar_items = {} - for cmd in self.interface.toolbar: - if isinstance(cmd, BaseCommand): - self._toolbar_items[toolbar_identifier(cmd)] = cmd + # Purge any existing toolbar items + self.purge_toolbar() - self._toolbar_native = NSToolbar.alloc().initWithIdentifier( - "Toolbar-%s" % id(self) - ) - self._toolbar_native.setDelegate(self.native) + # Create the new toolbar items. + if self.interface.toolbar: + for cmd in self.interface.toolbar: + if isinstance(cmd, Command): + self._toolbar_items[toolbar_identifier(cmd)] = cmd + + self.native_toolbar = NSToolbar.alloc().initWithIdentifier( + "Toolbar-%s" % id(self) + ) + self.native_toolbar.setDelegate(self.native) + else: + self.native_toolbar = None - self.native.setToolbar(self._toolbar_native) + self.native.setToolbar(self.native_toolbar) + + # Adding/removing a toolbar changes the size of the content window. + if self.interface.content: + self.interface.content.refresh() def set_content(self, widget): # Set the content of the window's container @@ -239,8 +294,22 @@ def cocoa_windowShouldClose(self): # The on_close handler has a cleanup method that will enforce # the close if the on_close handler requests it; this initial # "should close" request can always return False. - self.interface.on_close(None) + self.interface.on_close() return False def close(self): self.native.close() + + def get_image_data(self): + bitmap = self.container.native.bitmapImageRepForCachingDisplayInRect( + self.container.native.bounds + ) + bitmap.setSize(self.container.native.bounds.size) + self.container.native.cacheDisplayInRect( + self.container.native.bounds, toBitmapImageRep=bitmap + ) + data = bitmap.representationUsingType( + NSBitmapImageFileType.PNG, + properties=None, + ) + return nsdata_to_bytes(data) diff --git a/cocoa/tests/__init__.py b/cocoa/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/cocoa/tests/test_implementation.py b/cocoa/tests/test_implementation.py deleted file mode 100644 index 39e6781b4c..0000000000 --- a/cocoa/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_cocoa" - ) - ) - ) -) diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index e06b9e1c2b..ec9ffaea93 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,11 +1,24 @@ from pathlib import Path -from toga_cocoa.libs import NSApplication +from rubicon.objc import NSPoint, ObjCClass, objc_id, send_message + +from toga_cocoa.keys import cocoa_key, toga_key +from toga_cocoa.libs import ( + NSApplication, + NSEvent, + NSEventModifierFlagShift, + NSEventType, +) from .probe import BaseProbe +NSPanel = ObjCClass("NSPanel") + class AppProbe(BaseProbe): + supports_key = True + supports_key_mod3 = True + def __init__(self, app): super().__init__() self.app = app @@ -28,3 +41,136 @@ def cache_path(self): @property def logs_path(self): return Path.home() / "Library" / "Logs" / "org.beeware.toga.testbed" + + @property + def is_cursor_visible(self): + # There's no API level mechanism to detect cursor visibility; + # fall back to the implementation's proxy variable. + return self.app._impl._cursor_visible + + def is_full_screen(self, window): + return window.content._impl.native.isInFullScreenMode() + + def content_size(self, window): + return ( + window.content._impl.native.frame.size.width, + window.content._impl.native.frame.size.height, + ) + + def _menu_item(self, path): + main_menu = self.app._impl.native.mainMenu + + menu = main_menu + orig_path = path.copy() + while True: + label, path = path[0], path[1:] + item = menu.itemWithTitle(label) + if item is None: + raise AssertionError( + f"Menu {' > '.join(orig_path)} not found; " + f"no item named {label!r}; options are: " + + ",".join(f"{str(item.title)!r}" for item in menu.itemArray) + ) + + if path: + menu = item.submenu + if menu is None: + raise AssertionError( + f"Menu {' > '.join(orig_path)} not found; " + f"{str(item.title)} does not have a submenu" + ) + else: + # No more path segments; we've found the full path. + break + + return item + + def _activate_menu_item(self, path): + item = self._menu_item(path) + send_message( + self.app._impl.native.delegate, + item.action, + item, + restype=None, + argtypes=[objc_id], + ) + + def activate_menu_exit(self): + self._activate_menu_item(["*", "Quit Toga Testbed"]) + + def activate_menu_about(self): + self._activate_menu_item(["*", "About Toga Testbed"]) + + async def close_about_dialog(self): + about_dialog = self.app._impl.native.keyWindow + if isinstance(about_dialog, NSPanel): + about_dialog.close() + + def activate_menu_visit_homepage(self): + self._activate_menu_item(["Help", "Visit homepage"]) + + def assert_system_menus(self): + self.assert_menu_item(["*", "About Toga Testbed"], enabled=True) + self.assert_menu_item(["*", "Settings\u2026"], enabled=False) + self.assert_menu_item(["*", "Hide Toga Testbed"], enabled=True) + self.assert_menu_item(["*", "Hide Others"], enabled=True) + self.assert_menu_item(["*", "Show All"], enabled=True) + self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + + self.assert_menu_item(["File", "Close"], enabled=True) + self.assert_menu_item(["File", "Close All"], enabled=True) + + self.assert_menu_item(["Edit", "Undo"], enabled=True) + self.assert_menu_item(["Edit", "Redo"], enabled=True) + self.assert_menu_item(["Edit", "Cut"], enabled=True) + self.assert_menu_item(["Edit", "Copy"], enabled=True) + self.assert_menu_item(["Edit", "Paste"], enabled=True) + self.assert_menu_item(["Edit", "Paste and Match Style"], enabled=True) + self.assert_menu_item(["Edit", "Delete"], enabled=True) + self.assert_menu_item(["Edit", "Select All"], enabled=True) + + self.assert_menu_item(["Window", "Minimize"], enabled=True) + + self.assert_menu_item(["Help", "Visit homepage"], enabled=True) + + def activate_menu_close_window(self): + self._activate_menu_item(["File", "Close"]) + + def activate_menu_close_all_windows(self): + self._activate_menu_item(["File", "Close All"]) + + def activate_menu_minimize(self): + self._activate_menu_item(["Window", "Minimize"]) + + def assert_menu_item(self, path, enabled): + item = self._menu_item(path) + assert item.isEnabled() == enabled + + def keystroke(self, combination): + key, modifiers = cocoa_key(combination) + key_code = { + "a": 0, + "A": 0, + "1": 18, + "!": 18, + chr(0xF708): 96, # F5 + chr(0x2196): 115, # Home + }[key] + + # Add the shift modifier to disambiguate 1 from ! + if key in {"!"}: + modifiers |= NSEventModifierFlagShift + + event = NSEvent.keyEventWithType( + NSEventType.KeyDown, + location=NSPoint(0, 0), # key presses don't have a location. + modifierFlags=modifiers, + timestamp=0, + windowNumber=self.app.main_window._impl.native.windowNumber, + context=None, + characters="?", + charactersIgnoringModifiers="?", + isARepeat=False, + keyCode=key_code, + ) + return toga_key(event) diff --git a/cocoa/tests_backend/probe.py b/cocoa/tests_backend/probe.py index d28bfc049c..df70de74c7 100644 --- a/cocoa/tests_backend/probe.py +++ b/cocoa/tests_backend/probe.py @@ -59,3 +59,8 @@ async def redraw(self, message=None, delay=None): # Running at "normal" speed, we need to release to the event loop # for at least one iteration. `runUntilDate:None` does this. NSRunLoop.currentRunLoop.runUntilDate(None) + + def assert_image_size(self, image_size, size): + # Cocoa reports image sizing in the natural screen coordinates, not the size of + # the backing store. + assert image_size == size diff --git a/cocoa/tests_backend/widgets/canvas.py b/cocoa/tests_backend/widgets/canvas.py index b91d1ebb37..cd2472ef0a 100644 --- a/cocoa/tests_backend/widgets/canvas.py +++ b/cocoa/tests_backend/widgets/canvas.py @@ -40,12 +40,6 @@ def get_image(self): except KeyError: return image - def assert_image_size(self, image, width, height): - # Cocoa reports image sizing in the natural screen coordinates, not the size of - # the backing store. - assert image.width == width - assert image.height == height - async def mouse_press(self, x, y): await self.mouse_event( NSEventType.LeftMouseDown, diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 285adbb978..eb7e7ef96d 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +from rubicon.objc import objc_id, send_message from rubicon.objc.collections import ObjCListInstance from toga_cocoa.libs import ( @@ -35,7 +36,7 @@ def __init__(self, app, window): async def wait_for_window(self, message, minimize=False, full_screen=False): await self.redraw( message, - delay=0.75 if full_screen else 0.5 if minimize else None, + delay=0.75 if full_screen else 0.5 if minimize else 0.1, ) def close(self): @@ -233,3 +234,28 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): f"{'Multiselect' if multiple_select else ' Select'} folder dialog " f"({'OPEN' if result else 'CANCEL'}) dismissed" ) + + def has_toolbar(self): + return self.native.toolbar is not None + + def assert_is_toolbar_separator(self, index, section=False): + item = self.native.toolbar.items[index] + assert str(item.itemIdentifier).startswith("ToolbarSeparator-") + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self.native.toolbar.items[index] + + assert str(item.label) == label + assert (None if item.toolTip is None else str(item.toolTip)) == tooltip + assert (item.image is not None) == has_icon + assert item.isEnabled() == enabled + + def press_toolbar_button(self, index): + item = self.native.toolbar.items[index] + send_message( + item.target, + item.action, + item, + restype=None, + argtypes=[objc_id], + ) diff --git a/core/setup.cfg b/core/setup.cfg index dd9dbd8310..7b954d50ba 100644 --- a/core/setup.cfg +++ b/core/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent @@ -67,7 +67,7 @@ dev = pre-commit == 3.0.2 pytest == 7.3.1 pytest-asyncio == 0.21.0 - pytest-freezegun == 0.4.2 + pytest-freezer == 0.4.8 setuptools-scm[toml] == 7.0.5 tox == 4.3.5 docs = diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 1c8f24e6f4..705ee52e06 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -1,8 +1,8 @@ -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp, DocumentMainWindow, MainWindow # Resources from .colors import hsl, hsla, rgb, rgba -from .command import GROUP_BREAK, SECTION_BREAK, Command, CommandSet, Group +from .command import Command, Group from .documents import Document from .fonts import Font from .icons import Icon @@ -42,12 +42,10 @@ "App", "DocumentApp", "MainWindow", + "DocumentMainWindow", # Commands "Command", - "CommandSet", "Group", - "GROUP_BREAK", - "SECTION_BREAK", # Documents "Document", # Keys diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 1b1c50986f..18d90c0cfa 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -1,16 +1,18 @@ from __future__ import annotations +import asyncio +import importlib.metadata import signal import sys import warnings import webbrowser -from builtins import id as identifier -from collections.abc import MutableSet +from collections.abc import Collection, Iterator, Mapping, MutableSet from email.message import Message -from importlib import metadata as importlib_metadata -from typing import Any, Iterable, Protocol +from typing import Any, Protocol +from warnings import warn -from toga.command import CommandSet +from toga.command import Command, CommandSet +from toga.documents import Document from toga.handlers import wrapped_handler from toga.icons import Icon from toga.paths import Paths @@ -28,11 +30,9 @@ def __call__(self, app: App, **kwargs: Any) -> Widget: Called during app startup to set the initial main window content. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app instance that is starting. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. :returns: The widget to use as the main window content. """ ... @@ -45,11 +45,9 @@ def __call__(self, app: App, **kwargs: Any) -> bool: The return value of this callback controls whether the app is allowed to exit. This can be used to prevent the app exiting with unsaved changes, etc. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app instance that is exiting. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. :returns: ``True`` if the app is allowed to exit; ``False`` if the app is not allowed to exit. """ @@ -60,30 +58,27 @@ class BackgroundTask(Protocol): def __call__(self, app: App, **kwargs: Any) -> None: """Code that should be executed as a background task. - .. note:: - ``**kwargs`` ensures compatibility with additional arguments - introduced in future versions. - :param app: The app that is handling the background task. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. """ ... class WindowSet(MutableSet): - """A collection of windows managed by an app. - - A window can be added to app by using `app.windows.add(toga.Window(...))` or - `app.windows += toga.Window(...)` notations. Adding a window to app automatically - sets `window.app` property to the app. - """ + def __init__(self, app: App): + """A collection of windows managed by an app. - def __init__(self, app: App, iterable: Iterable[Window] = ()): + A window is automatically added to the app when it is created, and removed when + it is closed. Adding a window to an App's window set automatically sets the + :attr:`~toga.Window.app` property of the Window. + """ self.app = app - self.elements = set(iterable) + self.elements = set() def add(self, window: Window) -> None: if not isinstance(window, Window): - raise TypeError("Toga app.windows can only add objects of toga.Window type") + raise TypeError("Can only add objects of type toga.Window") # Silently not add if duplicate if window not in self.elements: self.elements.add(window) @@ -91,30 +86,45 @@ def add(self, window: Window) -> None: def discard(self, window: Window) -> None: if not isinstance(window, Window): - raise TypeError( - "Toga app.windows can only discard an object of a toga.Window type" - ) + raise TypeError("Can only discard objects of type toga.Window") if window not in self.elements: - raise AttributeError( - "The window you are trying to remove is not associated with this app" - ) + raise ValueError(f"{window!r} is not part of this app") self.elements.remove(window) - def __iadd__(self, window): - self.add(window) + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### + + def __iadd__(self, window: Window) -> None: + # The standard set type does not have a += operator. + warn( + "Windows are automatically associated with the app; += is not required", + DeprecationWarning, + stacklevel=2, + ) return self - def __isub__(self, other): - self.discard(other) + def __isub__(self, other: Window) -> None: + # The standard set type does have a -= operator, but it takes sets rather than + # individual items. + warn( + "Windows are automatically removed from the app; -= is not required", + DeprecationWarning, + stacklevel=2, + ) return self - def __iter__(self): + ###################################################################### + # End backwards compatibility + ###################################################################### + + def __iter__(self) -> Iterator: return iter(self.elements) - def __contains__(self, value): + def __contains__(self, value: Window) -> bool: return value in self.elements - def __len__(self): + def __len__(self) -> int: return len(self.elements) @@ -129,6 +139,8 @@ def __init__( size: tuple[int, int] = (640, 480), resizable: bool = True, minimizable: bool = True, + resizeable=None, # DEPRECATED + closeable=None, # DEPRECATED ): """Create a new main window. @@ -141,6 +153,8 @@ def __init__( pixels `. :param resizable: Can the window be resized by the user? :param minimizable: Can the window be minimized by the user? + :param resizeable: **DEPRECATED** - Use ``resizable``. + :param closeable: **DEPRECATED** - Use ``closable``. """ super().__init__( id=id, @@ -150,6 +164,9 @@ def __init__( resizable=resizable, closable=True, minimizable=minimizable, + # Deprecated arguments + resizeable=resizeable, + closeable=closeable, ) @property @@ -164,8 +181,7 @@ def on_close(self) -> None: Always returns ``None``. Main windows should use :meth:`toga.App.on_exit`, rather than ``on_close``. - :raises ValueError: if an attempt is made to set the ``on_close`` handler for a - MainWindow. + :raises ValueError: if an attempt is made to set the ``on_close`` handler. """ return None @@ -173,10 +189,54 @@ def on_close(self) -> None: def on_close(self, handler: Any): if handler: raise ValueError( - "Cannot set on_close handler for the main window. Use the app on_exit handler instead" + "Cannot set on_close handler for the main window. " + "Use the app on_exit handler instead." ) +class DocumentMainWindow(Window): + def __init__( + self, + doc: Document, + id: str | None = None, + title: str | None = None, + position: tuple[int, int] = (100, 100), + size: tuple[int, int] = (640, 480), + resizable: bool = True, + minimizable: bool = True, + ): + """Create a new document Main Window. + + This installs a default on_close handler that honors platform-specific document + closing behavior. If you want to control whether a document is allowed to close + (e.g., due to having unsaved change), override + :meth:`toga.Document.can_close()`, rather than implementing an on_close handler. + + :param document: The document being managed by this window + :param id: The ID of the window. + :param title: Title for the window. Defaults to the formal name of the app. + :param position: Position of the window, as a tuple of ``(x, y)`` coordinates. + :param size: Size of the window, as a tuple of ``(width, height)``, in pixels. + :param resizable: Can the window be manually resized by the user? + :param minimizable: Can the window be minimized by the user? + """ + self.doc = doc + super().__init__( + id=id, + title=title, + position=position, + size=size, + resizable=resizable, + closable=True, + minimizable=minimizable, + on_close=doc.handle_close, + ) + + @property + def _default_title(self) -> str: + return self.doc.path.name + + class App: app = None @@ -185,189 +245,155 @@ def __init__( formal_name: str | None = None, app_id: str | None = None, app_name: str | None = None, - id: str | None = None, + *, icon: Icon | str | None = None, author: str | None = None, version: str | None = None, home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - windows: Iterable[Window] = (), on_exit: OnExitHandler | None = None, - factory: None = None, # DEPRECATED ! + id=None, # DEPRECATED + windows=None, # DEPRECATED ): - """An App is the top level of any GUI program. - - The App is the manager of all the other aspects of execution. An app will - usually have a main window; this window will hold the widgets with which the - user will interact. - - When you create an App you need to provide a name, an id for uniqueness (by - convention, the identifier is a reversed domain name) and an optional startup - function which should run once the App has initialized. The startup function - constructs the initial user interface. If a startup function is not provided as - an argument, you must subclass the App class and define a ``startup()`` method. - - If the name and app_id are *not* provided, the application will attempt to find - application metadata. This process will determine the module in which the App - class is defined, and look for a ``.dist-info`` file matching that name. + """Create a new App instance. - Once the app is created you should invoke the ``main_loop()`` method, which will - start the event loop of your App. + Once the app has been created, you should invoke the + :meth:`~toga.App.main_loop()` method, which will start the event loop of your + App. - :param formal_name: The formal name of the application. Will be derived from - packaging metadata if not provided. + :param formal_name: The human-readable name of the app. If not provided, + the metadata key ``Formal-Name`` must be present. :param app_id: The unique application identifier. This will usually be a - reversed domain name, e.g. ``org.beeware.myapp``. Will be derived from - packaging metadata if not provided. - :param app_name: The name of the Python module containing the app. Will be - derived from the module defining the instance of the App class if not - provided. - :param id: The DOM identifier for the app (optional) - :param icon: Identifier for the application's icon. + reversed domain name, e.g. ``org.beeware.myapp``. If not provided, the + metadata key ``App-ID`` must be present. + :param app_name: The name of the distribution used to load metadata with + :any:`importlib.metadata`. If not provided, the following will be tried in + order: + + #. If the ``__main__`` module is contained in a package, that package's name + will be used. + #. If the ``app_id`` argument was provided, its last segment will be used. + For example, an ``app_id`` of ``com.example.my-app`` would yield a + distribution name of ``my-app``. + #. As a last resort, the name ``toga``. + :param icon: The :any:`Icon` for the app. If not provided, Toga will attempt to + load an icon from ``resources/app_name``, where ``app_name`` is defined + above. If no resource matching this name can be found, a warning will be + printed, and the app will fall back to a default icon. :param author: The person or organization to be credited as the author of the - application. Will be derived from application metadata if not provided. - :param version: The version number of the app. Will be derived from packaging - metadata if not provided. - :param home_page: A URL for a home page for the app. Used in auto-generated help - menu items. Will be derived from packaging metadata if not provided. - :param description: A brief (one line) description of the app. Will be derived - from packaging metadata if not provided. - :param startup: The callback method before starting the app, typically to add - the components. Must be a ``callable`` that expects a single argument of - :class:`~toga.App`. - :param windows: An iterable with objects of :class:`~toga.Window` that will be - the app's secondary windows. + app. If not provided, the metadata key ``Author`` will be used. + :param version: The version number of the app. If not provided, the metadata + key ``Version`` will be used. + :param home_page: The URL of a web page for the app. Used in auto-generated help + menu items. If not provided, the metadata key ``Home-page`` will be used. + :param description: A brief (one line) description of the app. If not provided, + the metadata key ``Summary`` will be used. + :param startup: A callable to run before starting the app. + :param on_exit: The initial :any:`on_exit` handler. + :param id: **DEPRECATED** - This argument will be ignored. If you need a + machine-friendly identifier, use ``app_id``. + :param windows: **DEPRECATED** – Windows are now automatically added to the + current app. Passing this argument will cause an exception. """ - ###################################################################### - # 2022-09: Backwards compatibility + # 2023-10: Backwards compatibility ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) + if id is not None: + warn( + "App.id is deprecated and will be ignored. Use app_id instead", + DeprecationWarning, + stacklevel=2, + ) + + if windows is not None: + raise ValueError( + "The `windows` constructor argument of toga.App has been removed. " + "Windows are now automatically added to the current app." + ) ###################################################################### - # End backwards compatibility. + # End backwards compatibility ###################################################################### # Initialize empty widgets registry self._widgets = WidgetRegistry() - # Keep an accessible copy of the app instance + # Keep an accessible copy of the app singleton instance App.app = self - # We need a module name to load app metadata. If an app_name has been - # provided, we can set the app name now, and derive the module name - # from there. - if app_name: - self._app_name = app_name - else: - # If the code is contained in appname.py, and you start the app - # using `python -m appname`, the main module package will report - # as ''. Set the initial app name as None. + # We need a distribution name to load app metadata. + if app_name is None: + # If the code is contained in appname.py, and you start the app using + # `python -m appname`, then __main__.__package__ will be an empty string. # - # If the code is contained in appname.py, and you start the app - # using `python appname.py`, the main module will report as None. + # If the code is contained in appname.py, and you start the app using + # `python appname.py`, then __main__.__package__ will be None. # - # If the code is contained in a folder, and you start the app - # using `python -m appname`, the main module will report as the - # name of the folder. + # If the code is contained in appname/__main__.py, and you start the app + # using `python -m appname`, then __main__.__package__ will be "appname". try: main_module_pkg = sys.modules["__main__"].__package__ - if main_module_pkg == "": - self._app_name = None - else: - self._app_name = main_module_pkg + if main_module_pkg: + app_name = main_module_pkg except KeyError: - # We use the existence of a __main__ module as a proxy for - # being in test conditions. This isn't *great*, but the __main__ - # module isn't meaningful during tests, and removing it allows - # us to avoid having explicit "if under test conditions" checks. - # If there's no __main__ module, we're in a test, and we can't - # imply an app name from that module name. - self._app_name = None - - # Try deconstructing the app name from the app ID - if self._app_name is None and app_id: - self._app_name = app_id.split(".")[-1] - - # Load the app metadata (if it is available) - # Apps packaged with Briefcase will have this metadata. - try: - self.metadata = importlib_metadata.metadata(self.module_name) - except importlib_metadata.PackageNotFoundError: - self.metadata = Message() + # If there's no __main__ module, we're probably in a test. + pass + + # Try deconstructing the distribution name from the app ID + if (app_name is None) and app_id: + app_name = app_id.split(".")[-1] - # Now that we have metadata, we can fix the app name (in the case - # where the app name and the module name differ - e.g., an app name - # of ``hello-world`` will have a module name of ``hello_world``). - # We use the PEP566-compliant key ``Name```, rather than the internally - # consistent key ``App-Name```. - if self.metadata["Name"] is not None: - self._app_name = self.metadata["Name"] - - # Whatever app name has been given, speculatively attempt to import - # the app module. Single-file apps won't have an app folder; apps with - # misleading or misconfigured app names haven't given us enough - # metadata to determine the app folder. In those cases, fall back to - # an app name that *will* exist (``toga```) + # If we still don't have a distribution name, fall back to ``toga`` as a + # last resort. + if app_name is None: + app_name = "toga" + + # Try to load the app metadata with our best guess of the distribution name. + self._app_name = app_name try: - sys.modules[self.module_name] - except KeyError: - # Well that didn't work... - self._app_name = "toga" + self.metadata = importlib.metadata.metadata(app_name) + except importlib.metadata.PackageNotFoundError: + self.metadata = Message() - # If a name has been provided, use it; otherwise, look to - # the module metadata. However, a name *must* be provided. + # If a formal name has been provided, use it; otherwise, look to + # the metadata. However, a formal name *must* be provided. if formal_name: self._formal_name = formal_name else: - self._formal_name = self.metadata["Formal-Name"] - + self._formal_name = self.metadata.get("Formal-Name") if self._formal_name is None: raise RuntimeError("Toga application must have a formal name") # If an app_id has been provided, use it; otherwise, look to - # the module metadata. However, an app_id *must* be provided + # the metadata. However, an app_id *must* be provided if app_id: self._app_id = app_id else: self._app_id = self.metadata.get("App-ID", None) - if self._app_id is None: - raise RuntimeError("Toga application must have an App ID") + raise RuntimeError("Toga application must have an app ID") - # If an author has been provided, use it; otherwise, look to - # the module metadata. + # Other metadata may be passed to the constructor, or loaded with importlib. if author: self._author = author else: self._author = self.metadata.get("Author", None) - # If a version has been provided, use it; otherwise, look to - # the module metadata. if version: self._version = version else: self._version = self.metadata.get("Version", None) - # If a home_page has been provided, use it; otherwise, look to - # the module metadata. if home_page: self._home_page = home_page else: self._home_page = self.metadata.get("Home-page", None) - # If a description has been provided, use it; otherwise, look to - # the module metadata. if description: self._description = description else: self._description = self.metadata.get("Summary", None) - # Set the application DOM ID; create an ID if one hasn't been provided. - self._id = str(id if id else identifier(self)) - # Get a platform factory. self.factory = get_platform_factory() @@ -375,106 +401,108 @@ class is defined, and look for a ``.dist-info`` file matching that name. self._paths = Paths() # If an icon (or icon name) has been explicitly provided, use it; - # otherwise, the icon will be based on the app name. + # otherwise, the icon will be based on the distribution name. if icon: self.icon = icon else: - self.icon = f"resources/{self.app_name}" + self.icon = f"resources/{app_name}" + + self.on_exit = on_exit - self.commands = CommandSet() + # We need the command set to exist so that startup et al can add commands; + # but we don't have an impl yet, so we can't set the on_change handler + self._commands = CommandSet() self._startup_method = startup self._main_window = None - self.windows = WindowSet(self, windows) + self._windows = WindowSet(self) self._full_screen_windows = None - self._impl = self._create_impl() - self.on_exit = on_exit + self._create_impl() + + # Now that we have an impl, set the on_change handler for commands + self.commands.on_change = self._impl.create_menus def _create_impl(self): - return self.factory.App(interface=self) + self.factory.App(interface=self) @property def paths(self) -> Paths: - """Paths for platform appropriate locations on the user's file system. - - Some platforms do not allow arbitrary file access to any location on - disk; even when arbitrary file system access is allowed, there are - "preferred" locations for some types of content. + """Paths for platform-appropriate locations on the user's file system. - The :class:`~toga.paths.Paths` object has a set of sub-properties that - return :class:`pathlib.Path` instances of platform-appropriate paths on - the file system. + Some platforms do not allow access to any file system location other than these + paths. Even when arbitrary file access is allowed, there are preferred locations + for each type of content. """ return self._paths @property def name(self) -> str: - """The formal name of the app.""" + """**DEPRECATED** – Use :any:`formal_name`.""" + warn( + "App.name is deprecated. Use formal_name instead", + DeprecationWarning, + stacklevel=2, + ) return self._formal_name @property def formal_name(self) -> str: - """The formal name of the app.""" + """The human-readable name of the app (read-only).""" return self._formal_name @property def app_name(self) -> str: - """The machine-readable, PEP508-compliant name of the app.""" + """The name of the distribution used to load metadata with + :any:`importlib.metadata` (read-only).""" return self._app_name - @property - def module_name(self) -> str | None: - """The module name for the app.""" - try: - return self._app_name.replace("-", "_") - except AttributeError: - # If the app was created from an interactive prompt, - # there won't be a module name. - return None - @property def app_id(self) -> str: - """The identifier for the app. - - This is a reversed domain name, often used for targeting resources, - etc. + """The unique application identifier (read-only). This will usually be a + reversed domain name, e.g. ``org.beeware.myapp``. """ return self._app_id @property - def author(self) -> str: - """The author of the app. This may be an organization name.""" + def author(self) -> str | None: + """The person or organization to be credited as the author of the app + (read-only).""" return self._author @property - def version(self) -> str: - """The version number of the app.""" + def version(self) -> str | None: + """The version number of the app (read-only).""" return self._version @property - def home_page(self) -> str: - """The URL of a web page for the app.""" + def home_page(self) -> str | None: + """The URL of a web page for the app (read-only). Used in auto-generated help + menu items.""" return self._home_page @property - def description(self) -> str: - """A brief description of the app.""" + def description(self) -> str | None: + """A brief (one line) description of the app (read-only).""" return self._description @property def id(self) -> str: - """The DOM identifier for the app. - - This id can be used to target CSS directives. - """ - return self._id + """**DEPRECATED** – Use :any:`app_id`.""" + warn( + "App.id is deprecated. Use app_id instead", DeprecationWarning, stacklevel=2 + ) + return self._app_id @property def icon(self) -> Icon: - """The Icon for the app.""" + """The Icon for the app. + + When setting the icon, you can provide either an :any:`Icon` instance, or a + path that will be passed to the ``Icon`` constructor. + """ return self._icon @icon.setter @@ -485,7 +513,7 @@ def icon(self, icon_or_name: Icon | str) -> None: self._icon = Icon(icon_or_name) @property - def widgets(self) -> WidgetRegistry: + def widgets(self) -> Mapping[str, Widget]: """The widgets managed by the app, over all windows. Can be used to look up widgets by ID over the entire app (e.g., @@ -493,6 +521,31 @@ def widgets(self) -> WidgetRegistry: """ return self._widgets + @property + def windows(self) -> Collection[Window]: + """The windows managed by the app. Windows are automatically added to the app + when they are created, and removed when they are closed.""" + return self._windows + + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### + + # Support WindowSet __iadd__ and __isub__ + @windows.setter + def windows(self, windows): + if windows is not self._windows: + raise AttributeError("can't set attribute 'windows'") + + ###################################################################### + # End backwards compatibility + ###################################################################### + + @property + def commands(self) -> MutableSet[Command]: + """The commands available in the app.""" + return self._commands + @property def main_window(self) -> MainWindow: """The main window for the app.""" @@ -504,15 +557,15 @@ def main_window(self, window: MainWindow) -> None: self._impl.set_main_window(window) @property - def current_window(self): - """Return the currently active content window.""" + def current_window(self) -> Window | None: + """Return the currently active window.""" window = self._impl.get_current_window() if window is None: - return window + return None return window.interface @current_window.setter - def current_window(self, window): + def current_window(self, window: Window): """Set a window into current active focus.""" self._impl.set_current_window(window) @@ -532,9 +585,8 @@ def set_full_screen(self, *windows: Window) -> None: those windows will not be visible. If no windows are specified, the app will exit full screen mode. """ - if not windows: - self.exit_full_screen() - else: + self.exit_full_screen() + if windows: self._impl.enter_full_screen(windows) self._full_screen_windows = windows @@ -545,7 +597,7 @@ def exit_full_screen(self) -> None: self._full_screen_windows = None def show_cursor(self) -> None: - """Show cursor.""" + """Make the cursor visible.""" self._impl.show_cursor() def hide_cursor(self) -> None: @@ -553,7 +605,12 @@ def hide_cursor(self) -> None: self._impl.hide_cursor() def startup(self) -> None: - """Create and show the main window for the application.""" + """Create and show the main window for the application. + + Subclasses can override this method to define customized startup behavior; + however, any override *must* ensure the :any:`main_window` has been assigned + before it returns. + """ self.main_window = MainWindow(title=self.formal_name) if self._startup_method: @@ -583,9 +640,9 @@ def about(self) -> None: self._impl.show_about_dialog() def visit_homepage(self) -> None: - """Open the application's homepage in the default browser. + """Open the application's :any:`home_page` in the default browser. - If the application metadata doesn't define a homepage, this is a no-op. + If the :any:`home_page` is ``None``, this is a no-op. """ if self.home_page is not None: webbrowser.open(self.home_page) @@ -595,9 +652,10 @@ def beep(self) -> None: self._impl.beep() def main_loop(self) -> None: - """Invoke the application to handle user input. + """Start the application. - This method typically only returns once the application is exiting. + On desktop platforms, this method will block until the application has exited. + On mobile and web platforms, it returns immediately. """ # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -605,27 +663,33 @@ def main_loop(self) -> None: self._impl.main_loop() def exit(self) -> None: - """Quit the application gracefully.""" - self.on_exit(None) + """Exit the application gracefully. + + This *does not* invoke the ``on_exit`` handler; the app will be immediately + and unconditionally closed. + """ + self._impl.exit() @property def on_exit(self) -> OnExitHandler: - """The handler to invoke before the application exits.""" + """The handler to invoke if the user attempts to exit the app.""" return self._on_exit @on_exit.setter def on_exit(self, handler: OnExitHandler | None) -> None: - if handler is None: - - def handler(app, *args, **kwargs): - app._impl.exit() - def cleanup(app, should_exit): - if should_exit: - app._impl.exit() + if should_exit or handler is None: + app.exit() self._on_exit = wrapped_handler(self, handler, cleanup=cleanup) + @property + def loop(self) -> asyncio.AbstractEventLoop: + """The `event loop + `__ of the app's main + thread (read-only).""" + return self._impl.loop + def add_background_task(self, handler: BackgroundTask) -> None: """Schedule a task to run in the background. @@ -637,7 +701,7 @@ def add_background_task(self, handler: BackgroundTask) -> None: :param handler: A coroutine, generator or callable. """ - self._impl.loop.call_soon_threadsafe(wrapped_handler(self, handler), None) + self.loop.call_soon_threadsafe(wrapped_handler(self, handler)) class DocumentApp(App): @@ -646,43 +710,35 @@ def __init__( formal_name: str | None = None, app_id: str | None = None, app_name: str | None = None, - id: str | None = None, + *, icon: str | None = None, author: str | None = None, version: str | None = None, home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, - document_types: list[str] | None = None, + document_types: dict[str, type[Document]] = None, on_exit: OnExitHandler | None = None, - factory: None = None, # DEPRECATED ! + id=None, # DEPRECATED ): - """Create a document-based Application. + """Create a document-based application. A document-based application is the same as a normal application, with the - exception that there is no main window. Instead, each document managed by - the app will have it's own window. + exception that there is no main window. Instead, each document managed by the + app will create and manage it's own window (or windows). - :param document_types: The file extensions that this application can manage. + :param document_types: Initial :any:`document_types` mapping. """ - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + if document_types is None: + raise ValueError("A document must manage at least one document type.") - self.document_types = document_types + self._document_types = document_types self._documents = [] super().__init__( formal_name=formal_name, app_id=app_id, app_name=app_name, - id=id, icon=icon, author=author, version=version, @@ -690,6 +746,7 @@ def __init__( description=description, startup=startup, on_exit=on_exit, + id=id, ) def _create_impl(self): @@ -700,6 +757,39 @@ def _verify_startup(self): pass @property - def documents(self) -> list[str]: + def document_types(self) -> dict[str, type[Document]]: + """The document types this app can manage. + + A dictionary of file extensions, without leading dots, mapping to the + :class:`toga.Document` subclass that will be created when a document with that + extension is opened. The subclass must take exactly 2 arguments in its + constructor: ``path`` and ``app``. + """ + return self._document_types + + @property + def documents(self) -> list[Document]: """The list of documents associated with this app.""" return self._documents + + def startup(self) -> None: + """No-op; a DocumentApp has no windows until a document is opened. + + Subclasses can override this method to define customized startup behavior. + """ + + def _open(self, path): + """Internal utility method; open a new document in this app, and shows the document. + + :param path: The path to the document to be opened. + :raises ValueError: If the document is of a type that can't be opened. Backends can + suppress this exception if necessary to presere platform-native behavior. + """ + try: + DocType = self.document_types[path.suffix[1:]] + except KeyError: + raise ValueError(f"Don't know how to open documents of type {path.suffix}") + else: + document = DocType(path, app=self) + self._documents.append(document) + document.show() diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 4f66dabd83..8dd95ea554 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -1,97 +1,80 @@ -import warnings +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol from toga.handlers import wrapped_handler from toga.icons import Icon +from toga.keys import Key from toga.platform import get_platform_factory -# BACKWARDS COMPATIBILITY: a token object that can be used to differentiate -# between an explicitly provided ``None``, and an unspecified value falling -# back to a default. -NOT_PROVIDED = object() +if TYPE_CHECKING: + from toga.app import App class Group: - """ - - Args: - text: - order: - parent: - """ - def __init__( self, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label` is removed - order=None, - section=None, - parent=None, - label=None, # DEPRECATED! + text: str, + *, + parent: Group | None = None, + section: int = 0, + order: int = 0, ): - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn( - "Group.label has been renamed Group.text", DeprecationWarning - ) - text = label - elif text is NOT_PROVIDED: - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - raise TypeError( - "Group.__init__ missing 1 required positional argument: 'text'" - ) - - ################################################################## - # End backwards compatibility. - ################################################################## - + """ + An collection of commands to display together. + + :param text: A label for the group. + :param parent: The parent of this group; use ``None`` to make a root group. + :param section: The section where the group should appear within its parent. A + section cannot be specified unless a parent is also specified. + :param order: The position where the group should appear within its section. + If multiple items have the same group, section and order, they will be + sorted alphabetically by their text. + """ self.text = text - self.order = order if order else 0 - if parent is None and section is not None: + self.order = order + if parent is None and section != 0: raise ValueError("Section cannot be set without parent group") - self.section = section if section else 0 + self.section = section - # First initialization needed for later + # Prime the underlying value of _parent so that the setter has a current value + # to work with self._parent = None self.parent = parent @property - def parent(self): + def parent(self) -> Group | None: + """The parent of this group; returns ``None`` if the group is a root group.""" return self._parent @parent.setter - def parent(self, parent): + def parent(self, parent: Group | None): if parent is None: self._parent = None - self._root = self - return - if parent == self or self.is_parent_of(parent): - error_message = ( - "Cannot set {} to be a parent of {} " - "because it causes a cyclic parenting." - ).format(parent.text, self.text) - raise ValueError(error_message) - self._parent = parent - self._root = parent.root + elif parent == self: + raise ValueError("A group cannot be it's own parent") + elif self.is_parent_of(parent): + raise ValueError( + f"Cannot set parent; {self.text!r} is an ancestor of {parent.text!r}." + ) + else: + self._parent = parent @property - def root(self): - return self._root + def root(self) -> Group: + """The root group for this group. + + This will be ``self`` if the group *is* a root group.""" + if self.parent is None: + return self + return self.parent.root + + def is_parent_of(self, child: Group | None) -> bool: + """Is this group a parent of the provided group, directly or indirectly? - def is_parent_of(self, child): + :param child: The potential child to check + :returns: True if this group is a parent of the provided child. + """ if child is None: return False if child.parent is None: @@ -100,68 +83,59 @@ def is_parent_of(self, child): return True return self.is_parent_of(child.parent) - def is_child_of(self, parent): + def is_child_of(self, parent: Group | None) -> bool: + """Is this group a child of the provided group, directly or indirectly? + + :param parent: The potential parent to check + :returns: True if this group is a child of the provided parent. + """ + if parent is None: + return False return parent.is_parent_of(self) - def __hash__(self): + def __hash__(self) -> int: return hash(self.key) - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: + if not isinstance(other, (Group, Command)): + return False return self.key < other.key - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: + if not isinstance(other, (Group, Command)): + return False return other < self - def __eq__(self, other): - if other is None: + def __eq__(self, other: Any) -> bool: + if not isinstance(other, (Group, Command)): return False return self.key == other.key - def __repr__(self): - parent_string = "None" if self.parent is None else self.parent.text - return "".format( - self.text, self.order, parent_string + def __repr__(self) -> str: + parent_string = ( + f" parent={self.parent} section={self.section}" + if self.parent is not None + else "" ) + return f"" @property - def key(self): + def key(self) -> tuple[(int, int, str)]: """A unique tuple describing the path to this group.""" self_tuple = (self.section, self.order, self.text) if self.parent is None: return tuple([self_tuple]) return tuple([*self.parent.key, self_tuple]) - @property - def path(self): - """A list containing the chain of groups that contain this group.""" - if self.parent is None: - return [self] - return [*self.parent.path, self] - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - # label replaced with text - @property - def label(self): - """Group text. - - **DEPRECATED: renamed as text** - - Returns: - The button text as a ``str`` - """ - warnings.warn("Group.label has been renamed Group.text", DeprecationWarning) - return self.text - - @label.setter - def label(self, label): - warnings.warn("Group.label has been renamed Group.text", DeprecationWarning) - self.text = label - - ###################################################################### - # End backwards compatibility. - ###################################################################### + # Standard groups - docstrings can only be provided within the `class` statement, + # but the objects can't be instantiated here. + APP = None #: Application-level commands + FILE = None #: File commands + EDIT = None #: Editing commands + VIEW = None #: Content appearance commands + COMMANDS = None #: Default group for user-provided commands + WINDOW = None #: Window management commands + HELP = None #: Help commands Group.APP = Group("*", order=0) @@ -173,180 +147,132 @@ def label(self, label): Group.HELP = Group("Help", order=100) -class Command: - """ - Args: - action: a function to invoke when the command is activated. - text: caption for the command. - shortcut: (optional) a key combination that can be used to invoke the - command. - tooltip: (optional) a short description for what the command will do. - icon: (optional) a path to an icon resource to decorate the command. - group: (optional) a Group object describing a collection of similar - commands. If no group is specified, a default "Command" group will - be used. - section: (optional) an integer providing a sub-grouping. If no section - is specified, the command will be allocated to section 0 within the - group. - order: (optional) an integer indicating where a command falls within a - section. If a Command doesn't have an order, it will be sorted - alphabetically by text within its section. - enabled: whether to enable the command or not. - """ +class ActionHandler(Protocol): + def __call__(self, command: Command, **kwargs) -> bool: + """A handler that will be invoked when a Command is invoked. + + :param command: The command that triggered the action. + :param kwargs: Ensures compatibility with additional arguments introduced in + future versions. + """ + ... + +class Command: def __init__( self, - action, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label` is removed - shortcut=None, - tooltip=None, - icon=None, - group=None, - section=None, - order=None, - enabled=True, - factory=None, # DEPRECATED! - label=None, # DEPRECATED! + action: ActionHandler | None, + text: str, + *, + shortcut: str | Key | None = None, + tooltip: str | None = None, + icon: str | Icon | None = None, + group: Group = Group.COMMANDS, + section: int = 0, + order: int = 0, + enabled: bool = True, ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn( - "Command.label has been renamed Command.text", DeprecationWarning - ) - text = label - elif text is NOT_PROVIDED: - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - raise TypeError( - "Command.__init__ missing 1 required positional argument: 'text'" - ) - - ################################################################## - # End backwards compatibility. - ################################################################## + """ + Create a new Command. + + Commands may not use all the arguments - for example, on some platforms, menus + will contain icons; on other platforms they won't. + + :param action: A handler to invoke when the command is activated. If this is + ``None``, the command will be disabled. + :param text: A label for the command. + :param shortcut: A key combination that can be used to invoke the command. + :param tooltip: A short description of what the command will do. + :param icon: The icon, or icon resource, that can be used to decorate the + command if the platform requires. + :param group: The group to which this command belongs. + :param section: The section where the command should appear within its group. + :param order: The position where the command should appear within its section. + If multiple items have the same group, section and order, they will be + sorted alphabetically by their text. + :param enabled: Is the Command currently enabled? + """ self.text = text self.shortcut = shortcut self.tooltip = tooltip self.icon = icon - self.group = group if group else Group.COMMANDS - self.section = section if section else 0 - self.order = order if order else 0 + self.group = group + self.section = section + self.order = order - orig_action = action self.action = wrapped_handler(self, action) self.factory = get_platform_factory() self._impl = self.factory.Command(interface=self) - self.enabled = enabled and orig_action is not None + self.enabled = enabled @property - def key(self): - """A unique tuple describing the path to this command.""" - return tuple([*self.group.key, (self.section, self.order, self.text)]) + def key(self) -> tuple[(int, int, str)]: + """A unique tuple describing the path to this command. - def bind(self, factory=None): - warnings.warn( - "Commands no longer need to be explicitly bound.", DeprecationWarning - ) - return self._impl + Each element in the tuple describes the (section, order, text) for the + groups that must be navigated to invoke this action. + """ + return tuple([*self.group.key, (self.section, self.order, self.text)]) @property - def enabled(self): + def enabled(self) -> bool: + """Is the command currently enabled?""" return self._enabled @enabled.setter - def enabled(self, value): - self._enabled = value - if self._impl is not None: - self._impl.set_enabled(value) + def enabled(self, value: bool): + self._enabled = value and getattr(self.action, "_raw", True) is not None + self._impl.set_enabled(value) @property - def icon(self): - """The Icon for the app. + def icon(self) -> Icon | None: + """The Icon for the command. - :returns: A ``toga.Icon`` instance for the app's icon. + When setting the icon, you can provide either an :any:`Icon` instance, or a + path that will be passed to the ``Icon`` constructor. """ return self._icon @icon.setter - def icon(self, icon_or_name): + def icon(self, icon_or_name: str | Icon): if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: self._icon = Icon(icon_or_name) - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: + if not isinstance(other, (Group, Command)): + return False return self.key < other.key - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: + if not isinstance(other, (Group, Command)): + return False return other < self - def __repr__(self): - return "".format( - self.text, - self.group, - self.section, - self.order, + def __repr__(self) -> bool: + return ( + f"" ) - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - # label replaced with text - @property - def label(self): - """Command text. - **DEPRECATED: renamed as text** +class Break: + def __init__(self, name: str): + """A representation of a separator between Command Groups, or between sections + in a Group. - Returns: - The command text as a ``str`` + :param name: A name of the break type. """ - warnings.warn("Command.label has been renamed Command.text", DeprecationWarning) - return self.text - - @label.setter - def label(self, label): - warnings.warn("Command.label has been renamed Command.text", DeprecationWarning) - self.text = label - - ###################################################################### - # End backwards compatibility. - ###################################################################### - - -class Break: - def __init__(self, name): self.name = name - def __repr__(self): + def __repr__(self) -> str: return f"<{self.name} break>" @@ -354,49 +280,64 @@ def __repr__(self): SECTION_BREAK = Break("Section") -class CommandSet: - """ +class CommandSetChangeHandler(Protocol): + def __call__(self) -> None: + """A handler that will be invoked when a Command or Group is added to the CommandSet. - Args: - factory: - widget: - on_change: + .. note:: + ``**kwargs`` ensures compatibility with additional arguments + introduced in future versions. + + :return: Nothing + """ + ... - Todo: - * Add missing Docstrings. - """ +class CommandSet: def __init__( self, - factory=None, # DEPRECATED! - widget=None, - on_change=None, + on_change: CommandSetChangeHandler = None, + app: App | None = None, ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - self.widget = widget + """ + A collection of commands. + + This is used as an internal representation of Menus, Toolbars, and any other + graphical manifestations of commands. You generally don't need to construct a + CommandSet of your own; you should use existing app or window level CommandSet + instances. + + The collection can be iterated over to provide the display order of the commands + managed by the group. + + :param on_change: A method that should be invoked when this command set changes. + :param app: The app this command set is associated with, if it is not the app's + own commandset. + """ + self._app = app self._commands = set() self.on_change = on_change - def add(self, *commands): - if self.widget and self.widget.app is not None: - self.widget.app.commands.add(*commands) + def add(self, *commands: Command | Group): + if self.app and self.app is not None: + self.app.commands.add(*commands) self._commands.update(commands) if self.on_change: self.on_change() - def __len__(self): + def clear(self): + self._commands = set() + if self.on_change: + self.on_change() + + @property + def app(self) -> App: + return self._app + + def __len__(self) -> int: return len(self._commands) - def __iter__(self): + def __iter__(self) -> Command | Group | Break: prev_cmd = None for cmd in sorted(self._commands): if prev_cmd: diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index 4604314a32..a4a02dd7c7 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -1,16 +1,118 @@ -class Document: - def __init__(self, filename, document_type, app=None): - self.filename = filename - self.document_type = document_type +from __future__ import annotations +import asyncio +import warnings +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from toga.app import App + from toga.window import Window + + +class Document(ABC): + def __init__( + self, + path: str | Path, + document_type: str, + app: App = None, + ): + """Create a new Document. + + :param path: The path where the document is stored. + :param document_type: A human-readable description of the document type. + :param app: The application the document is associated with. + """ + self._path = Path(path) + self._document_type = document_type self._app = app + self._main_window = None + + # Create the visual representation of the document + self.create() # Create a platform specific implementation of the Document self._impl = app.factory.Document(interface=self) + def can_close(self) -> bool: + """Is the main document window allowed to close? + + The default implementation always returns ``True``; subclasses can override this + to prevent a window closing with unsaved changes, etc. + + This default implementation is a function; however, subclasses can define it + as an asynchronous co-routine if necessary to allow for dialog confirmations. + """ + return True + + async def handle_close(self, window, **kwargs): + """An ``on-close`` handler for the main window of this document that implements + platform-specific document close behavior. + + It interrogates the :meth:`~toga.Document.can_close()` method to determine if + the document is allowed to close. + """ + if asyncio.iscoroutinefunction(self.can_close): + can_close = await self.can_close() + else: + can_close = self.can_close() + + if can_close: + if self._impl.SINGLE_DOCUMENT_APP: + self.app.exit() + return False + else: + return True + else: + return False + @property - def app(self): + def path(self) -> Path: + """The path where the document is stored (read-only).""" + return self._path + + @property + def filename(self) -> Path: + """**DEPRECATED** - Use :attr:`path`.""" + warnings.warn( + "Document.filename has been renamed Document.path.", + DeprecationWarning, + ) + return self._path + + @property + def document_type(self) -> Path: + """A human-readable description of the document type (read-only).""" + return self._document_type + + @property + def app(self) -> App: + """The app that this document is associated with (read-only).""" return self._app - def read(self): - raise NotImplementedError("Document class must define read()") + @property + def main_window(self) -> Window: + """The main window for the document.""" + return self._main_window + + @main_window.setter + def main_window(self, window): + self._main_window = window + + def show(self) -> None: + """Show the :any:`main_window` for this document.""" + self.main_window.show() + + @abstractmethod + def create(self) -> None: + """Create the window (or windows) for the document. + + This method must, at a minimum, assign the :any:`main_window` property. It + may also create additional windows or UI elements if desired. + """ + + @abstractmethod + def read(self) -> None: + """Load a representation of the document into memory and populate the document + window.""" diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 5d22ed279b..8d345883c6 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -69,7 +69,7 @@ def wrapped_handler(interface, handler, cleanup=None): if isinstance(handler, NativeHandler): return handler.native - def _handler(widget, *args, **kwargs): + def _handler(*args, **kwargs): if asyncio.iscoroutinefunction(handler): asyncio.ensure_future( handler_with_cleanup(handler, cleanup, interface, *args, **kwargs) @@ -98,7 +98,7 @@ def _handler(widget, *args, **kwargs): else: # A dummy no-op handler - def _handler(widget, *args, **kwargs): + def _handler(*args, **kwargs): try: if cleanup: cleanup(interface, None) diff --git a/core/src/toga/images.py b/core/src/toga/images.py index b072dd2124..a436a8103c 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -34,20 +34,23 @@ def __init__( self.path = path else: self.path = Path(path) - self.data = None else: self.path = None - self.data = data self.factory = get_platform_factory() - if self.data is not None: - self._impl = self.factory.Image(interface=self, data=self.data) + if data is not None: + self._impl = self.factory.Image(interface=self, data=data) else: self.path = toga.App.app.paths.app / self.path if not self.path.is_file(): raise FileNotFoundError(f"Image file {self.path} does not exist") self._impl = self.factory.Image(interface=self, path=self.path) + @property + def size(self) -> (int, int): + """The size of the image, as a tuple""" + return (self._impl.get_width(), self._impl.get_height()) + @property def width(self) -> int: """The width of the image, in pixels.""" @@ -58,6 +61,14 @@ def height(self) -> int: """The height of the image, in pixels.""" return self._impl.get_height() + @property + def data(self) -> bytes: + """The raw data for the image, in PNG format. + + :returns: The raw image data in PNG format. + """ + return self._impl.get_data() + def save(self, path: str | Path): """Save image to given path. diff --git a/core/src/toga/keys.py b/core/src/toga/keys.py index d6d02f6fd8..6e78728eb8 100644 --- a/core/src/toga/keys.py +++ b/core/src/toga/keys.py @@ -2,6 +2,9 @@ class Key(Enum): + """An enumeration providing a symbolic representation for the characters on + a keyboard.""" + A = "a" B = "b" C = "c" @@ -92,25 +95,25 @@ class Key(Enum): MOD_2 = "" # OPT on macOS, ALT on Linux/Windows MOD_3 = "" # CTRL on macOS, Flag on Windows, Tux on Linux - F1 = "" - F2 = "" - F3 = "" - F4 = "" - F5 = "" - F6 = "" - F7 = "" - F8 = "" - F9 = "" - F10 = "" - F11 = "" - F12 = "" - F13 = "" - F14 = "" - F15 = "" - F16 = "" - F17 = "" - F18 = "" - F19 = "" + F1 = "" + F2 = "" + F3 = "" + F4 = "" + F5 = "" + F6 = "" + F7 = "" + F8 = "" + F9 = "" + F10 = "" + F11 = "" + F12 = "" + F13 = "" + F14 = "" + F15 = "" + F16 = "" + F17 = "" + F18 = "" + F19 = "" EJECT = "" @@ -144,7 +147,8 @@ class Key(Enum): NUMPAD_MULTIPLY = "numpad:*" NUMPAD_PLUS = "numpad:+" - def is_printable(self): + def is_printable(self) -> bool: + """Does pressing the key result in a printable character?""" return not (self.value.startswith("<") and self.value.endswith(">")) def __add__(self, other): diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index 3bc9485682..cc6a61b9ad 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -4,6 +4,9 @@ class Box(Widget): + _MIN_WIDTH = 0 + _MIN_HEIGHT = 0 + def __init__( self, id: str | None = None, diff --git a/core/src/toga/widgets/canvas.py b/core/src/toga/widgets/canvas.py index 6592862416..96caa3fec3 100644 --- a/core/src/toga/widgets/canvas.py +++ b/core/src/toga/widgets/canvas.py @@ -3,7 +3,7 @@ import warnings from abc import ABC, abstractmethod from contextlib import contextmanager -from math import pi +from math import cos, pi, sin, tan from typing import Protocol from travertino.colors import Color @@ -1181,6 +1181,9 @@ def __call__(self, widget: Canvas, width: int, height: int, **kwargs): class Canvas(Widget): + _MIN_WIDTH = 0 + _MIN_HEIGHT = 0 + def __init__( self, id=None, @@ -1628,3 +1631,78 @@ def stroke( DeprecationWarning, ) return self.Stroke(color=color, line_width=line_width, line_dash=line_dash) + + +def sweepangle(startangle, endangle, anticlockwise): + """Returns an arc length in the range [-2 * pi, 2 * pi], where positive numbers are + clockwise. Based on the "ellipse method steps" in the HTML spec.""" + + if anticlockwise: + if endangle - startangle <= -2 * pi: + return -2 * pi + else: + if endangle - startangle >= 2 * pi: + return 2 * pi + + startangle %= 2 * pi + endangle %= 2 * pi + sweepangle = endangle - startangle + if anticlockwise: + if sweepangle > 0: + sweepangle -= 2 * pi + else: + if sweepangle < 0: + sweepangle += 2 * pi + + return sweepangle + + +# Based on https://stackoverflow.com/a/30279817 +def arc_to_bezier(sweepangle): + """Approximates an arc of a unit circle as a sequence of Bezier segments. + + :param sweepangle: Length of the arc in radians, where positive numbers are + clockwise. + :returns: [(1, 0), (cp1x, cp1y), (cp2x, cp2y), (x, y), ...], where each group of 3 + points has the same meaning as in the bezier_curve_to method, and there are + between 1 and 4 groups.""" + + matrices = [ + [1, 0, 0, 1], # 0 degrees + [0, -1, 1, 0], # 90 + [-1, 0, 0, -1], # 180 + [0, 1, -1, 0], # 270 + ] + + if sweepangle < 0: # Anticlockwise + sweepangle *= -1 + for matrix in matrices: + matrix[2] *= -1 + matrix[3] *= -1 + + result = [(1.0, 0.0)] + for matrix in matrices: + if sweepangle < 0: + break + + phi = min(sweepangle, pi / 2) + k = 4 / 3 * tan(phi / 4) + result += [ + transform(x, y, matrix) + for x, y in [ + (1, k), + (cos(phi) + k * sin(phi), sin(phi) - k * cos(phi)), + (cos(phi), sin(phi)), + ] + ] + + sweepangle -= pi / 2 + + return result + + +def transform(x, y, matrix): + return ( + x * matrix[0] + y * matrix[1], + x * matrix[2] + y * matrix[3], + ) diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 2ffabc74c6..a6feae80c9 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -109,7 +109,7 @@ def items(self, items): # Restore the original change handler and trigger it. self._on_change = orig_on_change - self.on_change(None) + self.on_change() self.refresh() diff --git a/core/src/toga/widgets/slider.py b/core/src/toga/widgets/slider.py index 8dc9e331ae..28d129af8d 100644 --- a/core/src/toga/widgets/slider.py +++ b/core/src/toga/widgets/slider.py @@ -98,7 +98,7 @@ def _programmatic_change(self): self._on_change = on_change if self.value != old_value: - on_change(None) + on_change() @property def value(self) -> float: @@ -404,7 +404,7 @@ def set_tick_count(self, tick_count): def on_change(self): span = self.max - self.min self.value = self.min + (self.get_int_value() / self.get_int_max() * span) - self.interface.on_change(None) + self.interface.on_change() @abstractmethod def get_int_value(self): diff --git a/core/src/toga/window.py b/core/src/toga/window.py index f223ee31f5..227bbb7bb4 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -2,11 +2,13 @@ import warnings from builtins import id as identifier +from collections.abc import Mapping, MutableSet from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload -from toga.command import CommandSet +from toga.command import Command, CommandSet from toga.handlers import AsyncResult, wrapped_handler +from toga.images import Image from toga.platform import get_platform_factory from toga.widgets.base import WidgetRegistry @@ -110,7 +112,7 @@ def __init__( # Needs to be a late import to avoid circular dependencies. from toga import App - self.widgets = WidgetRegistry() + self._widgets = WidgetRegistry() self._id = str(id if id else identifier(self)) self._impl = None @@ -130,10 +132,15 @@ def __init__( size=size, ) + # Add the window to the app self._app = None - App.app.windows += self + if App.app is None: + raise RuntimeError("Cannot create a Window before creating an App") + App.app.windows.add(self) + + # Create a toolbar that is linked to the app + self._toolbar = CommandSet(on_change=self._impl.create_toolbar, app=self._app) - self._toolbar = CommandSet(widget=self, on_change=self._impl.create_toolbar) self.on_close = on_close @property @@ -194,7 +201,7 @@ def minimizable(self) -> bool: return self._minimizable @property - def toolbar(self) -> CommandSet: + def toolbar(self) -> MutableSet[Command]: """Toolbar for the window.""" return self._toolbar @@ -225,6 +232,14 @@ def content(self, widget: Widget) -> None: # Update the geometry of the widget widget.refresh() + @property + def widgets(self) -> Mapping[str, Widget]: + """The widgets contained in the window. + + Can be used to look up widgets by ID (e.g., ``window.widgets["my_id"]``). + """ + return self._widgets + @property def size(self) -> tuple[int, int]: """Size of the window, as a tuple of ``(width, height)``, in @@ -310,10 +325,16 @@ def close(self) -> None: undefined, except for :attr:`closed` which can be used to check if the window was closed. """ - self.app.windows -= self + self.app.windows.discard(self) self._impl.close() self._closed = True + def as_image(self) -> Image: + """Render the current contents of the window as an image. + + :returns: A :class:`toga.Image` containing the window content.""" + return Image(data=self._impl.get_image_data()) + ############################################################ # Dialogs ############################################################ diff --git a/android/tests/__init__.py b/core/tests/app/__init__.py similarity index 100% rename from android/tests/__init__.py rename to core/tests/app/__init__.py diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py new file mode 100644 index 0000000000..deac2063f5 --- /dev/null +++ b/core/tests/app/test_app.py @@ -0,0 +1,623 @@ +import asyncio +import importlib.metadata +import sys +import webbrowser +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + +EXPLICIT_FULL_APP_KWARGS = dict( + formal_name="Explicit App", + app_id="org.beeware.explicit-app", + app_name="override-app", +) +EXPLICIT_MIN_APP_KWARGS = dict( + formal_name="Explicit App", + app_id="org.beeware.explicit-app", +) +APP_METADATA = { + "Formal-Name": "Test App", + "App-ID": "org.beeware.test-app", + "Name": "test-app", +} + + +@pytest.mark.parametrize( + ( + "kwargs, metadata, main_module, expected_formal_name, expected_app_id, " + "expected_app_name" + ), + [ + ########################################################################### + # Invoking as python my_app.py, or as an interactive prompt + # This causes a main package of None + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + # Explicit app properties, but implied distribution name from app_id, no + # metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "explicit-app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__=None), + "Test App", + "org.beeware.test-app", + "toga", + ), + # Explicit app properties, with metadata. Explicit values take precedence. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__=None), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + ########################################################################### + # Invoking as python -m my_app, where code is in my_app.py + # This causes a main module of "" + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + # Explicit app properties, but implied distribution name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "explicit-app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__=""), + "Test App", + "org.beeware.test-app", + "toga", + ), + # Explicit app properties, with metadata. Explicit values take precedence. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__=""), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + ########################################################################### + # Invoking as python -m my_app, where my_app is a folder with a __main__ + # This causes a main module of "my_app" + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + # Explicit app properties, but implied distribution name from __package__, no + # metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "my_app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + Mock(__package__="my_app"), + "Test App", + "org.beeware.test-app", + "my_app", + ), + # Explicit app properties, with metadata. Explicit values take precedence. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + Mock(__package__="my_app"), + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + ########################################################################### + # Invoking in a test harness, where there's no __main__ + ########################################################################### + # Explicit app properties, no metadata + ( + EXPLICIT_FULL_APP_KWARGS, + None, + None, + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + # Explicit app properties, but implied distribution name from app_id, no metadata + ( + EXPLICIT_MIN_APP_KWARGS, + None, + None, + "Explicit App", + "org.beeware.explicit-app", + "explicit-app", + ), + # No app properties, with metadata + ( + dict(), + APP_METADATA, + None, + "Test App", + "org.beeware.test-app", + "toga", + ), + # Explicit app properties, with metadata. Explicit values take precedence. + ( + EXPLICIT_FULL_APP_KWARGS, + APP_METADATA, + None, + "Explicit App", + "org.beeware.explicit-app", + "override-app", + ), + ], +) +def test_create( + monkeypatch, + kwargs, + metadata, + main_module, + expected_formal_name, + expected_app_id, + expected_app_name, +): + """A simple app can be created""" + # Monkeypatch the metadata retrieval function + if metadata: + metadata_mock = Mock(return_value=metadata) + else: + metadata_mock = Mock( + side_effect=importlib.metadata.PackageNotFoundError(expected_app_name) + ) + monkeypatch.setattr(importlib.metadata, "metadata", metadata_mock) + + # Monkeypatch the main module + if main_module is None: + try: + monkeypatch.delitem(sys.modules, "__main__") + except KeyError: + pass + else: + monkeypatch.setitem(sys.modules, "__main__", main_module) + + app = toga.App(**kwargs) + + assert app.formal_name == expected_formal_name + assert app.app_id == expected_app_id + assert app.app_name == expected_app_name + assert app.on_exit._raw is None + + metadata_mock.assert_called_once_with(expected_app_name) + + +@pytest.mark.parametrize( + "kwargs, exc_type, message", + [ + (dict(), RuntimeError, "Toga application must have a formal name"), + ( + dict(formal_name="Something"), + RuntimeError, + "Toga application must have an app ID", + ), + ( + dict(windows=()), + ValueError, + "The `windows` constructor argument of toga.App has been removed", + ), + ], +) +def test_bad_app_creation(kwargs, exc_type, message): + """Errors are raised""" + with pytest.raises(exc_type, match=message): + toga.App(**kwargs) + + +def test_app_metadata(monkeypatch): + """An app can load metadata from the .dist-info file""" + monkeypatch.setattr( + importlib.metadata, + "metadata", + Mock( + return_value={ + "Formal-Name": "Metadata Name", + "Name": "metadata", + "App-ID": "org.beeware.metadata", + "Author": "Jane Developer", + "Version": "1.2.3", + "Home-page": "https://example.com/test-app", + "Summary": "A test app", + } + ), + ) + + # We can't use the app fixture here, because we need the metadata to be loaded as + # part of app construction. + app = toga.App( + formal_name="Test App", + app_id="org.example.test-app", + ) + + assert app.author == "Jane Developer" + assert app.version == "1.2.3" + assert app.home_page == "https://example.com/test-app" + assert app.description == "A test app" + + +def test_explicit_app_metadata(monkeypatch): + """App metadata can be provided explicitly, overriding module-level metadata""" + monkeypatch.setattr( + importlib.metadata, + "metadata", + Mock( + return_value={ + "Formal-Name": "Metadata Name", + "Name": "metadata", + "App-ID": "org.beeware.metadata", + "Author": "Alice Metadata", + "Version": "2.3.4", + "Home-page": "https://example.com/metadata", + "Summary": "Metadata description of app", + } + ), + ) + + on_exit_handler = Mock() + + app = toga.App( + formal_name="Test App", + app_id="org.example.test-app", + author="Jane Developer", + version="1.2.3", + home_page="https://example.com/test-app", + description="A test app", + on_exit=on_exit_handler, + ) + + assert app.author == "Jane Developer" + assert app.version == "1.2.3" + assert app.home_page == "https://example.com/test-app" + assert app.description == "A test app" + + assert app.on_exit._raw == on_exit_handler + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon_construction(construct): + """The app icon can be set during construction""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + icon=icon, + ) + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == Path("path/to/icon") + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon(app, construct): + """The app icon can be changed""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + # Default icon matches distribution name + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == Path("resources/test-app") + + # Change icon + app.icon = icon + assert isinstance(app.icon, toga.Icon) + assert app.icon.path == Path("path/to/icon") + + +def test_current_window(app): + """The current window can be set and changed.""" + other_window = toga.Window() + + # There are two windows - the main window, plus "other" + assert len(app.windows) == 2 + assert_action_performed_with(app, "set_main_window", window=app.main_window) + + # The initial current window is the main window + assert app.current_window == app.main_window + + # Change the current window + app.current_window = other_window + assert app.current_window == other_window + assert_action_performed_with(app, "set_current_window", window=other_window) + + +def test_no_current_window(app): + """If there's no current window, current_window reflects this""" + # If all the windows are deleted, and there's no main window (e.g., if it's a document app) + # there might be no current window. + app._main_window = None + + # The current window evaluates as None + assert app.current_window is None + + +def test_full_screen(): + """The app can be put into full screen mode.""" + window1 = toga.Window() + window2 = toga.Window() + app = toga.App(formal_name="Test App", app_id="org.example.test") + + assert not app.is_full_screen + + # If we're not full screen, exiting full screen is a no-op + app.exit_full_screen() + assert_action_not_performed(app, "exit_full_screen") + + # Enter full screen with 2 windows + app.set_full_screen(window2, app.main_window) + assert app.is_full_screen + assert_action_performed_with( + app, "enter_full_screen", windows=(window2, app.main_window) + ) + + # Change the screens that are full screen + app.set_full_screen(app.main_window, window1) + assert app.is_full_screen + assert_action_performed_with( + app, "enter_full_screen", windows=(app.main_window, window1) + ) + + # Exit full screen mode + app.exit_full_screen() + assert not app.is_full_screen + assert_action_performed_with( + app, "exit_full_screen", windows=(app.main_window, window1) + ) + + +def test_set_empty_full_screen_window_list(): + """Setting the full screen window list to [] is an explicit exit""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + window1 = toga.Window() + window2 = toga.Window() + + assert not app.is_full_screen + + # Change the screens that are full screen + app.set_full_screen(window1, window2) + assert app.is_full_screen + assert_action_performed_with(app, "enter_full_screen", windows=(window1, window2)) + + # Exit full screen mode by setting no windows full screen + app.set_full_screen() + assert not app.is_full_screen + assert_action_performed_with(app, "exit_full_screen", windows=(window1, window2)) + + +def test_show_hide_cursor(app): + """The app cursor can be shown and hidden""" + app.hide_cursor() + assert_action_performed(app, "hide_cursor") + + app.show_cursor() + assert_action_performed(app, "show_cursor") + + +def test_startup_method(): + """If an app provides a startup method, it will be invoked during startup""" + startup = Mock() + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + startup=startup, + ) + + startup.assert_called_once_with(app) + + +def test_startup_subclass(): + """App can be subclassed""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow() + + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") + + # The main window will exist, and will have the app's formal name. + assert app.main_window.title == "Test App" + + +def test_startup_subclass_no_main_window(): + """If a subclassed app doesn't define a main window, an error is raised""" + + class SubclassedApp(toga.App): + def startup(self): + pass + + with pytest.raises(ValueError, match=r"Application does not have a main window."): + SubclassedApp(formal_name="Test App", app_id="org.example.test") + + +def test_about(app): + """The about dialog for the app can be shown""" + app.about() + assert_action_performed(app, "show_about_dialog") + + +def test_visit_homepage(monkeypatch): + """The app's homepage can be opened""" + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + home_page="https://example.com/test-app", + ) + open_webbrowser = Mock() + monkeypatch.setattr(webbrowser, "open", open_webbrowser) + + # The app has no homepage by default, so visit is a no-op + app.visit_homepage() + + open_webbrowser.assert_called_once_with("https://example.com/test-app") + + +def test_no_homepage(monkeypatch, app): + """If the app doesn't have a home page, visit_homepage is a no-op""" + open_webbrowser = Mock() + monkeypatch.setattr(webbrowser, "open", open_webbrowser) + + # The app has no homepage by default, so visit is a no-op + app.visit_homepage() + + open_webbrowser.assert_not_called() + + +def test_beep(app): + """The machine can go Bing!""" + app.beep() + assert_action_performed(app, "beep") + + +def test_exit_direct(app): + """An app can be exited directly""" + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Exit the app directly + app.exit() + + # App has been exited, but the exit handler has *not* been invoked. + assert_action_performed(app, "exit") + on_exit_handler.assert_not_called() + + +def test_exit_no_handler(app): + """A app without a exit handler can be exited""" + # Exit the app + app._impl.simulate_exit() + + # Window has been exitd, and is no longer in the app's list of windows. + assert_action_performed(app, "exit") + + +def test_exit_sucessful_handler(app): + """An app with a successful exit handler can be exited""" + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Close the app + app._impl.simulate_exit() + + # App has been exited + assert_action_performed(app, "exit") + on_exit_handler.assert_called_once_with(app) + + +def test_exit_rejected_handler(app): + """An app can have a exit handler that rejects the exit""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the window + app._impl.simulate_exit() + + # App has been *not* exited + assert_action_not_performed(app, "exit") + on_exit_handler.assert_called_once_with(app) + + +def test_loop(app, event_loop): + """The main thread's event loop can be accessed""" + assert isinstance(app.loop, asyncio.AbstractEventLoop) + assert app.loop is event_loop + + +def test_background_task(app): + """A background task can be queued""" + canary = Mock() + + async def background(app, **kwargs): + canary() + + app.add_background_task(background) + + # Create an async task that we can use to start the event loop for a short time. + async def waiter(): + await asyncio.sleep(0.1) + + app.loop.run_until_complete(waiter()) + + # Once the loop has executed, the background task should have executed as well. + canary.assert_called_once() + + +def test_deprecated_id(): + """The deprecated `id` constructor argument is ignored, and the property of the same + name is redirected to `app_id` + """ + id_warning = r"App.id is deprecated.* Use app_id instead" + with pytest.warns(DeprecationWarning, match=id_warning): + app = toga.App("Test App", "org.example.test", id="test_app_id") + + assert app.app_id == "org.example.test" + with pytest.warns(DeprecationWarning, match=id_warning): + assert app.id == "org.example.test" + + +def test_deprecated_name(): + """The deprecated `name` property is redirected to `formal_name`""" + name_warning = r"App.name is deprecated. Use formal_name instead" + app = toga.App("Test App", "org.example.test") + + assert app.formal_name == "Test App" + with pytest.warns(DeprecationWarning, match=name_warning): + assert app.name == "Test App" diff --git a/core/tests/app/test_documentapp.py b/core/tests/app/test_documentapp.py new file mode 100644 index 0000000000..f4c53af086 --- /dev/null +++ b/core/tests/app/test_documentapp.py @@ -0,0 +1,158 @@ +import asyncio +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga +from toga.platform import get_platform_factory +from toga_dummy.documents import Document as DummyDocument +from toga_dummy.utils import assert_action_performed + + +class ExampleDocument(toga.Document): + def __init__(self, path, app): + super().__init__(path=path, document_type="Example Document", app=app) + + def create(self): + self.main_window = toga.DocumentMainWindow(self) + + def read(self): + self.content = self.path + + +def test_create_no_cmdline(monkeypatch): + """A document app can be created with no command line.""" + monkeypatch.setattr(sys, "argv", ["app-exe"]) + + app = toga.DocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + app.main_loop() + + assert app._impl.interface == app + assert_action_performed(app, "create DocumentApp") + + assert app.document_types == {"foobar": ExampleDocument} + assert app.documents == [] + + +def test_create_with_cmdline(monkeypatch): + """If a document is specified at the command line, it is opened.""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) + + app = toga.DocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + app.main_loop() + + assert app._impl.interface == app + assert_action_performed(app, "create DocumentApp") + + assert app.document_types == {"foobar": ExampleDocument} + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + + # Document content has been read + assert app.documents[0].content == Path("/path/to/filename.foobar") + + # Document window has been created and shown + assert_action_performed(app.documents[0].main_window, "create Window") + assert_action_performed(app.documents[0].main_window, "show") + + +def test_create_with_unknown_document_type(monkeypatch): + """If the document specified at the command line is an unknown type, an exception is raised""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.unknown"]) + + with pytest.raises( + ValueError, + match=r"Don't know how to open documents of type .unknown", + ): + toga.DocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + +def test_create_no_document_type(): + """A document app must manage at least one document type.""" + with pytest.raises( + ValueError, + match=r"A document must manage at least one document type.", + ): + toga.DocumentApp("Test App", "org.beeware.document-app") + + +def test_close_single_document_app(): + """An app in single document mode closes the app when the window is closed""" + # Monkeypatch the dummy impl to use single document mode + DummyDocument.SINGLE_DOCUMENT_APP = True + + # Mock the app, but preserve the factory + app = Mock() + app.factory = get_platform_factory() + + doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) + + # Window technically was prevented from closing, but the app has been exited. + # This must be run as a co-routine. + async def _do_close(): + return await doc.handle_close(Mock()) + + assert not asyncio.get_event_loop().run_until_complete(_do_close()) + app.exit.assert_called_once_with() + + +def test_close_multiple_document_app(): + """An app in multiple document mode doesn't close when the window is closed""" + # Monkeypatch the dummy impl to use single document mode + DummyDocument.SINGLE_DOCUMENT_APP = False + + # Mock the app, but preserve the factory + app = Mock() + app.factory = get_platform_factory() + + doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) + + # Window has closed, but app has not exited. + # This must be run as a co-routine. + async def _do_close(): + return await doc.handle_close(Mock()) + + assert asyncio.get_event_loop().run_until_complete(_do_close()) + app.exit.assert_not_called() + + +@pytest.mark.parametrize("is_single_doc_app", [True, False]) +def test_no_close(monkeypatch, is_single_doc_app): + """A document can prevent itself from being closed.""" + # Monkeypatch the dummy impl to set the app mode + DummyDocument.SINGLE_DOCUMENT_APP = is_single_doc_app + + # Monkeypatch the Example document to prevent closing. + # Define this as a co-routine to simulate an implementation that called a dialog. + async def can_close(self): + return False + + ExampleDocument.can_close = can_close + + # Mock the app, but preserve the factory + app = Mock() + app.factory = get_platform_factory() + + doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) + + # Window was prevented from closing. + # This must be run as a co-routine. + async def _do_close(): + await doc.handle_close(Mock()) + + assert not asyncio.get_event_loop().run_until_complete(_do_close()) + app.exit.assert_not_called() diff --git a/core/tests/app/test_mainwindow.py b/core/tests/app/test_mainwindow.py new file mode 100644 index 0000000000..69749e9014 --- /dev/null +++ b/core/tests/app/test_mainwindow.py @@ -0,0 +1,41 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga_dummy.utils import assert_action_performed + + +def test_create(app): + "A MainWindow can be created with minimal arguments" + window = toga.MainWindow() + + assert window.app == app + assert window.content is None + + assert window._impl.interface == window + assert_action_performed(window, "create Window") + + # We can't know what the ID is, but it must be a string. + assert isinstance(window.id, str) + # Window title is the app title. + assert window.title == "Test App" + assert window.position == (100, 100) + assert window.size == (640, 480) + assert window.resizable + assert window.closable + assert window.minimizable + assert len(window.toolbar) == 0 + # No on-close handler + assert window.on_close is None + + +def test_no_close(): + "An on_close handler cannot be set on MainWindow" + window = toga.MainWindow() + + with pytest.raises( + ValueError, + match=r"Cannot set on_close handler for the main window. Use the app on_exit handler instead.", + ): + window.on_close = Mock() diff --git a/core/tests/app/test_windowset.py b/core/tests/app/test_windowset.py new file mode 100644 index 0000000000..fe0e771cd6 --- /dev/null +++ b/core/tests/app/test_windowset.py @@ -0,0 +1,89 @@ +import pytest + +import toga +from toga.app import WindowSet + + +@pytest.fixture +def window1(app): + return toga.Window(title="Window 1") + + +@pytest.fixture +def window2(app): + return toga.Window(title="Window 2") + + +def test_create(app): + """An empty windowset can be created.""" + windowset = WindowSet(app) + + assert windowset.app == app + assert len(windowset) == 0 + + +def test_add_discard(app, window1, window2): + """An item can be added to a windowset""" + # The windowset has 3 windows - the main window, plus 2 extras + assert len(app.windows) == 3 + + # Check the iterator works + assert set(iter(app.windows)) == {app.main_window, window1, window2} + + with pytest.raises( + TypeError, + match=r"Can only add objects of type toga.Window", + ): + app.windows.add(object()) + + # Explicitly re-add a window that is already in the windowset + app.windows.add(window2) + assert len(app.windows) == 3 + assert window2 in app.windows + assert window2.app == app + + # Explicitly discard a window that is in the windowset + app.windows.discard(window2) + assert window2 not in app.windows + + # Duplicate discard - it's no longer a member + with pytest.raises( + ValueError, + match=r" is not part of this app", + ): + app.windows.discard(window2) + + with pytest.raises( + TypeError, + match=r"Can only discard objects of type toga.Window", + ): + app.windows.discard(object()) + + +def test_iadd_isub(app, window1, window2): + """The deprecated += and -= operators are no-ops""" + # The windowset has 3 windows - the main window, plus 2 extras + assert window2 in app.windows + assert len(app.windows) == 3 + + with pytest.warns( + DeprecationWarning, + match=r"Windows are automatically associated with the app; \+= is not required", + ): + app.windows += window2 + + assert window2 in app.windows + assert len(app.windows) == 3 + + with pytest.warns( + DeprecationWarning, + match=r"Windows are automatically removed from the app; -= is not required", + ): + app.windows -= window2 + + # -= is a no-op. + assert window2 in app.windows + assert len(app.windows) == 3 + + with pytest.raises(AttributeError, match=r"can't set attribute 'windows'"): + app.windows = None diff --git a/core/tests/command/conftest.py b/core/tests/command/conftest.py new file mode 100644 index 0000000000..de127dd3b3 --- /dev/null +++ b/core/tests/command/conftest.py @@ -0,0 +1,28 @@ +import pytest + +import toga + + +@pytest.fixture +def parent_group_1(): + return toga.Group("P", order=1) + + +@pytest.fixture +def child_group_1(parent_group_1): + return toga.Group("C", order=2, parent=parent_group_1) + + +@pytest.fixture +def child_group_2(parent_group_1): + return toga.Group("B", order=4, parent=parent_group_1) + + +@pytest.fixture +def parent_group_2(): + return toga.Group("O", order=2) + + +@pytest.fixture +def child_group_3(parent_group_2): + return toga.Group("A", order=2, parent=parent_group_2) diff --git a/core/tests/command/constants.py b/core/tests/command/constants.py deleted file mode 100644 index e4cf81958b..0000000000 --- a/core/tests/command/constants.py +++ /dev/null @@ -1,38 +0,0 @@ -import toga - -PARENT_GROUP1 = toga.Group("P", 1) -CHILD_GROUP1 = toga.Group("C", order=2, parent=PARENT_GROUP1) -CHILD_GROUP2 = toga.Group("B", order=4, parent=PARENT_GROUP1) -PARENT_GROUP2 = toga.Group("O", 2) -CHILD_GROUP3 = toga.Group("A", 2, parent=PARENT_GROUP2) - -A = toga.Command(None, "A", group=PARENT_GROUP2, order=1) -S = toga.Command(None, "S", group=PARENT_GROUP1, order=5) -T = toga.Command(None, "T", group=CHILD_GROUP2, order=2) -U = toga.Command(None, "U", group=CHILD_GROUP2, order=1) -V = toga.Command(None, "V", group=PARENT_GROUP1, order=3) -B = toga.Command(None, "B", group=CHILD_GROUP1, section=2, order=1) -W = toga.Command(None, "W", group=CHILD_GROUP1, order=4) -X = toga.Command(None, "X", group=CHILD_GROUP1, order=2) -Y = toga.Command(None, "Y", group=CHILD_GROUP1, order=1) -Z = toga.Command(None, "Z", group=PARENT_GROUP1, order=1) - -COMMANDS_IN_ORDER = [Z, Y, X, W, B, V, U, T, S, A] -COMMANDS_IN_SET = [ - Z, - toga.GROUP_BREAK, - Y, - X, - W, - toga.SECTION_BREAK, - B, - toga.GROUP_BREAK, - V, - toga.GROUP_BREAK, - U, - T, - toga.GROUP_BREAK, - S, - toga.GROUP_BREAK, - A, -] diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 0b43d2e30b..5eb032a977 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -1,147 +1,193 @@ from pathlib import Path +from unittest.mock import Mock -from tests.command.constants import COMMANDS_IN_ORDER, PARENT_GROUP1 -from tests.utils import order_test +import pytest import toga -from toga_dummy.utils import TestCase - - -class TestCommand(TestCase): - def setUp(self): - super().setUp() - # We need to define a test app to instantiate paths. - self.app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - ) - - def test_command_init_defaults(self): - cmd = toga.Command(lambda x: print("Hello World"), "test") - self.assertEqual(cmd.text, "test") - self.assertEqual(cmd.shortcut, None) - self.assertEqual(cmd.tooltip, None) - self.assertEqual(cmd.icon, None) - self.assertEqual(cmd.group, toga.Group.COMMANDS) - self.assertEqual(cmd.section, 0) - self.assertEqual(cmd.order, 0) - self.assertTrue(cmd._enabled) - - def test_command_init_kargs(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - self.assertEqual(cmd.text, "test") - self.assertEqual(cmd.shortcut, "t") - self.assertEqual(cmd.tooltip, "test command") - self.assertEqual(cmd.icon.path, Path("icons/none.png")) - self.assertEqual(cmd.group, grp) - self.assertEqual(cmd.section, 1) - self.assertEqual(cmd.order, 1) - self.assertTrue(cmd._enabled) - self.assertTrue(cmd.enabled) - cmd.enabled = False - self.assertFalse(cmd._enabled) - self.assertFalse(cmd.enabled) - - def test_command_bind(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - - with self.assertWarns(DeprecationWarning): - return_val = cmd.bind() - self.assertEqual(return_val, cmd._impl) - - def test_command_enabler(self): - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - - cmd.enabled = False - self.assertActionPerformedWith(cmd, "set enabled", value=False) - cmd.enabled = True - self.assertActionPerformedWith(cmd, "set enabled", value=True) - - def test_command_repr(self): - self.assertEqual( - repr(toga.Command(None, "A", group=PARENT_GROUP1, order=1, section=4)), - " section=4 order=1>", - ) - - test_order_commands_by_text = order_test( - toga.Command(None, "A"), toga.Command(None, "B") +from toga.command import Break +from toga_dummy.utils import assert_action_performed_with + + +def assert_order(*items): + for i in range(0, len(items) - 1): + for j in range(i + 1, len(items)): + assert items[i] < items[j] + assert items[j] > items[i] + + # For good measure; check comparisons with other types + assert not items[i] < None + assert not items[i] < 42 + assert not items[i] > None + assert not items[i] > 42 + + +def test_break(): + """A break can be created""" + + example_break = Break("Example") + assert repr(example_break) == "" + + +def test_create(): + """A command can be created with defaults""" + cmd = toga.Command(None, "Test command") + + assert cmd.text == "Test command" + assert cmd.shortcut is None + assert cmd.tooltip is None + assert cmd.group == toga.Group.COMMANDS + assert cmd.section == 0 + assert cmd.order == 0 + assert cmd.action._raw is None + + assert ( + repr(cmd) + == " section=0 order=0>" + ) + + +def test_create_explicit(app): + """A command can be created with explicit arguments""" + grp = toga.Group("Test group", order=10) + + handler = Mock() + cmd = toga.Command( + handler, + text="Test command", + tooltip="This is a test command", + shortcut="t", + group=grp, + section=3, + order=4, + ) + + assert cmd.text == "Test command" + assert cmd.shortcut == "t" + assert cmd.tooltip == "This is a test command" + assert cmd.group == grp + assert cmd.section == 3 + assert cmd.order == 4 + + assert cmd.action._raw == handler + + assert ( + repr(cmd) + == " section=3 order=4>" + ) + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon_construction(app, construct): + """The command icon can be set during construction""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + cmd = toga.Command(None, "Test command", icon=icon) + assert isinstance(cmd.icon, toga.Icon) + assert cmd.icon.path == Path("path/to/icon") + + +@pytest.mark.parametrize("construct", [True, False]) +def test_icon(app, construct): + """The command icon can be changed""" + if construct: + icon = toga.Icon("path/to/icon") + else: + icon = "path/to/icon" + + cmd = toga.Command(None, "Test command") + + # No icon by default + assert cmd.icon is None + + # Change icon + cmd.icon = icon + + # Icon path matches + assert isinstance(cmd.icon, toga.Icon) + assert cmd.icon.path == Path("path/to/icon") + + +@pytest.mark.parametrize( + "action, enabled, initial_state", + [ + (Mock(), True, True), + (Mock(), False, False), + (None, True, False), + (None, False, False), + ], +) +def test_enable(action, enabled, initial_state): + cmd = toga.Command(action, text="Test command", enabled=enabled) + + assert cmd.enabled is initial_state + + # Set enabled; triggers an implementation response + cmd.enabled = True + assert_action_performed_with(cmd, "set enabled", value=True) + + # Disable; triggers an implementation response + cmd.enabled = False + assert_action_performed_with(cmd, "set enabled", value=False) + + # Disable again; triggers an implementation response + cmd.enabled = False + assert_action_performed_with(cmd, "set enabled", value=False) + + # Set enabled; triggers an implementation response + cmd.enabled = True + assert_action_performed_with(cmd, "set enabled", value=True) + + +def test_order_by_text(): + """Commands are ordered by text when group, section and order match""" + assert_order( + toga.Command(None, "A"), + toga.Command(None, "B"), ) - test_order_commands_by_number = order_test( - toga.Command(None, "B", order=1), toga.Command(None, "A", order=2) + + +def test_order_by_number(): + """Commands are ordered by number when group and section match""" + assert_order( + toga.Command(None, "B", order=1), + toga.Command(None, "A", order=2), ) - test_order_commands_by_section = order_test( - toga.Command(None, "B", group=PARENT_GROUP1, section=1, order=2), - toga.Command(None, "A", group=PARENT_GROUP1, section=2, order=1), + + +def test_order_by_section(parent_group_1): + """Section ordering takes priority over order and text""" + assert_order( + toga.Command(None, "B", group=parent_group_1, section=1, order=2), + toga.Command(None, "A", group=parent_group_1, section=2, order=1), + ) + + +def test_order_by_groups(parent_group_1, parent_group_2, child_group_1, child_group_2): + """Commands are ordered by group over""" + + command_z = toga.Command(None, "Z", group=parent_group_1, order=1) + command_y = toga.Command(None, "Y", group=child_group_1, order=1) + command_x = toga.Command(None, "X", group=child_group_1, order=2) + command_w = toga.Command(None, "W", group=child_group_1, order=4) + command_b = toga.Command(None, "B", group=child_group_1, section=2, order=1) + command_v = toga.Command(None, "V", group=parent_group_1, order=3) + command_u = toga.Command(None, "U", group=child_group_2, order=1) + command_t = toga.Command(None, "T", group=child_group_2, order=2) + command_s = toga.Command(None, "S", group=parent_group_1, order=5) + command_a = toga.Command(None, "A", group=parent_group_2, order=1) + + assert_order( + command_z, + command_y, + command_x, + command_w, + command_b, + command_v, + command_u, + command_t, + command_s, + command_a, ) - test_order_commands_by_groups = order_test(*COMMANDS_IN_ORDER) - - def test_missing_argument(self): - "If the no text is provided for the group, an error is raised" - # This test is only required as part of the backwards compatibility - # path renaming label->text; when that shim is removed, this teset - # validates default Python behavior - with self.assertRaises(TypeError): - toga.Command(lambda x: print("Hello World")) - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - - def test_label_deprecated(self): - cmd = toga.Command(lambda x: print("Hello World"), label="test") - new_text = "New Text" - with self.assertWarns(DeprecationWarning): - cmd.label = new_text - with self.assertWarns(DeprecationWarning): - self.assertEqual(cmd.label, new_text) - self.assertEqual(cmd.text, new_text) - - def test_init_with_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - toga.Command( - lambda x: print("Hello World"), - label="test", - ) - - # can't specify both label *and* text - with self.assertRaises(ValueError): - toga.Command( - lambda x: print("Hello World"), - label="test", - text="test", - ) - - ###################################################################### - # End backwards compatibility. - ###################################################################### diff --git a/core/tests/command/test_commands_group.py b/core/tests/command/test_commands_group.py deleted file mode 100644 index 2ab00fe2d8..0000000000 --- a/core/tests/command/test_commands_group.py +++ /dev/null @@ -1,183 +0,0 @@ -import unittest - -from tests.command.constants import PARENT_GROUP1, PARENT_GROUP2 -from tests.utils import order_test - -import toga - - -class TestCommandsGroup(unittest.TestCase): - def test_group_init_no_order(self): - grp = toga.Group("text") - self.assertEqual(grp.text, "text") - self.assertEqual(grp.order, 0) - - def test_group_init_with_order(self): - grp = toga.Group("text", 2) - self.assertEqual(grp.text, "text") - self.assertEqual(grp.order, 2) - - def test_hashable(self): - grp1 = toga.Group("text 1") - grp2 = toga.Group("text 2") - - # The hash is based on the full path, not just the text. - # This allows texts to be non-unique, as long as they're in - # different groups - grp1_child = toga.Group("text", parent=grp1) - grp2_child = toga.Group("text", parent=grp2) - - # Insert the groups as keys in a dict. This is - # only possible if Group is hashable. - groups = { - grp1: "First", - grp2: "Second", - grp1_child: "Child of 1", - grp2_child: "Child of 2", - } - - self.assertEqual(groups[grp1], "First") - self.assertEqual(groups[grp2], "Second") - self.assertEqual(groups[grp1_child], "Child of 1") - self.assertEqual(groups[grp2_child], "Child of 2") - - def test_group_eq(self): - self.assertEqual(toga.Group("A"), toga.Group("A")) - self.assertEqual(toga.Group("A", 1), toga.Group("A", 1)) - self.assertNotEqual(toga.Group("A"), toga.Group("B")) - self.assertNotEqual(toga.Group("A"), None) - self.assertNotEqual(toga.Group("A", 1), toga.Group("A", 2)) - self.assertNotEqual(toga.Group("A", 1), toga.Group("B", 1)) - - def test_set_parent_in_constructor(self): - parent = toga.Group("parent") - child = toga.Group("child", parent=parent) - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_set_parent_in_property(self): - parent = toga.Group("parent") - child = toga.Group("child") - child.parent = parent - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_change_parent(self): - parent1 = toga.Group("parent1") - parent2 = toga.Group("parent2") - child = toga.Group("child", parent=parent1) - child.parent = parent2 - self.assert_parent_and_root(parent1, None, parent1) - self.assert_parent_and_root(parent2, None, parent2) - self.assert_parent_and_root(child, parent2, parent2) - - def test_is_parent_and_is_child_of(self): - top = toga.Group("C") - middle = toga.Group("B", parent=top) - bottom = toga.Group("A", parent=middle) - groups = [top, middle, bottom] - for i in range(0, 2): - for j in range(i + 1, 3): - self.assertTrue(groups[i].is_parent_of(groups[j])) - self.assertTrue(groups[j].is_child_of(groups[i])) - - def test_is_parent_of_none(self): - group = toga.Group("A") - self.assertFalse(group.is_parent_of(None)) - - def test_root(self): - top = toga.Group("C") - middle = toga.Group("B", parent=top) - bottom = toga.Group("A", parent=middle) - self.assertEqual(top.root, top) - self.assertEqual(middle.root, top) - self.assertEqual(top.root, top) - self.assertEqual(bottom.root, top) - - test_order_by_number = order_test(toga.Group("A", 1), toga.Group("A", 2)) - test_order_ignore_text = order_test(toga.Group("B", 1), toga.Group("A", 2)) - test_order_by_text = order_test(toga.Group("A"), toga.Group("B")) - test_order_by_groups = order_test( - PARENT_GROUP1, - toga.Group("C", parent=PARENT_GROUP1), - toga.Group("D", parent=PARENT_GROUP1), - toga.Group("A", parent=PARENT_GROUP1, section=2), - PARENT_GROUP2, - toga.Group("B", parent=PARENT_GROUP2), - ) - - def test_group_repr(self): - parent = toga.Group("P") - self.assertEqual(repr(toga.Group("A")), "") - self.assertEqual( - repr(toga.Group("A", parent=parent)), "" - ) - - def test_set_section_without_parent(self): - with self.assertRaises(ValueError): - toga.Group("A", section=2) - - def test_set_parent_causes_cyclic_parenting(self): - parent = toga.Group("P") - child = toga.Group("C", parent=parent) - with self.assertRaises(ValueError): - parent.parent = child - self.assert_parent_and_root(parent, None, parent) - self.assert_parent_and_root(child, parent, parent) - - def test_cannot_set_self_as_parent(self): - group = toga.Group("P") - with self.assertRaises(ValueError): - group.parent = group - self.assert_parent_and_root(group, None, group) - - def test_cannot_set_child_to_be_a_parent_of_its_grandparent(self): - grandparent = toga.Group("G") - parent = toga.Group("P", parent=grandparent) - child = toga.Group("C", parent=parent) - with self.assertRaises(ValueError): - grandparent.parent = child - self.assert_parent_and_root(grandparent, None, grandparent) - self.assert_parent_and_root(parent, grandparent, grandparent) - self.assert_parent_and_root(child, parent, grandparent) - - def assert_parent_and_root(self, group, parent, root): - self.assertEqual(group.parent, parent) - self.assertEqual(group.root, root) - - def test_missing_argument(self): - "If the no text is provided for the group, an error is raised" - # This test is only required as part of the backwards compatibility - # path renaming label->text; when that shim is removed, this teset - # validates default Python behavior - with self.assertRaises(TypeError): - toga.Group() - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - - def test_label_deprecated(self): - grp = toga.Group(label="text") - new_text = "New Text" - with self.assertWarns(DeprecationWarning): - grp.label = new_text - with self.assertWarns(DeprecationWarning): - self.assertEqual(grp.label, new_text) - self.assertEqual(grp.text, new_text) - - def test_init_with_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - toga.Group(label="test") - - # can't specify both label *and* text - with self.assertRaises(ValueError): - toga.Group( - label="test", - text="test", - ) - - ###################################################################### - # End backwards compatibility. - ###################################################################### diff --git a/core/tests/command/test_commands_set.py b/core/tests/command/test_commands_set.py deleted file mode 100644 index 75139541b5..0000000000 --- a/core/tests/command/test_commands_set.py +++ /dev/null @@ -1,51 +0,0 @@ -import random -import unittest -from unittest.mock import Mock - -from tests.command.constants import COMMANDS_IN_ORDER, COMMANDS_IN_SET - -import toga - - -class TestCommandSet(unittest.TestCase): - changed = False - - def _changed(self): - self.changed = True - - def test_cmdset_init(self): - test_widget = toga.Widget() - cs = toga.CommandSet(test_widget) - self.assertEqual(cs._commands, set()) - self.assertEqual(cs.on_change, None) - - def test_cmdset_add(self): - self.changed = False - test_widget = toga.Widget() - cs = toga.CommandSet(widget=test_widget, on_change=self._changed) - grp = toga.Group("Test group", order=10) - cmd = toga.Command( - lambda x: print("Hello World"), - text="test", - tooltip="test command", - shortcut="t", - icon="icons/none.png", - group=grp, - section=1, - order=1, - ) - cs.add(cmd) - - self.assertTrue(self.changed) - self.assertIsNotNone(cmd._impl) - - def test_cmdset_iter_in_order(self): - test_widget = toga.Widget() - test_widget._impl = Mock() - test_widget.app = Mock() - cs = toga.CommandSet(widget=test_widget) - commands = list(COMMANDS_IN_ORDER) - random.shuffle(commands) - cs.add(*commands) - test_widget.app.commands.add.assert_called_once_with(*commands) - self.assertEqual(list(cs), COMMANDS_IN_SET) diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py new file mode 100644 index 0000000000..cfd2fa2970 --- /dev/null +++ b/core/tests/command/test_commandset.py @@ -0,0 +1,174 @@ +import random +from unittest.mock import Mock + +import pytest + +import toga +from toga.command import GROUP_BREAK, SECTION_BREAK, CommandSet + + +def test_create(): + """A CommandSet can be created with defaults""" + cs = CommandSet() + + assert list(cs) == [] + assert cs.on_change is None + + +def test_create_with_values(): + """A CommandSet can be created with values""" + change_handler = Mock() + cs = CommandSet(on_change=change_handler) + + assert list(cs) == [] + assert cs.on_change == change_handler + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add_clear(app, change_handler): + """Commands can be added and removed from a commandset""" + # Put some commands into the app + cmd_a = toga.Command(None, text="App command a") + cmd_b = toga.Command(None, text="App command b", order=10) + app.commands.add(cmd_a, cmd_b) + assert list(app.commands) == [cmd_a, cmd_b] + + # Create a standalone command set and add some commands + cs = CommandSet(on_change=change_handler) + cmd1a = toga.Command(None, text="Test command 1a", order=3) + cmd1b = toga.Command(None, text="Test command 1b", order=1) + cs.add(cmd1a, cmd1b) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the order is the opposite to the insertion order. + assert list(cs) == [cmd1b, cmd1a] + + # New Commands aren't known to the app + assert list(app.commands) == [cmd_a, cmd_b] + + # Clear the command set + cs.clear() + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set no commands. + assert list(cs) == [] + + # App command set hasn't changed. + assert list(app.commands) == [cmd_a, cmd_b] + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add_clear_with_app(app, change_handler): + """Commands can be added and removed from a commandset that is linked to an app""" + # Put some commands into the app + cmd_a = toga.Command(None, text="App command a") + cmd_b = toga.Command(None, text="App command b", order=10) + app.commands.add(cmd_a, cmd_b) + assert list(app.commands) == [cmd_a, cmd_b] + + # Create a command set that is linked to the app and add some commands + cs = CommandSet(on_change=change_handler, app=app) + cmd1a = toga.Command(None, text="Test command 1a", order=3) + cmd1b = toga.Command(None, text="Test command 1b", order=1) + cs.add(cmd1a, cmd1b) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the order is the opposite to the insertion order. + assert list(cs) == [cmd1b, cmd1a] + + # New Commands are known to the app + assert list(app.commands) == [cmd_a, cmd1b, cmd1a, cmd_b] + + # Add another command to the commandset + cmd2 = toga.Command(None, text="Test command 2", order=2) + cs.add(cmd2) + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set has commands, and the output is ordered. + assert list(cs) == [cmd1b, cmd2, cmd1a] + + # App also knows about the command + assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + + # Clear the command set + cs.clear() + + # Change handler was called once. + if change_handler: + change_handler.assert_called_once() + change_handler.reset_mock() + + # Command set no commands. + assert list(cs) == [] + + # App command set hasn't changed. + assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + + +def test_ordering(parent_group_1, parent_group_2, child_group_1, child_group_2): + """Ordering of groups, breaks and commands is preserved""" + + command_a = toga.Command(None, "A", group=parent_group_2, order=1) + command_b = toga.Command(None, "B", group=child_group_1, section=2, order=1) + command_s = toga.Command(None, "S", group=parent_group_1, order=5) + command_t = toga.Command(None, "T", group=child_group_2, order=2) + command_u = toga.Command(None, "U", group=child_group_2, order=1) + command_v = toga.Command(None, "V", group=parent_group_1, order=3) + command_w = toga.Command(None, "W", group=child_group_1, order=4) + command_x = toga.Command(None, "X", group=child_group_1, order=2) + command_y = toga.Command(None, "Y", group=child_group_1, order=1) + command_z = toga.Command(None, "Z", group=parent_group_1, order=1) + + commands = [ + command_z, + command_y, + command_x, + command_w, + command_b, + command_v, + command_u, + command_t, + command_s, + command_a, + ] + + # Do this a couple of times to make sure insertion order doesn't matter + for _ in range(0, 10): + random.shuffle(commands) + cs = CommandSet() + cs.add(*commands) + + assert list(cs) == [ + command_z, + GROUP_BREAK, + command_y, + command_x, + command_w, + SECTION_BREAK, + command_b, + GROUP_BREAK, + command_v, + GROUP_BREAK, + command_u, + command_t, + GROUP_BREAK, + command_s, + GROUP_BREAK, + command_a, + ] diff --git a/core/tests/command/test_group.py b/core/tests/command/test_group.py new file mode 100644 index 0000000000..e4519aa8ae --- /dev/null +++ b/core/tests/command/test_group.py @@ -0,0 +1,290 @@ +import pytest + +import toga + +from .test_command import assert_order + + +def test_create(): + """A group can be created with defaults""" + grp = toga.Group("Group name") + assert grp.text == "Group name" + assert grp.order == 0 + + assert repr(grp) == "" + + +def test_create_with_params(): + """A fully specified group can be created""" + parent = toga.Group("Parent name") + grp = toga.Group("Group name", order=2, section=3, parent=parent) + + assert grp.text == "Group name" + assert grp.order == 2 + assert grp.section == 3 + assert grp.parent == parent + + assert ( + repr(grp) + == " section=3>" + ) + + +def test_create_section_without_parent(): + """A group cannot be created with a section but no parent.""" + with pytest.raises( + ValueError, + match=r"Section cannot be set without parent group", + ): + toga.Group("Group name", order=2, section=3) + + +def test_hashable(): + """Groups are hashable.""" + grp1 = toga.Group("text 1") + grp2 = toga.Group("text 2") + + # The hash is based on the full path, not just the text. + # This allows texts to be non-unique, as long as they're in + # different groups + grp1_child = toga.Group("text", parent=grp1) + grp2_child = toga.Group("text", parent=grp2) + + # Insert the groups as keys in a dict. This is + # only possible if Group is hashable. + groups = { + grp1: "First", + grp2: "Second", + grp1_child: "Child of 1", + grp2_child: "Child of 2", + } + + assert groups[grp1] == "First" + assert groups[grp2] == "Second" + assert groups[grp1_child] == "Child of 1" + assert groups[grp2_child] == "Child of 2" + + +def test_group_eq(): + """Groups can be compared for equality.""" + group_a = toga.Group("A") + group_b = toga.Group("B") + group_a1 = toga.Group("A", order=1) + # Assign None to variable to trick flake8 into not giving an E711 + other = None + + # Same instance is equal + assert group_a == group_a + assert group_a1 == group_a1 + + # Same values are equal + assert group_a == toga.Group("A") + assert group_a1 == toga.Group("A", order=1) + + # Different values are not equal + assert group_a != group_b + assert group_a != other + + # Partially same values are not equal + assert group_a1 != group_a + assert group_a1 != toga.Group("B", order=1) + assert group_a1 != toga.Group("A", order=2) + + +def test_parent_creation(): + """Parents can be assigned at creation""" + group_a = toga.Group("A") + group_b = toga.Group("B", parent=group_a) + group_c = toga.Group("C", parent=group_b) + + # None checks + assert not group_a.is_parent_of(None) + assert not group_a.is_child_of(None) + + # Parent relationships + assert group_a.is_parent_of(group_b) + assert group_b.is_parent_of(group_c) + assert group_a.is_parent_of(group_c) # grandparent + + # Child relationships + assert group_b.is_child_of(group_a) + assert group_c.is_child_of(group_b) + assert group_c.is_child_of(group_a) # grandchild + + # Reverse direction relationships aren't true + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent == group_a + assert group_b.root == group_a + + assert group_c.parent == group_b + assert group_c.root == group_a + + +def test_parent_assignment(): + """Parents can be assigned at runtime""" + # Eventually, we'll end up with A->B->C, D. + group_a = toga.Group("A") + group_b = toga.Group("B") + group_c = toga.Group("C") + group_d = toga.Group("D") + + assert not group_a.is_parent_of(group_b) + assert not group_b.is_parent_of(group_c) + assert not group_a.is_parent_of(group_c) + assert not group_b.is_parent_of(group_d) + assert not group_a.is_parent_of(group_d) + + assert not group_b.is_child_of(group_a) + assert not group_c.is_child_of(group_b) + assert not group_c.is_child_of(group_a) + assert not group_d.is_child_of(group_b) + assert not group_d.is_child_of(group_a) + + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + assert not group_b.is_child_of(group_d) + assert not group_a.is_child_of(group_d) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + assert not group_d.is_parent_of(group_b) + assert not group_d.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent is None + assert group_b.root == group_b + + assert group_c.parent is None + assert group_c.root == group_c + + # Assign parents. + # C is assigned to B *before* B is assigned to A. + # D is assigned to B *after* B is assigned to A. + # This ensures that root isn't preserved in an intermediate state + group_c.parent = group_b + group_b.parent = group_a + group_d.parent = group_b + + assert group_a.is_parent_of(group_b) + assert group_b.is_parent_of(group_c) + assert group_a.is_parent_of(group_c) # grandparent + assert group_b.is_parent_of(group_d) + assert group_a.is_parent_of(group_d) # grandparent + + assert group_b.is_child_of(group_a) + assert group_c.is_child_of(group_b) + assert group_c.is_child_of(group_a) # grandchild + assert group_d.is_child_of(group_b) + assert group_d.is_child_of(group_a) # grandchild + + # Reverse direction relationships aren't true + assert not group_a.is_child_of(group_b) + assert not group_b.is_child_of(group_c) + assert not group_a.is_child_of(group_c) + assert not group_b.is_child_of(group_d) + assert not group_a.is_child_of(group_d) + + assert not group_b.is_parent_of(group_a) + assert not group_c.is_parent_of(group_b) + assert not group_c.is_parent_of(group_a) + assert not group_d.is_parent_of(group_b) + assert not group_d.is_parent_of(group_a) + + assert group_a.parent is None + assert group_a.root == group_a + + assert group_b.parent == group_a + assert group_b.root == group_a + + assert group_c.parent == group_b + assert group_c.root == group_a + + assert group_d.parent == group_b + assert group_d.root == group_a + + +def test_parent_loops(): + """Parent loops are prevented can be assigned at runtime""" + group_a = toga.Group("A") + group_b = toga.Group("B", parent=group_a) + group_c = toga.Group("C", parent=group_b) + + # + with pytest.raises( + ValueError, + match=r"A group cannot be it's own parent", + ): + group_a.parent = group_a + + with pytest.raises( + ValueError, + match=r"Cannot set parent; 'A' is an ancestor of 'B'.", + ): + group_a.parent = group_b + + with pytest.raises( + ValueError, + match=r"Cannot set parent; 'A' is an ancestor of 'C'.", + ): + group_a.parent = group_c + + +def test_order_by_text(): + """Groups are ordered by text if order and section are equivalent""" + assert_order(toga.Group("A"), toga.Group("B")) + + +def test_order_by_number(): + """Groups are ordered by number""" + assert_order(toga.Group("B", order=1), toga.Group("A", order=2)) + + +def test_order_by_groups(parent_group_1, parent_group_2): + """Groups are ordered by parent, then section, then order.""" + assert_order( + parent_group_1, + toga.Group("C", parent=parent_group_1), + toga.Group("D", parent=parent_group_1), + toga.Group("AA3", parent=parent_group_1, section=2), + toga.Group("AA2", parent=parent_group_1, section=3, order=1), + toga.Group("AA1", parent=parent_group_1, section=3, order=2), + parent_group_2, + toga.Group("B", parent=parent_group_2), + ) + + +# def test_cannot_set_self_as_parent(self): +# group = toga.Group("P") +# with self.assertRaises(ValueError): +# group.parent = group +# self.assert_parent_and_root(group, None, group) + + +# def test_cannot_set_child_to_be_a_parent_of_its_grandparent(self): +# grandparent = toga.Group("G") +# parent = toga.Group("P", parent=grandparent) +# child = toga.Group("C", parent=parent) +# with self.assertRaises(ValueError): +# grandparent.parent = child +# self.assert_parent_and_root(grandparent, None, grandparent) +# self.assert_parent_and_root(parent, grandparent, grandparent) +# self.assert_parent_and_root(child, parent, grandparent) + + +# def assert_parent_and_root(self, group, parent, root): +# self.assertEqual(group.parent, parent) +# self.assertEqual(group.root, root) diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 59c769cff1..e8f8aabe89 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -1,8 +1,27 @@ +import sys + import pytest +import toga from toga_dummy.utils import EventLog @pytest.fixture(autouse=True) def reset_event_log(): EventLog.reset() + + +@pytest.fixture(autouse=True) +def clear_sys_modules(monkeypatch): + try: + # App startup is influenced by things like the state of sys.modules, and the + # presence of __main__ in particular. Pytest doesn't need __main__ to work; + # so if it exists, delete it for the purposes of each test. + monkeypatch.delitem(sys.modules, "__main__") + except KeyError: + pass + + +@pytest.fixture +def app(event_loop): + return toga.App(formal_name="Test App", app_id="org.beeware.toga.test-app") diff --git a/core/tests/test_app.py b/core/tests/test_app.py deleted file mode 100644 index d5ad87ea3b..0000000000 --- a/core/tests/test_app.py +++ /dev/null @@ -1,214 +0,0 @@ -import asyncio -from pathlib import Path -from unittest.mock import Mock - -import toga -from toga.widgets.base import WidgetRegistry -from toga_dummy.utils import TestCase - - -class AppTests(TestCase): - def setUp(self): - super().setUp() - - self.name = "Test App" - self.app_id = "org.beeware.test-app" - self.id = "dom-id" - - self.content = Mock() - self.content_id = "content-id" - self.content.id = self.content_id - - self.started = False - - def test_startup_function(app): - self.started = True - return self.content - - self.app = toga.App( - formal_name=self.name, - app_id=self.app_id, - startup=test_startup_function, - id=self.id, - ) - - def test_app_name(self): - self.assertEqual(self.app.name, self.name) - - def test_app_icon(self): - # App icon will default to a name autodetected from the running module - self.assertEqual(self.app.icon.path, Path("resources/toga")) - # This icon will be bound - self.assertIsNotNone(self.app.icon._impl) - - # Set the icon to a different resource - self.app.icon = "other.icns" - self.assertEqual(self.app.icon.path, Path("other.icns")) - - # This icon name will *not* exist. The Impl will be the DEFAULT_ICON's impl - self.assertEqual(self.app.icon._impl, toga.Icon.DEFAULT_ICON._impl) - - def test_app_app_id(self): - self.assertEqual(self.app.app_id, self.app_id) - - def test_app_id(self): - self.assertEqual(self.app.id, self.id) - - def test_widgets_registry(self): - self.assertTrue(isinstance(self.app.widgets, WidgetRegistry)) - self.assertEqual(len(self.app.widgets), 0) - - def test_app_main_loop_call_impl_main_loop(self): - self.app.main_loop() - self.assertActionPerformed(self.app, "main loop") - - def test_app_startup(self): - self.app.startup() - - self.assertTrue(self.started) - self.assertEqual(self.app.main_window.content, self.content) - self.assertEqual(self.app.main_window.app, self.app) - self.assertActionPerformed(self.app.main_window, "show") - - def test_is_full_screen(self): - self.assertFalse(self.app.is_full_screen) - - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - - self.app.set_full_screen(["window1", "window2", "window3"]) - self.assertTrue(self.app.is_full_screen) - - self.app.set_full_screen() - self.assertFalse(self.app.is_full_screen) - - def test_app_exit(self): - def exit_handler(widget): - return True - - self.app.on_exit = exit_handler - self.assertIs(self.app.on_exit._raw, exit_handler) - self.app.exit() - - self.assertActionPerformed(self.app, "exit") - - def test_full_screen(self): - # set full screen and exit full screen - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - self.app.exit_full_screen() - self.assertFalse(self.app.is_full_screen) - # set full screen and set full with no args - self.app.set_full_screen(self.app.main_window) - self.assertTrue(self.app.is_full_screen) - self.app.set_full_screen() - self.assertFalse(self.app.is_full_screen) - - def test_add_window(self): - self.assertEqual(len(self.app.windows), 0) - test_window = toga.Window() - self.assertEqual(len(self.app.windows), 1) - self.app.windows += test_window - self.assertEqual(len(self.app.windows), 1) - self.assertIs(test_window.app, self.app) - - not_a_window = "not_a_window" - with self.assertRaises(TypeError): - self.app.windows += not_a_window - - def test_remove_window(self): - test_window = toga.Window() - self.assertEqual(len(self.app.windows), 1) - self.app.windows -= test_window - self.assertEqual(len(self.app.windows), 0) - - with self.assertRaises(TypeError): - self.app.windows -= "not_a_window" - - with self.assertRaises(AttributeError): - self.app.windows -= test_window - - def test_app_contains_window(self): - test_window = toga.Window() - self.assertTrue(test_window in self.app.windows) - self.app.windows -= test_window - self.assertFalse(test_window in self.app.windows) - - def test_window_iteration(self): - test_windows = [ - toga.Window(id=1), - toga.Window(id=2), - toga.Window(id=3), - ] - for window in test_windows: - self.app.windows += window - self.assertEqual(len(self.app.windows), 3) - - for window in self.app.windows: - self.assertIn(window, test_windows) - - def test_beep(self): - self.app.beep() - self.assertActionPerformed(self.app, "beep") - - def test_add_background_task(self): - thing = Mock() - - async def test_handler(sender): - thing() - - self.app.add_background_task(test_handler) - - async def run_test(): - # Give the background task time to run. - await asyncio.sleep(0.1) - thing.assert_called_once() - - self.app._impl.loop.run_until_complete(run_test()) - - def test_override_startup(self): - class BadApp(toga.App): - "A startup method that doesn't assign main window raises an error (#760)" - - def startup(self): - # Override startup but don't create a main window - pass - - app = BadApp(app_name="bad_app", formal_name="Bad Aoo", app_id="org.beeware") - with self.assertRaisesRegex( - ValueError, - r"Application does not have a main window.", - ): - app.main_loop() - - -class DocumentAppTests(TestCase): - def setUp(self): - super().setUp() - - self.name = "Test Document App" - self.app_id = "beeware.org" - self.id = "id" - - self.content = Mock() - - self.app = toga.DocumentApp(self.name, self.app_id, id=self.id) - - def test_app_documents(self): - self.assertEqual(self.app.documents, []) - - doc = Mock() - self.app._documents.append(doc) - self.assertEqual(self.app.documents, [doc]) - - def test_override_startup(self): - mock = Mock() - - class DocApp(toga.DocumentApp): - def startup(self): - # A document app doesn't have to provide a Main Window. - mock() - - app = DocApp(app_name="docapp", formal_name="Doc App", app_id="org.beeware") - app.main_loop() - mock.assert_called_once() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py deleted file mode 100644 index 4ce4680c58..0000000000 --- a/core/tests/test_deprecated_factory.py +++ /dev/null @@ -1,46 +0,0 @@ -import toga -from toga_dummy.utils import TestCase - - -class DeprecatedFactoryTests(TestCase): - def setUp(self): - super().setUp() - self.factory = object() - self.callback = lambda x: None - - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - - def test_app(self): - with self.assertWarns(DeprecationWarning): - widget = toga.App( - formal_name="Test", app_id="org.beeware.test-app", factory=self.factory - ) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - - def test_document_app(self): - with self.assertWarns(DeprecationWarning): - widget = toga.DocumentApp( - formal_name="Test", app_id="org.beeware.test-app", factory=self.factory - ) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - - def test_command(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Command(self.callback, "Test", factory=self.factory) - with self.assertWarns(DeprecationWarning): - widget.bind(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - - def test_command_set(self): - with self.assertWarns(DeprecationWarning): - toga.CommandSet(factory=self.factory) - - ###################################################################### - # End backwards compatibility. - ###################################################################### diff --git a/core/tests/test_documents.py b/core/tests/test_documents.py index cb00665d14..a0da06a9a9 100644 --- a/core/tests/test_documents.py +++ b/core/tests/test_documents.py @@ -1,22 +1,52 @@ +from pathlib import Path + +import pytest + import toga -import toga_dummy -from toga_dummy.utils import TestCase -class DocumentTests(TestCase): - def setUp(self): - super().setUp() +class MyDoc(toga.Document): + def __init__(self, path, app): + super().__init__(path, "Dummy Document", app) + pass + + def create(self): + pass + + def read(self): + pass + + +@pytest.mark.parametrize("path", ["/path/to/doc.mydoc", Path("/path/to/doc.mydoc")]) +def test_create_document(app, path): + doc = MyDoc(path, app) - self.filename = "path/to/document.txt" - self.document_type = "path/to/document.txt" - self.document = toga.Document( - filename=self.filename, document_type=self.document_type, app=toga_dummy + assert doc.path == Path(path) + assert doc.app == app + assert doc.document_type == "Dummy Document" + + +class MyDeprecatedDoc(toga.Document): + def __init__(self, filename, app): + super().__init__( + path=filename, + document_type="Deprecated Document", + app=app, ) - def test_app(self): - self.assertEqual(self.filename, self.document.filename) - self.assertEqual(self.document.app, toga_dummy) + def create(self): + pass + + def read(self): + pass + + +def test_deprecated_names(app): + """Deprecated names still work.""" + doc = MyDeprecatedDoc("/path/to/doc.mydoc", app) - def test_read(self): - with self.assertRaises(NotImplementedError): - self.document.read() + with pytest.warns( + DeprecationWarning, + match=r"Document.filename has been renamed Document.path.", + ): + assert doc.filename == Path("/path/to/doc.mydoc") diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 32dbdcc818..1d7e29f2e0 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -71,7 +71,7 @@ def handler(*args, **kwargs): assert wrapped._raw == handler # Invoke wrapper - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) # Handler arguments are as expected. assert handler_call == { @@ -95,7 +95,7 @@ def handler(*args, **kwargs): assert wrapped._raw == handler # Invoke handler. The exception is swallowed - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) # Handler arguments are as expected. assert handler_call == { @@ -127,7 +127,7 @@ def handler(*args, **kwargs): assert wrapped._raw == handler # Invoke handler - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) # Handler arguments are as expected. assert handler_call == { @@ -156,7 +156,7 @@ def handler(*args, **kwargs): assert wrapped._raw == handler # Invoke handler. The exception in cleanup is swallowed - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) # Handler arguments are as expected. assert handler_call == { @@ -194,7 +194,7 @@ def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -229,7 +229,7 @@ def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -272,7 +272,7 @@ def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -314,7 +314,7 @@ def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -358,7 +358,7 @@ async def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -392,7 +392,7 @@ async def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -433,7 +433,7 @@ async def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) @@ -472,7 +472,7 @@ async def handler(*args, **kwargs): # Invoke wrapper inside an active run loop. async def waiter(): - wrapped("dummy", "arg1", "arg2", kwarg1=3, kwarg2=4) + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) count = 0 while not handler_call.get("done", False) and count < 5: await asyncio.sleep(0.01) diff --git a/core/tests/test_images.py b/core/tests/test_images.py index 787cc2ed59..85f3321349 100644 --- a/core/tests/test_images.py +++ b/core/tests/test_images.py @@ -9,11 +9,6 @@ ABSOLUTE_FILE_PATH = Path(toga.__file__).parent / "resources" / "toga.png" -@pytest.fixture -def app(): - return toga.App("Images Test", "org.beeware.toga.images") - - @pytest.mark.parametrize( "args, kwargs", [ @@ -109,10 +104,18 @@ def test_dimensions(): image = toga.Image(path="resources/toga.png") + assert image.size == (60, 40) assert image.width == 60 assert image.height == 40 +def test_data(): + "The raw data of the image can be retrieved." + image = toga.Image(path="resources/toga.png") + + assert image.data == b"pretend this is PNG image data" + + def test_image_save(): "An image can be saved" save_path = Path("/path/to/save.png") diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 09bbf28ee4..61a5e1209e 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -11,11 +11,6 @@ ) -@pytest.fixture -def app(event_loop): - return toga.App("Test App", "org.beeware.toga.window") - - @pytest.fixture def window(app): return toga.Window() @@ -75,12 +70,22 @@ def test_window_created_explicit(app): assert window.on_close._raw == on_close_handler +def test_window_created_without_app(): + "A window cannot be created without an active app" + toga.App.app = None + with pytest.raises( + RuntimeError, match="Cannot create a Window before creating an App" + ): + toga.Window() + + def test_set_app(window, app): """A window's app cannot be reassigned""" assert window.app == app + app2 = toga.App("Test App 2", "org.beeware.toga.test-app-2") with pytest.raises(ValueError, match=r"Window is already associated with an App"): - window.app = app + window.app = app2 def test_set_app_with_content(window, app): @@ -110,6 +115,31 @@ def test_title(window, value, expected): assert window.title == expected +def test_toolbar_implicit_add(window, app): + """Adding an item to to a toolbar implicitly adds it to the app.""" + cmd1 = toga.Command(None, "Command 1") + cmd2 = toga.Command(None, "Command 2") + + toolbar = window.toolbar + assert list(toolbar) == [] + assert list(app.commands) == [] + + # Adding a command to the toolbar automatically adds it to the app + toolbar.add(cmd1) + assert list(toolbar) == [cmd1] + assert list(app.commands) == [cmd1] + + # But not vice versa + app.commands.add(cmd2) + assert list(toolbar) == [cmd1] + assert list(app.commands) == [cmd1, cmd2] + + # Adding a command to both places does not cause a duplicate + app.commands.add(cmd1) + assert list(toolbar) == [cmd1] + assert list(app.commands) == [cmd1, cmd2] + + def test_change_content(window, app): """The content of a window can be changed""" assert window.content is None @@ -316,6 +346,13 @@ def test_close_rejected_handler(window, app): on_close_handler.assert_called_once_with(window) +def test_as_image(window): + """A window can be captured as an image""" + image = window.as_image() + + assert image.data == b"pretend this is PNG image data" + + def test_info_dialog(window, app): """An info dialog can be shown""" on_result_handler = Mock() diff --git a/core/tests/utils.py b/core/tests/utils.py deleted file mode 100644 index 504132d874..0000000000 --- a/core/tests/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio - - -def async_test(coroutine): - """Run an async test to completion.""" - - def _test(self): - asyncio.run(coroutine(self)) - - return _test - - -def order_test(*items): - def _test(self): - for i in range(0, len(items) - 1): - for j in range(i + 1, len(items)): - self.assertLess(items[i], items[j]) - self.assertGreater(items[j], items[i]) - self.assertFalse(items[j] < items[i]) - self.assertFalse(items[i] > items[j]) - - return _test diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py new file mode 100644 index 0000000000..e3c414ce33 --- /dev/null +++ b/core/tests/widgets/canvas/test_helpers.py @@ -0,0 +1,218 @@ +from math import pi + +from pytest import approx + +from toga.widgets.canvas import arc_to_bezier, sweepangle + + +def test_sweepangle(): + # Zero start angles + for value in [0, 1, pi, 2 * pi]: + assert sweepangle(0, value, False) == approx(value) + + for value in [2.1 * pi, 3 * pi, 4 * pi, 5 * pi]: + assert sweepangle(0, value, False) == approx(2 * pi) + + # Non-zero start angles + assert sweepangle(pi, 2 * pi, False) == approx(pi) + assert sweepangle(pi, 2.5 * pi, False) == approx(1.5 * pi) + assert sweepangle(pi, 3 * pi, False) == approx(2 * pi) + assert sweepangle(pi, 3.1 * pi, False) == approx(2 * pi) + + # Zero crossings + assert sweepangle(0, 2 * pi, False) == approx(2 * pi) + assert sweepangle(0, -2 * pi, False) == approx(0) + assert sweepangle(0, 1.9 * pi, False) == approx(1.9 * pi) + assert sweepangle(0, 2.1 * pi, False) == approx(2 * pi) + assert sweepangle(0, -1.9 * pi, False) == approx(0.1 * pi) + assert sweepangle(0, -2.1 * pi, False) == approx(1.9 * pi) + assert sweepangle(pi, 0, False) == approx(pi) + assert sweepangle(pi, 2 * pi, False) == approx(pi) + assert sweepangle(pi, 0.1 * pi, False) == approx(1.1 * pi) + assert sweepangle(pi, 2.1 * pi, False) == approx(1.1 * pi) + + # Zero crossings, anticlockwise + assert sweepangle(0, 2 * pi, True) == approx(0) + assert sweepangle(0, -2 * pi, True) == approx(-2 * pi) + assert sweepangle(0, 1.9 * pi, True) == approx(-0.1 * pi) + assert sweepangle(0, 2.1 * pi, True) == approx(-1.9 * pi) + assert sweepangle(0, -1.9 * pi, True) == approx(-1.9 * pi) + assert sweepangle(0, -2.1 * pi, True) == approx(-2 * pi) + assert sweepangle(pi, 0, True) == approx(-pi) + assert sweepangle(pi, 2 * pi, True) == approx(-pi) + assert sweepangle(pi, 0.1 * pi, True) == approx(-0.9 * pi) + assert sweepangle(pi, 2.1 * pi, True) == approx(-0.9 * pi) + + +def assert_arc_to_bezier(sweepangle, expected): + actual = arc_to_bezier(sweepangle) + for a, e in zip(actual, expected): + assert a == approx(e, abs=0.000001) + + +def test_arc_to_bezier(): + assert_arc_to_bezier( + 0, + [ + (1.0, 0.0), + (1.0, 0.0), + (1.0, 0.0), + (1.0, 0.0), + ], + ) + + assert_arc_to_bezier( + 0.25 * pi, + [ + (1.0, 0.0), + (1.0, 0.2652164), + (0.8946431, 0.5195704), + (0.7071067, 0.7071067), + ], + ) + assert_arc_to_bezier( + -0.25 * pi, + [ + (1.0, 0.0), + (1.0, -0.2652164), + (0.8946431, -0.5195704), + (0.7071067, -0.7071067), + ], + ) + + assert_arc_to_bezier( + 0.5 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + ], + ) + assert_arc_to_bezier( + -0.5 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + ], + ) + + assert_arc_to_bezier( + 0.75 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.2652164, 1.0), + (-0.5195704, 0.8946431), + (-0.7071067, 0.7071067), + ], + ) + assert_arc_to_bezier( + -0.75 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.2652164, -1.0), + (-0.5195704, -0.8946431), + (-0.7071067, -0.7071067), + ], + ) + + assert_arc_to_bezier( + 1 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + ], + ) + assert_arc_to_bezier( + -1 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + ], + ) + + assert_arc_to_bezier( + 1.5 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + (-1.0, -0.5522847), + (-0.5522847, -1.0), + (0.0, -1.0), + ], + ) + assert_arc_to_bezier( + -1.5 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + (-1.0, 0.5522847), + (-0.5522847, 1.0), + (0.0, 1.0), + ], + ) + + assert_arc_to_bezier( + 2 * pi, + [ + (1.0, 0.0), + (1.0, 0.5522847), + (0.5522847, 1.0), + (0.0, 1.0), + (-0.5522847, 1.0), + (-1.0, 0.5522847), + (-1.0, 0.0), + (-1.0, -0.5522847), + (-0.5522847, -1.0), + (0.0, -1.0), + (0.5522847, -1.0), + (1.0, -0.5522847), + (1.0, 0.0), + ], + ) + assert_arc_to_bezier( + -2 * pi, + [ + (1.0, 0.0), + (1.0, -0.5522847), + (0.5522847, -1.0), + (0.0, -1.0), + (-0.5522847, -1.0), + (-1.0, -0.5522847), + (-1.0, 0.0), + (-1.0, 0.5522847), + (-0.5522847, 1.0), + (0.0, 1.0), + (0.5522847, 1.0), + (1.0, 0.5522847), + (1.0, 0.0), + ], + ) diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index b3f3ca810a..7bf7eba305 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -110,10 +110,9 @@ def test_add_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_add_child(widget): +def test_add_child(app, widget): "A child can be added to a node when there's an app & window" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -163,10 +162,9 @@ def test_add_child(widget): assert app.widgets["child_id"] == child -def test_add_multiple_children(widget): +def test_add_multiple_children(app, widget): "Multiple children can be added in one call" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -341,10 +339,9 @@ def test_insert_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_insert_child(widget): +def test_insert_child(app, widget): "A child can be inserted into a node when there's an app & window" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -397,10 +394,9 @@ def test_insert_child(widget): } -def test_insert_position(widget): +def test_insert_position(app, widget): "Insert can put a child into a specific position" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -473,10 +469,9 @@ def test_insert_position(widget): } -def test_insert_bad_position(widget): +def test_insert_bad_position(app, widget): "If the position is invalid, an error is raised" # Set the app and window for the widget. - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -637,13 +632,12 @@ def test_remove_child_without_app(widget): assert_action_performed_with(widget, "refresh") -def test_remove_child(widget): +def test_remove_child(app, widget): "A child associated with an app & window can be removed from a widget" # Add a child to the widget child = ExampleLeafWidget(id="child_id") widget.add(child) - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -684,7 +678,7 @@ def test_remove_child(widget): assert "child_id" not in app.widgets -def test_remove_multiple_children(widget): +def test_remove_multiple_children(app, widget): "Multiple children can be removed from a widget" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -692,7 +686,6 @@ def test_remove_multiple_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -744,7 +737,7 @@ def test_remove_multiple_children(widget): assert "child3_id" not in window.widgets -def test_clear_all_children(widget): +def test_clear_all_children(app, widget): "All children can be simultaneously removed from a widget" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -752,7 +745,6 @@ def test_clear_all_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - app = toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -807,9 +799,8 @@ def test_clear_all_children(widget): assert "child3_id" not in window.widgets -def test_clear_no_children(widget): +def test_clear_no_children(app, widget): "No changes are made (no-op) if widget has no children" - toga.App("Test", "com.example.test") window = toga.Window() window.content = widget # Clear the event log @@ -830,10 +821,9 @@ def test_clear_no_children(widget): assert_action_not_performed(window.content, "refresh") -def test_clear_leaf(): +def test_clear_leaf(app): "`clear` cannot be called on a leaf node" leaf = ExampleLeafWidget() - toga.App("Test", "com.example.test") window = toga.Window() window.content = leaf # Clear the event log @@ -881,9 +871,8 @@ def test_remove_from_non_parent(widget): assert_action_not_performed(widget, "refresh") -def test_set_app(widget): +def test_set_app(app, widget): "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -900,7 +889,7 @@ def test_set_app(widget): assert attribute_value(widget, "app") == app -def test_set_app_with_children(widget): +def test_set_app_with_children(app, widget): "If a widget has children, the children get the app assignment" # Add children to the widget child1 = ExampleLeafWidget(id="child1_id") @@ -908,8 +897,6 @@ def test_set_app_with_children(widget): child3 = ExampleLeafWidget(id="child3_id") widget.add(child1, child2, child3) - # Set up an app - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -937,9 +924,8 @@ def test_set_app_with_children(widget): assert attribute_value(child3, "app") == app -def test_set_same_app(widget): +def test_set_same_app(app, widget): "A widget can be re-assigned to the same app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -955,9 +941,8 @@ def test_set_same_app(widget): assert_attribute_not_set(widget, "app") -def test_reset_app(widget): +def test_reset_app(app, widget): "A widget can be re-assigned to no app" - app = toga.App("Test App", "org.beeware.test") assert len(app.widgets) == 0 # Assign the widget to an app @@ -979,10 +964,8 @@ def test_reset_app(widget): assert attribute_value(widget, "app") is None -def test_set_new_app(widget): +def test_set_new_app(app, widget): "A widget can be assigned to a different app" - app = toga.App("Test App", "org.beeware.test") - # Assign the widget to an app widget.app = app assert len(app.widgets) == 1 @@ -991,7 +974,7 @@ def test_set_new_app(widget): EventLog.reset() # Create a new app - new_app = toga.App("Test App", "org.beeware.test") + new_app = toga.App("Test App", "org.beeware.toga.test-app") assert len(new_app.widgets) == 0 # Assign the widget to the same app diff --git a/core/tests/widgets/test_imageview.py b/core/tests/widgets/test_imageview.py index 447d4542e2..f4d99c29c2 100644 --- a/core/tests/widgets/test_imageview.py +++ b/core/tests/widgets/test_imageview.py @@ -14,11 +14,6 @@ ) -@pytest.fixture -def app(): - return toga.App("ImageView Test", "org.beeware.toga.widgets.imageview") - - @pytest.fixture def widget(app): return toga.ImageView() diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 505f34d64c..4431b238fd 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -10,11 +10,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Option Container Test", "org.beeware.toga.option_container") - - @pytest.fixture def window(): return toga.Window() diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 114049aff6..024701bce9 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -11,11 +11,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Scroll Container Test", "org.beeware.toga.scroll_container") - - @pytest.fixture def window(): return toga.Window() diff --git a/core/tests/widgets/test_slider.py b/core/tests/widgets/test_slider.py index d804f79db4..8f53e12f84 100644 --- a/core/tests/widgets/test_slider.py +++ b/core/tests/widgets/test_slider.py @@ -229,36 +229,44 @@ def test_range(slider, on_change, min, max, value): @pytest.mark.parametrize( - "new_min, new_max", + "new_min, new_value, new_max", [ - [-5, 10], # less than old min - [5, 10], # more than old min, less than max - [15, 15], # more than max + [-5, 5, 10], # less than old min + [5, 5, 10], # more than old min + [6, 6, 10], # more than old min and value + [15, 15, 15], # more than old min, value and max ], ) -def test_min_clipping(slider, new_min, new_max): +def test_min_clipping(slider, new_min, new_value, new_max): + slider.tick_count = None slider.min = 0 + slider.value = 5 slider.max = 10 slider.min = new_min assert slider.min == new_min + assert slider.value == new_value assert slider.max == new_max @pytest.mark.parametrize( - "new_max, new_min", + "new_min, new_value, new_max", [ - [15, 0], # less than old max - [5, 0], # less than old max, more than min - [-5, -5], # less than min + [0, 5, 15], # more than old max + [0, 5, 5], # less than old max + [0, 4, 4], # less than old max and value + [-5, -5, -5], # less than old max, value and min ], ) -def test_max_clipping(slider, new_max, new_min): +def test_max_clipping(slider, new_min, new_value, new_max): + slider.tick_count = None slider.min = 0 + slider.value = 5 slider.max = 10 slider.max = new_max assert slider.min == new_min + assert slider.value == new_value assert slider.max == new_max @@ -429,7 +437,7 @@ def test_int_impl_continuous(): assert impl.int_value == int_value assert impl.get_value() == value - # Check a range that doesn't start at zero. + # Range that doesn't start at zero impl.set_min(-0.4) assert impl.get_min() == pytest.approx(-0.4) impl.set_max(0.6) @@ -437,6 +445,23 @@ def test_int_impl_continuous(): impl.set_value(0.5) assert impl.get_value() == 0.5 assert impl.int_value == 9000 + assert impl.int_max == 10000 + + # Empty range + impl.set_min(0) + impl.set_max(0) + impl.set_value(0) + assert impl.get_value() == 0 + assert impl.int_value == 0 + assert impl.int_max == 10000 + + # Empty range that doesn't start at zero + impl.set_min(1) + impl.set_max(1) + impl.set_value(1) + assert impl.get_value() == 1 + assert impl.int_value == 0 + assert impl.int_max == 10000 def test_int_impl_discrete(): @@ -467,7 +492,7 @@ def test_int_impl_discrete(): assert impl.get_value() == value assert impl.int_value == int_value - # Check a range that doesn't start at zero. + # Range that doesn't start at zero impl.set_min(-0.4) assert impl.get_min() == pytest.approx(-0.4) impl.set_max(0.6) @@ -475,6 +500,23 @@ def test_int_impl_discrete(): impl.set_value(0.5) assert impl.get_value() == 0.5 assert impl.int_value == 7 + assert impl.int_max == 8 + + # Empty range + impl.set_min(0) + impl.set_max(0) + impl.set_value(0) + assert impl.get_value() == 0 + assert impl.int_value == 0 + assert impl.int_max == 8 + + # Empty range that doesn't start at zero + impl.set_min(1) + impl.set_max(1) + impl.set_value(1) + assert impl.get_value() == 1 + assert impl.int_value == 0 + assert impl.int_max == 8 @pytest.mark.parametrize( @@ -501,7 +543,7 @@ def test_int_impl_on_change(tick_count, data): impl.int_value = int_value impl.on_change() assert impl.get_value() == approx(value) - impl.interface.on_change.assert_called_once_with(None) + impl.interface.on_change.assert_called_once_with() def test_deprecated(): diff --git a/core/tests/widgets/test_splitcontainer.py b/core/tests/widgets/test_splitcontainer.py index 6f6f88263c..e53b9a687c 100644 --- a/core/tests/widgets/test_splitcontainer.py +++ b/core/tests/widgets/test_splitcontainer.py @@ -8,11 +8,6 @@ ) -@pytest.fixture -def app(): - return toga.App("Split Container Test", "org.beeware.toga.split_container") - - @pytest.fixture def window(): return toga.Window() diff --git a/demo/setup.cfg b/demo/setup.cfg index 2831b799f6..d35fa90721 100644 --- a/demo/setup.cfg +++ b/demo/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/demo/toga_demo/app.py b/demo/toga_demo/app.py index b4e020bb5d..507659e0f3 100755 --- a/demo/toga_demo/app.py +++ b/demo/toga_demo/app.py @@ -6,7 +6,7 @@ class TogaDemo(toga.App): def startup(self): # Create the main window - self.main_window = toga.MainWindow(self.name) + self.main_window = toga.MainWindow() left_table = toga.Table( headings=["Hello", "World"], @@ -18,20 +18,24 @@ def startup(self): ], ) - left_tree = toga.Tree( - headings=["Navigate"], - data={ - ("root1",): {}, - ("root2",): { - ("root2.1",): None, - ("root2.2",): [ - ("root2.2.1",), - ("root2.2.2",), - ("root2.2.3",), - ], + try: + left_tree = toga.Tree( + headings=["Navigate"], + data={ + ("root1",): {}, + ("root2",): { + ("root2.1",): None, + ("root2.2",): { + ("root2.2.1",): None, + ("root2.2.2",): None, + ("root2.2.3",): None, + }, + }, }, - }, - ) + ) + except NotImplementedError: + # For now, Winforms doesn't implement tree. + left_tree = toga.Box() left_container = toga.OptionContainer( content=[ diff --git a/docs/_static/custom.css b/docs/_static/custom.css index c3b309ff5a..02b3970e25 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -23,3 +23,21 @@ span.beta, span.stable { font-size: x-large; line-height: 1; } + +.sphinx-tabs-tab span.beta { + color: lightslategray; + font-variant: italic; + font-size: smaller; + vertical-align: super; +} + +.sphinx-tabs-tab span.no { + color: lightcoral; + font-variant: italic; + font-size: smaller; + vertical-align: super; +} + +.sphinx-tabs-panel p { + text-align: center; +} diff --git a/docs/background/project/releases.rst b/docs/background/project/releases.rst index 6727bbd250..4e62b06f00 100644 --- a/docs/background/project/releases.rst +++ b/docs/background/project/releases.rst @@ -6,6 +6,150 @@ Release History .. towncrier release notes start +0.4.0 (2023-11-03) +================== + +Features +-------- + +* The Toga API has been fully audited. All APIs now have 100% test coverage, complete API documentation (including type annotations), and are internally consistent. ( `#1903 `__, `#1938 `__, `#1944 `__, `#1946 `__, `#1949 `__, `#1951 `__, `#1955 `__, `#1956 `__, `#1964 `__, `#1969 `__, `#1984 `__, `#1996 `__, `#2011 `__, `#2017 `__, `#2025 `__, `#2029 `__, `#2044 `__, `#2058 `__, `#2075 `__) +* Headings are no longer mandatory for Tree widgets. If headings are not provided, the widget will not display its header bar. (`#1767 `__) +* Support for custom font loading was added to the GTK, Cocoa and iOS backends. (`#1837 `__) +* The testbed app has better diagnostic output when running in test mode. (`#1847 `__) +* A Textual backend was added to support terminal applications. (`#1867 `__) +* Support for determining the currently active window was added to Winforms. (`#1872 `__) +* Programmatically scrolling to top and bottom in MultilineTextInput is now possible on iOS. (`#1876 `__) +* A handler has been added for users confirming the contents of a TextInput by pressing Enter/Return. (`#1880 `__) +* An API for giving a window focus was added. (`#1887 `__) +* Widgets now have a ``.clear()`` method to remove all child widgets. (`#1893 `__) +* Winforms now supports hiding and re-showing the app cursor. (`#1894 `__) +* ProgressBar and Switch widgets were added to the Web backend. (`#1901 `__) +* Missing value handling was added to the Tree widget. (`#1913 `__) +* App paths now include a ``config`` path for storing configuration files. (`#1964 `__) +* A more informative error message is returned when a platform backend doesn't support a widget. (`#1992 `__) +* The example apps were updated to support being run with ``briefcase run`` on all platforms. (`#1995 `__) +* Headings are no longer mandatory Table widgets. (`#2011 `__) +* Columns can now be added and removed from a Tree. (`#2017 `__) +* The default system notification sound can be played via ``App.beep()``. (`#2018 `__) +* DetailedList can now respond to "primary" and "secondary" user actions. These may be implemented as left and right swipe respectively, or using any other platform-appropriate mechanism. (`#2025 `__) +* A DetailedList can now provide a value to use when a row doesn't provide the required data. (`#2025 `__) +* The accessors used to populate a DetailedList can now be customized. (`#2025 `__) +* Transformations can now be applied to *any* canvas context, not just the root context. (`#2029 `__) +* Canvas now provides more ``list``-like methods for manipulating drawing objects in a context. (`#2029 `__) +* On Windows, the default font now follows the system theme. On most devices, this means it has changed from Microsoft Sans Serif 8pt to Segoe UI 9pt. (`#2029 `__) +* Font sizes are now consistently interpreted as CSS points. On Android, iOS and macOS, this means any numeric font sizes will appear 33% larger than before. The default font size on these platforms is unchanged. (`#2029 `__) +* MultilineTextInputs no longer show spelling suggestions when in read-only mode. (`#2136 `__) +* Applications now verify that a main window has been created as part of the ``startup()`` method. (`#2047 `__) +* An implementation of ActivityIndicator was added to the Web backend. (`#2050 `__) +* An implementation of Divider was added to the Web backend. (`#2051 `__) +* The ability to capture the contents of a window as an image has been added. (`#2063 `__) +* A PasswordInput widget was added to the Web backend. (`#2089 `__) +* The WebKit inspector is automatically enabled on all macOS WebViews, provided you're using macOS 13.3 (Ventura) or iOS 16.4, or later. (`#2109 `__) +* Text input widgets on macOS now support undo and redo. (`#2151 `__) +* The Divider widget was implemented on Android. (`#2181 `__) + +Bugfixes +-------- + +* The WinForms event loop was decoupled from the main form, allowing background tasks to run without a main window being present. (`#750 `__) +* Widgets are now removed from windows when the window is closed, preventing a memory leak on window closure. (`#1215 `__) +* Android and iOS apps no longer crash if you invoke ``App.hide_cursor()`` or ``App.show_cursor()``. (`#1235 `__) +* A Selection widget with no items now consistently returns a selected value of ``None`` on all platforms. (`#1723 `__) +* macOS widget methods that return strings are now guaranteed to return strings, rather than native Objective C string objects. (`#1779 `__) +* WebViews on Windows no longer have a black background when they are resized. (`#1855 `__) +* The interpretation of ``MultilineTextInput.readonly`` was corrected iOS (`#1866 `__) +* A window without an ``on_close`` handler can now be closed using the window frame close button. (`#1872 `__) +* Android apps running on devices older than API level 29 (Android 10) no longer crash. (`#1878 `__) +* Missing value handling on Tables was fixed on Android and Linux. (`#1879 `__) +* The GTK backend is now able to correctly identify the currently active window. (`#1892 `__) +* Error handling associated with the creation of Intents on Android has been improved. (`#1909 `__) +* The DetailedList widget on GTK now provides an accurate size hint during layout. (`#1920 `__) +* Apps on Linux no longer segfault if an X Windows display cannot be identified. (`#1921 `__) +* The ``on_result`` handler is now used by Cocoa file dialogs. (`#1947 `__) +* Pack layout now honors an explicit width/height setting of 0. (`#1958 `__) +* The minimum window size is now correctly recomputed and enforced if window content changes. (`#2020 `__) +* The title of windows can now be modified on Winforms. (`#2094 `__) +* An error on Winforms when a window has no content has been resolved. (`#2095 `__) +* iOS container views are now set to automatically resize with their parent view (`#2161 `__) + +Backward Incompatible Changes +----------------------------- + +* The ``weight``, ``style`` and ``variant`` arguments for ``Font`` and ``Font.register`` are now keyword-only. (`#1903 `__) +* The ``clear()`` method for resetting the value of a MultilineTextInput, TextInput and PasswordInput has been removed. This method was an ambiguous override of the ``clear()`` method on Widget that removed all child nodes. To remove all content from a text input widget, use ``widget.value = ""``. (`#1938 `__) +* The ability to perform multiple substring matches in a ``Contains`` validator has been removed. (`#1944 `__) +* The ``TextInput.validate`` method has been removed. Validation now happens automatically whenever the ``value`` or ``validators`` properties are changed. (`#1944 `__) +* The argument names used to construct validators have changed. Error message arguments now all end with ``_message``; ``compare_count`` has been renamed ``count``; and ``min_value`` and ``max_value`` have been renamed ``min_length`` and ``max_length``, respectively. (`#1944 `__) +* The ``get_dom()`` method on WebView has been removed. This method wasn't implemented on most platforms, and wasn't working on any of the platforms where it *was* implemented, as modern web view implementations don't provide a synchronous API for accessing web content in this way. (`#1949 `__) +* The ``evaluate_javascript()`` method on WebView has been modified to work in both synchronous and asynchronous contexts. In a synchronous context you can invoke the method and use a functional ``on_result`` callback to be notified when evaluation is complete. In an asynchronous context, you can await the result. (`#1949 `__) +* The ``on_key_down`` handler has been removed from WebView. If you need to catch user input, either use a handler in the embedded JavaScript, or create a ``Command`` with a key shortcut. (`#1949 `__) +* The ``invoke_javascript()`` method has been removed. All usage of ``invoke_javascript()`` can be replaced with ``evaluate_javascript()``. (`#1949 `__) +* The usage of local ``file://`` URLs has been explicitly prohibited. ``file://`` URLs have not been reliable for some time; their usage is now explicitly prohibited. (`#1949 `__) +* ``DatePicker`` has been renamed ``DateInput``. (`#1951 `__) +* ``TimePicker`` has been renamed ``TimeInput``. (`#1951 `__) +* The ``on_select`` handler on the Selection widget has been renamed ``on_change`` for consistency with other widgets. (`#1955 `__) +* The ``_notify()`` method on data sources has been renamed ``notify()``, reflecting its status as a public API. (`#1955 `__) +* The ``prepend()`` method was removed from the ``ListSource`` and ``TreeSource`` APIs. Calls to ``prepend(...)`` can be replaced with ``insert(0, ...)``. (`#1955 `__) +* The ``insert()`` and ``append()`` APIs on ``ListSource`` and ``TreeSource`` have been modified to provide an interface that is closer to that ``list`` API. These methods previously accepted a variable list of positional and keyword arguments; these arguments should be combined into a single tuple or dictionary. This matches the API provided by ``__setitem__()``. (`#1955 `__) +* Images and ImageViews no longer support loading images from URLs. If you need to display an image from a URL, use a background task to obtain the image data asynchronously, then create the Image and/or set the ImageView ``image`` property on the completion of the asynchronous load. (`#1956 `__) +* A row box contained inside a row box will now expand to the full height of its parent, rather than collapsing to the maximum height of the inner box's child content. (`#1958 `__) +* A column box contained inside a column box will now expand to the full width of its parent, rather than collapsing to the maximum width of the inner box's child content. (`#1958 `__) +* On Android, the user data folder is now a ``data`` sub-directory of the location returned by ``context.getFilesDir()``, rather than the bare ``context.getFilesDir()`` location. (`#1964 `__) +* GTK now returns ``~/.local/state/appname/log`` as the log file location, rather than ``~/.cache/appname/log``. (`#1964 `__) +* The location returned by ``toga.App.paths.app`` is now the folder that contains the Python source file that defines the app class used by the app. If you are using a ``toga.App`` instance directly, this may alter the path that is returned. (`#1964 `__) +* On Winforms, if an application doesn't define an author, an author of ``Unknown`` is now used in application data paths, rather than ``Toga``. (`#1964 `__) +* Winforms now returns ``%USERPROFILE%/AppData/Local///Data`` as the user data file location, rather than ``%USERPROFILE%/AppData/Local//``. (`#1964 `__) +* Support for SplitContainers with more than 2 panels of content has been removed. (`#1984 `__) +* Support for 3-tuple form of specifying SplitContainer items, used to prevent panels from resizing, has been removed. (`#1984 `__) +* The ability to increment and decrement the current OptionContainer tab was removed. Instead of `container.current_tab += 1`, use `container.current_tab = container.current_tab.index + 1` (`#1996 `__) +* ``OptionContainer.add()``, ``OptionContainer.remove()`` and ``OptionContainer.insert()`` have been removed, due to being ambiguous with base widget methods of the same name. Use the ``OptionContainer.content.append()``, ``OptionContainer.content.remove()`` and ``OptionContainer.content.insert()`` APIs instead. (`#1996 `__) +* The ``on_select`` handler for OptionContainer no longer receives the ``option`` argument providing the selected tab. Use ``current_tab`` to obtain the currently selected tab. (`#1996 `__) +* ``TimePicker.min_time`` and ``TimePicker.max_time`` has been renamed ``TimeInput.min`` and ``TimeInput.max``, respectively. (`#1999 `__) +* ``DatePicker.min_date`` and ``DatePicker.max_date`` has been renamed ``DateInput.min`` and ``DateInput.max``, respectively. (`#1999 `__) +* ``NumberInput.min_value`` and ``NumberInput.max_value`` have been renamed ``NumberInput.min`` and ``NumberInput.max``, respectively. (`#1999 `__) +* ``Slider.range`` has been replaced by ``Slider.min`` and ``Slider.max``. (`#1999 `__) +* Tables now use an empty string for the default missing value, rather than warning about missing values. (`#2011 `__) +* ``Table.add_column()`` has been deprecated in favor of ``Table.append_column()`` and ``Table.insert_column()`` (`#2011 `__) +* ``Table.on_double_click`` has been renamed ``Table.on_activate``. (`#2011 `__, `#2017 `__) +* Trees now use an empty string for the default missing value, rather than warning about missing values. (`#2017 `__) +* The ``parent`` argument has been removed from the ``insert`` and ``append`` calls on ``TreeSource``. This improves consistency between the API for ``TreeSource`` and the API for ``list``. To insert or append a row in to a descendant of a TreeSource root, use ``insert`` and ``append`` on the parent node itself - i.e., ``source.insert(parent, index, ...)`` becomes ``parent.insert(index, ...)``, and ``source.insert(None, index, ...)`` becomes ``source.insert(index, ...)``. (`#2017 `__) +* When constructing a DetailedList from a list of tuples, or a list of lists, the required order of values has changed from (icon, title, subtitle) to (title, subtitle, icon). (`#2025 `__) +* The ``on_select`` handler for DetailedList no longer receives the selected row as an argument. (`#2025 `__) +* The handling of row deletion in DetailedList widgets has been significantly altered. The ``on_delete`` event handler has been renamed ``on_primary_action``, and is now *only* a notification that a "swipe left" event (or platform equivalent) has been confirmed. This was previously inconsistent across platforms. Some platforms would update the data source to remove the row; some treated ``on_delete`` as a notification event and expected the application to handle the deletion. It is now the application's responsibility to perform the data deletion. (`#2025 `__) +* Support for Python 3.7 was removed. (`#2027 `__) +* ``fill()`` and ``stroke()`` now return simple drawing operations, rather than context managers. If you attempt to use ``fill()`` or ``stroke()`` on a context as a context manager, an exception will be raised; using these methods on Canvas will raise a warning, but return the appropriate context manager. (`#2029 `__) +* The ``clicks`` argument to ``Canvas.on_press`` has been removed. Instead, to detect "double clicks", you should use ``Canvas.on_activate``. The ``clicks`` argument has also been removed from ``Canvas.on_release``, ``Canvas.on_drag``, ``Canvas.on_alt_press``, ``Canvas.on_alt_release``, and ``Canvas.on_alt_drag``. (`#2029 `__) +* The ``new_path`` operation has been renamed ``begin_path`` for consistency with the HTML5 Canvas API. (`#2029 `__) +* Methods that generate new contexts have been renamed: ``context()``, ``closed_path()``, ``fill()`` and ``stroke()`` have become ``Context()``, ``ClosedPath()``, ``Fill()`` and ``Stroke()`` respectively. This has been done to make it easier to differentiate between primitive drawing operations and context-generating operations. (`#2029 `__) +* A Canvas is no longer implicitly a context object. The ``Canvas.context`` property now returns the root context of the canvas. If you were previously using ``Canvas.context()`` to generate an empty context, it should be replaced with ``Canvas.Context()``. Any operations to ``remove()`` drawing objects from the canvas or ``clear()`` the canvas of drawing objects should be made on ``Canvas.context``. Invoking these methods on ``Canvas`` will now call the base ``Widget`` implementations, which will throw an exception because ``Canvas`` widgets cannot have children. (`#2029 `__) +* The ``preserve`` option on ``Fill()`` operations has been deprecated. It was required for an internal optimization and can be safely removed without impact. (`#2029 `__) +* Drawing operations (e.g., ``arc``, ``line_to``, etc) can no longer be invoked directly on a Canvas. Instead, they should be invoked on the root context of the canvas, retrieved with via the `canvas` property. Context creating operations (``Fill``, ``Stroke`` and ``ClosedPath``) are not affected. (`#2029 `__) +* The ``tight`` argument to ``Canvas.measure_text()`` has been deprecated. It was a GTK implementation detail, and can be safely removed without impact. (`#2029 `__) +* The ``multiselect`` argument to Open File and Select Folder dialogs has been renamed ``multiple_select``, for consistency with other widgets that have multiple selection capability. (`#2058 `__) +* ``Window.resizeable`` and ``Window.closeable`` have been renamed ``Window.resizable`` and ``Window.closable``, to adhere to US spelling conventions. (`#2058 `__) +* Windows no longer need to be explicitly added to the app's window list. When a window is created, it will be automatically added to the windows for the currently running app. (`#2058 `__) +* The optional arguments of ``Command`` and ``Group`` are now keyword-only. (`#2075 `__) +* In ``App``, the properties ``id`` and ``name`` have been deprecated in favor of ``app_id`` and ``formal_name`` respectively, and the property ``module_name`` has been removed. (`#2075 `__) +* ``GROUP_BREAK``, ``SECTION_BREAK`` and ``CommandSet`` were removed from the ``toga`` namespace. End users generally shouldn't need to use these classes. If your code *does* need them for some reason, you can access them from the ``toga.command`` namespace. (`#2075 `__) +* The ``windows`` constructor argument of ``toga.App`` has been removed. Windows are now automatically added to the current app. (`#2075 `__) +* The ``filename`` argument and property of ``toga.Document`` has been renamed ``path``, and is now guaranteed to be a ``pathlib.Path`` object. (`#2075 `__) +* Documents must now provide a ``create()`` method to instantiate a ``main_window`` instance. (`#2075 `__) +* ``App.exit()`` now unconditionally exits the app, rather than confirming that the ``on_exit`` handler will permit the exit. (`#2075 `__) + +Documentation +------------- + +* Documentation for application paths was added. (`#1849 `__) +* The contribution guide was expanded to include more suggestions for potential projects, and to explain how the backend tests work. (`#1868 `__) +* All code blocks were updated to add a button to copy the relevant contents on to the user's clipboard. (`#1897 `__) +* Class references were updated to reflect their preferred import location, rather than location where they are defined in code. (`#2001 `__) +* The Linux system dependencies were updated to reflect current requirements for developing and using Toga. (`#2021 `__) + +Misc +---- + +* `#1865 `__, `#1875 `__, `#1881 `__, `#1882 `__, `#1886 `__, `#1889 `__, `#1895 `__, `#1900 `__, `#1902 `__, `#1906 `__, `#1916 `__, `#1917 `__, `#1918 `__, `#1926 `__, `#1933 `__, `#1948 `__, `#1950 `__, `#1952 `__, `#1954 `__, `#1963 `__, `#1972 `__, `#1977 `__, `#1980 `__, `#1988 `__, `#1989 `__, `#1998 `__, `#2008 `__, `#2014 `__, `#2019 `__, `#2022 `__, `#2028 `__, `#2034 `__, `#2035 `__, `#2039 `__, `#2052 `__, `#2053 `__, `#2055 `__, `#2056 `__, `#2057 `__, `#2059 `__, `#2067 `__, `#2068 `__, `#2069 `__, `#2085 `__, `#2090 `__, `#2092 `__, `#2093 `__, `#2101 `__, `#2102 `__, `#2113 `__, `#2114 `__, `#2115 `__, `#2116 `__, `#2118 `__, `#2119 `__, `#2123 `__, `#2124 `__, `#2127 `__, `#2128 `__, `#2131 `__, `#2132 `__, `#2146 `__, `#2147 `__, `#2148 `__, `#2149 `__, `#2150 `__, `#2163 `__, `#2165 `__, `#2166 `__, `#2171 `__, `#2177 `__, `#2180 `__, `#2184 `__, `#2186 `__ + 0.3.1 (2023-04-12) ================== diff --git a/docs/background/topics/commands.rst b/docs/background/topics/commands.rst deleted file mode 100644 index f2c79d95f0..0000000000 --- a/docs/background/topics/commands.rst +++ /dev/null @@ -1,111 +0,0 @@ -.. _commands: - -============================ -Commands, Menus and Toolbars -============================ - -A GUI requires more than just widgets laid out in a user interface - you'll -also want to allow the user to actually *do* something. In Toga, you do this -using ``Commands``. - -A command encapsulates a piece of functionality that the user can invoke - no -matter how they invoke it. It doesn't matter if they select a menu item, -press a button on a toolbar, or use a key combination - the functionality is -wrapped up in a Command. - -When a command is added to an application, Toga takes control of ensuring that -the command is exposed to the user in a way that they can access it. On desktop -platforms, this may result in a command being added to a menu. - -You can also choose to add a command (or commands) to a toolbar on a specific -window. - -Defining Commands -~~~~~~~~~~~~~~~~~ - -When you specify a ``Command``, you provide some additional metadata to help -classify and organize the commands in your application: - -* An **action** - a function to invoke when the command is activated. - -* A **label** - a name for the command to. - -* A **tooltip** - a short description of what the command will do - -* A **shortcut** - (optional) A key combination that can be used to invoke the command. - -* An **icon** - (optional) A path to an icon resource to decorate the command. - -* A **group** - (optional) a ``Group`` object describing a collection of similar commands. If no group is specified, a default "Command" group will be used. - -* A **section** - (optional) an integer providing a sub-grouping. If no section is specified, the command will be allocated to section 0 within the group. - -* An **order** - (optional) an integer indicating where a command falls within a section. If a ``Command`` doesn't have an order, it will be sorted alphabetically by label within its section. - -Commands may not use all the metadata - for example, on some platforms, menus -will contain icons; on other platforms they won't. Toga will use the metadata -if it is provided, but ignore it (or substitute an appropriate default) if it -isn't. - -Commands can be enabled and disabled; if you disable a command, it will -automatically disable any toolbar or menu item where the command appears. - -Groups -~~~~~~ - -Toga provides a number of ready-to-use groups: - -* ``Group.APP`` - Application level control -* ``Group.FILE`` - File commands -* ``Group.EDIT`` - Editing commands -* ``Group.VIEW`` - Commands to alter the appearance of content -* ``Group.COMMANDS`` - A Default -* ``Group.WINDOW`` - Commands for managing different windows in the app -* ``Group.HELP`` - Help content - -You can also define custom groups. - -Example -~~~~~~~ - -The following is an example of using menus and commands:: - - import toga - - def callback(sender): - print("Command activated") - - def build(app): - ... - stuff_group = Group('Stuff', order=40) - - cmd1 = toga.Command( - callback, - label='Example command', - tooltip='Tells you when it has been activated', - shortcut='k', - icon='icons/pretty.png', - group=stuff_group, - section=0 - ) - cmd2 = toga.Command( - ... - ) - ... - - app.commands.add(cmd1, cmd4, cmd3) - app.main_window.toolbar.add(cmd2, cmd3) - -This code defines a command ``cmd1`` that will be placed in the first section of -the "Stuff" group. It can be activated by pressing CTRL-k (or CMD-K on a Mac). - -The definitions for ``cmd2``, ``cmd3``, and ``cmd4`` have been omitted, but would -follow a similar pattern. - -It doesn't matter what order you add commands to the app - the group, section -and order will be used to put the commands in the right order. - -If a command is added to a toolbar, it will automatically be added to the app -as well. It isn't possible to have functionality exposed on a toolbar that -isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though -it wasn't explicitly added to the app commands. diff --git a/docs/background/topics/index.rst b/docs/background/topics/index.rst index 31c750910b..389681b01a 100644 --- a/docs/background/topics/index.rst +++ b/docs/background/topics/index.rst @@ -6,5 +6,4 @@ Topic guides :maxdepth: 1 layout - commands data-sources diff --git a/docs/conf.py b/docs/conf.py index 2969e977d0..b2764bb273 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -111,8 +111,11 @@ rst_prolog = """ .. role:: stable .. role:: beta +.. role:: no .. |y| replace:: :stable:`●` .. |b| replace:: :beta:`○` +.. |beta| replace:: :beta:`β` +.. |no| replace:: :no:`✖︎` """ intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} @@ -147,8 +150,13 @@ def autodoc_process_signature( # -- Options for link checking ------------------------------------------------- -# GitHub generates anchors in javascript -linkcheck_ignore = [r"https://github.com/.*#"] +linkcheck_ignore = [ + # GitHub generates anchors in javascript + r"https://github.com/.*#", + # References to Github issues/pulls should all be safe. + r"^https://github.com/beeware/toga/issues/\d+$", + r"^https://github.com/beeware/toga/pull/\d+$", +] # -- Options for copy button --------------------------------------------------- diff --git a/docs/how-to/contribute-code.rst b/docs/how-to/contribute-code.rst index 5006e2d697..c1a3d7f1e2 100644 --- a/docs/how-to/contribute-code.rst +++ b/docs/how-to/contribute-code.rst @@ -356,25 +356,6 @@ What should I do? Depending on your level of expertise, or areas of interest, there are a number of ways you can contribute to Toga's code. -Improve test coverage for the core API --------------------------------------- - -If this is your first time contributing, this is probably the easiest place to -start. - -Toga has a test suite that verifies that the public API behaves as expected. -This API is tested against a "dummy" backend - a backend that implements the -same API as the platform backends (e.g., ``toga-cocoa`` and ``toga-winforms``), -but without relying on any specific platform graphical behavior. The dummy -backend mocks the behavior of a real backend, and provides additional properties -to verify when various actions have been performed on the backend. - -We want to get our core API 100% coverage, but we're not there yet - and you can -help! Your task: create a test that improves coverage - even by one more line. - -Details on how to run the test suite and check coverage :ref:`can be found below -`. - Fix a bug in an existing widget ------------------------------- @@ -408,17 +389,16 @@ information provided by the reporter, and trying to reproduce it. Again, if you can't reproduce the problem, report what you have found as a comment on the ticket, and pick another ticket. -If you can reproduce the problem - try to fix it! Work out what combination of -core and backend-specific code is implementing the feature, and see if you can -work out what isn't working correctly. You may need to refer to platform -specific documentation (e.g., the `Cocoa AppKit -`__, `iOS UIKit -`__, `GTK +If you can reproduce the problem - try to fix it! Work out what combination of core and +backend-specific code is implementing the feature, and see if you can work out what +isn't working correctly. You may need to refer to platform specific documentation (e.g., +the `Cocoa AppKit `__, +`iOS UIKit `__, `GTK `__, `Winforms `__, -`Android `__ or `Shoelace -`__ API documentation) to work out why a widget isn't -behaving as expected. +`Android `__, `Shoelace +`__ or `Textual `__ API +documentation) to work out why a widget isn't behaving as expected. If you're able to fix the problem, you'll need to add tests for :ref:`the core API ` and/or :ref:`the testbed backend ` for @@ -431,27 +411,6 @@ the fix, that knowledge will often be enough for someone who knows more about a platform to solve the problem. Even a good reproduction case (a sample app that does nothing but reproduce the problem) can be a huge help. -Convert from ``unittest`` to pytest ------------------------------------ - -Toga's test suite was historically written using Python's builtin ``unittest`` -library. We're currently porting these old tests to pytest. Pick a widget that -has ``unittest``-based tests, and port those tests over to pytest format. As you -do this, make sure the test makes good use of pytest features (like fixtures and -parameterization). The tests that have been already been ported to pytest are a -good reference for what a good Toga pytest looks like. - -Improve test coverage for a backend API ---------------------------------------- - -If you've got expertise in a particular platform (for example, if you've got -experience writing iOS apps), or you'd *like* to have that experience, you might -want to look into writing tests for a platform backend. We want to get to 100% -coverage for all the backend APIs, but we're a long way from that goal. - -The platform backends are tested using a testbed app. Details on how to run the -testbed app for a given platform :ref:`can be found below `. - Contribute improvements to documentation ---------------------------------------- @@ -463,19 +422,21 @@ on. Implement a platform native widget ---------------------------------- -If the core library already specifies an interface for a widget, but the widget -isn't implemented on your platform of choice, implement that interface. The -:doc:`supported widgets by platform ` table can -show you the widgets that are missing on various platforms. You can also look -for log messages in a running app (or the direct ``factory.not_implemented()`` -function calls that produce those log messages). At present, the web backend -has a lot of missing widgets, so if you have web skills, or would like to learn -more about `PyScript `__ and `Shoelace -`__, this could be a good place to contribute. +If the core library already specifies an interface for a widget, but the widget isn't +implemented on your platform of choice, implement that interface. The :doc:`supported +widgets by platform ` table can show you the widgets +that are missing on various platforms. You can also look for log messages in a running +app (or the direct ``factory.not_implemented()`` function calls that produce those log +messages). At present, the Web and Textual backends have the most missing widgets. If +you have web skills, or would like to learn more about `PyScript +`__ and `Shoelace `__, the web backend +could be a good place to contribute; if you'd like to learn more about terminal +applications and the or `Textual `__ API, contributing to +the Textual backend could be a good place for you to contribute. Alternatively, if there's a widget that doesn't exist, propose an interface design, and implement it for at least one platform. You may find `this -presentation by BeeWare team member Dan Yeaw +presentation by BeeWare emeritus team member Dan Yeaw `__ helpful. This talk gives an architectural overview of Toga, as well as providing a guide to the process of adding new widgets. @@ -499,11 +460,10 @@ you add. Implement an entirely new platform backend ------------------------------------------ -Toga currently has support for 6 backends - but there's room for more! In +Toga currently has support for 7 backends - but there's room for more! In particular, we'd be interested in seeing a `Qt-based backend `__ to support KDE-based Linux -desktops, and a `Textual-based console backend -`__. +desktops. The first steps of any new platform backend are always the same: @@ -550,19 +510,19 @@ To run the core test suite: .. code-block:: console - (venv) $ tox -e py-core + (venv) $ tox -e py .. group-tab:: Linux .. code-block:: console - (venv) $ tox -e py-core + (venv) $ tox -e py .. group-tab:: Windows .. code-block:: doscon - (venv) C:\...>tox -e py-core + (venv) C:\...>tox -e py You should get some output indicating that tests have been run. You may see ``SKIPPED`` tests, but shouldn't ever get any ``FAIL`` or ``ERROR`` test @@ -571,51 +531,31 @@ discovers any problems, we don't merge the patch. If you do find a test error or failure, either there's something odd in your test environment, or you've found an edge case that we haven't seen before - either way, let us know! -Although the tests should all pass, the test suite itself is still incomplete. -There are many aspects of the Toga Core API that aren't currently tested (or -aren't tested thoroughly). To work out what *isn't* tested, Toga uses a tool -called `coverage `__. Coverage allows -you to check which lines of code have (and haven't) been executed - which then -gives you an idea of what code has (and hasn't) been tested. - At the end of the test output there should be a report of the coverage data that was gathered:: - Name Stmts Miss Cover Missing - ------------------------------------------------------------------ - toga/__init__.py 29 0 100% - toga/app.py 50 0 100% - ... - toga/window.py 79 18 77% 58, 75, 87, 92, 104, 141, 155, 164, 168, 172-173, 176, 192, 204, 216, 228, 243, 257 - ------------------------------------------------------------------ - TOTAL 1034 258 75% - -What does this all mean? Well, the "Cover" column tells you what proportion of -lines in a given file were executed during the test run. In this run, every -line of ``toga/app.py`` was executed; but only 77% of lines in -``toga/window.py`` were executed. Which lines were missed? They're listed in -the next column: lines 58, 75, 87, and so on weren't executed. - -Ideally, every single line in every single file will have 100% coverage. If you -look in `core/tests`, you should find a test file that matches the name of the -file that has insufficient coverage. If you don't, it's possible the entire test -file is missing - so you'll have to create it! - -Once you've written a test, re-run the test suite to generate fresh coverage -data. Let's say we added a test for line 58 of ``toga/window.py`` - we'd -expect to see something like:: - - Name Stmts Miss Cover Missing - ------------------------------------------------------------------ - toga/__init__.py 29 0 100% - toga/app.py 50 0 100% - ... - toga/window.py 79 17 78% 75, 87, 92, 104, 141, 155, 164, 168, 172-173, 176, 192, 204, 216, 228, 243, 257 - ------------------------------------------------------------------ - TOTAL 1034 257 75% - -That is, one more test has been executed, resulting in one less missing line -in the coverage results. + Name Stmts Miss Branch BrPart Cover Missing + ---------------------------------------------------- + TOTAL 4345 0 1040 0 100.0% + +This tells us that the test suite has executed every possible branching path +in the ``toga-core`` library. This isn't a 100% guarantee that there are no bugs, +but it does mean that we're exercising every line of code in the core API. + +If you make changes to the core API, it's possible you'll introduce a gap in this +coverage. When this happens, the coverage report will tell you which lines aren't +being executed. For example, lets say we made a change to ``toga/window.py``, +adding some new logic. The coverage report might look something like:: + + Name Stmts Miss Branch BrPart Cover Missing + ---------------------------------------------------------------- + src/toga/window.py 186 2 22 2 98.1% 211, 238-240 + ---------------------------------------------------------------- + TOTAL 4345 2 1040 2 99.9% + +This tells us that line 211, and lines 238-240 are not being executed by the test +suite. You'll need to add new tests (or modify an existing test) to restore this +coverage. When you're developing your new test, it may be helpful to run *just* that one test. To do this, you can pass in the name of a specific test file (or a @@ -628,19 +568,19 @@ specific test, using `pytest specifiers .. code-block:: console - (venv) $ tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) $ tox -e py -- tests/path_to_test_file/test_some_test.py .. group-tab:: Linux .. code-block:: console - (venv) $ tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) $ tox -e py -- tests/path_to_test_file/test_some_test.py .. group-tab:: Windows .. code-block:: doscon - (venv) C:\...>tox -e py-core -- tests/path_to_test_file/test_some_test.py + (venv) C:\...>tox -e py -- tests/path_to_test_file/test_some_test.py These test paths are relative to the ``core`` directory. You'll still get a coverage report when running a part of the test suite - but the coverage results @@ -836,6 +776,8 @@ result, the Cocoa implementation of the ``color`` `property of the Button probe `__ performs an ``xfail`` describing that limitation. +.. _pr-housekeeping: + Submitting a pull request ========================= diff --git a/docs/how-to/contribute-docs.rst b/docs/how-to/contribute-docs.rst index 755818c42a..1654c5df5d 100644 --- a/docs/how-to/contribute-docs.rst +++ b/docs/how-to/contribute-docs.rst @@ -174,3 +174,10 @@ However, you don't need to be constrained by these tickets. If you can identify a gap in Toga's documentation, or an improvement that can be made, start writing! Anything that improves the experience of the end user is a welcome change. + +Submitting a pull request +========================= + +Before you submit a pull request, there's a few bits of housekeeping to do. See the +section on submitting a pull request in the :ref:`code contribution guide +` for details on our submission process. diff --git a/docs/how-to/internal/release.rst b/docs/how-to/internal/release.rst index 77e0ba5294..6a718ed566 100644 --- a/docs/how-to/internal/release.rst +++ b/docs/how-to/internal/release.rst @@ -37,7 +37,16 @@ The procedure for cutting a new release is as follows: $ tox -e towncrier - to generate the updated release notes. + to generate the updated release notes. After doing any edits that may be + required, run: + + .. code-block:: console + + $ tox -r -e docs-lint,docs + + to confirm that there are no spelling errors or formatting problems with the + new release notes, and the docs build using the current documentation tool + versions. #. Tag the release, and push the branch and tag upstream: @@ -68,8 +77,16 @@ The procedure for cutting a new release is as follows: code change, delete the old tag, make the code change, and re-tag the release. -#. Download the "packages" artifact from the GitHub workflow, and use its wheels - to build some apps and perform any pre-release testing that may be appropriate. +#. Create a clean virtual environment, install the new release from Test PyPI, and + perform any pre-release testing that may be appropriate: + + .. code-block:: console + + $ python3 -m venv testvenv + $ . ./testvenv/bin/activate + (testvenv) $ pip install --extra-index-url https://test.pypi.org/simple/ toga==1.2.3 + (testvenv) $ toga-demo + (testvenv) $ #... any other manual checks you want to perform ... #. Log into ReadTheDocs, visit the `Versions tab `__, and activate the diff --git a/docs/images/toga-demo-cocoa.png b/docs/images/toga-demo-cocoa.png new file mode 100644 index 0000000000..684c7c20d3 Binary files /dev/null and b/docs/images/toga-demo-cocoa.png differ diff --git a/docs/images/toga-demo-gtk.png b/docs/images/toga-demo-gtk.png new file mode 100644 index 0000000000..16d0c6cade Binary files /dev/null and b/docs/images/toga-demo-gtk.png differ diff --git a/docs/images/toga-demo-winforms.png b/docs/images/toga-demo-winforms.png new file mode 100644 index 0000000000..663fbd0902 Binary files /dev/null and b/docs/images/toga-demo-winforms.png differ diff --git a/docs/index.rst b/docs/index.rst index 92c7c3f0c7..c6b38bf7af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,9 +9,25 @@ development. Toga is available on macOS, Windows, Linux (GTK), Android, iOS, and for single-page web apps. -.. figure:: tutorial/screenshots/tutorial-2.png - :align: center - :width: 500 +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /images/toga-demo-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /images/toga-demo-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /images/toga-demo-winforms.png + :align: center + :width: 450px .. rst-class:: row diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index 087cadeb3d..d0c097a974 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -1,5 +1,7 @@ -Application -=========== +App +=== + +The top-level representation of an application. .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) @@ -8,53 +10,71 @@ Application :included_cols: 4,5,6,7,8,9,10 :exclude: {0: '(?!(Application|Component))'} -The app is the main entry point and container for the Toga GUI. Usage ----- -The app class is used by instantiating with a name, namespace and callback to a startup delegate which takes 1 argument of the app instance. +The App class is the top level representation of all application activity. It is a +singleton object - any given process can only have a single App. That +application may manage multiple windows, but it is guaranteed to have at least one +window (called the :attr:`~toga.App.main_window`); when the App's +:attr:`~toga.App.main_window` is closed, the application will exit. -To start a UI loop, call ``app.main_loop()`` +The application is started by calling :meth:`~toga.App.main_loop()`. This will invoke +the :meth:`~toga.App.startup()` method of the app. .. code-block:: python import toga + app = toga.App("Simplest App", "com.example.simplest") + app.main_loop() - def build(app): - # build UI - pass +You can populate an app's main window by passing a callable as the ``startup`` argument +to the :class:`toga.App` constructor. This ``startup`` method must return the content +that will be added to the main window of the app. +.. code-block:: python - if __name__ == '__main__': - app = toga.App('First App', 'org.beeware.helloworld', startup=build) - app.main_loop() + import toga -Alternatively, you can subclass App and implement the startup method + def create_content(app): + return toga.Box(children=[toga.Label("Hello!")]) + + app = toga.App("Simple App", "com.example.simple", startup=create_content) + app.main_loop() + +This approach to app construction is most useful with simple apps. For most complex +apps, you should subclass :class:`toga.App`, and provide an implementation of +:meth:`~toga.App.startup()`. This implementation *must* create and assign a +``main_window`` for the app. .. code-block:: python import toga - class MyApp(toga.App): def startup(self): - # build UI - pass - + self.main_window = toga.MainWindow() + self.main_window.content = toga.Box(children=[toga.Label("Hello!")]) + self.main_window.show() if __name__ == '__main__': - app = MyApp('First App', 'org.beeware.helloworld') + app = MyApp("Realistic App", "org.beeware.realistic") app.main_loop() -All App instances must have assigned their :attr:`~toga.App.main_window` before the -conclusion of their :meth:`~toga.App.startup` method. +Every app must have a formal name (a human readable name), and an app ID (a +machine-readable identifier - usually a reversed domain name). In the examples above, +these are provided as constructor arguments. However, you can also provide these +details, along with many of the other constructor arguments, as packaging metadata in a +format compatible with :any:`importlib.metadata`. If you deploy your app with `Briefcase +`__, this will be done automatically. Reference --------- .. autoclass:: toga.App + :exclude-members: app .. autoprotocol:: toga.app.AppStartupMethod .. autoprotocol:: toga.app.BackgroundTask diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index cdb019484f..5db89609ac 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -3,17 +3,41 @@ OptionContainer A container that can display multiple labeled tabs of content. -.. figure:: /reference/images/OptionContainer.png - :align: center - :width: 300px +.. tabs:: -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(OptionContainer|Component))'} + .. group-tab:: macOS + .. figure:: /reference/images/optioncontainer-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/optioncontainer-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/optioncontainer-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index f43c9dfc3a..cb068354f1 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -4,16 +4,45 @@ ScrollContainer A container that can display a layout larger than the area of the container, with overflow controlled by scroll bars. -.. figure:: /reference/images/ScrollContainer.png - :align: center - :width: 300px - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(ScrollContainer|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/scrollcontainer-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/scrollcontainer-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/scrollcontainer-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android + + .. figure:: /reference/images/scrollcontainer-android.png + :align: center + :width: 450px + + .. group-tab:: iOS + + .. figure:: /reference/images/scrollcontainer-iOS.png + :align: center + :width: 450px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/containers/splitcontainer.rst b/docs/reference/api/containers/splitcontainer.rst index 53777f397a..48c0ea8e13 100644 --- a/docs/reference/api/containers/splitcontainer.rst +++ b/docs/reference/api/containers/splitcontainer.rst @@ -3,17 +3,41 @@ SplitContainer A container that divides an area into two panels with a movable border. -.. figure:: /reference/images/SplitContainer.png - :align: center - :width: 300px +.. tabs:: -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(SplitContainer|Component))'} + .. group-tab:: macOS + .. figure:: /reference/images/splitcontainer-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/splitcontainer-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/splitcontainer-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/documentapp.rst b/docs/reference/api/documentapp.rst new file mode 100644 index 0000000000..88a96fe724 --- /dev/null +++ b/docs/reference/api/documentapp.rst @@ -0,0 +1,90 @@ +DocumentApp +=========== + +The top-level representation of an application that manages documents. + +.. rst-class:: widget-support +.. csv-filter:: Availability (:ref:`Key `) + :header-rows: 1 + :file: ../data/widgets_by_platform.csv + :included_cols: 4,5,6,7,8,9,10 + :exclude: {0: '(?!(DocumentApp|Component))'} + + +Usage +----- + +A DocumentApp is a specialized subclass of App that is used to manage documents. A +DocumentApp does *not* have a main window; each document that the app manages has it's +own main window. Each document may also define additional windows, if necessary. + +The types of documents that the DocumentApp can manage must be declared as part of the +instantiation of the DocumentApp. This requires that you define a subclass of +:class:`toga.Document` that describes how your document can be read and displayed. In +this example, the code declares an "Example Document" document type, whose files have an +extension of ``mydoc``: + +.. code-block:: python + + import toga + + class ExampleDocument(toga.Document): + def __init__(self, path, app): + super().__init__(document_type="Example Document", path=path, app=app) + + def create(self): + # Create the representation for the document's main window + self.main_window = toga.DocumentMainWindow(self) + self.main_window.content = toga.MultilineTextInput() + + def read(self): + # Put your logic to read the document here. For example: + with self.path.open() as f: + self.content = f.read() + + self.main_window.content.value = self.content + + app = toga.DocumentApp("Document App", "com.example.document", {"mydoc": MyDocument}) + app.main_loop() + +The exact behavior of a DocumentApp is slightly different on each platform, reflecting +platform differences. + +macOS +~~~~~ + +On macOS, there is only ever a single instance of a DocumentApp running at any given +time. That instance can manage multiple documents. If you use the Finder to open a +second document of a type managed by the DocumentApp, it will be opened in the existing +DocumentApp instance. Closing all documents will not cause the app to exit; the app will +keep executing until explicitly exited. + +If the DocumentApp is started without an explicit file reference, a file dialog will be +displayed prompting the user to select a file to open. If this dialog can be dismissed, +the app will continue running. Selecting "Open" from the file menu will also display this +dialog; if a file is selected, a new document window will be opened. + +Linux/Windows +~~~~~~~~~~~~~ + +On Linux and Windows, each DocumentApp instance manages a single document. If your app +is running, and you use the file manager to open a second document, a second instance of +the app will be started. If you close a document's main window, the app instance +associated with that document will exit, but any other app instances will keep running. + +If the DocumentApp is started without an explicit file reference, a file dialog will be +displayed prompting the user to select a file to open. If this dialog is dismissed, the +app will continue running, but will show an empty document. Selecting "Open" from the +file menu will also display this dialog; if a file is selected, the current document +will be replaced. + +Reference +--------- + +.. autoclass:: toga.DocumentApp + :members: + :undoc-members: + +.. autoclass:: toga.Document + :members: + :undoc-members: diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 2d20fcc2c8..3b46b6e653 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -7,13 +7,14 @@ API Reference Core application components --------------------------- -=============================================== =================================================== - Component Description -=============================================== =================================================== - :doc:`Application ` The application itself - :doc:`Window ` An operating system-managed container of widgets. - :doc:`MainWindow ` The main window of the application. -=============================================== =================================================== +================================================= =================================================== + Component Description +================================================= =================================================== + :doc:`App ` The top-level representation of an application. + :doc:`DocumentApp ` An application that manages documents. + :doc:`Window ` An operating system-managed container of widgets. + :doc:`MainWindow ` The main window of the application. +================================================= =================================================== General widgets --------------- @@ -76,9 +77,9 @@ Resources ==================================================================== ======================================================================== :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations for an application. - :doc:`Command ` Command + :doc:`Command ` A representation of app functionality that the user can invoke from + menus or toolbars. :doc:`Font ` Fonts - :doc:`Group ` Command group :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image :doc:`ListSource ` A data source describing an ordered list of data. @@ -95,15 +96,18 @@ Other Component Description ============================================== ======================================================================== :doc:`Constants ` Symbolic constants used by various APIs. + :doc:`Keys ` Symbolic representation of keys used for keyboard shortcuts. ============================================== ======================================================================== .. toctree:: :hidden: app - mainwindow + documentapp window + mainwindow containers/index resources/index widgets/index constants + keys diff --git a/docs/reference/api/keys.rst b/docs/reference/api/keys.rst new file mode 100644 index 0000000000..31e1f175a4 --- /dev/null +++ b/docs/reference/api/keys.rst @@ -0,0 +1,41 @@ +Keys +==== + +A symbolic representation of keys used for keyboard shortcuts. + +Most keys have a constant that matches the text on the key, or the name of the +key if the text on the key isn't a legal Python identifier. + +However, due to differences between platforms, there's no representation of +"modifier" keys like Control, Command, Option, or the Windows Key. Instead, Toga +provides three generic modifier constants, and maps those to the modifier keys, +matching the precedence with which they are used on the underlying platforms: + +========== ============== ============== ================== + Platform :any:`MOD_1` :any:`MOD_2` :any:`MOD_3` +========== ============== ============== ================== + Linux Control Alt Tux/Windows/Meta + macOS Command (⌘) Option Control (^) + Windows Control Alt Not supported +========== ============== ============== ================== + +Key combinations can be expressed by combining multiple ``Key`` values with the +``+`` operator. + +.. code-block:: python + + from toga import Key + + just_an_a = Key.A + shift_a = Key.SHIFT + Key.A + # Windows/Linux - Control-Shift-A: + # macOS - Command-Shift-A: + modified_shift_a = Key.MOD_1 + Key.SHIFT + Key.A + +The order of addition is not significant. ``Key.SHIFT + Key.A`` and ``Key.A + +Key.SHIFT`` will produce the same key representation. + +Reference +--------- + +.. autoclass:: toga.Key diff --git a/docs/reference/api/mainwindow.rst b/docs/reference/api/mainwindow.rst index 57e9deb1ef..7dc17936d7 100644 --- a/docs/reference/api/mainwindow.rst +++ b/docs/reference/api/mainwindow.rst @@ -3,16 +3,53 @@ MainWindow The main window of the application. -.. figure:: /reference/images/MainWindow.png - :align: center - :width: 300px - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(MainWindow|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/mainwindow-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/mainwindow-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/mainwindow-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android + + .. figure:: /reference/images/mainwindow-android.png + :align: center + :width: 450px + + .. group-tab:: iOS + + .. figure:: /reference/images/mainwindow-iOS.png + :align: center + :width: 450px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/mainwindow-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |beta| + + .. .. figure:: /reference/images/mainwindow-textual.png + .. :align: center + .. :width: 300px + + Screenshot not available Usage ----- diff --git a/docs/reference/api/resources/app_paths.rst b/docs/reference/api/resources/app_paths.rst index e4b174a59d..12fa3e48d0 100644 --- a/docs/reference/api/resources/app_paths.rst +++ b/docs/reference/api/resources/app_paths.rst @@ -25,7 +25,7 @@ cases, hard restrictions) over where certain file types should be stored. For example, macOS provides the ``~/Library/Application Support`` folder; Linux encourages use of the ``~/.config`` folder (amongst others), and Windows provides the ``AppData/Local`` folder in the user's home directory. Application -sandbox and security policies will prevent sometimes prevent reading or +sandbox and security policies will sometimes prevent reading or writing files in any location other than these pre-approved locations. To assist with finding an appropriate location to store application files, every diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index ed58049084..9eb805f59e 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -1,6 +1,8 @@ Command ======= +A representation of app functionality that the user can invoke from menus or toolbars. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -12,7 +14,73 @@ Command Usage ----- +Aside from event handlers on widgets, most GUI toolkits also provide other ways for +the user to give instructions to an app. In Toga, these UI patterns are supported +by the :class:`~toga.Command` class. + +A command encapsulates a piece of functionality that the user can invoke - no matter how +they invoke it. It doesn't matter if they select a menu item, press a button on a +toolbar, or use a key combination - the functionality is wrapped up in a Command. + +Commands are added to an app using the properties :any:`toga.App.commands` and +:any:`toga.Window.toolbar`. Toga then takes control of ensuring that the +command is exposed to the user in a way that they can access. On desktop platforms, +this may result in a command being added to a menu. + +Commands can be organized into a :class:`~toga.Group` of similar commands. Groups are +hierarchical, so a group can contain a sub-group, which can contain a sub-group, and so +on. Inside a group, commands can be organized into sections. + +For example: + +.. code-block:: python + + import toga + + def callback(sender, **kwargs): + print("Command activated") + + stuff_group = Group('Stuff', order=40) + + cmd1 = toga.Command( + callback, + label='Example command', + tooltip='Tells you when it has been activated', + shortcut=toga.Key.MOD_1 + 'k', + icon='icons/pretty.png', + group=stuff_group, + section=0 + ) + cmd2 = toga.Command( + ... + ) + ... + + app.commands.add(cmd1, cmd4, cmd3) + app.main_window.toolbar.add(cmd2, cmd3) + +This code defines a command ``cmd1`` that will be placed in the first section of the +"Stuff" group. It can be activated by pressing CTRL-k (or CMD-K on a Mac). + +The definitions for ``cmd2``, ``cmd3``, and ``cmd4`` have been omitted, but would follow +a similar pattern. + +It doesn't matter what order you add commands to the app - the group, section and order +will be used to display the commands in the right order. + +If a command is added to a toolbar, it will automatically be added to the app +as well. It isn't possible to have functionality exposed on a toolbar that +isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though +it wasn't explicitly added to the app commands. + + Reference --------- .. autoclass:: toga.Command + :exclude-members: key + +.. autoclass:: toga.Group + :exclude-members: key + +.. autoprotocol:: toga.command.ActionHandler diff --git a/docs/reference/api/resources/group.rst b/docs/reference/api/resources/group.rst deleted file mode 100644 index 8f1354846c..0000000000 --- a/docs/reference/api/resources/group.rst +++ /dev/null @@ -1,19 +0,0 @@ -Group -===== - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Group|Component))'} - - - -Usage ------ - -Reference ---------- - -.. autoclass:: toga.Group diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 7f2d6e94d1..0544de2680 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -6,7 +6,6 @@ Resources app_paths fonts command - group icons images sources/source diff --git a/docs/reference/api/widgets/activityindicator.rst b/docs/reference/api/widgets/activityindicator.rst index 168bc99625..0a19d4be23 100644 --- a/docs/reference/api/widgets/activityindicator.rst +++ b/docs/reference/api/widgets/activityindicator.rst @@ -4,15 +4,43 @@ ActivityIndicator A small animated indicator showing activity on a task of indeterminate length, usually rendered as a "spinner" animation. -.. figure:: /reference/images/ActivityIndicator.jpeg - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(ActivityIndicator|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/activityindicator-cocoa.png + :align: center + :width: 100px + + .. group-tab:: Linux + + .. figure:: /reference/images/activityindicator-gtk.png + :align: center + :width: 100px + + .. group-tab:: Windows |no| + + Not supported + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/activityindicator-web.png + .. :align: center + .. :width: 100px + + Screenshot not available + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/button.rst b/docs/reference/api/widgets/button.rst index 2f6b3118ae..cb588d43ea 100644 --- a/docs/reference/api/widgets/button.rst +++ b/docs/reference/api/widgets/button.rst @@ -3,16 +3,53 @@ Button A button that can be pressed or clicked. -.. figure:: /reference/images/Button.jpeg - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Button|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/button-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/button-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/button-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/button-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/button-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/button-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |beta| + + .. .. figure:: /reference/images/button-textual.png + .. :align: center + .. :width: 300px + + Screenshot not available Usage ----- diff --git a/docs/reference/api/widgets/canvas.rst b/docs/reference/api/widgets/canvas.rst index 2cc384edd8..8f184520da 100644 --- a/docs/reference/api/widgets/canvas.rst +++ b/docs/reference/api/widgets/canvas.rst @@ -3,16 +3,45 @@ Canvas A drawing area for 2D vector graphics. -.. figure:: /reference/images/Canvas.png - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Canvas|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/canvas-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/canvas-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/canvas-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/canvas-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/canvas-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/dateinput.rst b/docs/reference/api/widgets/dateinput.rst index 670a889e1f..2854869094 100644 --- a/docs/reference/api/widgets/dateinput.rst +++ b/docs/reference/api/widgets/dateinput.rst @@ -3,16 +3,39 @@ DateInput A widget to select a calendar date. -.. figure:: /reference/images/DateInput.png - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(DateInput|Component))'} +.. tabs:: + + .. group-tab:: macOS |no| + + Not supported + + .. group-tab:: Linux |no| + + Not supported + + .. group-tab:: Windows + + .. figure:: /reference/images/dateinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/dateinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/detailedlist.rst b/docs/reference/api/widgets/detailedlist.rst index 9ba0e20be6..f679552281 100644 --- a/docs/reference/api/widgets/detailedlist.rst +++ b/docs/reference/api/widgets/detailedlist.rst @@ -1,19 +1,46 @@ DetailedList ============ -An ordered list where each item has an icon, a title, and a line of text. Scroll bars -will be provided if necessary. - -.. figure:: /reference/images/DetailedList.png - :width: 300px - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(DetailedList|Component))'} + +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/detailedlist-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/detailedlist-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows |beta| + + .. figure:: /reference/images/detailedlist-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android + + .. figure:: /reference/images/detailedlist-android.png + :align: center + :width: 450px + + .. group-tab:: iOS + + .. figure:: /reference/images/detailedlist-iOS.png + :align: center + :width: 450px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/divider.rst b/docs/reference/api/widgets/divider.rst index c5d34a3db1..d2a8fa9a8b 100644 --- a/docs/reference/api/widgets/divider.rst +++ b/docs/reference/api/widgets/divider.rst @@ -3,15 +3,47 @@ Divider A separator used to visually distinguish two sections of content in a layout. -.. figure:: /reference/images/Divider.jpeg - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Divider|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/divider-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/divider-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/divider-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/divider-android.png + :align: center + :width: 300px + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/divider-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/imageview.rst b/docs/reference/api/widgets/imageview.rst index 7ff4cd1bdd..90f9a0da47 100644 --- a/docs/reference/api/widgets/imageview.rst +++ b/docs/reference/api/widgets/imageview.rst @@ -1,15 +1,48 @@ ImageView ========= -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(ImageView|Component)$)'} - A widget that displays an image. +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/imageview.png + :align: center + :width: 150px + + .. group-tab:: Linux + + .. figure:: /reference/images/imageview.png + :align: center + :width: 150px + + .. group-tab:: Windows + + .. figure:: /reference/images/imageview.png + :align: center + :width: 150px + + .. group-tab:: Android + + .. figure:: /reference/images/imageview.png + :align: center + :width: 150px + + .. group-tab:: iOS + + .. figure:: /reference/images/imageview.png + :align: center + :width: 150px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported + Usage ----- diff --git a/docs/reference/api/widgets/label.rst b/docs/reference/api/widgets/label.rst index a0f1bc1591..cfc5119e0c 100644 --- a/docs/reference/api/widgets/label.rst +++ b/docs/reference/api/widgets/label.rst @@ -3,15 +3,53 @@ Label A text label for annotating forms or interfaces. -.. figure:: /reference/images/Label.jpeg - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Label|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/label-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/label-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/label-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/label-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/label-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/label-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |beta| + + .. .. figure:: /reference/images/label-textual.png + .. :align: center + .. :width: 300px + + Screenshot not available Usage ----- diff --git a/docs/reference/api/widgets/multilinetextinput.rst b/docs/reference/api/widgets/multilinetextinput.rst index 8dbf204692..35fbe4f176 100644 --- a/docs/reference/api/widgets/multilinetextinput.rst +++ b/docs/reference/api/widgets/multilinetextinput.rst @@ -3,16 +3,45 @@ MultilineTextInput A scrollable panel that allows for the display and editing of multiple lines of text. -.. figure:: /reference/images/MultilineTextInput.png - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(MultilineTextInput|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/multilinetextinput-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/multilinetextinput-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/multilinetextinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/multilinetextinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/multilinetextinput-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/numberinput.rst b/docs/reference/api/widgets/numberinput.rst index bb05f5cac2..68bde9474d 100644 --- a/docs/reference/api/widgets/numberinput.rst +++ b/docs/reference/api/widgets/numberinput.rst @@ -3,15 +3,45 @@ NumberInput A text input that is limited to numeric input. -.. figure:: /reference/images/NumberInput.png - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(NumberInput|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/numberinput-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/numberinput-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/numberinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/numberinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/numberinput-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/passwordinput.rst b/docs/reference/api/widgets/passwordinput.rst index 0f59d097f3..6ecfc810d9 100644 --- a/docs/reference/api/widgets/passwordinput.rst +++ b/docs/reference/api/widgets/passwordinput.rst @@ -5,17 +5,45 @@ A widget to allow the entry of a password. Any value typed by the user will be obscured, allowing the user to see the number of characters they have typed, but not the actual characters. -.. figure:: /reference/images/PasswordInput.png - :align: center - :width: 300 +.. tabs:: -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(PasswordInput|Component)$)'} + .. group-tab:: macOS + .. figure:: /reference/images/passwordinput-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/passwordinput-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/passwordinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/passwordinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/passwordinput-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/progressbar.rst b/docs/reference/api/widgets/progressbar.rst index 3530362d61..91185a747f 100644 --- a/docs/reference/api/widgets/progressbar.rst +++ b/docs/reference/api/widgets/progressbar.rst @@ -4,15 +4,49 @@ ProgressBar A horizontal bar to visualize task progress. The task being monitored can be of known or indeterminate length. -.. figure:: /reference/images/ProgressBar.jpeg - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(ProgressBar|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/progressbar-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/progressbar-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/progressbar-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/progressbar-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/progressbar-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/progressbar-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/selection.rst b/docs/reference/api/widgets/selection.rst index 6063d59cb8..d0ba61a19a 100644 --- a/docs/reference/api/widgets/selection.rst +++ b/docs/reference/api/widgets/selection.rst @@ -3,16 +3,45 @@ Selection A widget to select a single option from a list of alternatives. -.. figure:: /reference/images/Selection.png - :align: center +.. tabs:: -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Selection|Component)$)'} + .. group-tab:: macOS + .. figure:: /reference/images/selection-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/selection-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/selection-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/selection-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/selection-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/slider.rst b/docs/reference/api/widgets/slider.rst index c698d017b4..a2569d5bdb 100644 --- a/docs/reference/api/widgets/slider.rst +++ b/docs/reference/api/widgets/slider.rst @@ -4,16 +4,45 @@ Slider A widget for selecting a value within a range. The range is shown as a horizontal line, and the selected value is shown as a draggable marker. -.. figure:: /reference/images/Slider.png - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Slider|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/slider-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/slider-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/slider-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/slider-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/slider-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index 81d1599242..d4081b9141 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -4,16 +4,49 @@ Switch A clickable button with two stable states: True (on, checked); and False (off, unchecked). The button has a text label. -.. figure:: /reference/images/Switch.jpeg - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Switch|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/switch-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/switch-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/switch-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/switch-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/switch-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/switch-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index a2b67c2615..5257fc0e9c 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -4,16 +4,43 @@ Table A widget for displaying columns of tabular data. Scroll bars will be provided if necessary. -.. figure:: /reference/images/Table.png - :width: 300px - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Table|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/table-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/table-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/table-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android |beta| + + .. figure:: /reference/images/table-android.png + :align: center + :width: 450px + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/textinput.rst b/docs/reference/api/widgets/textinput.rst index ee1d6f4da4..dfed425639 100644 --- a/docs/reference/api/widgets/textinput.rst +++ b/docs/reference/api/widgets/textinput.rst @@ -3,16 +3,53 @@ TextInput A widget for the display and editing of a single line of text. -.. figure:: /reference/images/TextInput.png - :align: center - :width: 300 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(TextInput|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/textinput-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/textinput-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/textinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/textinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS + + .. figure:: /reference/images/textinput-iOS.png + :align: center + :width: 300px + + .. group-tab:: Web |beta| + + .. .. figure:: /reference/images/textinput-web.png + .. :align: center + .. :width: 300px + + Screenshot not available + + .. group-tab:: Textual |beta| + + .. .. figure:: /reference/images/textinput-textual.png + .. :align: center + .. :width: 300px + + Screenshot not available Usage ----- diff --git a/docs/reference/api/widgets/timeinput.rst b/docs/reference/api/widgets/timeinput.rst index 381b4e28c1..c6d8527b72 100644 --- a/docs/reference/api/widgets/timeinput.rst +++ b/docs/reference/api/widgets/timeinput.rst @@ -3,16 +3,39 @@ TimeInput A widget to select a clock time. -.. figure:: /reference/images/TimeInput.png - :align: center - :width: 160 - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(TimeInput|Component))'} +.. tabs:: + + .. group-tab:: macOS |no| + + Not supported + + .. group-tab:: Linux |no| + + Not supported + + .. group-tab:: Windows + + .. figure:: /reference/images/timeinput-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android + + .. figure:: /reference/images/timeinput-android.png + :align: center + :width: 300px + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index 07a1da4318..9bb05f5706 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -4,16 +4,39 @@ Tree A widget for displaying a hierarchical tree of tabular data. Scroll bars will be provided if necessary. -.. figure:: /reference/images/Tree.png - :width: 300px - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Tree|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/tree-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/tree-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows |no| + + Not supported + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/widgets/webview.rst b/docs/reference/api/widgets/webview.rst index 9068d912ba..6fbcf2c2a6 100644 --- a/docs/reference/api/widgets/webview.rst +++ b/docs/reference/api/widgets/webview.rst @@ -3,15 +3,45 @@ WebView An embedded web browser. -.. figure:: /reference/images/WebView.jpeg - :align: center - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(WebView|Component)$)'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/webview-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/webview-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/webview-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android + + .. figure:: /reference/images/webview-android.png + :align: center + :width: 450px + + .. group-tab:: iOS + + .. figure:: /reference/images/webview-iOS.png + :align: center + :width: 450px + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index f622a9cd5f..f0730f0968 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -3,16 +3,41 @@ Window An operating system-managed container of widgets. -.. figure:: /reference/images/Window.png - :align: center - :width: 300px - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Window|Component))'} +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/window-cocoa.png + :align: center + :width: 300px + + .. group-tab:: Linux + + .. figure:: /reference/images/window-gtk.png + :align: center + :width: 300px + + .. group-tab:: Windows + + .. figure:: /reference/images/window-winforms.png + :align: center + :width: 300px + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported Usage ----- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index ca5d10cadf..d671c8fa57 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,13 +1,14 @@ Component,Type,Component,Description,macOS,GTK,Windows,iOS,Android,Web,Terminal -Application,Core Component,:class:`~toga.App`,The application itself,|b|,|b|,|b|,|b|,|b|,|b|,|b| -Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|y|,|y|,|y|,|y|,|y|,|b|,|b| +Application,Core Component,:class:`~toga.App`,The application itself,|y|,|y|,|y|,|y|,|y|,|b|,|b| +DocumentApp,Core Component,:class:`~toga.DocumentApp`,An application that manages documents.,|b|,|b|,,,,, +Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|y|,|y|,|y|,,,, MainWindow,Core Component,:class:`~toga.MainWindow`,The main window of the application.,|y|,|y|,|y|,|y|,|y|,|b|,|b| ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning activity animation,|y|,|y|,,,,|b|, Button,General Widget,:class:`~toga.Button`,Basic clickable Button,|y|,|y|,|y|,|y|,|y|,|b|,|b| Canvas,General Widget,:class:`~toga.Canvas`,A drawing area for 2D vector graphics.,|y|,|y|,|y|,|y|,|y|,, DateInput,General Widget,:class:`~toga.DateInput`,A widget to select a calendar date,,,|y|,,|y|,|b|, DetailedList,General Widget,:class:`~toga.DetailedList`,"An ordered list of content where each item has an icon, a main heading, and a line of supplementary text.",|y|,|y|,|b|,|y|,|y|,, -Divider,General Widget,:class:`~toga.Divider`,A horizontal or vertical line,|y|,|y|,|y|,,,|b|, +Divider,General Widget,:class:`~toga.Divider`,A horizontal or vertical line,|y|,|y|,|y|,,|y|,|b|, ImageView,General Widget,:class:`~toga.ImageView`,A widget that displays an image,|y|,|y|,|y|,|y|,|y|,, Label,General Widget,:class:`~toga.Label`,Text label,|y|,|y|,|y|,|y|,|y|,|b|,|b| MultilineTextInput,General Widget,:class:`~toga.MultilineTextInput`,Multi-line Text Input field,|y|,|y|,|y|,|y|,|y|,, @@ -29,7 +30,6 @@ SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divi OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, -Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|,, -Group,Resource,:class:`~toga.Group`,Command group,|b|,|b|,|b|,|b|,|b|,, +Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b| Image,Resource,:class:`~toga.Image`,An image,|y|,|y|,|y|,|y|,|y|,, diff --git a/docs/reference/images/ActivityIndicator.jpeg b/docs/reference/images/ActivityIndicator.jpeg deleted file mode 100644 index 31804075bf..0000000000 Binary files a/docs/reference/images/ActivityIndicator.jpeg and /dev/null differ diff --git a/docs/reference/images/Button.jpeg b/docs/reference/images/Button.jpeg deleted file mode 100644 index b5de5b73bc..0000000000 Binary files a/docs/reference/images/Button.jpeg and /dev/null differ diff --git a/docs/reference/images/Canvas.png b/docs/reference/images/Canvas.png deleted file mode 100644 index 8c2b92a184..0000000000 Binary files a/docs/reference/images/Canvas.png and /dev/null differ diff --git a/docs/reference/images/DateInput.png b/docs/reference/images/DateInput.png deleted file mode 100755 index 7bf3c6ac5d..0000000000 Binary files a/docs/reference/images/DateInput.png and /dev/null differ diff --git a/docs/reference/images/DetailedList.png b/docs/reference/images/DetailedList.png deleted file mode 100644 index 9daa34faca..0000000000 Binary files a/docs/reference/images/DetailedList.png and /dev/null differ diff --git a/docs/reference/images/Divider.jpeg b/docs/reference/images/Divider.jpeg deleted file mode 100644 index 7ed71a3357..0000000000 Binary files a/docs/reference/images/Divider.jpeg and /dev/null differ diff --git a/docs/reference/images/Label.jpeg b/docs/reference/images/Label.jpeg deleted file mode 100644 index c1004c493b..0000000000 Binary files a/docs/reference/images/Label.jpeg and /dev/null differ diff --git a/docs/reference/images/MainWindow.png b/docs/reference/images/MainWindow.png deleted file mode 100644 index b8d72e6fd0..0000000000 Binary files a/docs/reference/images/MainWindow.png and /dev/null differ diff --git a/docs/reference/images/MultilineTextInput.png b/docs/reference/images/MultilineTextInput.png deleted file mode 100644 index 5e0205d378..0000000000 Binary files a/docs/reference/images/MultilineTextInput.png and /dev/null differ diff --git a/docs/reference/images/NumberInput.png b/docs/reference/images/NumberInput.png deleted file mode 100644 index 5929344b4d..0000000000 Binary files a/docs/reference/images/NumberInput.png and /dev/null differ diff --git a/docs/reference/images/OptionContainer.png b/docs/reference/images/OptionContainer.png deleted file mode 100644 index 13c1f6f4f9..0000000000 Binary files a/docs/reference/images/OptionContainer.png and /dev/null differ diff --git a/docs/reference/images/PasswordInput.png b/docs/reference/images/PasswordInput.png deleted file mode 100644 index a8b588f089..0000000000 Binary files a/docs/reference/images/PasswordInput.png and /dev/null differ diff --git a/docs/reference/images/ProgressBar.jpeg b/docs/reference/images/ProgressBar.jpeg deleted file mode 100644 index e5feb8a8e4..0000000000 Binary files a/docs/reference/images/ProgressBar.jpeg and /dev/null differ diff --git a/docs/reference/images/ScrollContainer.png b/docs/reference/images/ScrollContainer.png deleted file mode 100644 index 919121bf21..0000000000 Binary files a/docs/reference/images/ScrollContainer.png and /dev/null differ diff --git a/docs/reference/images/Selection.png b/docs/reference/images/Selection.png deleted file mode 100644 index 7f94ef8243..0000000000 Binary files a/docs/reference/images/Selection.png and /dev/null differ diff --git a/docs/reference/images/Slider.png b/docs/reference/images/Slider.png deleted file mode 100644 index 0bdd7a77ba..0000000000 Binary files a/docs/reference/images/Slider.png and /dev/null differ diff --git a/docs/reference/images/SplitContainer.png b/docs/reference/images/SplitContainer.png deleted file mode 100644 index d45e981406..0000000000 Binary files a/docs/reference/images/SplitContainer.png and /dev/null differ diff --git a/docs/reference/images/Switch.jpeg b/docs/reference/images/Switch.jpeg deleted file mode 100644 index 450717d5ee..0000000000 Binary files a/docs/reference/images/Switch.jpeg and /dev/null differ diff --git a/docs/reference/images/Table.png b/docs/reference/images/Table.png deleted file mode 100644 index 51179c16ac..0000000000 Binary files a/docs/reference/images/Table.png and /dev/null differ diff --git a/docs/reference/images/TextInput.png b/docs/reference/images/TextInput.png deleted file mode 100644 index fc2d45ea27..0000000000 Binary files a/docs/reference/images/TextInput.png and /dev/null differ diff --git a/docs/reference/images/TimeInput.png b/docs/reference/images/TimeInput.png deleted file mode 100755 index 82fe39f14a..0000000000 Binary files a/docs/reference/images/TimeInput.png and /dev/null differ diff --git a/docs/reference/images/Tree.png b/docs/reference/images/Tree.png deleted file mode 100644 index 9dd61810ca..0000000000 Binary files a/docs/reference/images/Tree.png and /dev/null differ diff --git a/docs/reference/images/WebView.jpeg b/docs/reference/images/WebView.jpeg deleted file mode 100644 index 0451d48426..0000000000 Binary files a/docs/reference/images/WebView.jpeg and /dev/null differ diff --git a/docs/reference/images/Window.png b/docs/reference/images/Window.png deleted file mode 100644 index 2efa3ce470..0000000000 Binary files a/docs/reference/images/Window.png and /dev/null differ diff --git a/docs/reference/images/activityindicator-cocoa.png b/docs/reference/images/activityindicator-cocoa.png new file mode 100644 index 0000000000..faf6b69f8f Binary files /dev/null and b/docs/reference/images/activityindicator-cocoa.png differ diff --git a/docs/reference/images/activityindicator-gtk.png b/docs/reference/images/activityindicator-gtk.png new file mode 100644 index 0000000000..fe04f7533d Binary files /dev/null and b/docs/reference/images/activityindicator-gtk.png differ diff --git a/docs/reference/images/button-android.png b/docs/reference/images/button-android.png new file mode 100644 index 0000000000..4df42ee9fb Binary files /dev/null and b/docs/reference/images/button-android.png differ diff --git a/docs/reference/images/button-cocoa.png b/docs/reference/images/button-cocoa.png new file mode 100644 index 0000000000..1d7d7f888b Binary files /dev/null and b/docs/reference/images/button-cocoa.png differ diff --git a/docs/reference/images/button-gtk.png b/docs/reference/images/button-gtk.png new file mode 100644 index 0000000000..d6d4bb556b Binary files /dev/null and b/docs/reference/images/button-gtk.png differ diff --git a/docs/reference/images/button-iOS.png b/docs/reference/images/button-iOS.png new file mode 100644 index 0000000000..5834ddb36d Binary files /dev/null and b/docs/reference/images/button-iOS.png differ diff --git a/docs/reference/images/button-winforms.png b/docs/reference/images/button-winforms.png new file mode 100644 index 0000000000..f19fa751b1 Binary files /dev/null and b/docs/reference/images/button-winforms.png differ diff --git a/docs/reference/images/canvas-android.png b/docs/reference/images/canvas-android.png new file mode 100644 index 0000000000..a67d70e75e Binary files /dev/null and b/docs/reference/images/canvas-android.png differ diff --git a/docs/reference/images/canvas-cocoa.png b/docs/reference/images/canvas-cocoa.png new file mode 100644 index 0000000000..687f076004 Binary files /dev/null and b/docs/reference/images/canvas-cocoa.png differ diff --git a/docs/reference/images/canvas-gtk.png b/docs/reference/images/canvas-gtk.png new file mode 100644 index 0000000000..458308995c Binary files /dev/null and b/docs/reference/images/canvas-gtk.png differ diff --git a/docs/reference/images/canvas-iOS.png b/docs/reference/images/canvas-iOS.png new file mode 100644 index 0000000000..c171913ff3 Binary files /dev/null and b/docs/reference/images/canvas-iOS.png differ diff --git a/docs/reference/images/canvas-winforms.png b/docs/reference/images/canvas-winforms.png new file mode 100644 index 0000000000..9386dae82a Binary files /dev/null and b/docs/reference/images/canvas-winforms.png differ diff --git a/docs/reference/images/dateinput-android.png b/docs/reference/images/dateinput-android.png new file mode 100644 index 0000000000..de857eb42a Binary files /dev/null and b/docs/reference/images/dateinput-android.png differ diff --git a/docs/reference/images/dateinput-winforms.png b/docs/reference/images/dateinput-winforms.png new file mode 100644 index 0000000000..cdc1e4f103 Binary files /dev/null and b/docs/reference/images/dateinput-winforms.png differ diff --git a/docs/reference/images/detailedlist-android.png b/docs/reference/images/detailedlist-android.png new file mode 100644 index 0000000000..038da17583 Binary files /dev/null and b/docs/reference/images/detailedlist-android.png differ diff --git a/docs/reference/images/detailedlist-cocoa.png b/docs/reference/images/detailedlist-cocoa.png new file mode 100644 index 0000000000..0fd3667458 Binary files /dev/null and b/docs/reference/images/detailedlist-cocoa.png differ diff --git a/docs/reference/images/detailedlist-gtk.png b/docs/reference/images/detailedlist-gtk.png new file mode 100644 index 0000000000..5ffdca94e9 Binary files /dev/null and b/docs/reference/images/detailedlist-gtk.png differ diff --git a/docs/reference/images/detailedlist-iOS.png b/docs/reference/images/detailedlist-iOS.png new file mode 100644 index 0000000000..d76789eb1e Binary files /dev/null and b/docs/reference/images/detailedlist-iOS.png differ diff --git a/docs/reference/images/detailedlist-winforms.png b/docs/reference/images/detailedlist-winforms.png new file mode 100644 index 0000000000..526407ce43 Binary files /dev/null and b/docs/reference/images/detailedlist-winforms.png differ diff --git a/docs/reference/images/divider-android.png b/docs/reference/images/divider-android.png new file mode 100644 index 0000000000..3318af431c Binary files /dev/null and b/docs/reference/images/divider-android.png differ diff --git a/docs/reference/images/divider-cocoa.png b/docs/reference/images/divider-cocoa.png new file mode 100644 index 0000000000..69cc675f48 Binary files /dev/null and b/docs/reference/images/divider-cocoa.png differ diff --git a/docs/reference/images/divider-gtk.png b/docs/reference/images/divider-gtk.png new file mode 100644 index 0000000000..a208030a50 Binary files /dev/null and b/docs/reference/images/divider-gtk.png differ diff --git a/docs/reference/images/divider-winforms.png b/docs/reference/images/divider-winforms.png new file mode 100644 index 0000000000..8b9d9332fc Binary files /dev/null and b/docs/reference/images/divider-winforms.png differ diff --git a/docs/reference/images/imageview.png b/docs/reference/images/imageview.png new file mode 100644 index 0000000000..29ab02cb6d Binary files /dev/null and b/docs/reference/images/imageview.png differ diff --git a/docs/reference/images/label-android.png b/docs/reference/images/label-android.png new file mode 100644 index 0000000000..667dc4da6a Binary files /dev/null and b/docs/reference/images/label-android.png differ diff --git a/docs/reference/images/label-cocoa.png b/docs/reference/images/label-cocoa.png new file mode 100644 index 0000000000..f69e897f42 Binary files /dev/null and b/docs/reference/images/label-cocoa.png differ diff --git a/docs/reference/images/label-gtk.png b/docs/reference/images/label-gtk.png new file mode 100644 index 0000000000..aa3a22ec9e Binary files /dev/null and b/docs/reference/images/label-gtk.png differ diff --git a/docs/reference/images/label-iOS.png b/docs/reference/images/label-iOS.png new file mode 100644 index 0000000000..e1c1eece5b Binary files /dev/null and b/docs/reference/images/label-iOS.png differ diff --git a/docs/reference/images/label-winforms.png b/docs/reference/images/label-winforms.png new file mode 100644 index 0000000000..bc092823a0 Binary files /dev/null and b/docs/reference/images/label-winforms.png differ diff --git a/docs/reference/images/mainwindow-android.png b/docs/reference/images/mainwindow-android.png new file mode 100644 index 0000000000..dcf30a08c2 Binary files /dev/null and b/docs/reference/images/mainwindow-android.png differ diff --git a/docs/reference/images/mainwindow-cocoa.png b/docs/reference/images/mainwindow-cocoa.png new file mode 100644 index 0000000000..74bc9e1840 Binary files /dev/null and b/docs/reference/images/mainwindow-cocoa.png differ diff --git a/docs/reference/images/mainwindow-gtk.png b/docs/reference/images/mainwindow-gtk.png new file mode 100644 index 0000000000..f4ed9727a2 Binary files /dev/null and b/docs/reference/images/mainwindow-gtk.png differ diff --git a/docs/reference/images/mainwindow-iOS.png b/docs/reference/images/mainwindow-iOS.png new file mode 100644 index 0000000000..09ef4c73b7 Binary files /dev/null and b/docs/reference/images/mainwindow-iOS.png differ diff --git a/docs/reference/images/mainwindow-winforms.png b/docs/reference/images/mainwindow-winforms.png new file mode 100644 index 0000000000..557037e0aa Binary files /dev/null and b/docs/reference/images/mainwindow-winforms.png differ diff --git a/docs/reference/images/multilinetextinput-android.png b/docs/reference/images/multilinetextinput-android.png new file mode 100644 index 0000000000..496d938a58 Binary files /dev/null and b/docs/reference/images/multilinetextinput-android.png differ diff --git a/docs/reference/images/multilinetextinput-cocoa.png b/docs/reference/images/multilinetextinput-cocoa.png new file mode 100644 index 0000000000..19e6e0bf69 Binary files /dev/null and b/docs/reference/images/multilinetextinput-cocoa.png differ diff --git a/docs/reference/images/multilinetextinput-gtk.png b/docs/reference/images/multilinetextinput-gtk.png new file mode 100644 index 0000000000..255c1113c5 Binary files /dev/null and b/docs/reference/images/multilinetextinput-gtk.png differ diff --git a/docs/reference/images/multilinetextinput-iOS.png b/docs/reference/images/multilinetextinput-iOS.png new file mode 100644 index 0000000000..972a1bbc56 Binary files /dev/null and b/docs/reference/images/multilinetextinput-iOS.png differ diff --git a/docs/reference/images/multilinetextinput-winforms.png b/docs/reference/images/multilinetextinput-winforms.png new file mode 100644 index 0000000000..c462607815 Binary files /dev/null and b/docs/reference/images/multilinetextinput-winforms.png differ diff --git a/docs/reference/images/numberinput-android.png b/docs/reference/images/numberinput-android.png new file mode 100644 index 0000000000..90092374ca Binary files /dev/null and b/docs/reference/images/numberinput-android.png differ diff --git a/docs/reference/images/numberinput-cocoa.png b/docs/reference/images/numberinput-cocoa.png new file mode 100644 index 0000000000..9502675aec Binary files /dev/null and b/docs/reference/images/numberinput-cocoa.png differ diff --git a/docs/reference/images/numberinput-gtk.png b/docs/reference/images/numberinput-gtk.png new file mode 100644 index 0000000000..67313cd1e4 Binary files /dev/null and b/docs/reference/images/numberinput-gtk.png differ diff --git a/docs/reference/images/numberinput-iOS.png b/docs/reference/images/numberinput-iOS.png new file mode 100644 index 0000000000..81ff8a4fe4 Binary files /dev/null and b/docs/reference/images/numberinput-iOS.png differ diff --git a/docs/reference/images/numberinput-winforms.png b/docs/reference/images/numberinput-winforms.png new file mode 100644 index 0000000000..6a297af34a Binary files /dev/null and b/docs/reference/images/numberinput-winforms.png differ diff --git a/docs/reference/images/optioncontainer-cocoa.png b/docs/reference/images/optioncontainer-cocoa.png new file mode 100644 index 0000000000..951d86afc2 Binary files /dev/null and b/docs/reference/images/optioncontainer-cocoa.png differ diff --git a/docs/reference/images/optioncontainer-gtk.png b/docs/reference/images/optioncontainer-gtk.png new file mode 100644 index 0000000000..461e3dba23 Binary files /dev/null and b/docs/reference/images/optioncontainer-gtk.png differ diff --git a/docs/reference/images/optioncontainer-winforms.png b/docs/reference/images/optioncontainer-winforms.png new file mode 100644 index 0000000000..2d3abf2f17 Binary files /dev/null and b/docs/reference/images/optioncontainer-winforms.png differ diff --git a/docs/reference/images/passwordinput-android.png b/docs/reference/images/passwordinput-android.png new file mode 100644 index 0000000000..91e5b13eae Binary files /dev/null and b/docs/reference/images/passwordinput-android.png differ diff --git a/docs/reference/images/passwordinput-cocoa.png b/docs/reference/images/passwordinput-cocoa.png new file mode 100644 index 0000000000..e90efd3262 Binary files /dev/null and b/docs/reference/images/passwordinput-cocoa.png differ diff --git a/docs/reference/images/passwordinput-gtk.png b/docs/reference/images/passwordinput-gtk.png new file mode 100644 index 0000000000..03064bdda9 Binary files /dev/null and b/docs/reference/images/passwordinput-gtk.png differ diff --git a/docs/reference/images/passwordinput-iOS.png b/docs/reference/images/passwordinput-iOS.png new file mode 100644 index 0000000000..7c8f8feeec Binary files /dev/null and b/docs/reference/images/passwordinput-iOS.png differ diff --git a/docs/reference/images/passwordinput-winforms.png b/docs/reference/images/passwordinput-winforms.png new file mode 100644 index 0000000000..82220d5376 Binary files /dev/null and b/docs/reference/images/passwordinput-winforms.png differ diff --git a/docs/reference/images/progressbar-android.png b/docs/reference/images/progressbar-android.png new file mode 100644 index 0000000000..f6470f2498 Binary files /dev/null and b/docs/reference/images/progressbar-android.png differ diff --git a/docs/reference/images/progressbar-cocoa.png b/docs/reference/images/progressbar-cocoa.png new file mode 100644 index 0000000000..b97105bc79 Binary files /dev/null and b/docs/reference/images/progressbar-cocoa.png differ diff --git a/docs/reference/images/progressbar-gtk.png b/docs/reference/images/progressbar-gtk.png new file mode 100644 index 0000000000..43a404746c Binary files /dev/null and b/docs/reference/images/progressbar-gtk.png differ diff --git a/docs/reference/images/progressbar-iOS.png b/docs/reference/images/progressbar-iOS.png new file mode 100644 index 0000000000..40cdc6c96c Binary files /dev/null and b/docs/reference/images/progressbar-iOS.png differ diff --git a/docs/reference/images/progressbar-winforms.png b/docs/reference/images/progressbar-winforms.png new file mode 100644 index 0000000000..98c3f8ad6e Binary files /dev/null and b/docs/reference/images/progressbar-winforms.png differ diff --git a/docs/reference/images/scrollcontainer-android.png b/docs/reference/images/scrollcontainer-android.png new file mode 100644 index 0000000000..115318b02d Binary files /dev/null and b/docs/reference/images/scrollcontainer-android.png differ diff --git a/docs/reference/images/scrollcontainer-cocoa.png b/docs/reference/images/scrollcontainer-cocoa.png new file mode 100644 index 0000000000..c0210b90b9 Binary files /dev/null and b/docs/reference/images/scrollcontainer-cocoa.png differ diff --git a/docs/reference/images/scrollcontainer-gtk.png b/docs/reference/images/scrollcontainer-gtk.png new file mode 100644 index 0000000000..1767fbc81d Binary files /dev/null and b/docs/reference/images/scrollcontainer-gtk.png differ diff --git a/docs/reference/images/scrollcontainer-iOS.png b/docs/reference/images/scrollcontainer-iOS.png new file mode 100644 index 0000000000..4a82d02934 Binary files /dev/null and b/docs/reference/images/scrollcontainer-iOS.png differ diff --git a/docs/reference/images/scrollcontainer-winforms.png b/docs/reference/images/scrollcontainer-winforms.png new file mode 100644 index 0000000000..11d624207b Binary files /dev/null and b/docs/reference/images/scrollcontainer-winforms.png differ diff --git a/docs/reference/images/selection-android.png b/docs/reference/images/selection-android.png new file mode 100644 index 0000000000..05f6697946 Binary files /dev/null and b/docs/reference/images/selection-android.png differ diff --git a/docs/reference/images/selection-cocoa.png b/docs/reference/images/selection-cocoa.png new file mode 100644 index 0000000000..a21abf65ec Binary files /dev/null and b/docs/reference/images/selection-cocoa.png differ diff --git a/docs/reference/images/selection-gtk.png b/docs/reference/images/selection-gtk.png new file mode 100644 index 0000000000..97b7ae6b86 Binary files /dev/null and b/docs/reference/images/selection-gtk.png differ diff --git a/docs/reference/images/selection-iOS.png b/docs/reference/images/selection-iOS.png new file mode 100644 index 0000000000..eaf2df9cdf Binary files /dev/null and b/docs/reference/images/selection-iOS.png differ diff --git a/docs/reference/images/selection-winforms.png b/docs/reference/images/selection-winforms.png new file mode 100644 index 0000000000..b8b1920b30 Binary files /dev/null and b/docs/reference/images/selection-winforms.png differ diff --git a/docs/reference/images/slider-android.png b/docs/reference/images/slider-android.png new file mode 100644 index 0000000000..076a1e48e8 Binary files /dev/null and b/docs/reference/images/slider-android.png differ diff --git a/docs/reference/images/slider-cocoa.png b/docs/reference/images/slider-cocoa.png new file mode 100644 index 0000000000..c488720619 Binary files /dev/null and b/docs/reference/images/slider-cocoa.png differ diff --git a/docs/reference/images/slider-gtk.png b/docs/reference/images/slider-gtk.png new file mode 100644 index 0000000000..1d5cc06e87 Binary files /dev/null and b/docs/reference/images/slider-gtk.png differ diff --git a/docs/reference/images/slider-iOS.png b/docs/reference/images/slider-iOS.png new file mode 100644 index 0000000000..92d9695a8a Binary files /dev/null and b/docs/reference/images/slider-iOS.png differ diff --git a/docs/reference/images/slider-winforms.png b/docs/reference/images/slider-winforms.png new file mode 100644 index 0000000000..74c30ef4d4 Binary files /dev/null and b/docs/reference/images/slider-winforms.png differ diff --git a/docs/reference/images/splitcontainer-cocoa.png b/docs/reference/images/splitcontainer-cocoa.png new file mode 100644 index 0000000000..bfcdd31698 Binary files /dev/null and b/docs/reference/images/splitcontainer-cocoa.png differ diff --git a/docs/reference/images/splitcontainer-gtk.png b/docs/reference/images/splitcontainer-gtk.png new file mode 100644 index 0000000000..860591adf2 Binary files /dev/null and b/docs/reference/images/splitcontainer-gtk.png differ diff --git a/docs/reference/images/splitcontainer-winforms.png b/docs/reference/images/splitcontainer-winforms.png new file mode 100644 index 0000000000..9f49315412 Binary files /dev/null and b/docs/reference/images/splitcontainer-winforms.png differ diff --git a/docs/reference/images/switch-android.png b/docs/reference/images/switch-android.png new file mode 100644 index 0000000000..9c88e2aea5 Binary files /dev/null and b/docs/reference/images/switch-android.png differ diff --git a/docs/reference/images/switch-cocoa.png b/docs/reference/images/switch-cocoa.png new file mode 100644 index 0000000000..c2de515a5f Binary files /dev/null and b/docs/reference/images/switch-cocoa.png differ diff --git a/docs/reference/images/switch-gtk.png b/docs/reference/images/switch-gtk.png new file mode 100644 index 0000000000..ef679a893c Binary files /dev/null and b/docs/reference/images/switch-gtk.png differ diff --git a/docs/reference/images/switch-iOS.png b/docs/reference/images/switch-iOS.png new file mode 100644 index 0000000000..11215ece8f Binary files /dev/null and b/docs/reference/images/switch-iOS.png differ diff --git a/docs/reference/images/switch-winforms.png b/docs/reference/images/switch-winforms.png new file mode 100644 index 0000000000..f719732185 Binary files /dev/null and b/docs/reference/images/switch-winforms.png differ diff --git a/docs/reference/images/table-android.png b/docs/reference/images/table-android.png new file mode 100644 index 0000000000..fa7dfa1146 Binary files /dev/null and b/docs/reference/images/table-android.png differ diff --git a/docs/reference/images/table-cocoa.png b/docs/reference/images/table-cocoa.png new file mode 100644 index 0000000000..4beca508b9 Binary files /dev/null and b/docs/reference/images/table-cocoa.png differ diff --git a/docs/reference/images/table-gtk.png b/docs/reference/images/table-gtk.png new file mode 100644 index 0000000000..9e50d0d8e6 Binary files /dev/null and b/docs/reference/images/table-gtk.png differ diff --git a/docs/reference/images/table-winforms.png b/docs/reference/images/table-winforms.png new file mode 100644 index 0000000000..a95c81f500 Binary files /dev/null and b/docs/reference/images/table-winforms.png differ diff --git a/docs/reference/images/textinput-android.png b/docs/reference/images/textinput-android.png new file mode 100644 index 0000000000..42e1e4d01e Binary files /dev/null and b/docs/reference/images/textinput-android.png differ diff --git a/docs/reference/images/textinput-cocoa.png b/docs/reference/images/textinput-cocoa.png new file mode 100644 index 0000000000..574fb03106 Binary files /dev/null and b/docs/reference/images/textinput-cocoa.png differ diff --git a/docs/reference/images/textinput-gtk.png b/docs/reference/images/textinput-gtk.png new file mode 100644 index 0000000000..b7235d677e Binary files /dev/null and b/docs/reference/images/textinput-gtk.png differ diff --git a/docs/reference/images/textinput-iOS.png b/docs/reference/images/textinput-iOS.png new file mode 100644 index 0000000000..4607f0b726 Binary files /dev/null and b/docs/reference/images/textinput-iOS.png differ diff --git a/docs/reference/images/textinput-winforms.png b/docs/reference/images/textinput-winforms.png new file mode 100644 index 0000000000..4bdf2d0f53 Binary files /dev/null and b/docs/reference/images/textinput-winforms.png differ diff --git a/docs/reference/images/timeinput-android.png b/docs/reference/images/timeinput-android.png new file mode 100644 index 0000000000..c2f4a62f1b Binary files /dev/null and b/docs/reference/images/timeinput-android.png differ diff --git a/docs/reference/images/timeinput-winforms.png b/docs/reference/images/timeinput-winforms.png new file mode 100644 index 0000000000..b1e5ffd725 Binary files /dev/null and b/docs/reference/images/timeinput-winforms.png differ diff --git a/docs/reference/images/tree-cocoa.png b/docs/reference/images/tree-cocoa.png new file mode 100644 index 0000000000..40867d6bff Binary files /dev/null and b/docs/reference/images/tree-cocoa.png differ diff --git a/docs/reference/images/tree-gtk.png b/docs/reference/images/tree-gtk.png new file mode 100644 index 0000000000..ab4ac5ab7c Binary files /dev/null and b/docs/reference/images/tree-gtk.png differ diff --git a/docs/reference/images/webview-android.png b/docs/reference/images/webview-android.png new file mode 100644 index 0000000000..72f1c7a4d8 Binary files /dev/null and b/docs/reference/images/webview-android.png differ diff --git a/docs/reference/images/webview-cocoa.png b/docs/reference/images/webview-cocoa.png new file mode 100644 index 0000000000..9578bad12f Binary files /dev/null and b/docs/reference/images/webview-cocoa.png differ diff --git a/docs/reference/images/webview-gtk.png b/docs/reference/images/webview-gtk.png new file mode 100644 index 0000000000..0f24ca06d8 Binary files /dev/null and b/docs/reference/images/webview-gtk.png differ diff --git a/docs/reference/images/webview-iOS.png b/docs/reference/images/webview-iOS.png new file mode 100644 index 0000000000..454fc4d412 Binary files /dev/null and b/docs/reference/images/webview-iOS.png differ diff --git a/docs/reference/images/webview-winforms.png b/docs/reference/images/webview-winforms.png new file mode 100644 index 0000000000..1338e00bd4 Binary files /dev/null and b/docs/reference/images/webview-winforms.png differ diff --git a/docs/reference/images/window-cocoa.png b/docs/reference/images/window-cocoa.png new file mode 100644 index 0000000000..0435b80e91 Binary files /dev/null and b/docs/reference/images/window-cocoa.png differ diff --git a/docs/reference/images/window-gtk.png b/docs/reference/images/window-gtk.png new file mode 100644 index 0000000000..839f32f9a2 Binary files /dev/null and b/docs/reference/images/window-gtk.png differ diff --git a/docs/reference/images/window-winforms.png b/docs/reference/images/window-winforms.png new file mode 100644 index 0000000000..f40b0a45c0 Binary files /dev/null and b/docs/reference/images/window-winforms.png differ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 6c77e72c6e..a24a19c47a 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -16,6 +16,7 @@ codepoint coroutine CSS Ctrl +dialogs Django draggable Flexbox @@ -58,9 +59,11 @@ reStructuredText runtime scrollable scrollers +Segoe selectable Stimpy stylesheet +subclasses Subclasses substring substrings diff --git a/dummy/setup.cfg b/dummy/setup.cfg index f960856f4d..b84bab24c0 100644 --- a/dummy/setup.cfg +++ b/dummy/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 7e9f635310..0f752b5e7d 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -1,6 +1,8 @@ import asyncio +import sys +from pathlib import Path -from .utils import LoggedObject, not_required_on +from .utils import LoggedObject from .window import Window @@ -12,23 +14,24 @@ class App(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface - self.loop = asyncio.new_event_loop() + self.interface._impl = self + + self.loop = asyncio.get_event_loop() + self.create() def create(self): - self._action("create") + self._action("create App") self.interface._startup() - @not_required_on("mobile") def create_menus(self): - self._action("create menus") + self._action("create App menus") def main_loop(self): print("Starting app using Dummy backend.") self._action("main loop") - self.create() def set_main_window(self, window): - self._set_value("main_window", window) + self._action("set_main_window", window=window) def show_about_dialog(self): self._action("show_about_dialog") @@ -39,31 +42,39 @@ def beep(self): def exit(self): self._action("exit") - @not_required_on("mobile") def get_current_window(self): - self._action("get_current_window") + try: + return self._get_value("current_window", self.interface.main_window._impl) + except AttributeError: + return None - @not_required_on("mobile") - def set_current_window(self): - self._action("set_current_window") + def set_current_window(self, window): + self._action("set_current_window", window=window) + self._set_value("current_window", window._impl) - @not_required_on("mobile") def enter_full_screen(self, windows): self._action("enter_full_screen", windows=windows) - @not_required_on("mobile") def exit_full_screen(self, windows): self._action("exit_full_screen", windows=windows) - @not_required_on("mobile") def show_cursor(self): self._action("show_cursor") - @not_required_on("mobile") def hide_cursor(self): self._action("hide_cursor") + def simulate_exit(self): + self.interface.on_exit() + -@not_required_on("mobile", "web") class DocumentApp(App): - pass + def create(self): + self._action("create DocumentApp") + self.interface._startup() + + try: + # Create and show the document instance + self.interface._open(Path(sys.argv[1])) + except IndexError: + pass diff --git a/dummy/src/toga_dummy/dialogs.py b/dummy/src/toga_dummy/dialogs.py index 9bd760065b..39e23d35e1 100644 --- a/dummy/src/toga_dummy/dialogs.py +++ b/dummy/src/toga_dummy/dialogs.py @@ -1,7 +1,3 @@ -from .utils import not_required - - -@not_required # Testbed coverage is complete. class BaseDialog: def __init__(self, interface, on_result): self.interface = interface @@ -9,11 +5,10 @@ def __init__(self, interface, on_result): self.on_result = on_result def simulate_result(self, result): - self.on_result(None, result) + self.on_result(result) self.interface.future.set_result(result) -@not_required # Testbed coverage is complete. class InfoDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): super().__init__(interface, on_result=on_result) @@ -24,7 +19,6 @@ def __init__(self, interface, title, message, on_result=None): ) -@not_required # Testbed coverage is complete. class QuestionDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): super().__init__(interface, on_result=on_result) @@ -35,7 +29,6 @@ def __init__(self, interface, title, message, on_result=None): ) -@not_required # Testbed coverage is complete. class ConfirmDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): super().__init__(interface, on_result=on_result) @@ -46,7 +39,6 @@ def __init__(self, interface, title, message, on_result=None): ) -@not_required # Testbed coverage is complete. class ErrorDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): super().__init__(interface, on_result=on_result) @@ -57,7 +49,6 @@ def __init__(self, interface, title, message, on_result=None): ) -@not_required # Testbed coverage is complete. class StackTraceDialog(BaseDialog): def __init__(self, interface, title, message, content, retry, on_result=None): super().__init__(interface, on_result=on_result) @@ -70,7 +61,6 @@ def __init__(self, interface, title, message, content, retry, on_result=None): ) -@not_required # Testbed coverage is complete. class SaveFileDialog(BaseDialog): def __init__( self, @@ -91,7 +81,6 @@ def __init__( ) -@not_required # Testbed coverage is complete. class OpenFileDialog(BaseDialog): def __init__( self, @@ -112,7 +101,6 @@ def __init__( ) -@not_required # Testbed coverage is complete. class SelectFolderDialog(BaseDialog): def __init__( self, diff --git a/dummy/src/toga_dummy/documents.py b/dummy/src/toga_dummy/documents.py index a22d2bb254..9de290b969 100644 --- a/dummy/src/toga_dummy/documents.py +++ b/dummy/src/toga_dummy/documents.py @@ -5,3 +5,4 @@ class Document(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface + self.interface.read() diff --git a/dummy/src/toga_dummy/fonts.py b/dummy/src/toga_dummy/fonts.py index 833e3444e8..ff8d442123 100644 --- a/dummy/src/toga_dummy/fonts.py +++ b/dummy/src/toga_dummy/fonts.py @@ -1,7 +1,6 @@ -from .utils import LoggedObject, not_required +from .utils import LoggedObject -@not_required # Testbed coverage is complete for this class class Font(LoggedObject): def __init__(self, interface): super().__init__() diff --git a/dummy/src/toga_dummy/icons.py b/dummy/src/toga_dummy/icons.py index 98a5c66596..fd765e1dd4 100644 --- a/dummy/src/toga_dummy/icons.py +++ b/dummy/src/toga_dummy/icons.py @@ -1,7 +1,6 @@ -from .utils import LoggedObject, not_required +from .utils import LoggedObject -@not_required # Testbed coverage is complete. class Icon(LoggedObject): EXTENSIONS = [".png", ".ico"] SIZES = None diff --git a/dummy/src/toga_dummy/images.py b/dummy/src/toga_dummy/images.py index ef70b8a9eb..5949e72c40 100644 --- a/dummy/src/toga_dummy/images.py +++ b/dummy/src/toga_dummy/images.py @@ -1,7 +1,6 @@ -from .utils import LoggedObject, not_required +from .utils import LoggedObject -@not_required # Testbed coverage is complete class Image(LoggedObject): def __init__(self, interface, path=None, data=None): super().__init__() @@ -17,5 +16,8 @@ def get_width(self): def get_height(self): return 40 + def get_data(self): + return b"pretend this is PNG image data" + def save(self, path): self._action("save", path=path) diff --git a/dummy/src/toga_dummy/test_implementation.py b/dummy/src/toga_dummy/test_implementation.py deleted file mode 100644 index fbd5600254..0000000000 --- a/dummy/src/toga_dummy/test_implementation.py +++ /dev/null @@ -1,456 +0,0 @@ -import ast -import importlib -import os -import unittest -from collections import defaultdict, namedtuple - -try: - # Usually, the pattern is "import module; if it doesn't exist, - # import the shim". However, we need the 3.10 API for entry_points, - # as the 3.8 didn't support the `groups` argument to entry_points. - # Therefore, we try to import the compatibility shim first; and fall - # back to the stdlib module if the shim isn't there. - from importlib_metadata import entry_points -except ImportError: - from importlib.metadata import entry_points - -from itertools import zip_longest -from os.path import join -from pathlib import Path - -import toga_dummy - - -class NoDefault: - """This utility class to indicate that no argument default exists. - - The use of `None` is not possible because it itself could be a default argument - value. - """ - - def __eq__(self, other): - if isinstance(other, NoDefault): - return True - else: - return False - - def __repr__(self): - return "no_default" - - -FunctionArguments = namedtuple( - "FunctionArguments", ["args", "vararg", "kwarg", "kwonlyargs"] -) - - -class DefinitionExtractor: - """The DefinitionExtractor consumes a .py file and extracts information, with the - help of the 'ast' module from it. - - Non-existing files result in an empty DefinitionExtractor, this means the - all properties return empty lists or dicts. - - Args: - path (str): The path to the .py file. - """ - - def __init__(self, path, platform_category=None): - self.exists = os.path.isfile(path) - self._classes = {} - self._methods = defaultdict(dict) - self.platform = platform_category if platform_category else None - - if self.exists: - # open the file and parse it with the ast module. - with open(path) as f: - lines = f.read() - self.tree = ast.parse(lines) - self._extract_file() - - def _extract_file(self): - self._extract_classes() - self._extract_class_methods() - - @property - def class_names(self): - return self._classes.keys() - - @property - def method_names(self): - return self._methods.keys() - - def _extract_classes(self): - for node in ast.walk(self.tree): - if isinstance(node, ast.ClassDef): - if self.is_required_for_platform(node): - self._classes[node.name] = node # use the class name as the key - elif isinstance(node, ast.Assign) and node.col_offset == 0: - # Allow a class with no new methods to be defined by assigning - # from an existing class. The col_offset means we only pay - # attention to assignments at the top level of a module, not - # assignments inside method bodies. - for target in node.targets: - if isinstance(target, ast.Name): - self._classes[target.id] = node - - def is_required_for_platform(self, node): - """Checks if the class or function is required for the given platform. It looks - for a decorator with the name `not_required_on`. - - Returns: - `True` if the class/function is required for the platform. - `False` if the class/function is not required and can be dropped for this platform. - """ - if node.decorator_list: # check if a decorator list exists - for decorator in node.decorator_list: - try: - # @not_required is a bare decorator, so the decorator node - # has an `id` attribute. - # @not_required_on is a decorator factory, so the decorator - # node contains a function that has an id. - if getattr(decorator, "id", None) == "not_required": - return False - elif decorator.func.id == "not_required_on": - platforms_to_skip = [arg.s for arg in decorator.args] - if self.platform.intersection(set(platforms_to_skip)): - return False - except Exception: - pass - return True - - @staticmethod - def _get_function_defaults(node, kwonlyargs=False): - if kwonlyargs: - to_extract = node.kw_defaults - else: - to_extract = node.defaults - - defaults = [] - for default in to_extract: - if isinstance(default, ast.NameConstant): - defaults.append(default.value) - elif isinstance(default, ast.Str): - defaults.append(default.s) - elif isinstance(default, ast.Num): - defaults.append(default.n) - elif isinstance(default, ast.Tuple) or isinstance(default, ast.List): - defaults.append(default.elts) - elif isinstance(default, ast.Call): - defaults.append(default.func) - elif isinstance(default, ast.Attribute): - defaults.append(default.value) - elif isinstance(default, ast.Name): - defaults.append(default.id) - else: - raise RuntimeWarning( - 'ast classes of type "{}" can not be handled at the moment. ' - "Please implement to make this warning disappear.".format(default) - ) - return defaults - - def _extract_class_methods(self): - """Extract all the methods from the classes and save them in `self.methods`. - - Use the combination of class and method name, like so: - `.` as the key. - """ - for class_name in self._classes: - for node in ast.walk(self._classes[class_name]): - if isinstance(node, ast.FunctionDef): - if self.is_required_for_platform(node): - function_id = f"{class_name}.{node.name}" - self._methods[function_id]["node"] = node - self._methods[function_id][ - "arguments" - ] = self._extract_function_signature(node) - - def _extract_function_signature(self, node): - for node in ast.walk(node): - if isinstance(node, ast.arguments): - # Extract positional arguments and possible default values. - args = [arg.arg for arg in node.args] - args_defaults = self._get_function_defaults(node) - # Extract kwonlyargs and defaults. - kwonlyargs = [arg.arg for arg in node.kwonlyargs] - kwonlyargs_defaults = self._get_function_defaults(node, kwonlyargs=True) - - # Combine arguments and their corresponding default values, - # if no default value exists fill it with a NoDefault object. - args_plus_defaults = list( - zip_longest( - reversed(args), reversed(args_defaults), fillvalue=NoDefault() - ) - ) - kwonlyargs_plus_defaults = list( - zip_longest( - reversed(kwonlyargs), - reversed(kwonlyargs_defaults), - fillvalue=NoDefault(), - ) - ) - - vararg = node.vararg.arg if node.vararg is not None else None - kwarg = node.kwarg.arg if node.kwarg is not None else None - - return FunctionArguments( - args=args_plus_defaults, - vararg=vararg, - kwarg=kwarg, - kwonlyargs=kwonlyargs_plus_defaults, - ) - - def get_function_def(self, function_id): - return self._methods[function_id] - - def methods_of_class(self, class_name): - """Get all methods names of a class. - - Args: - class_name(str): Name of the class to extract the methods - - Returns: - Returns a `List` of (str) with all methods names of the class. - - Warnings: - Does not return inherited methods. Only methods that are present in the class and the actual .py file. - """ - methods = [] - if self.exists: - if class_name in self._classes.keys(): - class_node = self._classes[class_name] - for node in ast.walk(class_node): - if isinstance(node, ast.FunctionDef): - if self.is_required_for_platform( - node - ) and not node.name.startswith("simulate_"): - methods.append(node.name) - return methods - - -def get_platform_category(path_to_backend): - backend_name = os.path.basename(path_to_backend) - importlib.import_module(backend_name) - platform = {ep.value: ep.name for ep in entry_points(group="toga.backends")}[ - backend_name - ] - - return { - # Desktop - "macOS": {"desktop", backend_name.split("_")[-1]}, - "windows": {"desktop", backend_name.split("_")[-1]}, - "linux": {"desktop", backend_name.split("_")[-1]}, - # Mobile - "iOS": {"mobile", backend_name.split("_")[-1]}, - "android": {"mobile", backend_name.split("_")[-1]}, - }.get(platform, {platform, backend_name.split("_")[-1]}) - - -def get_required_files(platform_category, path_to_backend): - # Find the list of files in the dummy backend - # that aren't *this* file, or an __init__.py. - files = [ - str(p.relative_to(Path(__file__).parent)) - for p in Path(__file__).parent.rglob("**/*.py") - if str(p) != __file__ and p.name != "__init__.py" - ] - if "desktop" in platform_category: - for f in TOGA_DESKTOP_EXCLUDED_FILES: - files.remove(f) - if "mobile" in platform_category: - for f in TOGA_MOBILE_EXCLUDED_FILES: - files.remove(f) - if "web" in platform_category: - for f in TOGA_WEB_EXCLUDED_FILES: - files.remove(f) - if "console" in platform_category: - for f in TOGA_CONSOLE_EXCLUDED_FILES: - files.remove(f) - if "settop" in platform_category: - for f in TOGA_SETTOP_EXCLUDED_FILES: - files.remove(f) - if "watch" in platform_category: - for f in TOGA_WATCH_EXCLUDED_FILES: - files.remove(f) - - return files - - -def create_impl_tests(root): - """Calling this function with the path to a Toga backend will return the - implementation tests for this backend. - - Args: - root (str): The absolute path to a toga backend. - - Returns: - A dictionary of test classes. - """ - platform_category = get_platform_category(root) - dummy_files = collect_dummy_files(get_required_files(platform_category, root)) - tests = {} - for name, dummy_path in dummy_files: - if "widgets" in dummy_path: - path = os.path.join(root, f"widgets/{name}.py") - else: - path = os.path.join(root, f"{name}.py") - - tests.update(make_toga_impl_check_class(path, dummy_path, platform_category)) - return tests - - -TestFile = namedtuple("TestFile", ["name", "path"]) - - -def collect_dummy_files(required_files): - dummy_files = [] - toga_dummy_base = os.path.dirname(toga_dummy.__file__) - - for root, dirs, filenames in os.walk(toga_dummy_base): - for filename in filenames: - # exclude non .py filenames or start with '__' - if filename.startswith("__") or not filename.endswith(".py"): - continue - - full_filename = os.path.join(root, filename)[len(toga_dummy_base) + 1 :] - if full_filename in required_files: - f = TestFile(filename[:-3], os.path.join(root, filename)) - dummy_files.append(f) - - return dummy_files - - -def make_test_function(element, element_list, error_msg=None): - def fn(self): - self.assertIn(element, element_list, msg=error_msg if error_msg else fn.__doc__) - - return fn - - -def make_test_class(path, cls, expected, actual, skip): - class_name = f"{cls}ImplTest" - test_class = type(class_name, (unittest.TestCase,), {}) - - if skip: - test_class = unittest.skip(skip)(test_class) - - fn = make_test_function(cls, actual.class_names) - fn.__doc__ = ( - "Expect class {} to be defined in {}, to be consistent with dummy implementation" - ).format(cls, path) - test_class.test_class_exists = fn - - for method in expected.methods_of_class(cls): - # create a test that checks if the method exists in the class. - fn = make_test_function(method, actual.methods_of_class(cls)) - fn.__doc__ = f"The method {cls}.{method}(...) exists" - setattr(test_class, f"test_{method}_exists", fn) - - # create tests that check for the right method arguments. - method_id = f"{cls}.{method}" - method_def = expected.get_function_def(method_id)["arguments"] - try: - actual_method_def = actual.get_function_def(method_id)["arguments"] - except KeyError: - actual_method_def = None - - if actual_method_def: - # Create test whether the method takes the right arguments - # and if the arguments have the right name. - - # ARGS - for arg in method_def.args: - fn = make_test_function(arg, actual_method_def.args) - fn.__doc__ = "The argument {}.{}(..., {}={}, ...) exists".format( - cls, method, *arg - ) - setattr( - test_class, "test_{}_arg_{}_default_{}".format(method, *arg), fn - ) - - # *varargs - if method_def.vararg: - vararg = method_def.vararg - actual_vararg = ( - actual_method_def.vararg if actual_method_def.vararg else [] - ) - fn = make_test_function(vararg, actual_vararg) - fn.__doc__ = f"The vararg {cls}.{method}(..., *{vararg}, ...) exists" - setattr(test_class, f"test_{method}_vararg_{vararg}", fn) - - # **kwarg - if method_def.kwarg: - kwarg = method_def.kwarg - actual_kwarg = ( - actual_method_def.kwarg if actual_method_def.kwarg else [] - ) - fn = make_test_function( - kwarg, - actual_kwarg, - error_msg="The method does not take kwargs or the " - 'variable is not named "{}".'.format(kwarg), - ) - fn.__doc__ = ( - f"The kw argument {cls}.{method}(..., **{kwarg}, ...) exists" - ) - setattr(test_class, f"test_{method}_kw_{kwarg}", fn) - - # kwonlyargs - if method_def.kwonlyargs: - for kwonlyarg in method_def.kwonlyargs: - fn = make_test_function(kwonlyarg, actual_method_def.kwonlyargs) - fn.__doc__ = ( - "The kwonly argument {}.{}(..., {}={}, ...) exists".format( - cls, method, *kwonlyarg - ) - ) - setattr( - test_class, - "test_{}_kwonly_{}_default_{}".format(method, *kwonlyarg), - fn, - ) - - return class_name, test_class - - -def make_toga_impl_check_class(path, dummy_path, platform): - prefix = os.path.commonprefix([path, dummy_path]) - expected = DefinitionExtractor(dummy_path, platform) - if os.path.isfile(path): - skip = None - actual = DefinitionExtractor(path) - else: - skip = f"Implementation file {path[len(prefix):]} does not exist" - actual = DefinitionExtractor(path) - - test_classes = {} - - for cls in expected.class_names: - class_name, test_class = make_test_class( - path[len(prefix) :], cls, expected, actual, skip - ) - test_classes[class_name] = test_class - - return test_classes - - -# Files that do not need to be present in mobile implementations of Toga. -TOGA_MOBILE_EXCLUDED_FILES = [ - join("widgets", "splitcontainer.py"), -] - -# Files that do not need to be present in desktop implementations of Toga. -TOGA_DESKTOP_EXCLUDED_FILES = [] - -# Files do not need to be present in web implementations of Toga. -TOGA_WEB_EXCLUDED_FILES = [ - join("widgets", "splitcontainer.py"), -] - -# Files that do not need to be present in console implementations of Toga. -TOGA_CONSOLE_EXCLUDED_FILES = [] - -# Files that do not need to be present in set-top box implementations of Toga. -TOGA_SETTOP_EXCLUDED_FILES = [] - -# Files that do not need to be present in watch implementations of Toga. -TOGA_WATCH_EXCLUDED_FILES = [] diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index 629b31ec18..e5c3a772ac 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -1,5 +1,3 @@ -import sys -import unittest from unittest.mock import Mock import pytest @@ -8,49 +6,6 @@ from travertino.size import BaseIntrinsicSize -def not_required(method_or_class): - """This decorator function is used to mark methods or classes that they are not - required for interface compliance. - - :param method_or_class: The method or class to decorate - :returns: The method or class being decorated - """ - return method_or_class - - -def not_required_on(*args): - """This decorator function is used to mark methods or classes that they are not - required on certain platforms. This is only used by the implementation checks - creation mechanism. - - Examples: - >>> # Marks the function as only required on platforms that are not "mobile". - >>> @not_required_on('mobile') - >>> def open_window(): - >>> self.window.open() - - >>> # Function is not required on "mobile" and "gtk" backends. - >>> @not_required_on('mobile', 'gtk') - >>> def open_window(): - >>> self.window.open() - - :param args: The platform(s) on which the method or class isn't required. - Can accept a specific backend (e.g., `gtk`, `iOS`), or a class of platform - (e.g., `mobile`, `desktop`). - :returns: The method or class being decorated - """ - - def _dec(method_or_class): - return method_or_class - - return _dec - - -########################################################################### -# The event types that can be logged -########################################################################### - - class EventLog: # Event types that can be logged SET_VALUE = "set attribute" @@ -437,99 +392,3 @@ def assert_action_performed_with(_widget, _action, **test_data): except AttributeError as e: # None of the recorded actions match the test data. pytest.fail(str(e)) - - -########################################################################### -# Unittest widget assertions -# -# These have been (re)written in terms of Pytest assertions; this base -# class is deprecated and should not be used for new tests. -############################################################################ -class TestCase(unittest.TestCase): - def setUp(self): - EventLog.reset() - - # We use the existence of a __main__ module as a proxy for being in test - # conditions. This isn't *great*, but the __main__ module isn't meaningful - # during tests, and removing it allows us to avoid having explicit "if - # under test conditions" checks in paths.py. - if "__main__" in sys.modules: - del sys.modules["__main__"] - - def reset_event_log(self): - EventLog.reset() - - def pytest_assert(self, assertion, *args, **kwargs): - try: - return assertion(*args, **kwargs) - except AssertionError as e: - self.fail(str(e)) - - ##### - - def assertValueSet(self, _widget, _attr, value): - """Assert that the widget implementation has set an attribute to a value. - - Args: - _widget: The interface of the widget to check - _attr: The attribute that should have been set - value: The value that the attribute have been set to. - """ - self.assertEqual(self.pytest_assert(attribute_value, _widget, _attr), value) - - def assertValuesSet(self, _widget, _attr, values): - """Assert that the widget implementation has been set to multiple values. - - Args: - _widget: The interface of the widget to check - _attr: The attribute that should have been set - values: The values that the attribute have been set to. - """ - self.assertEqual(self.pytest_assert(attribute_values, _widget, _attr), values) - - def assertValueGet(self, _widget, _attr): - """Assert that the widget implementation attempted to retrieve an attribute. - - Args: - _widget: The interface of the widget to check - _attr: The attribute that should have been retrieved - """ - self.pytest_assert(assert_attribute_retrieved, _widget, _attr) - - def assertValueNotGet(self, _widget, _attr): - self.pytest_assert(assert_attribute_not_retrieved, _widget, _attr) - - def assertValueNotSet(self, _widget, _attr): - self.pytest_assert(assert_attribute_not_set, _widget, _attr) - - def assertActionNotPerformed(self, _widget, _action): - """Assert that the named action was *not* performed by a widget. - - Args: - _widget: The interface of the widget that should not have performed the action. - _action: The name of the action to check - """ - self.pytest_assert(assert_action_not_performed, _widget, _action) - - def assertActionPerformed(self, _widget, _action): - """Assert that the named action performed by a widget. - - Args: - _widget: The interface of the widget that should have performed the action. - _action: The name of the action to check - """ - self.pytest_assert(assert_action_performed, _widget, _action) - - def assertActionPerformedWith(self, _widget, _action, **test_data): - """Was the action performed with specific test data. - - Args: - _widget: The interface of the widget that should have performed the action. - _action: The name of the action to check. - **test_data: The arguments that should have been passed to the action. - - Returns: - If a matching action was performed, the full data of - the performed action if. False otherwise. - """ - self.pytest_assert(assert_action_performed_with, _widget, _action, **test_data) diff --git a/dummy/src/toga_dummy/widgets/activityindicator.py b/dummy/src/toga_dummy/widgets/activityindicator.py index 4fab7c6fde..fe648b6d65 100644 --- a/dummy/src/toga_dummy/widgets/activityindicator.py +++ b/dummy/src/toga_dummy/widgets/activityindicator.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class ActivityIndicator(Widget): def create(self): self._action("create ActivityIndicator") diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index eefe815b99..4868be0eda 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -1,7 +1,6 @@ -from ..utils import LoggedObject, not_required +from ..utils import LoggedObject -@not_required # Testbed coverage is complete for this widget. class Widget(LoggedObject): def __init__(self, interface): super().__init__() diff --git a/dummy/src/toga_dummy/widgets/box.py b/dummy/src/toga_dummy/widgets/box.py index 59ac712269..9f23c7f41f 100644 --- a/dummy/src/toga_dummy/widgets/box.py +++ b/dummy/src/toga_dummy/widgets/box.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Box(Widget): def create(self): self._action("create Box") diff --git a/dummy/src/toga_dummy/widgets/button.py b/dummy/src/toga_dummy/widgets/button.py index 3a42efabf8..0937584f2a 100644 --- a/dummy/src/toga_dummy/widgets/button.py +++ b/dummy/src/toga_dummy/widgets/button.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Button(Widget): def create(self): self._action("create Button") @@ -14,4 +12,4 @@ def set_text(self, text): self._set_value("text", text) def simulate_press(self): - self.interface.on_press(None) + self.interface.on_press() diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index 5892ebba04..32b515b292 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -2,11 +2,9 @@ from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Canvas(Widget): def create(self): self._action("create Canvas") @@ -259,22 +257,22 @@ def simulate_resize(self): # 'Mouse' button handlers def simulate_press(self, x, y): - self.interface.on_press(None, x=x, y=y) + self.interface.on_press(x=x, y=y) def simulate_activate(self, x, y): - self.interface.on_activate(None, x=x, y=y) + self.interface.on_activate(x=x, y=y) def simulate_alt_press(self, x, y): - self.interface.on_alt_press(None, x=x, y=y) + self.interface.on_alt_press(x=x, y=y) def simulate_release(self, x, y): - self.interface.on_release(None, x=x, y=y) + self.interface.on_release(x=x, y=y) def simulate_alt_release(self, x, y): - self.interface.on_alt_release(None, x=x, y=y) + self.interface.on_alt_release(x=x, y=y) def simulate_drag(self, x, y): - self.interface.on_drag(None, x=x, y=y) + self.interface.on_drag(x=x, y=y) def simulate_alt_drag(self, x, y): - self.interface.on_alt_drag(None, x=x, y=y) + self.interface.on_alt_drag(x=x, y=y) diff --git a/dummy/src/toga_dummy/widgets/dateinput.py b/dummy/src/toga_dummy/widgets/dateinput.py index b7596088b1..219f5e9af9 100644 --- a/dummy/src/toga_dummy/widgets/dateinput.py +++ b/dummy/src/toga_dummy/widgets/dateinput.py @@ -1,10 +1,8 @@ from datetime import date -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class DateInput(Widget): def create(self): self._action("create DateInput") @@ -14,7 +12,7 @@ def get_value(self): def set_value(self, value): self._set_value("value", value) - self.interface.on_change(None) + self.interface.on_change() def get_min_date(self): return self._get_value("min date", date(1901, 1, 1)) diff --git a/dummy/src/toga_dummy/widgets/detailedlist.py b/dummy/src/toga_dummy/widgets/detailedlist.py index 09e84fdbb1..df4326cc66 100644 --- a/dummy/src/toga_dummy/widgets/detailedlist.py +++ b/dummy/src/toga_dummy/widgets/detailedlist.py @@ -1,15 +1,13 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class DetailedList(Widget): def create(self): self._action("create DetailedList") def change_source(self, source): self._action("change source", source=source) - self.interface.on_select(None) + self.interface.on_select() def insert(self, index, item): self._action("insert item", index=index, item=item) @@ -43,7 +41,7 @@ def scroll_to_row(self, row): def simulate_selection(self, row): self._set_value("selection", row) - self.interface.on_select(None) + self.interface.on_select() def stimulate_refresh(self): - self.interface.on_refresh(None) + self.interface.on_refresh() diff --git a/dummy/src/toga_dummy/widgets/divider.py b/dummy/src/toga_dummy/widgets/divider.py index 41d7eb8031..cf439c3652 100644 --- a/dummy/src/toga_dummy/widgets/divider.py +++ b/dummy/src/toga_dummy/widgets/divider.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Divider(Widget): def create(self): self._action("create Divider") diff --git a/dummy/src/toga_dummy/widgets/imageview.py b/dummy/src/toga_dummy/widgets/imageview.py index b56d326dcb..d967d8bce7 100644 --- a/dummy/src/toga_dummy/widgets/imageview.py +++ b/dummy/src/toga_dummy/widgets/imageview.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete class ImageView(Widget): def create(self): self._action("create ImageView") diff --git a/dummy/src/toga_dummy/widgets/label.py b/dummy/src/toga_dummy/widgets/label.py index 137857b124..2dd5517022 100644 --- a/dummy/src/toga_dummy/widgets/label.py +++ b/dummy/src/toga_dummy/widgets/label.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Label(Widget): def create(self): self._action("create Label") diff --git a/dummy/src/toga_dummy/widgets/multilinetextinput.py b/dummy/src/toga_dummy/widgets/multilinetextinput.py index 1c2a02c2c8..705ae25bc4 100644 --- a/dummy/src/toga_dummy/widgets/multilinetextinput.py +++ b/dummy/src/toga_dummy/widgets/multilinetextinput.py @@ -1,15 +1,13 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class MultilineTextInput(Widget): def create(self): self._action("create MultilineTextInput") def set_value(self, value): self._set_value("value", value) - self.interface.on_change(None) + self.interface.on_change() def get_value(self): return self._get_value("value") @@ -33,4 +31,4 @@ def scroll_to_top(self): self._action("scroll to top") def simulate_change(self): - self.interface.on_change(None) + self.interface.on_change() diff --git a/dummy/src/toga_dummy/widgets/numberinput.py b/dummy/src/toga_dummy/widgets/numberinput.py index 024a273fdb..7e6a0500f4 100644 --- a/dummy/src/toga_dummy/widgets/numberinput.py +++ b/dummy/src/toga_dummy/widgets/numberinput.py @@ -1,10 +1,8 @@ from toga.widgets.numberinput import _clean_decimal -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class NumberInput(Widget): def create(self): self._action("create NumberInput") @@ -26,7 +24,7 @@ def set_max_value(self, value): def set_value(self, value): self._set_value("value", value) - self.interface.on_change(None) + self.interface.on_change() def get_value(self): value = self._get_value("value", None) @@ -39,4 +37,4 @@ def set_on_change(self, handler): self._set_value("on_change", handler) def simulate_change(self): - self.interface.on_change(None) + self.interface.on_change() diff --git a/dummy/src/toga_dummy/widgets/optioncontainer.py b/dummy/src/toga_dummy/widgets/optioncontainer.py index d35e02d710..c1bcef8a72 100644 --- a/dummy/src/toga_dummy/widgets/optioncontainer.py +++ b/dummy/src/toga_dummy/widgets/optioncontainer.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required class Option: def __init__(self, text, widget, enabled): self.text = text @@ -10,7 +8,6 @@ def __init__(self, text, widget, enabled): self.enabled = enabled -@not_required # Testbed coverage is complete for this widget. class OptionContainer(Widget): def create(self): self._action("create OptionContainer") @@ -44,7 +41,7 @@ def get_option_text(self, index): def set_current_tab_index(self, current_tab_index): self._set_value("current_tab_index", current_tab_index) - self.interface.on_select(None) + self.interface.on_select() def get_current_tab_index(self): return self._get_value("current_tab_index", None) diff --git a/dummy/src/toga_dummy/widgets/passwordinput.py b/dummy/src/toga_dummy/widgets/passwordinput.py index 0d6b2b5cc7..a3c6315384 100644 --- a/dummy/src/toga_dummy/widgets/passwordinput.py +++ b/dummy/src/toga_dummy/widgets/passwordinput.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .textinput import TextInput -@not_required # Testbed coverage is complete for this widget. class PasswordInput(TextInput): def create(self): self._action("create PasswordInput") diff --git a/dummy/src/toga_dummy/widgets/progressbar.py b/dummy/src/toga_dummy/widgets/progressbar.py index ef2aa59f83..cea8c52ccf 100644 --- a/dummy/src/toga_dummy/widgets/progressbar.py +++ b/dummy/src/toga_dummy/widgets/progressbar.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class ProgressBar(Widget): def create(self): self._action("create ProgressBar") diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index b93347fa1e..863d5164ea 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -1,9 +1,7 @@ -from ..utils import not_required from ..window import Container from .base import Widget -@not_required # Testbed coverage is complete for this widget. class ScrollContainer(Widget): def create(self): self._action("create ScrollContainer") @@ -31,7 +29,7 @@ def set_vertical(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if value is False: self._set_value("vertical_position", 0) - self.interface.on_scroll(None) + self.interface.on_scroll() def get_horizontal(self): return self._get_value("horizontal", True) @@ -42,7 +40,7 @@ def set_horizontal(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if value is False: self._set_value("horizontal_position", 0) - self.interface.on_scroll(None) + self.interface.on_scroll() def set_on_scroll(self, on_scroll): self._set_value("on_scroll", on_scroll) @@ -50,7 +48,7 @@ def set_on_scroll(self, on_scroll): def set_position(self, horizontal_position, vertical_position): self._set_value("horizontal_position", horizontal_position) self._set_value("vertical_position", vertical_position) - self.interface.on_scroll(None) + self.interface.on_scroll() def get_horizontal_position(self): return self._get_value("horizontal_position", 0) diff --git a/dummy/src/toga_dummy/widgets/selection.py b/dummy/src/toga_dummy/widgets/selection.py index 44903cf18e..d5af292365 100644 --- a/dummy/src/toga_dummy/widgets/selection.py +++ b/dummy/src/toga_dummy/widgets/selection.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Selection(Widget): def create(self): self._action("create Selection") @@ -47,4 +45,4 @@ def get_selected_index(self): def simulate_selection(self, item): self._set_value("selected_item", item) - self.interface.on_change(None) + self.interface.on_change() diff --git a/dummy/src/toga_dummy/widgets/slider.py b/dummy/src/toga_dummy/widgets/slider.py index 59236b1944..31c2a4137a 100644 --- a/dummy/src/toga_dummy/widgets/slider.py +++ b/dummy/src/toga_dummy/widgets/slider.py @@ -1,10 +1,8 @@ import toga -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Slider(Widget, toga.widgets.slider.SliderImpl): def create(self): self._action("create Slider") diff --git a/dummy/src/toga_dummy/widgets/splitcontainer.py b/dummy/src/toga_dummy/widgets/splitcontainer.py index 88690bb594..d6e7d2b107 100644 --- a/dummy/src/toga_dummy/widgets/splitcontainer.py +++ b/dummy/src/toga_dummy/widgets/splitcontainer.py @@ -1,11 +1,9 @@ from toga.constants import Direction -from ..utils import not_required from ..window import Container from .base import Widget -@not_required # Testbed coverage is complete for this widget. class SplitContainer(Widget): def create(self): self._action("create SplitContainer") diff --git a/dummy/src/toga_dummy/widgets/switch.py b/dummy/src/toga_dummy/widgets/switch.py index 33cdc82fdf..0bf88fa5a7 100644 --- a/dummy/src/toga_dummy/widgets/switch.py +++ b/dummy/src/toga_dummy/widgets/switch.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Switch(Widget): def create(self): self._action("create Switch") @@ -20,7 +18,7 @@ def set_value(self, value): old_value = self._get_value("value", None) self._set_value("value", value) if value != old_value: - self.interface.on_change(None) + self.interface.on_change() def simulate_toggle(self): - self.interface.on_change(None) + self.interface.on_change() diff --git a/dummy/src/toga_dummy/widgets/table.py b/dummy/src/toga_dummy/widgets/table.py index 1e5d42b710..add89254c7 100644 --- a/dummy/src/toga_dummy/widgets/table.py +++ b/dummy/src/toga_dummy/widgets/table.py @@ -1,15 +1,13 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class Table(Widget): def create(self): self._action("create Table") def change_source(self, source): self._action("change source", source=source) - self.interface.on_select(None) + self.interface.on_select() def insert(self, index, item): self._action("insert row", index=index, item=item) @@ -40,7 +38,7 @@ def remove_column(self, index): def simulate_selection(self, row): self._set_value("selection", row) - self.interface.on_select(None) + self.interface.on_select() def simulate_activate(self, row): - self.interface.on_activate(None, row=self.interface.data[row]) + self.interface.on_activate(row=self.interface.data[row]) diff --git a/dummy/src/toga_dummy/widgets/textinput.py b/dummy/src/toga_dummy/widgets/textinput.py index 97703f96fd..6459786ec4 100644 --- a/dummy/src/toga_dummy/widgets/textinput.py +++ b/dummy/src/toga_dummy/widgets/textinput.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class TextInput(Widget): def create(self): self._action("create TextInput") @@ -38,14 +36,14 @@ def is_valid(self): return self._get_value("valid") def simulate_change(self): - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() def simulate_confirm(self): - self.interface.on_confirm(None) + self.interface.on_confirm() def simulate_gain_focus(self): - self.interface.on_gain_focus(None) + self.interface.on_gain_focus() def simulate_lose_focus(self): - self.interface.on_lose_focus(None) + self.interface.on_lose_focus() diff --git a/dummy/src/toga_dummy/widgets/timeinput.py b/dummy/src/toga_dummy/widgets/timeinput.py index 5fc37f105d..5e176fe6fd 100644 --- a/dummy/src/toga_dummy/widgets/timeinput.py +++ b/dummy/src/toga_dummy/widgets/timeinput.py @@ -1,10 +1,8 @@ from datetime import datetime, time -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class TimeInput(Widget): def create(self): self._action("create TimeInput") @@ -14,7 +12,7 @@ def get_value(self): def set_value(self, value): self._set_value("value", value) - self.interface.on_change(None) + self.interface.on_change() def get_min_time(self): return self._get_value("min time", time(0, 0, 0)) diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index e35868a57d..a5d45edfbe 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -1,8 +1,6 @@ -from ..utils import not_required from .base import Widget -@not_required def node_for_path(data, path): "Convert a path tuple into a specific node" if path is None: @@ -13,14 +11,13 @@ def node_for_path(data, path): return result -@not_required # Testbed coverage is complete for this widget. class Tree(Widget): def create(self): self._action("create Tree") def change_source(self, source): self._action("change source", source=source) - self.interface.on_select(None) + self.interface.on_select() def insert(self, parent, index, item): self._action("insert node", parent=parent, index=index, item=item) @@ -65,7 +62,7 @@ def remove_column(self, index): def simulate_selection(self, path): self._set_value("selection", path) - self.interface.on_select(None) + self.interface.on_select() def simulate_activate(self, path): - self.interface.on_activate(None, node=node_for_path(self.interface.data, path)) + self.interface.on_activate(node=node_for_path(self.interface.data, path)) diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 3e6bec50e9..be6bb46835 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -1,10 +1,8 @@ from toga.widgets.webview import JavaScriptResult -from ..utils import not_required from .base import Widget -@not_required # Testbed coverage is complete for this widget. class WebView(Widget): def create(self): self._action("create WebView") @@ -32,7 +30,7 @@ def evaluate_javascript(self, javascript, on_result=None): return self._js_result def simulate_page_loaded(self): - self.interface.on_webview_load(self.interface) + self.interface.on_webview_load() loaded_future = self._get_value("loaded_future", None) if loaded_future: diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 97ffbb920e..7179239f79 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -1,7 +1,6 @@ -from .utils import LoggedObject, not_required +from .utils import LoggedObject -@not_required # not part of the formal API spec class Container: def __init__(self, content=None): self.baseline_dpi = 96 @@ -37,7 +36,6 @@ def refreshed(self): self.content.refresh() -@not_required # Testbed coverage is complete class Window(LoggedObject): def __init__(self, interface, title, position, size): super().__init__() @@ -93,8 +91,11 @@ def close(self): self._action("close") self._set_value("visible", False) + def get_image_data(self): + return b"pretend this is PNG image data" + def set_full_screen(self, is_full_screen): self._action("set full screen", full_screen=is_full_screen) def simulate_close(self): - self.interface.on_close(None) + self.interface.on_close() diff --git a/examples/.template/{{ cookiecutter.name }}/{{ cookiecutter.name }}/app.py b/examples/.template/{{ cookiecutter.name }}/{{ cookiecutter.name }}/app.py index 96b5e4f1d5..9117a2c703 100644 --- a/examples/.template/{{ cookiecutter.name }}/{{ cookiecutter.name }}/app.py +++ b/examples/.template/{{ cookiecutter.name }}/{{ cookiecutter.name }}/app.py @@ -13,7 +13,7 @@ def do_clear(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label to show responses. self.label = toga.Label('Ready.') diff --git a/examples/activityindicator/activityindicator/app.py b/examples/activityindicator/activityindicator/app.py index 00bba37b9f..3542fe4820 100644 --- a/examples/activityindicator/activityindicator/app.py +++ b/examples/activityindicator/activityindicator/app.py @@ -15,7 +15,7 @@ def do_stuff(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.spinner = toga.ActivityIndicator(style=Pack(padding_left=10)) self.button = toga.Button( diff --git a/examples/beeliza/beeliza/app.py b/examples/beeliza/beeliza/app.py index ef9dc25254..cac7c62ddd 100644 --- a/examples/beeliza/beeliza/app.py +++ b/examples/beeliza/beeliza/app.py @@ -46,7 +46,7 @@ async def handle_input(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.partner = Eliza() diff --git a/examples/box/box/app.py b/examples/box/box/app.py index 0140bd52f6..18812722a2 100644 --- a/examples/box/box/app.py +++ b/examples/box/box/app.py @@ -5,11 +5,8 @@ class ExampleBoxApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size - # Also make the window non-resizable and non-minimizable. self.main_window = toga.MainWindow( - title=self.name, size=(800, 500), resizeable=False, minimizable=False + size=(800, 500), resizable=False, minimizable=False ) self.yellow_button = toga.Button( text="Set yellow color", diff --git a/examples/button/button/app.py b/examples/button/button/app.py index d51137ca81..d362d9f4b3 100644 --- a/examples/button/button/app.py +++ b/examples/button/button/app.py @@ -8,11 +8,8 @@ class ExampleButtonApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size - # Also make the window non-resizable and non-minimizable. self.main_window = toga.MainWindow( - title=self.name, size=(800, 500), resizeable=False, minimizable=False + size=(800, 500), resizable=False, minimizable=False ) # Common style of the inner boxes diff --git a/examples/canvas/canvas/app.py b/examples/canvas/canvas/app.py index 3f47dad0a3..b5a5c5fd4a 100644 --- a/examples/canvas/canvas/app.py +++ b/examples/canvas/canvas/app.py @@ -34,7 +34,7 @@ class ExampleCanvasApp(toga.App): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name, size=(750, 500)) + self.main_window = toga.MainWindow(size=(750, 500)) self.canvas = toga.Canvas( style=Pack(flex=1), diff --git a/examples/colors/colors/app.py b/examples/colors/colors/app.py index e3ee68e7bc..a643a31409 100644 --- a/examples/colors/colors/app.py +++ b/examples/colors/colors/app.py @@ -28,7 +28,7 @@ def _change_color_background(widget): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name, size=(700, 800)) + self.main_window = toga.MainWindow(size=(700, 800)) # create widgets to test colors on button = toga.Button("This is a button", style=Pack(padding=5)) diff --git a/examples/command/command/app.py b/examples/command/command/app.py index 259a1f6793..06ebef0bbf 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -45,10 +45,9 @@ def startup(self): tiberius_icon_256 = "resources/tiberius-256" # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Add commands - print("adding commands") # Create a "Things" menu group to contain some of the commands. # No explicit ordering is provided on the group, so it will appear # after application-level menus, but *before* the Command group. @@ -113,16 +112,18 @@ def startup(self): ) cmd7 = toga.Command( self.action7, - text="TB action 7", + text="TB Action 7", tooltip="Perform toolbar action 7", + shortcut=toga.Key.MOD_1 + "p", order=30, icon=tiberius_icon_256, group=sub_menu, + enabled=False, ) def action4(widget): print("action 4") - cmd3.enabled = not cmd3.enabled + cmd7.enabled = not cmd7.enabled self.textpanel.value += "action 4\n" cmd4 = toga.Command( @@ -134,8 +135,12 @@ def action4(widget): ) # The order in which commands are added to the app or the toolbar won't - # alter anything. Ordering is defined by the command definitions. - self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3) + # alter anything. Ordering is determined by the command's properties. + # + # cmd2 and cmd5 are only explicitly added to the toolbar, but that should + # automatically add them to the app. cmd7 is added to both places, but this + # should not cause a duplicate menu item. + self.app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd3, cmd7) self.app.main_window.toolbar.add(cmd2, cmd5, cmd7) # Buttons @@ -164,7 +169,6 @@ def action4(widget): def main(): - print("app.main") return ExampleTestCommandApp("Test Command", "org.beeware.widgets.command") diff --git a/examples/date_and_time/date_and_time/app.py b/examples/date_and_time/date_and_time/app.py index 6bc5419b8d..3942581b06 100644 --- a/examples/date_and_time/date_and_time/app.py +++ b/examples/date_and_time/date_and_time/app.py @@ -13,7 +13,7 @@ def changed_time(self, widget): print(f"{widget.id} is {widget.value.strftime('%I:%M:%S %p')}") def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() any_date_box = toga.Box( children=[ diff --git a/examples/detailedlist/detailedlist/app.py b/examples/detailedlist/detailedlist/app.py index 3e2a3bd8e4..7cc0500c2e 100644 --- a/examples/detailedlist/detailedlist/app.py +++ b/examples/detailedlist/detailedlist/app.py @@ -57,7 +57,7 @@ def remove_handler(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Buttons btn_style = Pack(flex=1, padding=10) diff --git a/examples/dialogs/dialogs/app.py b/examples/dialogs/dialogs/app.py index 8286b064f2..7f827ba900 100644 --- a/examples/dialogs/dialogs/app.py +++ b/examples/dialogs/dialogs/app.py @@ -181,8 +181,6 @@ async def window_close_handler(self, window): def action_open_secondary_window(self, widget): self.window_counter += 1 window = toga.Window(title=f"New Window {self.window_counter}") - # Both self.windows.add() and self.windows += work: - self.windows += window self.set_window_label_text(len(self.windows) - 1) secondary_label = toga.Label(text="You are in a secondary window!") @@ -214,7 +212,7 @@ def set_window_label_text(self, num_windows): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.on_exit = self.exit_handler # Label to show responses. diff --git a/examples/divider/divider/app.py b/examples/divider/divider/app.py index 48bd09cf8b..71cd0cc6e2 100644 --- a/examples/divider/divider/app.py +++ b/examples/divider/divider/app.py @@ -5,9 +5,7 @@ class DividerApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(300, 150)) + self.main_window = toga.MainWindow(size=(300, 150)) style = Pack(padding_top=24) substyle = Pack(padding_right=12, padding_left=12, flex=1) diff --git a/examples/documentapp/Ironbark.exampledoc b/examples/documentapp/Ironbark.exampledoc new file mode 100644 index 0000000000..b76fa61f1b --- /dev/null +++ b/examples/documentapp/Ironbark.exampledoc @@ -0,0 +1,58 @@ +The Man from Ironbark +===================== + +It was the man from Ironbark who struck the Sydney town, +He wandered over street and park, he wandered up and down. +He loitered here, he loitered there, till he was like to drop, +Until at last in sheer despair he sought a barber's shop. +"'Ere! shave my beard and whiskers off, I'll be a man of mark, +I'll go and do the Sydney toff up home in Ironbark." + +The barber man was small and flash, as barbers mostly are, +He wore a strike-your-fancy sash, he smoked a huge cigar; +He was a humorist of note and keen at repartee, +He laid the odds and kept a "tote", whatever that may be, +And when he saw our friend arrive, he whispered, "Here's a lark! +Just watch me catch him all alive, this man from Ironbark." + +There were some gilded youths that sat along the barber's wall. +Their eyes were dull, their heads were flat, they had no brains at all; +To them the barber passed the wink, his dexter eyelid shut, +"I'll make this bloomin' yokel think his bloomin' throat is cut." +And as he soaped and rubbed it in he made a rude remark: +"I s'pose the flats is pretty green up there in Ironbark." + +A grunt was all reply he got; he shaved the bushman's chin, +Then made the water boiling hot and dipped the razor in. +He raised his hand, his brow grew black, he paused awhile to gloat, +Then slashed the red-hot razor-back across his victim's throat: +Upon the newly-shaven skin it made a livid mark - +No doubt it fairly took him in - the man from Ironbark. + +He fetched a wild up-country yell might wake the dead to hear, +And though his throat, he knew full well, was cut from ear to ear, +He struggled gamely to his feet, and faced the murd'rous foe: +"You've done for me! you dog, I'm beat! one hit before I go! +I only wish I had a knife, you blessed murdering shark! +But you'll remember all your life the man from Ironbark." + +He lifted up his hairy paw, with one tremendous clout +He landed on the barber's jaw, and knocked the barber out. +He set to work with nail and tooth, he made the place a wreck; +He grabbed the nearest gilded youth, and tried to break his neck. +And all the while his throat he held to save his vital spark, +And "Murder! Bloody murder!" yelled the man from Ironbark. + +A peeler man who heard the din came in to see the show; +He tried to run the bushman in, but he refused to go. +And when at last the barber spoke, and said "'Twas all in fun— +'Twas just a little harmless joke, a trifle overdone." +"A joke!" he cried, "By George, that's fine; a lively sort of lark; +I'd like to catch that murdering swine some night in Ironbark." + +And now while round the shearing floor the list'ning shearers gape, +He tells the story o'er and o'er, and brags of his escape. +"Them barber chaps what keeps a tote, By George, I've had enough, +One tried to cut my bloomin' throat, but thank the Lord it's tough." +And whether he's believed or no, there's one thing to remark, +That flowing beards are all the go way up in Ironbark. diff --git a/examples/documentapp/Jabberwocky.exampledoc b/examples/documentapp/Jabberwocky.exampledoc new file mode 100644 index 0000000000..dfbec58ec1 --- /dev/null +++ b/examples/documentapp/Jabberwocky.exampledoc @@ -0,0 +1,37 @@ +Jabberwocky +=========== + +'Twas brillig, and the slithy toves +Did gyre and gimble in the wabe; +All mimsy were the borogoves, +And the mome raths outgrabe. + +"Beware the Jabberwock, my son! +The jaws that bite, the claws that catch! +Beware the Jubjub bird, and shun +The frumious Bandersnatch!" + +He took his vorpal sword in hand: +Long time the manxome foe he sought— +So rested he by the Tumtum tree, +And stood awhile in thought. + +And as in uffish thought he stood, +The Jabberwock, with eyes of flame, +Came whiffling through the tulgey wood, +And burbled as it came! + +One, two! One, two! And through and through +The vorpal blade went snicker-snack! +He left it dead, and with its head +He went galumphing back. + +"And hast thou slain the Jabberwock? +Come to my arms, my beamish boy! +O frabjous day! Callooh! Callay!" +He chortled in his joy. + +'Twas brillig, and the slithy toves +Did gyre and gimble in the wabe; +All mimsy were the borogoves, +And the mome raths outgrabe. diff --git a/examples/documentapp/README.rst b/examples/documentapp/README.rst new file mode 100644 index 0000000000..b6be1485e7 --- /dev/null +++ b/examples/documentapp/README.rst @@ -0,0 +1,16 @@ +DocumentApp +=========== + +An example DocumentApp. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m documentapp + +Or, pass in a file at the command line + + $ python -m documentapp Jabberwocky.exampledoc diff --git a/examples/documentapp/documentapp/__init__.py b/examples/documentapp/documentapp/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/documentapp/documentapp/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/documentapp/documentapp/__main__.py b/examples/documentapp/documentapp/__main__.py new file mode 100644 index 0000000000..974a50572c --- /dev/null +++ b/examples/documentapp/documentapp/__main__.py @@ -0,0 +1,4 @@ +from documentapp.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/documentapp/documentapp/app.py b/examples/documentapp/documentapp/app.py new file mode 100644 index 0000000000..6cbb733bd9 --- /dev/null +++ b/examples/documentapp/documentapp/app.py @@ -0,0 +1,41 @@ +import toga + + +class ExampleDocument(toga.Document): + def __init__(self, path, app): + super().__init__(path=path, document_type="Example Document", app=app) + + async def can_close(self): + return await self.main_window.question_dialog( + "Are you sure?", + "Do you want to close this document?", + ) + + def create(self): + # Create the main window for the document. + self.main_window = toga.DocumentMainWindow( + doc=self, + title=f"Example: {self.path.name}", + ) + self.main_window.content = toga.MultilineTextInput() + + def read(self): + with self.path.open() as f: + self.content = f.read() + + self.main_window.content.value = self.content + + +def main(): + return toga.DocumentApp( + "Document App", + "org.beeware.widgets.documentapp", + document_types={ + "exampledoc": ExampleDocument, + }, + ) + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/documentapp/documentapp/resources/README b/examples/documentapp/documentapp/resources/README new file mode 100644 index 0000000000..84f0abfa08 --- /dev/null +++ b/examples/documentapp/documentapp/resources/README @@ -0,0 +1 @@ +Put any icons or images in this directory. diff --git a/examples/documentapp/pyproject.toml b/examples/documentapp/pyproject.toml new file mode 100644 index 0000000000..204f699deb --- /dev/null +++ b/examples/documentapp/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Document App" +bundle = "org.beeware" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = "Tiberius Yak" +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.documentapp] +formal_name = "Document App" +description = "A testing app" +sources = ["documentapp"] +requires = [ + "../../core", +] + + +[tool.briefcase.app.documentapp.macOS] +requires = [ + "../../cocoa", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.documentapp.linux] +requires = [ + "../../gtk", +] + +[tool.briefcase.app.documentapp.windows] +requires = [ + "../../winforms", +] + +# Mobile deployments +[tool.briefcase.app.documentapp.iOS] +requires = [ + "../../iOS", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.documentapp.android] +requires = [ + "../../android", +] + +# Web deployment +[tool.briefcase.app.documentapp.web] +requires = [ + "../../web", +] +style_framework = "Shoelace v2.3" diff --git a/examples/examples_overview/examples_overview/app.py b/examples/examples_overview/examples_overview/app.py index 279fb68143..5ae3d18e44 100644 --- a/examples/examples_overview/examples_overview/app.py +++ b/examples/examples_overview/examples_overview/app.py @@ -8,7 +8,7 @@ from toga.constants import COLUMN from toga.style import Pack -examples_dir = Path(__file__).parents[3] +examples_dir = Path(__file__).parents[2] class ExampleExamplesOverviewApp(toga.App): @@ -31,8 +31,8 @@ def open(self, widget, **kwargs): else: subprocess.run(["xdg-open", row.path]) - def on_example_selected(self, widget, row): - readme_path = row.path / "README.rst" + def on_example_selected(self, widget): + readme_path = widget.selection.path / "README.rst" try: with open(readme_path) as f: @@ -45,7 +45,7 @@ def on_example_selected(self, widget, row): def startup(self): # ==== Set up main window ====================================================== - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label for user instructions label = toga.Label( @@ -60,7 +60,7 @@ def startup(self): # search for all folders that contain modules for root, dirs, files in os.walk(examples_dir): # skip hidden folders - dirs[:] = [d for d in dirs if not d.startswith(".")] + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "build"] if any(name == "__main__.py" for name in files): path = Path(root) self.examples.append(dict(name=path.name, path=path.parent)) @@ -70,7 +70,7 @@ def startup(self): self.table = toga.Table( headings=["Name", "Path"], data=self.examples, - on_double_click=self.run, + on_activate=self.run, on_select=self.on_example_selected, style=Pack(padding_bottom=10, flex=1), ) @@ -102,11 +102,14 @@ def startup(self): ), ) - split_container = toga.SplitContainer(content=[left_box, self.info_view]) + split_container = toga.SplitContainer( + content=[left_box, self.info_view], + style=Pack(flex=1), + ) outer_box = toga.Box( children=[label, split_container], - style=Pack(padding=10, direction=COLUMN), + style=Pack(padding=10, direction=COLUMN, flex=1), ) # Add the content on the main window diff --git a/examples/focus/focus/app.py b/examples/focus/focus/app.py index ad2adf46e4..242209bcdd 100644 --- a/examples/focus/focus/app.py +++ b/examples/focus/focus/app.py @@ -11,11 +11,8 @@ class ExampleFocusApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size - # Also make the window non-resizable and non-minimizable. self.main_window = toga.MainWindow( - title=self.name, size=(800, 500), resizeable=False, minimizable=False + size=(800, 500), resizable=False, minimizable=False ) self.a_button = toga.Button("A", on_press=self.on_button_press) @@ -112,12 +109,12 @@ def on_switch_toggle(self, widget: toga.Switch): on_off = "on" if widget.value else "off" self.info_label.text = f"Switch turned {on_off}!" - def on_textinput_gain_focus(self, widget: toga.TextInput): + def on_textinput_gain_focus(self, widget: toga.TextInput, **kwargs): self.info_label.text = "TextInput has previously had focus " "{} times".format( self.text_input_focus_count ) - def on_textinput_lose_focus(self, widget: toga.TextInput): + def on_textinput_lose_focus(self, widget: toga.TextInput, **kwargs): self.text_input_focus_count += 1 def focus_with_label(self, widget: toga.Widget): diff --git a/examples/font/font/app.py b/examples/font/font/app.py index be9b87c677..b2a7b047ed 100644 --- a/examples/font/font/app.py +++ b/examples/font/font/app.py @@ -37,7 +37,7 @@ def do_add_content(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # register fonts toga.Font.register( diff --git a/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py index d0bb4dfaa4..c92d6f1de3 100644 --- a/examples/handlers/handlers/app.py +++ b/examples/handlers/handlers/app.py @@ -54,7 +54,7 @@ async def do_background_task(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Labels to show responses. self.label = toga.Label("Ready.", style=Pack(padding=10)) diff --git a/examples/imageview/imageview/app.py b/examples/imageview/imageview/app.py index 655baca80e..55792b4b4f 100644 --- a/examples/imageview/imageview/app.py +++ b/examples/imageview/imageview/app.py @@ -8,7 +8,7 @@ class ImageViewApp(toga.App): def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() box = toga.Box( style=Pack( diff --git a/examples/multilinetextinput/multilinetextinput/app.py b/examples/multilinetextinput/multilinetextinput/app.py index 3648f4475f..83232df9a9 100644 --- a/examples/multilinetextinput/multilinetextinput/app.py +++ b/examples/multilinetextinput/multilinetextinput/app.py @@ -33,7 +33,7 @@ def set_label(self, widget): self.label.text = f"{number_of_lines} lines have been written" def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.multiline_input = toga.MultilineTextInput( placeholder="Enter text here...", diff --git a/examples/numberinput/numberinput/app.py b/examples/numberinput/numberinput/app.py index 0c8e52ccf7..1339681c27 100644 --- a/examples/numberinput/numberinput/app.py +++ b/examples/numberinput/numberinput/app.py @@ -20,22 +20,22 @@ def on_change(self, widget): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label to show responses. self.label = toga.Label("Ready.") label1 = toga.Label("Enter value from -12 to 72:") self.input1 = toga.NumberInput( - min_value=-12, - max_value=72, + min=-12, + max=72, step=2, value=37, on_change=self.on_change, ) label2 = toga.Label("Enter value from 1.2 to 7.2:") self.input2 = toga.NumberInput( - min_value=1.2, - max_value=7.2, + min=1.2, + max=7.2, step=0.1, value=3.7, on_change=self.on_change, diff --git a/examples/optioncontainer/optioncontainer/app.py b/examples/optioncontainer/optioncontainer/app.py index 7e521aecc8..bc6de7add0 100644 --- a/examples/optioncontainer/optioncontainer/app.py +++ b/examples/optioncontainer/optioncontainer/app.py @@ -82,7 +82,7 @@ def on_select_tab(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # styles style_flex = Pack(flex=1, padding=5) diff --git a/examples/passwordinput/passwordinput/app.py b/examples/passwordinput/passwordinput/app.py index 595bda9f70..b0fc62a640 100644 --- a/examples/passwordinput/passwordinput/app.py +++ b/examples/passwordinput/passwordinput/app.py @@ -30,7 +30,7 @@ def get_password_content_label(self, content): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() PADDING = 5 # Label to show responses. self.label = toga.Label("Testing Password") diff --git a/examples/positron-django/src/positron/app.py b/examples/positron-django/src/positron/app.py index 64941f1536..8288d6dc26 100644 --- a/examples/positron-django/src/positron/app.py +++ b/examples/positron-django/src/positron/app.py @@ -50,7 +50,7 @@ def startup(self): host, port = self._httpd.socket.getsockname() self.web_view.url = f"http://{host}:{port}/" - self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window = toga.MainWindow() self.main_window.content = self.web_view self.main_window.show() diff --git a/examples/positron-static/src/positron/app.py b/examples/positron-static/src/positron/app.py index 06a2f14617..8150d80e5a 100644 --- a/examples/positron-static/src/positron/app.py +++ b/examples/positron-static/src/positron/app.py @@ -44,7 +44,7 @@ def startup(self): host, port = self._httpd.socket.getsockname() self.web_view.url = f"http://{host}:{port}/" - self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window = toga.MainWindow() self.main_window.content = self.web_view self.main_window.show() diff --git a/examples/progressbar/progressbar/app.py b/examples/progressbar/progressbar/app.py index c7ed8d8e28..d98b3400ac 100644 --- a/examples/progressbar/progressbar/app.py +++ b/examples/progressbar/progressbar/app.py @@ -9,8 +9,7 @@ class ProgressBarApp(toga.App): def startup(self): - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(500, 500)) + self.main_window = toga.MainWindow(size=(500, 500)) # the user may change the value with +/- buttons self.progress_adder = toga.ProgressBar(max=MAX_PROGRESSBAR_VALUE) diff --git a/examples/screenshot/README.rst b/examples/screenshot/README.rst new file mode 100644 index 0000000000..4e0fc4dde6 --- /dev/null +++ b/examples/screenshot/README.rst @@ -0,0 +1,12 @@ +Screenshot Generator +==================== + +Test app for the Screenshot Generator widget. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m screenshot diff --git a/examples/screenshot/pyproject.toml b/examples/screenshot/pyproject.toml new file mode 100644 index 0000000000..63c7f965ca --- /dev/null +++ b/examples/screenshot/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Screenshot Generator" +bundle = "org.beeware" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = "Tiberius Yak" +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.screenshot] +formal_name = "Screenshot Generator" +description = "A testing app" +sources = ["screenshot"] +requires = [ + "../../core", + "pillow", +] + + +[tool.briefcase.app.screenshot.macOS] +requires = [ + "../../cocoa", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.screenshot.linux] +requires = [ + "../../gtk", +] + +[tool.briefcase.app.screenshot.windows] +requires = [ + "../../winforms", +] + +# Mobile deployments +[tool.briefcase.app.screenshot.iOS] +requires = [ + "../../iOS", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.screenshot.android] +requires = [ + "../../android", +] diff --git a/examples/screenshot/screenshot/__init__.py b/examples/screenshot/screenshot/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/screenshot/screenshot/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/screenshot/screenshot/__main__.py b/examples/screenshot/screenshot/__main__.py new file mode 100644 index 0000000000..1dfc6569a0 --- /dev/null +++ b/examples/screenshot/screenshot/__main__.py @@ -0,0 +1,4 @@ +from screenshot.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/screenshot/screenshot/app.py b/examples/screenshot/screenshot/app.py new file mode 100644 index 0000000000..b14d592bb7 --- /dev/null +++ b/examples/screenshot/screenshot/app.py @@ -0,0 +1,486 @@ +import asyncio +from datetime import date, time +from io import BytesIO + +from PIL import Image + +import toga +from toga.constants import CENTER, COLUMN +from toga.style import Pack + +from .canvas import draw_tiberius + + +class ScreenshotGeneratorApp(toga.App): + def create_activityindicator(self): + return toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + toga.ActivityIndicator(running=True, style=Pack(padding=10)), + toga.Box(style=Pack(flex=1)), + ], + style=Pack(width=100), + ) + + def create_button(self): + return toga.Box( + children=[ + toga.Button( + "Launch rocket", + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_canvas(self): + canvas = toga.Canvas(style=Pack(padding=10, width=280, height=290)) + draw_tiberius(canvas) + + return toga.Box(children=[canvas], style=Pack(width=280, height=290)) + + def create_dateinput(self): + return toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + toga.DateInput(value=date(2014, 4, 21), style=Pack(padding=10)), + toga.Box(style=Pack(flex=1)), + ], + style=Pack(width=300), + ) + + def create_detailedlist(self): + brutus_icon = toga.Icon("resources/brutus.png") + user_icon = toga.Icon("resources/user.png") + return toga.DetailedList( + data=[ + { + "icon": brutus_icon, + "title": "Brutus", + "subtitle": "Are you the very model of a modern major general?", + }, + { + "icon": user_icon, + "title": "Major General", + "subtitle": "I have information animal, mineral, and vegetable...", + }, + { + "icon": brutus_icon, + "title": "Brutus", + "subtitle": "Ah - but do you know the kings of England?", + }, + { + "icon": user_icon, + "title": "Major General", + "subtitle": "I can quote the fights historical!", + }, + ], + style=Pack(padding=10, width=self.MAX_WIDTH, height=300), + ) + + def create_divider(self): + return toga.Box( + children=[ + toga.Label( + "I'm on top", style=Pack(flex=1, padding=5, text_align=CENTER) + ), + toga.Divider(direction=toga.Divider.HORIZONTAL, style=Pack(padding=5)), + toga.Label( + "I'm below", style=Pack(flex=1, padding=5, text_align=CENTER) + ), + ], + style=Pack(width=300, direction=COLUMN), + ) + + def create_label(self): + return toga.Box( + children=[ + toga.Label( + "Brutus was here!", + style=Pack(padding=10, text_align=CENTER, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_multilinetextinput(self): + return toga.MultilineTextInput( + value="\n".join( + [ + "I am the very model of a modern Major-General.", + "I've information animal, mineral, and vegetable.", + "I know the kings of England, and I quote the fights historical", + "From Marathon to Waterloo, in order categorical.", + "I'm very well acquainted, too, with matters mathematical,", + "I understand equations, both the simple and quadratical,", + "About binomial theorem I'm teeming with a lot o' news,", + "With many cheerful facts about the square of the hypotenuse.", + "", + "I'm very good at integral and differential calculus;", + "I know the scientific names of beings animalculous:", + "In short, in matters vegetable, animal, and mineral,", + "I am the very model of a modern Major-General.", + ] + ), + style=Pack(padding=10, width=self.MAX_WIDTH, height=200), + ) + + def create_numberinput(self): + return toga.Box( + children=[ + toga.NumberInput( + value=2.71818, + step=0.00001, + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_passwordinput(self): + return toga.Box( + children=[ + toga.PasswordInput( + value="secret", + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_progressbar(self): + return toga.Box( + children=[ + toga.ProgressBar( + value=42, + max=100, + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_selection(self): + return toga.Box( + children=[ + toga.Selection( + items=["Titanium", "Yttrium", "Yterbium"], + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_slider(self): + return toga.Box( + children=[ + toga.Slider( + value=42, + max=100, + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_switch(self): + return toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + toga.Switch( + "Turbo", + value=True, + style=Pack(padding=10), + ), + toga.Box(style=Pack(flex=1)), + ], + style=Pack(width=150), + ) + + def create_table(self): + return toga.Table( + headings=["Name", "Age", "Planet"], + data=[ + ("Arthur Dent", 42, "Earth"), + ("Ford Prefect", 37, "Betelgeuse Five"), + ("Tricia McMillan", 38, "Earth"), + ("Slartibartfast", 1005, "Magrathea"), + ], + style=Pack(padding=10, width=self.MAX_WIDTH, height=200), + ) + + def create_textinput(self): + return toga.Box( + children=[ + toga.TextInput( + value="Brutus was here!", + style=Pack(padding=10, flex=1), + ) + ], + style=Pack(width=300), + ) + + def create_timeinput(self): + return toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + toga.TimeInput(value=time(9, 7, 37), style=Pack(padding=10)), + toga.Box(style=Pack(flex=1)), + ], + style=Pack(width=300), + ) + + def create_tree(self): + tree = toga.Tree( + headings=["Name", "Age", "Status"], + data={ + "Earth": { + ("Arthur Dent", 42, "Anxious"): None, + ("Tricia McMillan", 38, "Overqualified"): None, + }, + "Betelgeuse Five": { + ("Ford Prefect", 37, "Hoopy"): None, + }, + "Magrathea": { + ("Slartibartfast", 1005, "Annoyed"): None, + }, + }, + style=Pack(padding=10, width=self.MAX_WIDTH, height=200), + ) + tree.expand() + return tree + + def create_webview(self): + return toga.WebView( + url="https://beeware.org", + style=Pack(padding=10, width=self.MAX_WIDTH, height=300), + ) + + def create_optioncontainer(self): + container = toga.OptionContainer( + content=[ + ( + "Blue", + toga.Box(style=Pack(background_color="cornflowerblue")), + ), + ("Green", toga.Box()), + ("Red", toga.Box()), + ], + style=Pack(padding=10, width=self.MAX_WIDTH, height=300), + ) + + return container + + def create_scrollcontainer(self): + container = toga.ScrollContainer( + content=toga.Box( + children=[ + toga.Box( + style=Pack( + background_color="cornflowerblue", width=900, height=600 + ) + ), + ], + style=Pack(direction=COLUMN), + ), + style=Pack(padding=10, width=self.MAX_WIDTH, height=300), + ) + + return container + + def create_splitcontainer(self): + container = toga.SplitContainer( + content=[ + toga.Box(style=Pack(background_color="goldenrod")), + toga.Box(style=Pack(background_color="cornflowerblue")), + ], + style=Pack(padding=10, width=self.MAX_WIDTH, height=300), + ) + + return container + + def create_window(self): + if toga.platform.current_platform in {"iOS", "android"}: + return None + + return toga.Window(title="Toga", position=(800, 200), size=(300, 250)) + + def create_main_window(self): + # No widget to create + return True + + async def manual_screenshot(self, content=None): + loop = asyncio.get_event_loop() + future = loop.create_future() + + def proceed(button, **kwargs): + future.set_result(True) + + proceed_button = toga.Button( + "Done", + on_press=proceed, + style=Pack(padding=10), + ) + + if content: + self.main_window.content = toga.Box( + children=[ + content, + toga.Box(style=Pack(flex=1)), + proceed_button, + ], + style=Pack(direction=COLUMN), + ) + else: + self.main_window.content = toga.Box() + await future + + async def sequence(self, app, **kwargs): + print(f"Saving screenshots to {self.app.paths.data}") + self.app.paths.data.mkdir(parents=True, exist_ok=True) + for content_type in [ + "activityindicator", + "button", + "canvas", + "dateinput", + "detailedlist", + "divider", + "label", + "multilinetextinput", + "numberinput", + "passwordinput", + "progressbar", + "selection", + "slider", + "switch", + "table", + "textinput", + "timeinput", + "tree", + "webview", + "optioncontainer", + "scrollcontainer", + "splitcontainer", + "window", + "main_window", + ]: + try: + content = getattr(self.app, f"create_{content_type}")() + if content: + if content_type == "main_window": + # image = self.main_window.screen.as_image() + # cropped = image.crop(... crop to window size ...) + # + # TODO: Crop the desktop image, rather than use a manual screenshot + await self.main_window.info_dialog( + "Manual intervention", + "Screenshot the main window, and then quit the app.", + ) + self.main_window.toolbar.add(self.command2, self.command1) + self.main_window.content = toga.Box() + + cropped = None + elif content_type == "window": + content.show() + + # image = self.main_window.screen.as_image() + # cropped = image.crop(... crop to window size ...) + # + # TODO: Crop the desktop image, rather than use a manual screenshot + await self.main_window.info_dialog( + "Manual intervention", + "Screenshot the secondary window, then press Done.", + ) + await self.manual_screenshot(toga.Box()) + cropped = None + + content.close() + + elif ( + content_type == "webview" + and toga.platform.current_platform == "macOS" + ): + # Manual screenshot required on macOS because webviews aren't + # rendered directly on the Window. + await self.main_window.info_dialog( + "Manual intervention", + "Screenshot the web widget content, then press Done.", + ) + await self.manual_screenshot(content) + cropped = None + else: + self.main_window.content = toga.Box( + children=[content], + style=Pack(direction=COLUMN), + ) + + await asyncio.sleep( + { + "webview": 4, + }.get(content_type, 2) + ) + image = Image.open(BytesIO(self.main_window.as_image().data)) + + scale_x = ( + image.size[0] + / self.main_window.content.layout.content_width + ) + scale_y = ( + image.size[1] + / self.main_window.content.layout.content_height + ) + + cropped = image.crop( + ( + 0, + 0, + (content.layout.content_width + 20) * scale_x, + (content.layout.content_height + 20) * scale_y, + ) + ) + + if cropped: + cropped.save( + self.app.paths.data + / f"{content_type}-{toga.platform.current_platform}.png" + ) + + except NotImplementedError: + pass + + def startup(self): + if toga.platform.current_platform in {"iOS", "android"}: + self.MAX_WIDTH = 370 + else: + self.MAX_WIDTH = 450 + + # Set up main window + self.main_window = toga.MainWindow(title="My Application") + + self.command1 = toga.Command( + lambda _: None, + text="Twist", + icon=toga.Icon.DEFAULT_ICON, + ) + self.command2 = toga.Command( + lambda _: None, + text="Shout", + icon="resources/brutus", + ) + + # Add the content on the main window + self.main_window.content = toga.Box() + + # Show the main window + self.main_window.show() + + self.add_background_task(self.sequence) + + +def main(): + return ScreenshotGeneratorApp("My Application", "org.beeware.widgets.screenshot") + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/screenshot/screenshot/canvas.py b/examples/screenshot/screenshot/canvas.py new file mode 100644 index 0000000000..245e22042a --- /dev/null +++ b/examples/screenshot/screenshot/canvas.py @@ -0,0 +1,103 @@ +import math + +from toga.colors import REBECCAPURPLE, WHITE, rgb +from toga.constants import Baseline +from toga.fonts import SANS_SERIF, Font + + +def fill_head(canvas): + with canvas.Fill(color=rgb(149, 119, 73)) as head_filler: + head_filler.move_to(112, 103) + head_filler.line_to(112, 113) + head_filler.ellipse(73, 114, 39, 47, 0, 0, math.pi) + head_filler.line_to(35, 84) + head_filler.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + head_filler.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + + +def stroke_head(canvas): + with canvas.Stroke(line_width=4.0) as head_stroker: + with head_stroker.ClosedPath(112, 103) as closed_head: + closed_head.line_to(112, 113) + closed_head.ellipse(73, 114, 39, 47, 0, 0, math.pi) + closed_head.line_to(35, 84) + closed_head.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + closed_head.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + + +def draw_eyes(canvas): + with canvas.Fill(color=WHITE) as eye_whites: + eye_whites.arc(58, 92, 15) + eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + + # Draw eyes separately to avoid miter join + with canvas.Stroke(line_width=4.0) as eye_outline: + eye_outline.arc(58, 92, 15) + with canvas.Stroke(line_width=4.0) as eye_outline: + eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + + with canvas.Fill() as eye_pupils: + eye_pupils.arc(58, 97, 3) + eye_pupils.arc(88, 97, 3) + + +def draw_horns(canvas): + with canvas.Context() as r_horn: + with r_horn.Fill(color=rgb(212, 212, 212)) as r_horn_filler: + r_horn_filler.move_to(112, 99) + r_horn_filler.quadratic_curve_to(145, 65, 139, 36) + r_horn_filler.quadratic_curve_to(130, 60, 109, 75) + with r_horn.Stroke(line_width=4.0) as r_horn_stroker: + r_horn_stroker.move_to(112, 99) + r_horn_stroker.quadratic_curve_to(145, 65, 139, 36) + r_horn_stroker.quadratic_curve_to(130, 60, 109, 75) + + with canvas.Context() as l_horn: + with l_horn.Fill(color=rgb(212, 212, 212)) as l_horn_filler: + l_horn_filler.move_to(35, 99) + l_horn_filler.quadratic_curve_to(2, 65, 6, 36) + l_horn_filler.quadratic_curve_to(17, 60, 37, 75) + with l_horn.Stroke(line_width=4.0) as l_horn_stroker: + l_horn_stroker.move_to(35, 99) + l_horn_stroker.quadratic_curve_to(2, 65, 6, 36) + l_horn_stroker.quadratic_curve_to(17, 60, 37, 75) + + +def draw_nostrils(canvas): + with canvas.Fill(color=rgb(212, 212, 212)) as nose_filler: + nose_filler.move_to(45, 145) + nose_filler.bezier_curve_to(51, 123, 96, 123, 102, 145) + nose_filler.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) + with canvas.Fill() as nostril_filler: + nostril_filler.arc(63, 140, 3) + nostril_filler.arc(83, 140, 3) + with canvas.Stroke(line_width=4.0) as nose_stroker: + nose_stroker.move_to(45, 145) + nose_stroker.bezier_curve_to(51, 123, 96, 123, 102, 145) + + +def draw_text(canvas): + font = Font(family=SANS_SERIF, size=20) + text_width, text_height = canvas.measure_text("Tiberius", font) + + x = (150 - text_width) // 2 + y = 175 + + with canvas.Stroke(color=REBECCAPURPLE, line_width=4.0) as rect_stroker: + rect_stroker.rect( + x - 5, + y - 5, + text_width + 10, + text_height + 10, + ) + with canvas.Fill(color=rgb(149, 119, 73)) as text_filler: + text_filler.write_text("Tiberius", x, y, font, Baseline.TOP) + + +def draw_tiberius(canvas): + fill_head(canvas) + draw_eyes(canvas) + draw_horns(canvas) + draw_nostrils(canvas) + stroke_head(canvas) + draw_text(canvas) diff --git a/examples/screenshot/screenshot/resources/README b/examples/screenshot/screenshot/resources/README new file mode 100644 index 0000000000..84f0abfa08 --- /dev/null +++ b/examples/screenshot/screenshot/resources/README @@ -0,0 +1 @@ +Put any icons or images in this directory. diff --git a/examples/screenshot/screenshot/resources/brutus.png b/examples/screenshot/screenshot/resources/brutus.png new file mode 100644 index 0000000000..39184054eb Binary files /dev/null and b/examples/screenshot/screenshot/resources/brutus.png differ diff --git a/examples/screenshot/screenshot/resources/user.png b/examples/screenshot/screenshot/resources/user.png new file mode 100644 index 0000000000..c8ca465165 Binary files /dev/null and b/examples/screenshot/screenshot/resources/user.png differ diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index 0a19513bf4..ac050b54a5 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -60,7 +60,7 @@ def startup(self): self.scroller.content = self.inner_box main_box.add(self.scroller) - self.main_window = toga.MainWindow(self.name, size=(400, 700)) + self.main_window = toga.MainWindow(size=(400, 700)) self.main_window.content = main_box self.main_window.show() diff --git a/examples/selection/selection/app.py b/examples/selection/selection/app.py index 573174fbb5..4d27ecf2df 100644 --- a/examples/selection/selection/app.py +++ b/examples/selection/selection/app.py @@ -15,8 +15,7 @@ class SelectionApp(toga.App): ] def startup(self): - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(640, 400)) + self.main_window = toga.MainWindow(size=(640, 400)) # set up common styles label_style = Pack(flex=1, padding_right=24) diff --git a/examples/slider/slider/app.py b/examples/slider/slider/app.py index fb311c62c3..d099da6d88 100644 --- a/examples/slider/slider/app.py +++ b/examples/slider/slider/app.py @@ -8,8 +8,7 @@ class SliderApp(toga.App): def startup(self): - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(1000, 500)) + self.main_window = toga.MainWindow(size=(1000, 500)) # set up common styles label_style = Pack(flex=1, padding_right=24) @@ -27,14 +26,16 @@ def startup(self): self.discrete_label = toga.Label("Discrete\n(with commands)", style=label_style) self.discrete_slider = toga.Slider( on_change=self.my_discrete_on_change, - range=(MIN_VAL, MAX_VAL), + min=MIN_VAL, + max=MAX_VAL, tick_count=MAX_VAL - MIN_VAL + 1, style=slider_style, ) self.scared_label = toga.Label("Try to catch me!", style=label_style) self.scared_slider = toga.Slider( - range=(100, 300.5), + min=100, + max=300.5, value=123.4, on_press=self.scared_on_press, on_release=self.scared_on_release, diff --git a/examples/splitcontainer/splitcontainer/app.py b/examples/splitcontainer/splitcontainer/app.py index b12c5d2670..29bf3da358 100644 --- a/examples/splitcontainer/splitcontainer/app.py +++ b/examples/splitcontainer/splitcontainer/app.py @@ -90,7 +90,7 @@ def startup(self): ], ) - self.main_window = toga.MainWindow(self.name) + self.main_window = toga.MainWindow() self.main_window.content = main_box self.main_window.show() diff --git a/examples/switch_demo/switch_demo/app.py b/examples/switch_demo/switch_demo/app.py index 673f19cc7c..a5ee5e5d4c 100644 --- a/examples/switch_demo/switch_demo/app.py +++ b/examples/switch_demo/switch_demo/app.py @@ -6,9 +6,7 @@ class SwitchApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(350, 300)) + self.main_window = toga.MainWindow(size=(350, 300)) # Add the content on the main window self.main_window.content = toga.Box( diff --git a/examples/table/table/app.py b/examples/table/table/app.py index 9ef72df62e..95449ec9a3 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -94,7 +94,7 @@ def bottom_handler(self, widget, **kwargs): self.table1.scroll_to_bottom() def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label to show which row is currently selected. self.label_table1 = toga.Label("Ready.", style=Pack(flex=1, padding_right=5)) diff --git a/examples/table_source/table_source/app.py b/examples/table_source/table_source/app.py index 9b9577f479..94362548ea 100644 --- a/examples/table_source/table_source/app.py +++ b/examples/table_source/table_source/app.py @@ -139,7 +139,7 @@ def clear_handler(self, widget, **kwargs): self.table1.data.clear() def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label to show which row is currently selected. self.label = toga.Label("Ready.") diff --git a/examples/textinput/textinput/app.py b/examples/textinput/textinput/app.py index f30439c4e9..0ecfe70055 100644 --- a/examples/textinput/textinput/app.py +++ b/examples/textinput/textinput/app.py @@ -47,7 +47,7 @@ def do_extract_values(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() PADDING = 5 # Labels to show responses. diff --git a/examples/tree/tree/app.py b/examples/tree/tree/app.py index 77d6c1d3a9..edc50d8907 100644 --- a/examples/tree/tree/app.py +++ b/examples/tree/tree/app.py @@ -89,7 +89,7 @@ def remove_handler(self, widget, **kwargs): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() # Label to show responses. self.label = toga.Label("Ready.", style=Pack(padding=10)) diff --git a/examples/tree_source/tree_source/app.py b/examples/tree_source/tree_source/app.py index 9a0c7f8769..d331495875 100644 --- a/examples/tree_source/tree_source/app.py +++ b/examples/tree_source/tree_source/app.py @@ -139,7 +139,7 @@ def activate_handler(self, widget, node): def startup(self): # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.fs_source = FileSystemSource(Path.cwd()) diff --git a/examples/tutorial2/tutorial/app.py b/examples/tutorial2/tutorial/app.py index f78cd285bf..c17d89cf28 100644 --- a/examples/tutorial2/tutorial/app.py +++ b/examples/tutorial2/tutorial/app.py @@ -144,7 +144,9 @@ def action4(widget): # alter anything. Ordering is defined by the command definitions. self.commands.add(cmd1, cmd0, cmd6, cmd4, cmd5, cmd3) - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() + # Command 2 has not been *explicitly* added to the app. Adding it to + # a toolbar implicitly adds it to the app. self.main_window.toolbar.add(cmd1, cmd3, cmd2, cmd4) self.main_window.content = split diff --git a/examples/tutorial3/tutorial/app.py b/examples/tutorial3/tutorial/app.py index 309cd07655..d6dfa21851 100644 --- a/examples/tutorial3/tutorial/app.py +++ b/examples/tutorial3/tutorial/app.py @@ -4,7 +4,7 @@ class Graze(toga.App): def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.webview = toga.WebView( on_webview_load=self.on_webview_loaded, style=Pack(flex=1) diff --git a/examples/tutorial4/tutorial/app.py b/examples/tutorial4/tutorial/app.py index 0b3d8c2c9e..5c1752b33a 100644 --- a/examples/tutorial4/tutorial/app.py +++ b/examples/tutorial4/tutorial/app.py @@ -9,8 +9,7 @@ class StartApp(toga.App): def startup(self): - # Main window of the application with title and size - self.main_window = toga.MainWindow(title=self.name, size=(150, 250)) + self.main_window = toga.MainWindow(size=(150, 250)) # Create empty canvas self.canvas = toga.Canvas( @@ -51,9 +50,13 @@ def draw_eyes(self): with self.canvas.Fill(color=WHITE) as eye_whites: eye_whites.arc(58, 92, 15) eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + + # Draw eyes separately to avoid miter join with self.canvas.Stroke(line_width=4.0) as eye_outline: eye_outline.arc(58, 92, 15) + with self.canvas.Stroke(line_width=4.0) as eye_outline: eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.Fill() as eye_pupils: eye_pupils.arc(58, 97, 3) eye_pupils.arc(88, 97, 3) diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index c7dcfcbf06..a8b0924080 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -49,7 +49,7 @@ def on_set_agent(self, widget, **kwargs): self.webview.user_agent = "Mr Roboto" def startup(self): - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.label = toga.Label("www is loading |", style=Pack(flex=1, padding=5)) button_box = toga.Box( diff --git a/examples/window/window/app.py b/examples/window/window/app.py index 329e2c4d1d..caf84c2811 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -127,7 +127,7 @@ def startup(self): self.close_count = 0 # Set up main window - self.main_window = toga.MainWindow(title=self.name) + self.main_window = toga.MainWindow() self.on_exit = self.exit_handler # Label to show responses. diff --git a/gtk/setup.cfg b/gtk/setup.cfg index 23018f1181..cc09df2006 100644 --- a/gtk/setup.cfg +++ b/gtk/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 02e0cd2b4a..6fdf4adc02 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -1,9 +1,7 @@ import asyncio -import os -import os.path import signal import sys -from urllib.parse import unquote, urlparse +from pathlib import Path import gbulb @@ -16,20 +14,9 @@ from .window import Window -def gtk_menu_item_activate(cmd): - """Convert a GTK menu item activation into a command invocation.""" - - def _handler(action, data): - cmd.action(cmd) - - return _handler - - class MainWindow(Window): - _IMPL_CLASS = Gtk.ApplicationWindow - def create(self): - super().create() + self.native = Gtk.ApplicationWindow() self.native.set_role("MainWindow") icon_impl = toga_App.app.icon._impl self.native.set_icon(icon_impl.native_72) @@ -42,7 +29,8 @@ def gtk_delete_event(self, *args): # closing the window) should be performed; so # "should_exit == True" must be converted to a return # value of False. - return not self.interface.app.exit() + self.interface.app.on_exit() + return True class App: @@ -68,6 +56,7 @@ def create(self): application_id=self.interface.app_id, flags=Gio.ApplicationFlags.FLAGS_NONE, ) + self.native_about_dialog = None # Connect the GTK signal that will cause app startup to occur self.native.connect("startup", self.gtk_startup) @@ -79,15 +68,15 @@ def gtk_startup(self, data=None): # Set up the default commands for the interface. self.interface.commands.add( Command( - lambda _: self.interface.about(), - "About " + self.interface.name, + self._menu_about, + "About " + self.interface.formal_name, group=toga.Group.HELP, ), Command(None, "Preferences", group=toga.Group.APP), # Quit should always be the last item, in a section on its own Command( - lambda _: self.interface.exit(), - "Quit " + self.interface.name, + self._menu_quit, + "Quit " + self.interface.formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, @@ -125,6 +114,12 @@ def _create_app_commands(self): def gtk_activate(self, data=None): pass + def _menu_about(self, app, **kwargs): + self.interface.about() + + def _menu_quit(self, app, **kwargs): + self.interface.on_exit() + def create_menus(self): # Only create the menu if the menu item index has been created. self._menu_items = {} @@ -147,8 +142,7 @@ def create_menus(self): cmd_id = "command-%s" % id(cmd) action = Gio.SimpleAction.new(cmd_id, None) - if cmd.action: - action.connect("activate", gtk_menu_item_activate(cmd)) + action.connect("activate", cmd._impl.gtk_activate) cmd._impl.native.append(action) cmd._impl.set_enabled(cmd.enabled) @@ -180,7 +174,7 @@ def _submenu(self, group, menubar): text = group.text if text == "*": - text = self.interface.name + text = self.interface.formal_name parent_menu.append_submenu(text, submenu) @@ -199,34 +193,36 @@ def set_main_window(self, window): pass def show_about_dialog(self): - about = Gtk.AboutDialog() + self.native_about_dialog = Gtk.AboutDialog() + self.native_about_dialog.set_modal(True) icon_impl = toga_App.app.icon._impl - about.set_logo(icon_impl.native_72) + self.native_about_dialog.set_logo(icon_impl.native_72) - if self.interface.name is not None: - about.set_program_name(self.interface.name) + self.native_about_dialog.set_program_name(self.interface.formal_name) if self.interface.version is not None: - about.set_version(self.interface.version) + self.native_about_dialog.set_version(self.interface.version) if self.interface.author is not None: - about.set_authors([self.interface.author]) + self.native_about_dialog.set_authors([self.interface.author]) if self.interface.description is not None: - about.set_comments(self.interface.description) + self.native_about_dialog.set_comments(self.interface.description) if self.interface.home_page is not None: - about.set_website(self.interface.home_page) + self.native_about_dialog.set_website(self.interface.home_page) - about.run() - about.destroy() + self.native_about_dialog.show() + self.native_about_dialog.connect("close", self._close_about) + + def _close_about(self, dialog): + self.native_about_dialog.destroy() + self.native_about_dialog = None def beep(self): - Gdk.gdk_beep() + Gdk.beep() - def exit(self): + # We can't call this under test conditions, because it would kill the test harness + def exit(self): # pragma: no cover self.native.quit() - def set_on_exit(self, value): - pass - def get_current_window(self): return self.native.get_active_window()._impl @@ -248,7 +244,7 @@ def hide_cursor(self): self.interface.factory.not_implemented("App.hide_cursor()") -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): self.interface.commands.add( toga.Command( @@ -265,38 +261,24 @@ def gtk_startup(self, data=None): try: # Look for a filename specified on the command line - file_name = os.path.abspath(sys.argv[1]) + self.interface._open(Path(sys.argv[1])) except IndexError: # Nothing on the command line; open a file dialog instead. - # TODO: This causes a blank window to be shown. - # Is there a way to open a file dialog without having a window? + # Create a temporary window so we have context for the dialog m = toga.Window() - file_name = m.select_folder_dialog(self.interface.name, None, False)[0] - - self.open_document(file_name) + m.open_file_dialog( + self.interface.formal_name, + file_types=self.interface.document_types.keys(), + on_result=lambda dialog, path: self.interface._open(path) + if path + else self.exit(), + ) def open_file(self, widget, **kwargs): - # TODO: This causes a blank window to be shown. - # Is there a way to open a file dialog without having a window? + # Create a temporary window so we have context for the dialog m = toga.Window() - file_name = m.select_folder_dialog(self.interface.name, None, False)[0] - - self.open_document(file_name) - - def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ - # Convert the fileURL to a file path. - fileURL = fileURL.rstrip("/") - path = unquote(urlparse(fileURL).path) - extension = os.path.splitext(path)[1][1:] - - # Create the document instance - DocType = self.interface.document_types[extension] - document = DocType(fileURL, self.interface) - self.interface._documents.append(document) - - document.show() + m.open_file_dialog( + self.interface.formal_name, + file_types=self.interface.document_types.keys(), + on_result=lambda dialog, path: self.interface._open(path) if path else None, + ) diff --git a/gtk/src/toga_gtk/command.py b/gtk/src/toga_gtk/command.py index c8cec0cca7..95d4e0e3e4 100644 --- a/gtk/src/toga_gtk/command.py +++ b/gtk/src/toga_gtk/command.py @@ -9,6 +9,12 @@ def __init__(self, interface): self.interface = interface self.native = [] + def gtk_activate(self, action, data): + self.interface.action() + + def gtk_clicked(self, action): + self.interface.action() + def set_enabled(self, value): enabled = self.interface.enabled for widget in self.native: diff --git a/gtk/src/toga_gtk/dialogs.py b/gtk/src/toga_gtk/dialogs.py index 7b0395b5ee..0c3b598f3c 100644 --- a/gtk/src/toga_gtk/dialogs.py +++ b/gtk/src/toga_gtk/dialogs.py @@ -46,7 +46,7 @@ def gtk_response(self, dialog, response): else: result = None - self.on_result(self, result) + self.on_result(result) self.interface.future.set_result(result) self.native.destroy() @@ -218,7 +218,7 @@ def gtk_response(self, dialog, response): else: result = None - self.on_result(self, result) + self.on_result(result) self.interface.future.set_result(result) self.native.destroy() diff --git a/gtk/src/toga_gtk/documents.py b/gtk/src/toga_gtk/documents.py index 6020f29a0b..9000fc5ce5 100644 --- a/gtk/src/toga_gtk/documents.py +++ b/gtk/src/toga_gtk/documents.py @@ -1,4 +1,7 @@ -class Document: +class Document: # pragma: no cover + # GTK has 1-1 correspondence between document and app instances. + SINGLE_DOCUMENT_APP = True + def __init__(self, interface): self.interface = interface self.interface.read() diff --git a/gtk/src/toga_gtk/images.py b/gtk/src/toga_gtk/images.py index 32bc735869..cf8534d1d5 100644 --- a/gtk/src/toga_gtk/images.py +++ b/gtk/src/toga_gtk/images.py @@ -25,6 +25,14 @@ def get_width(self): def get_height(self): return self.native.get_height() + def get_data(self): + success, buffer = self.native.save_to_bufferv("png") + if success: + return buffer + else: # pragma: nocover + # This shouldn't ever happen, and it's difficult to manufacture in test conditions + raise ValueError("Unable to get PNG data for image") + def save(self, path): path = Path(path) try: diff --git a/gtk/src/toga_gtk/keys.py b/gtk/src/toga_gtk/keys.py index c9628379f8..7c92fab4c4 100644 --- a/gtk/src/toga_gtk/keys.py +++ b/gtk/src/toga_gtk/keys.py @@ -215,9 +215,7 @@ } GTK_MODIFIER_CODES = { - Key.CAPSLOCK: "", Key.SHIFT: "", - # TODO: Confirm the mapping of Control, Meta and Hyper are correct. Key.MOD_1: "", Key.MOD_2: "", Key.MOD_3: "", @@ -226,26 +224,20 @@ def toga_key(event): """Convert a GDK Key Event into a Toga key.""" - try: - key = GDK_KEYS[event.keyval] + key = GDK_KEYS[event.keyval] - modifiers = set() + modifiers = set() - # TODO: Confirm the mapping of Control, Meta and Hyper are correct. - if event.state & Gdk.ModifierType.LOCK_MASK: - modifiers.add(Key.CAPSLOCK) - if event.state & Gdk.ModifierType.SHIFT_MASK: - modifiers.add(Key.SHIFT) - if event.state & Gdk.ModifierType.CONTROL_MASK: - modifiers.add(Key.MOD_1) - if event.state & Gdk.ModifierType.META_MASK: - modifiers.add(Key.MOD_2) - if event.state & Gdk.ModifierType.HYPER_MASK: - modifiers.add(Key.MOD_3) + if event.state & Gdk.ModifierType.SHIFT_MASK: + modifiers.add(Key.SHIFT) + if event.state & Gdk.ModifierType.CONTROL_MASK: + modifiers.add(Key.MOD_1) + if event.state & Gdk.ModifierType.META_MASK: + modifiers.add(Key.MOD_2) + if event.state & Gdk.ModifierType.HYPER_MASK: + modifiers.add(Key.MOD_3) - return {"key": key, "modifiers": modifiers} - except KeyError: - return None + return {"key": key, "modifiers": modifiers} def gtk_accel(shortcut): @@ -264,6 +256,11 @@ def gtk_accel(shortcut): accel = accel.replace(key.value, "") modifiers.append(code) + # If the accelerator text is upper case, add a shift modifier. + if accel.isalpha() and accel.isupper(): + accel = accel.lower() + modifiers.append("") + # Find the canonical definition of the remaining key. for key, code in GTK_KEY_CODES.items(): if key.value == accel: diff --git a/gtk/src/toga_gtk/widgets/button.py b/gtk/src/toga_gtk/widgets/button.py index e5563b804f..4a714e4775 100644 --- a/gtk/src/toga_gtk/widgets/button.py +++ b/gtk/src/toga_gtk/widgets/button.py @@ -35,4 +35,4 @@ def rehint(self): self.interface.intrinsic.height = height[1] def gtk_clicked(self, event): - self.interface.on_press(None) + self.interface.on_press() diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 15504e0fcb..534c73e4f9 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -63,31 +63,31 @@ def gtk_draw_callback(self, widget, cairo_context): def gtk_on_size_allocate(self, widget, allocation): """Called on widget resize, and calls the handler set on the interface, if any.""" - self.interface.on_resize(None, width=allocation.width, height=allocation.height) + self.interface.on_resize(width=allocation.width, height=allocation.height) def mouse_down(self, obj, event): if event.button == 1: if event.type == Gdk.EventType._2BUTTON_PRESS: - self.interface.on_activate(None, event.x, event.y) + self.interface.on_activate(event.x, event.y) else: - self.interface.on_press(None, event.x, event.y) + self.interface.on_press(event.x, event.y) elif event.button == 3: - self.interface.on_alt_press(None, event.x, event.y) + self.interface.on_alt_press(event.x, event.y) else: # pragma: no cover # Don't handle other button presses pass def mouse_move(self, obj, event): if event.state == Gdk.ModifierType.BUTTON1_MASK: - self.interface.on_drag(None, event.x, event.y) + self.interface.on_drag(event.x, event.y) if event.state == Gdk.ModifierType.BUTTON3_MASK: - self.interface.on_alt_drag(None, event.x, event.y) + self.interface.on_alt_drag(event.x, event.y) def mouse_up(self, obj, event): if event.button == 1: - self.interface.on_release(None, event.x, event.y) + self.interface.on_release(event.x, event.y) elif event.button == 3: - self.interface.on_alt_release(None, event.x, event.y) + self.interface.on_alt_release(event.x, event.y) else: # pragma: no cover # Don't handle other button presses pass diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index f627e4d5f3..7e47035cea 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -283,11 +283,11 @@ def gtk_on_value_changed(self, adj): self.hide_actions() def gtk_on_refresh_clicked(self, widget): - self.interface.on_refresh(self.interface) + self.interface.on_refresh() def gtk_on_row_selected(self, w: Gtk.ListBox, item_impl: Gtk.ListBoxRow): self.hide_actions() - self.interface.on_select(self.interface) + self.interface.on_select() def gtk_on_right_click(self, gesture, n_press, x, y): rect = Gdk.Rectangle() @@ -307,11 +307,11 @@ def hide_actions(self): self._active_row = None def gtk_on_primary_clicked(self, widget): - self.interface.on_primary_action(None, row=self._active_row.row) + self.interface.on_primary_action(row=self._active_row.row) self.hide_actions() def gtk_on_secondary_clicked(self, widget): - self.interface.on_secondary_action(None, row=self._active_row.row) + self.interface.on_secondary_action(row=self._active_row.row) self.hide_actions() def update_refresh_button(self): diff --git a/gtk/src/toga_gtk/widgets/multilinetextinput.py b/gtk/src/toga_gtk/widgets/multilinetextinput.py index 66bdece4fd..35cb86922b 100644 --- a/gtk/src/toga_gtk/widgets/multilinetextinput.py +++ b/gtk/src/toga_gtk/widgets/multilinetextinput.py @@ -81,7 +81,7 @@ def set_value(self, value): self.native_textview.set_buffer(self.buffer) else: # See gtk_on_change for why this is needed - self.interface.on_change(None) + self.interface.on_change() if not self.has_focus: self.native_textview.set_buffer(self.placeholder) else: @@ -127,7 +127,7 @@ def gtk_on_changed(self, *args): # deliberately cleared, we add an explicit signal handler to set_value() # for the empty value case. if self.get_value(): - self.interface.on_change(None) + self.interface.on_change() def gtk_on_focus_in(self, *args): # When focus is gained, make sure the content buffer is active. diff --git a/gtk/src/toga_gtk/widgets/numberinput.py b/gtk/src/toga_gtk/widgets/numberinput.py index d8100bf728..176825bf3a 100644 --- a/gtk/src/toga_gtk/widgets/numberinput.py +++ b/gtk/src/toga_gtk/widgets/numberinput.py @@ -20,7 +20,7 @@ def create(self): self.native.connect("changed", self.gtk_on_change) def gtk_on_change(self, widget): - self.interface.on_change(widget) + self.interface.on_change() def get_readonly(self): return not self.native.get_property("editable") @@ -67,5 +67,7 @@ def rehint(self): width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH + width[1]) + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, width[1]) + ) self.interface.intrinsic.height = height[1] diff --git a/gtk/src/toga_gtk/widgets/optioncontainer.py b/gtk/src/toga_gtk/widgets/optioncontainer.py index ebf99ed718..6b3f3cba28 100644 --- a/gtk/src/toga_gtk/widgets/optioncontainer.py +++ b/gtk/src/toga_gtk/widgets/optioncontainer.py @@ -10,7 +10,7 @@ def create(self): self.sub_containers = [] def gtk_on_switch_page(self, widget, page, page_num): - self.interface.on_select(None) + self.interface.on_select() def add_content(self, index, text, widget): sub_container = TogaContainer() diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 2fbe8538c8..914ab7087b 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -23,7 +23,7 @@ def create(self): self.native.add(self.document_container) def gtk_on_changed(self, *args): - self.interface.on_scroll(None) + self.interface.on_scroll() def set_content(self, widget): self.document_container.content = widget @@ -54,7 +54,7 @@ def set_horizontal(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: self.native.get_hadjustment().set_value(0) - self.interface.on_scroll(None) + self.interface.on_scroll() def get_vertical(self): return self.native.get_policy()[1] == Gtk.PolicyType.AUTOMATIC @@ -69,7 +69,7 @@ def set_vertical(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: self.native.get_vadjustment().set_value(0) - self.interface.on_scroll(None) + self.interface.on_scroll() def get_max_vertical_position(self): return max( @@ -98,4 +98,4 @@ def get_horizontal_position(self): def set_position(self, horizontal_position, vertical_position): self.native.get_hadjustment().set_value(horizontal_position) self.native.get_vadjustment().set_value(vertical_position) - self.interface.on_scroll(None) + self.interface.on_scroll() diff --git a/gtk/src/toga_gtk/widgets/selection.py b/gtk/src/toga_gtk/widgets/selection.py index cd2e3195ff..7ff6ab2330 100644 --- a/gtk/src/toga_gtk/widgets/selection.py +++ b/gtk/src/toga_gtk/widgets/selection.py @@ -20,7 +20,7 @@ def suspend_notifications(self): def gtk_on_changed(self, widget): if self._send_notifications: - self.interface.on_change(None) + self.interface.on_change() # FIXME: 2023-05-31 Everything I can find in documentation, and every test I # do with manual stylesheet in the GTK Inspector, says that `.toga button` @@ -77,7 +77,7 @@ def remove(self, index, item): def clear(self): with self.suspend_notifications(): self.native.remove_all() - self.interface.on_change(None) + self.interface.on_change() def select_item(self, index, item): self.native.set_active(index) diff --git a/gtk/src/toga_gtk/widgets/slider.py b/gtk/src/toga_gtk/widgets/slider.py index 36f3cec11d..301cbfa356 100644 --- a/gtk/src/toga_gtk/widgets/slider.py +++ b/gtk/src/toga_gtk/widgets/slider.py @@ -23,15 +23,15 @@ def create(self): self.native.connect( "value-changed", - lambda native: self.interface.on_change(None), + lambda native: self.interface.on_change(), ) self.native.connect( "button-press-event", - lambda native, event: self.interface.on_press(None), + lambda native, event: self.interface.on_press(), ) self.native.connect( "button-release-event", - lambda native, event: self.interface.on_release(None), + lambda native, event: self.interface.on_release(), ) # Despite what the set_digits documentation says, set_round_digits has no effect diff --git a/gtk/src/toga_gtk/widgets/switch.py b/gtk/src/toga_gtk/widgets/switch.py index 349dc131ff..8c86d2bc1f 100644 --- a/gtk/src/toga_gtk/widgets/switch.py +++ b/gtk/src/toga_gtk/widgets/switch.py @@ -24,7 +24,7 @@ def create(self): self.native.pack_start(self.native_switch, False, False, 0) def gtk_notify_active(self, widget, state): - self.interface.on_change(None) + self.interface.on_change() def get_enabled(self): return self.native_switch.get_sensitive() diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index 6c76e88882..feeb8b8f2b 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -90,10 +90,10 @@ def _create_columns(self): def gtk_on_row_activated(self, widget, path, column): row = self.store[path][0].value - self.interface.on_activate(None, row=row) + self.interface.on_activate(row=row) def gtk_on_select(self, selection): - self.interface.on_select(None) + self.interface.on_select() def change_source(self, source): # Temporarily disconnecting the TreeStore improves performance for large diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index 3e3d68c57b..ecce9555d5 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -16,19 +16,19 @@ def create(self): self.native.connect("key-press-event", self.gtk_key_press_event) def gtk_on_change(self, entry): - self.interface.on_change(self.interface) + self.interface.on_change() self.interface._validate() def gtk_focus_in_event(self, entry, user_data): - self.interface.on_gain_focus(self.interface) + self.interface.on_gain_focus() def gtk_focus_out_event(self, entry, user_data): - self.interface.on_lose_focus(self.interface) + self.interface.on_lose_focus() def gtk_key_press_event(self, entry, user_data): key_pressed = toga_key(user_data) if key_pressed and key_pressed["key"] in {Key.ENTER, Key.NUMPAD_ENTER}: - self.interface.on_confirm(None) + self.interface.on_confirm() def get_readonly(self): return not self.native.get_property("editable") @@ -59,10 +59,12 @@ def rehint(self): # self._impl.get_preferred_width(), self._impl.get_preferred_height(), # getattr(self, '_fixed_height', False), getattr(self, '_fixed_width', False) # ) - # width = self.native.get_preferred_width() + width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, width[1]) + ) self.interface.intrinsic.height = height[1] def set_error(self, error_message): diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 3c6e945cce..9a4a645dcd 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -55,11 +55,11 @@ def _create_columns(self): self.native_tree.append_column(column) def gtk_on_select(self, selection): - self.interface.on_select(None) + self.interface.on_select() def gtk_on_row_activated(self, widget, path, column): node = self.store[path][0].value - self.interface.on_activate(None, node=node) + self.interface.on_activate(node=node) def change_source(self, source): # Temporarily disconnecting the TreeStore improves performance for large diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 4cb86660e2..3d17b33f71 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -34,7 +34,7 @@ def create(self): def gtk_on_load_changed(self, widget, load_event, *args): if load_event == WebKit2.LoadEvent.FINISHED: - self.interface.on_webview_load(None) + self.interface.on_webview_load() if self.load_future: self.load_future.set_result(None) diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 208ee63ffe..0bcea2d8a0 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -1,13 +1,10 @@ from toga.command import GROUP_BREAK, SECTION_BREAK -from toga.handlers import wrapped_handler from .container import TogaContainer -from .libs import Gtk +from .libs import Gdk, Gtk class Window: - _IMPL_CLASS = Gtk.Window - def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self @@ -16,7 +13,7 @@ def __init__(self, interface, title, position, size): self.layout = None - self.native = self._IMPL_CLASS() + self.create() self.native._impl = self self.native.connect("delete-event", self.gtk_delete_event) @@ -33,20 +30,27 @@ def __init__(self, interface, title, position, size): # Window Decorator when resizable == False self.native.set_resizable(self.interface.resizable) - self.toolbar_native = None - self.toolbar_items = None - # The GTK window's content is the layout; any user content is placed # into the container, which is the bottom widget in the layout. The # toolbar (if required) will be added at the top of the layout. - # + self.layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.native_toolbar = Gtk.Toolbar() + self.native_toolbar.set_style(Gtk.ToolbarStyle.BOTH) + self.native_toolbar.set_visible(False) + self.toolbar_items = {} + self.layout.pack_start(self.native_toolbar, expand=False, fill=False, padding=0) + # Because expand and fill are True, the container will fill the available # space, and will get a size_allocate callback if the window is resized. - self.layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.container = TogaContainer() self.layout.pack_end(self.container, expand=True, fill=True, padding=0) + self.native.add(self.layout) + def create(self): + self.native = Gtk.Window() + def get_title(self): return self.native.get_title() @@ -57,18 +61,20 @@ def set_app(self, app): app.native.add_window(self.native) def create_toolbar(self): - if self.toolbar_items is None: - self.toolbar_native = Gtk.Toolbar() - self.toolbar_items = {} - self.layout.pack_start( - self.toolbar_native, expand=False, fill=False, padding=0 - ) - else: - for cmd, item_impl in self.toolbar_items.items(): - self.toolbar_native.remove(item_impl) + # Remove any pre-existing toolbar content + if self.toolbar_items: + self.native_toolbar.set_visible(False) + + for cmd, item_impl in self.toolbar_items.items(): + self.native_toolbar.remove(item_impl) + try: cmd._impl.native.remove(item_impl) + except AttributeError: + # Breaks don't have _impls, so there's no native to clean up + pass - self.toolbar_native.set_style(Gtk.ToolbarStyle.BOTH) + # Create the new toolbar items + self.toolbar_items = {} for cmd in self.interface.toolbar: if cmd == GROUP_BREAK: item_impl = Gtk.SeparatorToolItem() @@ -79,13 +85,20 @@ def create_toolbar(self): else: item_impl = Gtk.ToolButton() if cmd.icon: - item_impl.set_icon_widget(cmd.icon._impl.native_32) + item_impl.set_icon_widget( + Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32) + ) item_impl.set_label(cmd.text) - item_impl.set_tooltip_text(cmd.tooltip) - item_impl.connect("clicked", wrapped_handler(cmd, cmd.action)) + if cmd.tooltip: + item_impl.set_tooltip_text(cmd.tooltip) + item_impl.connect("clicked", cmd._impl.gtk_clicked) cmd._impl.native.append(item_impl) self.toolbar_items[cmd] = item_impl - self.toolbar_native.insert(item_impl, -1) + self.native_toolbar.insert(item_impl, -1) + + if self.toolbar_items: + self.native_toolbar.set_visible(True) + self.native_toolbar.show_all() def set_content(self, widget): # Set the new widget to be the container's content @@ -104,7 +117,7 @@ def gtk_delete_event(self, widget, data): if self._is_closing: should_close = True else: - should_close = self.interface.on_close(self.interface.app) + should_close = self.interface.on_close() # Return value of the GTK on_close handler indicates # whether the event has been fully handled. Returning @@ -136,3 +149,32 @@ def set_full_screen(self, is_full_screen): self.native.fullscreen() else: self.native.unfullscreen() + + def get_image_data(self): + display = self.native.get_display() + display.flush() + + # For some reason, converting the *window* to a pixbuf fails. But if you extract + # a *part* of the overall screen, that works. So - work out the origin of the + # window, then the allocation for the container relative to that window, and + # capture that rectangle. + window = self.native.get_window() + origin = window.get_origin() + allocation = self.container.get_allocation() + + screen = display.get_default_screen() + root_window = screen.get_root_window() + screenshot = Gdk.pixbuf_get_from_window( + root_window, + origin.x + allocation.x, + origin.y + allocation.y, + allocation.width, + allocation.height, + ) + + success, buffer = screenshot.save_to_bufferv("png") + if success: + return buffer + else: # pragma: nocover + # This shouldn't ever happen, and it's difficult to manufacture in test conditions + raise ValueError(f"Unable to generate screenshot of {self}") diff --git a/gtk/tests/__init__.py b/gtk/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/tests/test_implementation.py b/gtk/tests/test_implementation.py deleted file mode 100644 index 18f301b09f..0000000000 --- a/gtk/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_gtk") - ) - ) -) diff --git a/gtk/tests/widgets/__init__.py b/gtk/tests/widgets/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 72d677d0c7..bc69ba1089 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -1,11 +1,17 @@ from pathlib import Path -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.keys import gtk_accel, toga_key +from toga_gtk.libs import Gdk, Gtk from .probe import BaseProbe class AppProbe(BaseProbe): + supports_key = True + supports_key_mod3 = True + def __init__(self, app): super().__init__() self.app = app @@ -26,3 +32,125 @@ def cache_path(self): @property def logs_path(self): return Path.home() / ".local" / "state" / "testbed" / "log" + + @property + def is_cursor_visible(self): + pytest.skip("Cursor visibility not implemented on GTK") + + def is_full_screen(self, window): + return bool( + window._impl.native.get_window().get_state() & Gdk.WindowState.FULLSCREEN + ) + + def content_size(self, window): + content_allocation = window._impl.container.get_allocation() + return (content_allocation.width, content_allocation.height) + + def _menu_item(self, path): + main_menu = self.app._impl.native.get_menubar() + menu = main_menu + orig_path = path.copy() + try: + while True: + label, path = path[0], path[1:] + items = {} + for index in range(menu.get_n_items()): + section = menu.get_item_link(index, "section") + if section: + for section_index in range(section.get_n_items()): + items[ + section.get_item_attribute_value( + section_index, "label" + ).get_string() + ] = (section, section_index) + else: + items[ + menu.get_item_attribute_value(index, "label").get_string() + ] = (menu, index) + + if label == "*": + item = items[self.app.formal_name] + else: + item = items[label] + + menu = item[0].get_item_link(item[1], "submenu") + except IndexError: + pass + except AttributeError: + raise AssertionError(f"Menu {' > '.join(orig_path)} not found") + + action_name = item[0].get_item_attribute_value(item[1], "action").get_string() + cmd_id = action_name.split(".")[1] + action = self.app._impl.native.lookup_action(cmd_id) + return action + + def _activate_menu_item(self, path): + item = self._menu_item(path) + item.emit("activate", None) + + def activate_menu_exit(self): + self._activate_menu_item(["*", "Quit Toga Testbed"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + async def close_about_dialog(self): + self.app._impl._close_about(self.app._impl.native_about_dialog) + + def activate_menu_visit_homepage(self): + # Homepage is a link on the GTK about page. + pytest.xfail("GTK doesn't have a visit homepage menu item") + + def assert_system_menus(self): + self.assert_menu_item(["*", "Preferences"], enabled=False) + self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + + self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) + + def activate_menu_close_window(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def activate_menu_close_all_windows(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def activate_menu_minimize(self): + pytest.xfail("GTK doesn't have a window management menu items") + + def assert_menu_item(self, path, enabled): + item = self._menu_item(path) + assert item.get_enabled() == enabled + + def keystroke(self, combination): + accel = gtk_accel(combination) + state = 0 + + if "" in accel: + state |= Gdk.ModifierType.CONTROL_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.META_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.HYPER_MASK + accel = accel.replace("", "") + if "" in accel: + state |= Gdk.ModifierType.SHIFT_MASK + accel = accel.replace("", "") + + keyval = getattr( + Gdk, + f"KEY_{accel}", + { + "!": Gdk.KEY_exclam, + "": Gdk.KEY_Home, + "F5": Gdk.KEY_F5, + }.get(accel, None), + ) + + event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) + event.keyval = keyval + event.length = 1 + event.is_modifier = state != 0 + event.state = state + + return toga_key(event) diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index 597c0a5470..abb09434fd 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -20,3 +20,6 @@ async def redraw(self, message=None, delay=None): if delay: await asyncio.sleep(delay) + + def assert_image_size(self, image_size, size): + assert image_size == size diff --git a/gtk/tests_backend/widgets/canvas.py b/gtk/tests_backend/widgets/canvas.py index 391cdf04ec..81deb6caa9 100644 --- a/gtk/tests_backend/widgets/canvas.py +++ b/gtk/tests_backend/widgets/canvas.py @@ -19,10 +19,6 @@ def reference_variant(self, reference): def get_image(self): return Image.open(BytesIO(self.impl.get_image_data())) - def assert_image_size(self, image, width, height): - assert image.width == width - assert image.height == height - async def mouse_press(self, x, y): event = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS) event.button = 1 diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 8075343e2f..a90b60f8ef 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -25,7 +25,7 @@ def __init__(self, app, window): assert isinstance(self.native, Gtk.Window) async def wait_for_window(self, message, minimize=False, full_screen=False): - await self.redraw(message, delay=0.5 if full_screen or minimize else 0.1) + await self.redraw(message, delay=0.5 if (full_screen or minimize) else 0.1) def close(self): if self.is_closable: @@ -229,3 +229,25 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): f"({'OPEN' if result else 'CANCEL'}) dismissed" ), ) + + def has_toolbar(self): + return self.impl.native_toolbar.get_n_items() > 0 + + def assert_is_toolbar_separator(self, index, section=False): + item = self.impl.native_toolbar.get_nth_item(index) + assert isinstance(item, Gtk.SeparatorToolItem) + assert item.get_draw() == (not section) + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self.impl.native_toolbar.get_nth_item(index) + assert item.get_label() == label + # FIXME: get_tooltip_text() doesn't work. The tooltip can be set, but the + # API to return the value just doesn't work. If it is ever fixed, this + # is the test for it: + # assert (None if item.get_tooltip_text() is None else item.get_tooltip_text()) == tooltip + assert (item.get_icon_widget() is not None) == has_icon + assert item.get_sensitive() == enabled + + def press_toolbar_button(self, index): + item = self.impl.native_toolbar.get_nth_item(index) + item.emit("clicked") diff --git a/iOS/setup.cfg b/iOS/setup.cfg index 9ec85252d7..18d9fe6002 100644 --- a/iOS/setup.cfg +++ b/iOS/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/iOS/setup.py b/iOS/setup.py index f9c2736edd..30217c2e40 100644 --- a/iOS/setup.py +++ b/iOS/setup.py @@ -7,7 +7,7 @@ version=version, install_requires=[ "fonttools >= 4.42.1, < 5.0.0", - "rubicon-objc >= 0.4.5rc1, < 0.5.0", + "rubicon-objc >= 0.4.7, < 0.5.0", f"toga-core == {version}", ], ) diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 48c4f7f653..343790c2a1 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -3,7 +3,7 @@ from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle -from toga_iOS.libs import UIResponder +from toga_iOS.libs import UIResponder, av_foundation from toga_iOS.window import Window @@ -67,10 +67,14 @@ def create(self): """Calls the startup method on the interface.""" self.interface._startup() - def open_document(self, fileURL): + def open_document(self, fileURL): # pragma: no cover """Add a new document to this app.""" pass + def create_menus(self): + # No menus on an iOS app (for now) + pass + 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 @@ -81,17 +85,38 @@ def main_loop(self): def set_main_window(self, window): pass + def get_current_window(self): + # iOS only has a main window. + return self.interface.main_window._impl + + def set_current_window(self, window): + # iOS only has a main window, so this is a no-op + pass + def show_about_dialog(self): self.interface.factory.not_implemented("App.show_about_dialog()") def beep(self): - self.interface.factory.not_implemented("App.beep()") + # 1013 is a magic constant that is the "SMS RECEIVED 5" sound, + # sounding like a single strike of a bell. + av_foundation.AudioServicesPlayAlertSound(1013) + + def exit(self): # pragma: no cover + # Mobile apps can't be exited, but the entry point needs to exist + pass + + def enter_full_screen(self, windows): + # No-op; mobile doesn't support full screen + pass - def exit(self): + def exit_full_screen(self, windows): + # No-op; mobile doesn't support full screen pass def hide_cursor(self): + # No-op; mobile doesn't support cursors pass def show_cursor(self): + # No-op; mobile doesn't support cursors pass diff --git a/iOS/src/toga_iOS/dialogs.py b/iOS/src/toga_iOS/dialogs.py index 300a52e76a..9216de940c 100644 --- a/iOS/src/toga_iOS/dialogs.py +++ b/iOS/src/toga_iOS/dialogs.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod -from rubicon.objc import Block -from rubicon.objc.runtime import objc_id +from rubicon.objc import Block, objc_id from toga_iOS.libs import ( UIAlertAction, @@ -39,7 +38,7 @@ def populate_dialog(self, native): ... def response(self, value): - self.on_result(self, value) + self.on_result(value) self.interface.future.set_result(value) def null_response(self, action: objc_id) -> None: diff --git a/iOS/src/toga_iOS/images.py b/iOS/src/toga_iOS/images.py index c3da2eba8a..2d0c0cbf3e 100644 --- a/iOS/src/toga_iOS/images.py +++ b/iOS/src/toga_iOS/images.py @@ -1,3 +1,4 @@ +from ctypes import POINTER, c_char, cast from pathlib import Path from toga_iOS.libs import ( @@ -7,6 +8,15 @@ ) +def nsdata_to_bytes(data: NSData) -> bytes: + """Convert an NSData into a raw bytes representation""" + # data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to + # POINTER(c_char) to get an addressable array of bytes, and slice that array to + # the known length. We don't use c_char_p because it has handling of NUL + # termination, and POINTER(c_char) allows array subscripting. + return cast(data.bytes, POINTER(c_char))[: data.length] + + class Image: def __init__(self, interface, path=None, data=None): self.interface = interface @@ -33,6 +43,9 @@ def get_width(self): def get_height(self): return self.native.size.height + def get_data(self): + return nsdata_to_bytes(NSData(uikit.UIImagePNGRepresentation(self.native))) + def save(self, path): path = Path(path) try: diff --git a/iOS/src/toga_iOS/libs/__init__.py b/iOS/src/toga_iOS/libs/__init__.py index 105a6400a7..50dd37acf7 100644 --- a/iOS/src/toga_iOS/libs/__init__.py +++ b/iOS/src/toga_iOS/libs/__init__.py @@ -1,3 +1,4 @@ +from .av_foundation import * # NOQA from .core_graphics import * # NOQA from .foundation import * # NOQA from .uikit import * # NOQA diff --git a/iOS/src/toga_iOS/libs/av_foundation.py b/iOS/src/toga_iOS/libs/av_foundation.py new file mode 100644 index 0000000000..5367d885e5 --- /dev/null +++ b/iOS/src/toga_iOS/libs/av_foundation.py @@ -0,0 +1,13 @@ +########################################################################## +# System/Library/Frameworks/AVFoundation.framework +########################################################################## +from ctypes import c_uint32, cdll, util + +###################################################################### +av_foundation = cdll.LoadLibrary(util.find_library("AVFoundation")) +###################################################################### + +SystemSoundID = c_uint32 + +av_foundation.AudioServicesPlayAlertSound.restype = None +av_foundation.AudioServicesPlayAlertSound.argtypes = [SystemSoundID] diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 929f8f0d52..356fcd2419 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -198,3 +198,13 @@ class CGAffineTransform(Structure): def CGRectMake(x, y, w, h): return CGRect(CGPoint(x, y), CGSize(w, h)) + + +###################################################################### +# CGImage.h + +CGImageRef = c_void_p +register_preferred_encoding(b"^{CGImage=}", CGImageRef) + +core_graphics.CGImageCreateWithImageInRect.argtypes = [CGImageRef, CGRect] +core_graphics.CGImageCreateWithImageInRect.restype = CGImageRef diff --git a/iOS/src/toga_iOS/widgets/button.py b/iOS/src/toga_iOS/widgets/button.py index a3724beeb2..007b2e7792 100644 --- a/iOS/src/toga_iOS/widgets/button.py +++ b/iOS/src/toga_iOS/widgets/button.py @@ -19,7 +19,7 @@ class TogaButton(UIButton): @objc_method def onPress_(self, obj) -> None: - self.interface.on_press(None) + self.interface.on_press() class Button(Widget): diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 4ceacc8838..9fc213f304 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -1,4 +1,3 @@ -from ctypes import POINTER, c_char, cast from math import ceil from rubicon.objc import ( @@ -10,15 +9,16 @@ NSPoint, NSRect, NSSize, + objc_id, objc_method, objc_property, ) -from rubicon.objc.runtime import objc_id from travertino.size import at_least from toga.colors import BLACK, TRANSPARENT, color from toga.constants import Baseline, FillRule from toga_iOS.colors import native_color +from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( CGPathDrawingMode, CGRectMake, @@ -51,17 +51,17 @@ def drawRect_(self, rect: CGRect) -> None: @objc_method def touchesBegan_withEvent_(self, touches, event) -> None: position = touches.allObjects()[0].locationInView(self) - self.interface.on_press(None, position.x, position.y) + self.interface.on_press(position.x, position.y) @objc_method def touchesMoved_withEvent_(self, touches, event) -> None: position = touches.allObjects()[0].locationInView(self) - self.interface.on_drag(None, position.x, position.y) + self.interface.on_drag(position.x, position.y) @objc_method def touchesEnded_withEvent_(self, touches, event) -> None: position = touches.allObjects()[0].locationInView(self) - self.interface.on_release(None, position.x, position.y) + self.interface.on_release(position.x, position.y) class Canvas(Widget): @@ -75,7 +75,7 @@ def create(self): def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) - self.interface.on_resize(None, width=width, height=height) + self.interface.on_resize(width=width, height=height) def redraw(self): self.native.setNeedsDisplay() @@ -311,13 +311,9 @@ def render(context): self.native.bounds, afterScreenUpdates=True ) - data = renderer.PNGDataWithActions(Block(render, None, objc_id)) - - # data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to - # POINTER(c_char) to get an addressable array of bytes, and slice that array to - # the known length. We don't use c_char_p because it has handling of NUL - # termination, and POINTER(c_char) allows array subscripting. - return cast(data.bytes, POINTER(c_char))[: data.length] + return nsdata_to_bytes( + renderer.PNGDataWithActions(Block(render, None, objc_id)) + ) # Rehint def rehint(self): diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index 26a55e71a3..6212de3504 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -76,7 +76,7 @@ def tableView_cellForRowAtIndexPath_(self, tableView, indexPath): @objc_method def tableView_didSelectRowAtIndexPath_(self, tableView, indexPath): - self.interface.on_select(None) + self.interface.on_select() # UITableViewDelegate methods @objc_method @@ -119,7 +119,7 @@ def tableView_leadingSwipeActionsConfigurationForRowAtIndexPath_( @objc_method def refresh(self): - self.interface.on_refresh(None) + self.interface.on_refresh() class DetailedList(Widget): @@ -164,7 +164,7 @@ def handle_primary_action( actionPerformed: ObjCInstance, ) -> None: item = self.interface.data[row] - self.interface.on_primary_action(self, row=item) + self.interface.on_primary_action(row=item) ObjCBlock(actionPerformed, None, bool)(True) return handle_primary_action @@ -179,7 +179,7 @@ def handle_secondary_action( actionPerformed: ObjCInstance, ) -> None: item = self.interface.data[row] - self.interface.on_secondary_action(self, row=item) + self.interface.on_secondary_action(row=item) ObjCBlock(actionPerformed, None, bool)(True) return handle_secondary_action diff --git a/iOS/src/toga_iOS/widgets/multilinetextinput.py b/iOS/src/toga_iOS/widgets/multilinetextinput.py index 16da6d64f2..600dbfb60d 100644 --- a/iOS/src/toga_iOS/widgets/multilinetextinput.py +++ b/iOS/src/toga_iOS/widgets/multilinetextinput.py @@ -51,7 +51,7 @@ def textViewDidEndEditing_(self, text_view): @objc_method def textViewDidChange_(self, text_view): - self.interface.on_change(None) + self.interface.on_change() class MultilineTextInput(Widget): @@ -134,7 +134,7 @@ def get_value(self): def set_value(self, value): self.native.text = value self.placeholder_label.setHidden(self.has_focus or len(self.native.text) > 0) - self.interface.on_change(None) + self.interface.on_change() def set_color(self, value): color = native_color(value) diff --git a/iOS/src/toga_iOS/widgets/numberinput.py b/iOS/src/toga_iOS/widgets/numberinput.py index 9e50d3047d..cbb7ca0b45 100644 --- a/iOS/src/toga_iOS/widgets/numberinput.py +++ b/iOS/src/toga_iOS/widgets/numberinput.py @@ -22,7 +22,7 @@ class TogaNumericTextField(UITextField): @objc_method def textFieldDidChange_(self, notification) -> None: - self.interface.on_change(self.interface) + self.interface.on_change() @objc_method def textField_shouldChangeCharactersInRange_replacementString_( @@ -106,7 +106,7 @@ def set_value(self, value): self.native.text = "" else: self.native.text = str(value) - self.interface.on_change(None) + self.interface.on_change() def set_alignment(self, value): self.native.textAlignment = NSTextAlignment(value) diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index cb32c84c66..8cd35c4cb3 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -12,7 +12,7 @@ class TogaScrollView(UIScrollView): @objc_method def scrollViewDidScroll_(self, scrollView) -> None: - self.interface.on_scroll(None) + self.interface.on_scroll() @objc_method def refreshContent(self): @@ -85,7 +85,7 @@ def set_vertical(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() def get_horizontal(self): return self._allow_horizontal @@ -99,7 +99,7 @@ def set_horizontal(self, value): # Disabling scrolling implies a position reset; that's a scroll event. if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() def get_horizontal_position(self): if not self.get_horizontal(): @@ -130,7 +130,7 @@ def set_position(self, horizontal_position, vertical_position): ): # iOS doesn't generate a scroll event unless the position actually changes. # Treat all scroll position assignments as a change. - self.interface.on_scroll(None) + self.interface.on_scroll() else: self.native.setContentOffset( NSMakePoint(horizontal_position, vertical_position), animated=True diff --git a/iOS/src/toga_iOS/widgets/selection.py b/iOS/src/toga_iOS/widgets/selection.py index a0fa70439f..52af947f42 100644 --- a/iOS/src/toga_iOS/widgets/selection.py +++ b/iOS/src/toga_iOS/widgets/selection.py @@ -48,7 +48,7 @@ def pickerView_didSelectRow_inComponent_( item = self.interface.items[row] label = self.interface._title_for_item(item) self.native.text = label - self.interface.on_change(None) + self.interface.on_change() class Selection(Widget): @@ -156,7 +156,7 @@ def select_item(self, index, item): self.native_picker.selectRow(index, inComponent=0, animated=False) else: self.native.text = "" - self.interface.on_change(None) + self.interface.on_change() def get_selected_index(self): if self._empty: diff --git a/iOS/src/toga_iOS/widgets/slider.py b/iOS/src/toga_iOS/widgets/slider.py index a6cec491e5..6db9758378 100644 --- a/iOS/src/toga_iOS/widgets/slider.py +++ b/iOS/src/toga_iOS/widgets/slider.py @@ -31,16 +31,16 @@ class TogaSlider(UISlider): @objc_method def onSlide_(self, obj) -> None: self.impl.value = self.interface._round_value(self.value) - self.interface.on_change(None) + self.interface.on_change() @objc_method def onPress_(self, obj) -> None: - self.interface.on_press(None) + self.interface.on_press() @objc_method def onRelease_(self, obj) -> None: self.impl.set_value(self.impl.value) - self.interface.on_release(None) + self.interface.on_release() class Slider(Widget): diff --git a/iOS/src/toga_iOS/widgets/switch.py b/iOS/src/toga_iOS/widgets/switch.py index f82f02fa2f..9e42c7b016 100644 --- a/iOS/src/toga_iOS/widgets/switch.py +++ b/iOS/src/toga_iOS/widgets/switch.py @@ -19,7 +19,7 @@ class TogaSwitch(UISwitch): @objc_method def onPress_(self, obj) -> None: - self.interface.on_change(None) + self.interface.on_change() class Switch(Widget): @@ -63,7 +63,7 @@ def set_value(self, value): old_value = self.native_switch.isOn() self.native_switch.setOn(value, animated=True) if value != old_value: - self.interface.on_change(None) + self.interface.on_change() def get_enabled(self): return self.native_switch.isEnabled() diff --git a/iOS/src/toga_iOS/widgets/textinput.py b/iOS/src/toga_iOS/widgets/textinput.py index 91f6649b69..62a080f041 100644 --- a/iOS/src/toga_iOS/widgets/textinput.py +++ b/iOS/src/toga_iOS/widgets/textinput.py @@ -25,20 +25,20 @@ class TogaTextField(UITextField): @objc_method def textFieldDidBeginEditing_(self, textField) -> None: - self.interface.on_gain_focus(None) + self.interface.on_gain_focus() @objc_method def textFieldDidChange_(self, textField) -> None: - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() @objc_method def textFieldDidEndEditing_(self, textField) -> None: - self.interface.on_lose_focus(None) + self.interface.on_lose_focus() @objc_method def textFieldShouldReturn_(self, textField) -> bool: - self.interface.on_confirm(None) + self.interface.on_confirm() return True @@ -127,7 +127,7 @@ def get_value(self): def set_value(self, value): self.native.text = value - self.interface.on_change(None) + self.interface.on_change() self.interface._validate() def set_alignment(self, value): diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index b3b8989dcf..7ef4852485 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -1,5 +1,4 @@ -from rubicon.objc import objc_method, objc_property, py_from_ns -from rubicon.objc.runtime import objc_id +from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least from toga.widgets.webview import JavaScriptResult @@ -30,7 +29,7 @@ class TogaWebView(WKWebView): @objc_method def webView_didFinishNavigation_(self, navigation) -> None: - self.interface.on_webview_load(self.interface) + self.interface.on_webview_load() if self.impl.loaded_future: self.impl.loaded_future.set_result(None) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 24aca68a48..7bd8bda595 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -1,8 +1,22 @@ +from rubicon.objc import ( + Block, + NSPoint, + NSRect, + NSSize, + objc_id, +) + from toga_iOS.container import RootContainer +from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( + NSData, UIColor, + UIGraphicsImageRenderer, + UIImage, UIScreen, UIWindow, + core_graphics, + uikit, ) @@ -81,7 +95,7 @@ def set_app(self, app): pass def create_toolbar(self): - pass + pass # pragma: no cover def show(self): self.native.makeKeyAndVisible() @@ -100,3 +114,73 @@ def set_full_screen(self, is_full_screen): def close(self): pass + + def get_image_data(self): + # This is... baroque. + # + # The iOS root container has an offset at the top, because the root view + # flows *under* the title bar. We don't want this in the screenshot. + # + # You can render a view using UIView.drawViewHierarchyInRect(), which + # takes a rect defining the region to be captured. It needs to be + # invoked in a graphics rendering context, which is initialized with a + # size. You'd *think* that you could specify the size of the final + # output image, and then render a rectangle that has that size at any + # position offset you choose... but no. If you do this, you end up with + # the *full* view, scaled to fit the provided size of the graphics + # context, with the offset being used in reverse to offset the origin of + # the scaling function. I'm sure this is useful to someone, but it's not + # useful to us. + # + # So - we capture the *entire* view, then crop to remove the section at + # the top of the image. + # + # Of course, the screenshot functionality uses UIImage, and UIImage has + # tooling to convert into PNG format... but doesn't contain *crop* + # functionality. + # + # So, we need to convert from UIImage to CGImage, and use Core Graphics + # to crop the image. + # + # Except that UIImage works in scaled coordinate, and Core Graphics + # works in native coordinates, so we need to do a size transformation + # along the way. + # + # I need a drink. + + renderer = UIGraphicsImageRenderer.alloc().initWithSize( + self.container.native.bounds.size + ) + + def render(context): + self.container.native.drawViewHierarchyInRect( + self.container.native.bounds, afterScreenUpdates=True + ) + + # Render the full image + full_image = UIImage.imageWithData( + renderer.PNGDataWithActions(Block(render, None, objc_id)) + ) + + # Get the size of the actual content (offsetting for the header) in raw coordinates. + container_bounds = self.container.content.native.bounds + image_bounds = NSRect( + NSPoint( + container_bounds.origin.x * UIScreen.mainScreen.scale, + (container_bounds.origin.y + self.container.top_offset) + * UIScreen.mainScreen.scale, + ), + NSSize( + container_bounds.size.width * UIScreen.mainScreen.scale, + container_bounds.size.height * UIScreen.mainScreen.scale, + ), + ) + + # Crop the image, + cropped_image = core_graphics.CGImageCreateWithImageInRect( + full_image.CGImage, image_bounds + ) + # Convert back into a UIGraphics + final_image = UIImage.imageWithCGImage(cropped_image) + # Convert into PNG data. + return nsdata_to_bytes(NSData(uikit.UIImagePNGRepresentation(final_image))) diff --git a/iOS/tests/__init__.py b/iOS/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/iOS/tests/test_implementation.py b/iOS/tests/test_implementation.py deleted file mode 100644 index bb39231812..0000000000 --- a/iOS/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_iOS") - ) - ) -) diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index ec8a355599..98a4ba0369 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from toga_iOS.libs import ( NSFileManager, NSSearchPathDirectory, @@ -11,10 +13,13 @@ class AppProbe(BaseProbe): + supports_key = False + def __init__(self, app): super().__init__() self.app = app - assert isinstance(self.app._impl.native, UIApplication) + self.native = self.app._impl.native + assert isinstance(self.native, UIApplication) def get_path(self, search_path): file_manager = NSFileManager.defaultManager @@ -38,3 +43,29 @@ def cache_path(self): @property def logs_path(self): return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs" + + def assert_system_menus(self): + pytest.skip("Menus not implemented on iOS") + + def activate_menu_about(self): + pytest.skip("Menus not implemented on iOS") + + def activate_menu_visit_homepage(self): + pytest.skip("Menus not implemented on iOS") + + def assert_menu_item(self, path, enabled): + pytest.skip("Menus not implemented on iOS") + + def enter_background(self): + self.native.delegate.applicationWillResignActive(self.native) + self.native.delegate.applicationDidEnterBackground(self.native) + + def enter_foreground(self): + self.native.delegate.applicationWillEnterForeground(self.native) + + def terminate(self): + self.native.delegate.applicationWillTerminate(self.native) + + def rotate(self): + self.native = self.app._impl.native + self.native.delegate.application(self.native, didChangeStatusBarOrientation=0) diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index e600fb2e43..c3a4653d11 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,6 +1,6 @@ import asyncio -from toga_iOS.libs import NSRunLoop +from toga_iOS.libs import NSRunLoop, UIScreen class BaseProbe: @@ -17,3 +17,8 @@ async def redraw(self, message=None, delay=None): # Running at "normal" speed, we need to release to the event loop # for at least one iteration. `runUntilDate:None` does this. NSRunLoop.currentRunLoop.runUntilDate(None) + + def assert_image_size(self, image_size, size): + # Retina displays render images at a higher resolution than their reported size. + scale = int(UIScreen.mainScreen.scale) + assert image_size == (size[0] * scale, size[1] * scale) diff --git a/iOS/tests_backend/widgets/canvas.py b/iOS/tests_backend/widgets/canvas.py index 5d233f0862..45de11f784 100644 --- a/iOS/tests_backend/widgets/canvas.py +++ b/iOS/tests_backend/widgets/canvas.py @@ -4,7 +4,7 @@ from PIL import Image from rubicon.objc import NSObject, NSPoint, ObjCClass, objc_method -from toga_iOS.libs import UIScreen, UIView +from toga_iOS.libs import UIView from .base import SimpleProbe @@ -33,12 +33,6 @@ def reference_variant(self, reference): def get_image(self): return Image.open(BytesIO(self.impl.get_image_data())) - def assert_image_size(self, image, width, height): - # Retina displays render images at a higher resolution than their reported size. - scale = int(UIScreen.mainScreen.scale) - assert image.width == width * scale - assert image.height == height * scale - async def mouse_press(self, x, y): touch = MockTouch.alloc().init() touches = NSSet.setWithObject(touch) diff --git a/iOS/tests_backend/widgets/detailedlist.py b/iOS/tests_backend/widgets/detailedlist.py index 16d990a507..f397797c9b 100644 --- a/iOS/tests_backend/widgets/detailedlist.py +++ b/iOS/tests_backend/widgets/detailedlist.py @@ -1,4 +1,5 @@ import asyncio +import platform from rubicon.objc.api import Block @@ -48,9 +49,13 @@ def assert_cell_content(self, row, title, subtitle, icon=None): @property def max_scroll_position(self): - return max( - 0, int(self.native.contentSize.height - self.native.frame.size.height) - ) + max_value = int(self.native.contentSize.height - self.native.frame.size.height) + # The max value is a little higher on iOS 17. + # Not sure where the 34 extra pixels are coming from. It appears to be + # a constant, independent of the number of rows of data. + if int(platform.release().split(".")[0]) >= 17: + max_value += 34 + return max(0, max_value) @property def scroll_position(self): diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 199353464c..08f9a34295 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -1,6 +1,6 @@ import pytest -from toga_iOS.libs import UIWindow +from toga_iOS.libs import UIApplication, UIWindow from .probe import BaseProbe @@ -19,9 +19,14 @@ async def wait_for_window(self, message, minimize=False, full_screen=False): @property def content_size(self): + # Content height doesn't include the status bar or navigation bar. return ( self.native.contentView.frame.size.width, - self.native.contentView.frame.size.height, + self.native.contentView.frame.size.height + - ( + UIApplication.sharedApplication.statusBarFrame.size.height + + self.native.rootViewController.navigationBar.frame.size.height + ), ) async def close_info_dialog(self, dialog): @@ -69,3 +74,6 @@ async def close_open_file_dialog(self, dialog, result, multiple_select): async def close_select_folder_dialog(self, dialog, result, multiple_select): pytest.skip("Select Folder dialog not implemented on iOS") + + def has_toolbar(self): + pytest.skip("Toolbars not implemented on iOS") diff --git a/pyproject.toml b/pyproject.toml index 2e117a0560..da9145dc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ exclude_lines = [ profile = "black" split_on_trailing_comma = true combine_as_imports = true +known_third_party = [ + "android", # isort defaults to making this first-party for some reason. +] known_first_party = [ "testbed", "toga", @@ -53,7 +56,7 @@ known_first_party = [ directory = "changes" package = "toga" package_dir = "core/src" -filename = "docs/background/releases.rst" +filename = "docs/background/project/releases.rst" title_format = "{version} ({project_date})" issue_format = "`#{issue} `__" template = "changes/template.rst" diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 20ffd097fe..bddb6e0ac7 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import toga @@ -8,6 +10,76 @@ def startup(self): # suite exiting/crashing. self.returncode = -1 + # Commands exist on the app's lifecycle, and the app API isn't designed to deal + # with destroying commands, so we create all the commands up front for the app + # to use. + + self.cmd_action = Mock() + # A command with everything, in a group + group = toga.Group("Other") + self.cmd1 = toga.Command( + self.cmd_action, + "Full command", + icon=toga.Icon.DEFAULT_ICON, + tooltip="A full command definition", + shortcut=toga.Key.MOD_1 + "1", + group=group, + ) + # A command with no tooltip, in the default group, with a non-printable shortcut + self.cmd2 = toga.Command( + self.cmd_action, + "No Tooltip", + icon=toga.Icon.DEFAULT_ICON, + shortcut=toga.Key.MOD_1 + toga.Key.DOWN, + ) + # A command without an icon, in the default group + self.cmd3 = toga.Command( + self.cmd_action, + "No Icon", + tooltip="A command with no icon", + shortcut=toga.Key.MOD_1 + "3", + ) + # A command in another section + self.cmd4 = toga.Command( + self.cmd_action, + "Sectioned", + icon=toga.Icon.DEFAULT_ICON, + tooltip="I'm in another section", + section=2, + ) + # Submenus inside the "other" group + subgroup1 = toga.Group("Submenu1", section=2, parent=group) + subgroup1_1 = toga.Group("Submenu1 menu1", parent=subgroup1) + subgroup2 = toga.Group("Submenu2", section=2, parent=group) + + # Items on submenu1 + # An item that is disabled by default + self.disabled_cmd = toga.Command( + self.cmd_action, + "Disabled", + enabled=False, + group=subgroup1, + ) + # An item that has no action + self.no_action_cmd = toga.Command(None, "No Action", group=subgroup1) + # An item deep in a menu + self.deep_cmd = toga.Command(self.cmd_action, "Deep", group=subgroup1_1) + + # Items on submenu2 + self.cmd5 = toga.Command(self.cmd_action, "Jiggle", group=subgroup2) + + # Add all the commands + self.commands.add( + self.cmd1, + self.cmd2, + self.cmd3, + self.cmd4, + self.disabled_cmd, + self.no_action_cmd, + self.deep_cmd, + self.cmd5, + ) + self.main_window = toga.MainWindow(title=self.formal_name) self.main_window.content = toga.Box( children=[ diff --git a/testbed/src/testbed/resources/canvas/ellipse_path.png b/testbed/src/testbed/resources/canvas/ellipse_path.png new file mode 100644 index 0000000000..cf520147a0 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/ellipse_path.png differ diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index b169c0a206..09c442f667 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -6,6 +6,8 @@ from pytest import fixture, register_assert_rewrite, skip import toga +from toga.colors import GOLDENROD +from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules # recursively; however we've already imported "tests", so that raises a warning. @@ -50,6 +52,21 @@ def main_window(app): return app.main_window +@fixture(scope="session") +async def main_window_probe(app, main_window): + old_content = main_window.content + + # Put something in the content window so that we know it's an app test + main_window.content = toga.Box(style=Pack(background_color=GOLDENROD)) + + module = import_module("tests_backend.window") + if app.run_slow: + print("\nConstructing Window probe") + yield getattr(module, "WindowProbe")(app, main_window) + + main_window.content = old_content + + # Controls the event loop used by pytest-asyncio. @fixture(scope="session") def event_loop(app): diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py new file mode 100644 index 0000000000..326860a6a8 --- /dev/null +++ b/testbed/tests/test_app.py @@ -0,0 +1,552 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE +from toga.style.pack import Pack + +from .test_window import window_probe + + +@pytest.fixture +def mock_app_exit(monkeypatch, app): + # We can't actually exit during a test, so monkeypatch the exit met""" + app_exit = Mock() + monkeypatch.setattr(toga.App, "exit", app_exit) + return app_exit + + +# Mobile platforms have different windowing characterics, so they have different tests. +if toga.platform.current_platform in {"iOS", "android"}: + #################################################################################### + # Mobile platform tests + #################################################################################### + + async def test_show_hide_cursor(app): + """The app cursor methods can be invoked""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.show_cursor() + app.hide_cursor() + + async def test_full_screen(app): + """Window can be made full screen""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.set_full_screen(app.current_window) + app.exit_full_screen() + + async def test_current_window(app, main_window, main_window_probe): + """The current window can be retrieved""" + assert app.current_window == main_window + + # Explicitly set the current window + app.current_window = main_window + await main_window_probe.wait_for_window("Main window is still current") + assert app.current_window == main_window + + async def test_app_lifecycle(app, app_probe): + """Application lifecycle can be exercised""" + app_probe.enter_background() + await app_probe.redraw("App pre-background logic has been invoked") + + app_probe.enter_foreground() + await app_probe.redraw("App restoration logic has been invoked") + + app_probe.terminate() + await app_probe.redraw("App pre-termination logic has been invoked") + + async def test_device_rotation(app, app_probe): + """App responds to device rotation""" + app_probe.rotate() + await app_probe.redraw("Device has been rotated") + +else: + #################################################################################### + # Desktop platform tests + #################################################################################### + + async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): + """An app can be exited by closing the main window""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the main window + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + main_window_probe.close() + await main_window_probe.redraw("Main window close requested, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + async def test_menu_exit(app, app_probe, mock_app_exit): + """An app can be exited by using the menu item""" + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Close the main window + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window1.show() + window2.show() + window3.show() + + app.current_window = window2 + + await app_probe.redraw("Extra windows added") + + app_probe.activate_menu_close_window() + await app_probe.redraw("Window 2 closed") + + assert window2 not in app.windows + + app_probe.activate_menu_close_all_windows() + await app_probe.redraw("All windows closed") + + # Close all windows will attempt to close the main window as well. + # This would be an app exit, but we can't allow that; so, the only + # window that *actually* remains will be the main window. + mock_app_exit.assert_called_once_with() + assert window1 not in app.windows + assert window2 not in app.windows + assert window3 not in app.windows + + await app_probe.redraw("Extra windows closed") + + # Now that we've "closed" all the windows, we're in a state where there + # aren't any windows. Patch get_current_window to reflect this. + monkeypatch.setattr( + app._impl, + "get_current_window", + Mock(return_value=None), + ) + app_probe.activate_menu_close_window() + await app_probe.redraw("No windows; Close Window is a no-op") + + app_probe.activate_menu_minimize() + await app_probe.redraw("No windows; Minimize is a no-op") + + finally: + if window1 in app.windows: + window1.close() + if window2 in app.windows: + window2.close() + if window3 in app.windows: + window3.close() + + async def test_menu_minimize(app, app_probe): + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window1.show() + + window1_probe = window_probe(app, window1) + + app.current_window = window1 + await app_probe.redraw("Extra window added") + + app_probe.activate_menu_minimize() + + await window1_probe.wait_for_window("Extra window minimized", minimize=True) + assert window1_probe.is_minimized + finally: + window1.close() + + async def test_full_screen(app, app_probe): + """Window can be made full screen""" + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) + + window1.show() + window2.show() + await app_probe.redraw("Extra windows are visible") + + assert not app.is_full_screen + assert not app_probe.is_full_screen(window1) + assert not app_probe.is_full_screen(window2) + initial_content1_size = app_probe.content_size(window1) + initial_content2_size = app_probe.content_size(window2) + + # Make window 2 full screen via the app + app.set_full_screen(window2) + await window2_probe.wait_for_window( + "Second extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert app_probe.is_full_screen(window2) + assert app_probe.content_size(window2)[0] > 1000 + assert app_probe.content_size(window2)[1] > 700 + + # Make window 1 full screen via the app, window 2 no longer full screen + app.set_full_screen(window1) + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen + app.exit_full_screen() + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Go full screen again on window 1 + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen by passing no windows + app.set_full_screen() + + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + finally: + window1.close() + window2.close() + + async def test_show_hide_cursor(app, app_probe): + """The app cursor can be hidden and shown""" + assert app_probe.is_cursor_visible + app.hide_cursor() + await app_probe.redraw("Cursor is hidden") + assert not app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.hide_cursor() + await app_probe.redraw("Cursor is still hidden") + assert not app_probe.is_cursor_visible + + # Show the cursor again + app.show_cursor() + await app_probe.redraw("Cursor is visible") + assert app_probe.is_cursor_visible + + # Showing again can't make it more visible + app.show_cursor() + await app_probe.redraw("Cursor is still visible") + assert app_probe.is_cursor_visible + + async def test_current_window(app, app_probe, main_window): + """The current window can be retrieved.""" + try: + assert app.current_window == main_window + + # When all windows are hidden, WinForms and Cocoa return None, while GTK + # returns the last active window. + main_window.hide() + assert app.current_window in [None, main_window] + + main_window.show() + assert app.current_window == main_window + finally: + main_window.show() + + try: + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + # We don't need to probe anything window specific; we just need + # a window probe to enforce appropriate delays. + window1_probe = window_probe(app, window1) + + window1.show() + window2.show() + window3.show() + + await window1_probe.wait_for_window("Extra windows added") + + app.current_window = window2 + await window1_probe.wait_for_window("Window 2 is current") + assert app.current_window == window2 + + app.current_window = window3 + await window1_probe.wait_for_window("Window 3 is current") + assert app.current_window == window3 + + # app_probe.platform tests? + finally: + window1.close() + window2.close() + window3.close() + + +async def test_main_window_toolbar(app, main_window, main_window_probe): + """A toolbar can be added to a main window""" + # Add some items to show the toolbar + assert not main_window_probe.has_toolbar() + main_window.toolbar.add(app.cmd1, app.cmd2) + + # Add some more items to an existing toolbar + main_window.toolbar.add(app.cmd3, app.cmd4) + + await main_window_probe.redraw("Main window has a toolbar") + assert main_window_probe.has_toolbar() + # Ordering is lexicographical for cmd 2 and 3. + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=True, + ) + main_window_probe.assert_is_toolbar_separator(1) + main_window_probe.assert_toolbar_item( + 2, + label="No Icon", + tooltip="A command with no icon", + has_icon=False, + enabled=True, + ) + main_window_probe.assert_toolbar_item( + 3, + label="No Tooltip", + tooltip=None, + has_icon=True, + enabled=True, + ) + main_window_probe.assert_is_toolbar_separator(4, section=True) + main_window_probe.assert_toolbar_item( + 5, + label="Sectioned", + tooltip="I'm in another section", + has_icon=True, + enabled=True, + ) + + # Press the first toolbar button + main_window_probe.press_toolbar_button(0) + await main_window_probe.redraw("Command 1 invoked") + app.cmd_action.assert_called_once_with(app.cmd1) + app.cmd_action.reset_mock() + + # Disable the first toolbar button + app.cmd1.enabled = False + await main_window_probe.redraw("Command 1 disabled") + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=False, + ) + + # Re-enable the first toolbar button + app.cmd1.enabled = True + await main_window_probe.redraw("Command 1 re-enabled") + main_window_probe.assert_toolbar_item( + 0, + label="Full command", + tooltip="A full command definition", + has_icon=True, + enabled=True, + ) + + # Remove the toolbar + main_window.toolbar.clear() + await main_window_probe.redraw("Main window has no toolbar") + assert not main_window_probe.has_toolbar() + + # Removing it again should have no effect + main_window.toolbar.clear() + await main_window_probe.redraw("Main window has no toolbar") + assert not main_window_probe.has_toolbar() + + +async def test_system_menus(app_probe): + """System-specific menus behave as expected""" + # Check that the system menus (which can be platform specific) exist. + app_probe.assert_system_menus() + + +async def test_menu_about(monkeypatch, app, app_probe): + """The about menu can be displayed""" + app_probe.activate_menu_about() + # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. + await app_probe.redraw("About dialog shown", delay=0.1) + + await app_probe.close_about_dialog() + await app_probe.redraw("About dialog destroyed") + + # Make the app definition minimal to verify the dialog still displays + monkeypatch.setattr(app, "_author", None) + monkeypatch.setattr(app, "_version", None) + monkeypatch.setattr(app, "_home_page", None) + monkeypatch.setattr(app, "_description", None) + + app_probe.activate_menu_about() + # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. + await app_probe.redraw("About dialog with no details shown", delay=0.1) + + await app_probe.close_about_dialog() + await app_probe.redraw("About dialog with no details destroyed") + + +async def test_menu_visit_homepage(monkeypatch, app, app_probe): + """The visit homepage menu item can be used""" + # We don't actually want to open a web browser; just check that the interface method + # was invoked. + visit_homepage = Mock() + monkeypatch.setattr(app, "visit_homepage", visit_homepage) + + app_probe.activate_menu_visit_homepage() + + # Browser opened + visit_homepage.assert_called_once_with() + + +async def test_menu_items(app, app_probe): + """Menu items can be created, disabled and invoked""" + + app_probe.assert_menu_item( + ["Other", "Full command"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "Submenu1 menu1", "Deep"], + enabled=True, + ) + + app_probe.assert_menu_item( + ["Commands", "No Tooltip"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Commands", "No Icon"], + enabled=True, + ) + app_probe.assert_menu_item( + ["Commands", "Sectioned"], + enabled=True, + ) + + # Enabled the disabled items + app.disabled_cmd.enabled = True + app.no_action_cmd.enabled = True + await app_probe.redraw("Menu items enabled") + + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=True, + ) + # Item has no action - it can't be enabled + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + + # Dislble the items + app.disabled_cmd.enabled = False + app.no_action_cmd.enabled = False + + await app_probe.redraw("Menu item disabled again") + app_probe.assert_menu_item( + ["Other", "Submenu1", "Disabled"], + enabled=False, + ) + app_probe.assert_menu_item( + ["Other", "Submenu1", "No Action"], + enabled=False, + ) + + +async def test_beep(app): + """The machine can go Bing!""" + # This isn't a very good test. It ensures coverage, which verifies that the method + # can be invoked without raising an error, but there's no way to verify that the app + # actually made a noise. + app.beep() diff --git a/testbed/tests/test_images.py b/testbed/tests/test_images.py index 7f9b9717d3..dd38d8e26b 100644 --- a/testbed/tests/test_images.py +++ b/testbed/tests/test_images.py @@ -47,6 +47,12 @@ async def test_data_image(app): assert image.width == 110 assert image.height == 30 + # Construct a second image from the first image's data + image2 = toga.Image(data=image.data) + + assert image2.width == 110 + assert image2.height == 30 + async def test_bad_image_data(app): "If data isn't a valid image, an error is raised" diff --git a/testbed/tests/test_keys.py b/testbed/tests/test_keys.py new file mode 100644 index 0000000000..76c662f76e --- /dev/null +++ b/testbed/tests/test_keys.py @@ -0,0 +1,49 @@ +import pytest + +from toga.keys import Key + + +@pytest.mark.parametrize( + "key_combo, key_data", + [ + # lower case + ("a", {"key": Key.A, "modifiers": set()}), + # upper case + ("A", {"key": Key.A, "modifiers": {Key.SHIFT}}), + # single modifier + (Key.MOD_1 + "a", {"key": Key.A, "modifiers": {Key.MOD_1}}), + (Key.MOD_2 + "a", {"key": Key.A, "modifiers": {Key.MOD_2}}), + (Key.MOD_3 + "a", {"key": Key.A, "modifiers": {Key.MOD_3}}), + # modifier combinations + ( + Key.MOD_1 + Key.MOD_2 + "a", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2}}, + ), + ( + Key.MOD_2 + Key.MOD_1 + "a", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2}}, + ), + ( + Key.MOD_1 + Key.MOD_2 + Key.MOD_3 + "A", + {"key": Key.A, "modifiers": {Key.MOD_1, Key.MOD_2, Key.MOD_3, Key.SHIFT}}, + ), + # A key which is shift modified + ("1", {"key": Key._1, "modifiers": set()}), + ("!", {"key": Key.EXCLAMATION, "modifiers": set()}), + # Special keys + (Key.F5, {"key": Key.F5, "modifiers": set()}), + (Key.HOME, {"key": Key.HOME, "modifiers": set()}), + (Key.HOME + Key.MOD_1, {"key": Key.HOME, "modifiers": {Key.MOD_1}}), + ], +) +def test_key_combinations(app_probe, key_combo, key_data): + """Key combinations can be round tripped""" + + if not app_probe.supports_key: + pytest.xfail("This backend doesn't use keyboard shortcuts") + + if (Key.MOD_3 in key_data["modifiers"]) and not app_probe.supports_key_mod3: + with pytest.raises(ValueError): + app_probe.keystroke(key_combo) + else: + assert app_probe.keystroke(key_combo) == key_data diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index 11acca0453..8b7fe877c0 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -35,11 +35,6 @@ async def second_window_probe(app, second_window): second_window.close() -@pytest.fixture -async def main_window_probe(app, main_window): - yield window_probe(app, main_window) - - async def test_title(main_window, main_window_probe): """The title of a window can be changed""" original_title = main_window.title @@ -247,6 +242,20 @@ async def test_secondary_window_cleanup(app_probe): assert window_ref() is None assert impl_ref() is None + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 300), size=(400, 200))], + ) + async def test_secondary_window_toolbar(app, second_window, second_window_probe): + """A toolbar can be added to a secondary window""" + second_window.toolbar.add(app.cmd1) + + # Window doesn't have content. This is intentional. + second_window.show() + + assert second_window_probe.has_toolbar() + await second_window_probe.redraw("Secondary window has a toolbar") + @pytest.mark.parametrize( "second_window_kwargs", [dict(title="Not Resizable", resizable=False, position=(200, 150))], @@ -471,6 +480,13 @@ async def test_full_screen(second_window, second_window_probe): assert second_window_probe.content_size == initial_content_size +async def test_as_image(main_window, main_window_probe): + """The window can be captured as a screenshot""" + + screenshot = main_window.as_image() + main_window_probe.assert_image_size(screenshot.size, main_window_probe.content_size) + + ######################################################################################## # Dialog tests ######################################################################################## diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index cc8e0489aa..03f983431e 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -75,8 +75,8 @@ def run_tests(app, cov, args, report_coverage, run_slow): ) if total < 100.0: print("Test coverage is incomplete") - # Uncomment the next line to enforce test coverage - # TODO: app.returncode = 1 + app.returncode = 1 + except BaseException: traceback.print_exc() app.returncode = 1 @@ -161,7 +161,10 @@ def get_terminal_size(*args, **kwargs): report_coverage=report_coverage, ) ) - thread.start() + # Queue a background task to run that will start the main thread. We do this, + # instead of just starting the thread directly, so that we can make sure the App has + # been fully initialized, and the event loop is running. + app.add_background_task(lambda app, **kwargs: thread.start()) # Start the test app. app.main_loop() diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index f63ef067df..db6a90c3ea 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -20,7 +20,7 @@ async def probe(main_window, widget): box = toga.Box(children=[widget]) main_window.content = box probe = get_probe(widget) - await probe.redraw(f"Constructing {widget.__class__.__name__} probe") + await probe.redraw(f"\nConstructing {widget.__class__.__name__} probe") probe.assert_container(box) yield probe diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 0c7b335cbb..0b376e7862 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -74,7 +74,7 @@ async def test_focus(widget, probe, other, other_probe, verify_focus_handlers): assert not other_probe.has_focus if verify_focus_handlers: - on_gain_handler.assert_called_once() + on_gain_handler.assert_called_once_with(widget) # Reset the mock so it can be tested again on_gain_handler.reset_mock() @@ -93,7 +93,7 @@ async def test_focus(widget, probe, other, other_probe, verify_focus_handlers): assert other_probe.has_focus if verify_focus_handlers: - on_lose_handler.assert_called_once() + on_lose_handler.assert_called_once_with(widget) async def test_focus_noop(widget, probe, other, other_probe): diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 00facca865..67ca42fb06 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -153,3 +153,17 @@ async def test_parenting(widget, probe): probe.assert_layout(position=(0, 0), size=(100, 200)) other_probe.assert_layout(position=(150, 0), size=(100, 200)) child_probe.assert_layout(position=(100, 0), size=(50, 75)) + + +async def test_tab_index(widget, probe, other): + if toga.platform.current_platform not in {"windows"}: + assert widget.tab_index is None + assert other.tab_index is None + else: + assert widget.tab_index == 1 + assert other.tab_index == 2 + + widget.tab_index = 4 + other.tab_index = 2 + assert widget.tab_index == 4 + assert other.tab_index == 2 diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index a01b90d280..a7b723ce0c 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1,5 +1,5 @@ import math -from math import pi +from math import pi, radians from unittest.mock import Mock, call import pytest @@ -116,6 +116,7 @@ async def test_resize(widget, probe, on_resize_handler): # the most recent of which has a large await probe.redraw("Canvas should be full size of window") assert on_resize_handler.call_count >= 1 + assert on_resize_handler.call_args.args == (widget,) assert on_resize_handler.call_args.kwargs["width"] > 300 assert on_resize_handler.call_args.kwargs["height"] > 300 on_resize_handler.reset() @@ -123,6 +124,7 @@ async def test_resize(widget, probe, on_resize_handler): widget.style.width = 100 await probe.redraw("Canvas should be tall and narrow") assert on_resize_handler.call_count >= 1 + assert on_resize_handler.call_args.args == (widget,) assert on_resize_handler.call_args.kwargs["width"] == 100 assert on_resize_handler.call_args.kwargs["height"] > 300 on_resize_handler.reset() @@ -130,6 +132,7 @@ async def test_resize(widget, probe, on_resize_handler): widget.style.height = 100 await probe.redraw("Canvas should be small") assert on_resize_handler.call_count >= 1 + assert on_resize_handler.call_args.args == (widget,) assert on_resize_handler.call_args.kwargs["width"] == 100 assert on_resize_handler.call_args.kwargs["height"] == 100 on_resize_handler.reset() @@ -137,6 +140,7 @@ async def test_resize(widget, probe, on_resize_handler): del widget.style.width await probe.redraw("Canvas should be width of the screen") assert on_resize_handler.call_count >= 1 + assert on_resize_handler.call_args.args == (widget,) assert on_resize_handler.call_args.kwargs["width"] > 300 assert on_resize_handler.call_args.kwargs["height"] == 100 on_resize_handler.reset() @@ -227,7 +231,7 @@ async def test_image_data(canvas, probe): # Cloned image is the right size. The platform may do DPI scaling; # let the probe determine the correct scaled size. - probe.assert_image_size(image, 200, 200) + probe.assert_image_size(image.size, (200, 200)) def assert_reference(probe, reference, threshold=0.0): @@ -307,6 +311,8 @@ async def test_paths(canvas, probe): # A stroked path requires an explicit close. For an open stroke, see test_stroke. canvas.context.begin_path() + # When there are two consecutive move_tos, the first one should leave no trace. + canvas.context.move_to(140, 140) canvas.context.move_to(180, 180) canvas.context.line_to(180, 60) canvas.context.line_to(60, 180) @@ -318,6 +324,12 @@ async def test_paths(canvas, probe): canvas.context.close_path() canvas.context.stroke(RED) + # A path containing only move_to commands should not appear. + canvas.context.begin_path() + canvas.context.move_to(140, 140) + canvas.context.move_to(160, 160) + canvas.context.stroke(RED) + await probe.redraw("Pair of triangles should be drawn") assert_reference(probe, "paths", threshold=0.04) @@ -435,6 +447,37 @@ async def test_ellipse(canvas, probe): assert_reference(probe, "ellipse", threshold=0.04) +async def test_ellipse_path(canvas, probe): + "An elliptical arc can be connected to other segments of a path" + + context = canvas.context + ellipse_args = dict(x=100, y=100, radiusx=70, radiusy=40, rotation=radians(30)) + + # Start of path -> arc + context.ellipse(**ellipse_args, startangle=radians(80), endangle=radians(160)) + # Arc -> arc + context.ellipse(**ellipse_args, startangle=radians(220), endangle=radians(260)) + context.stroke() + + context.begin_path() + context.move_to(120, 20) + # Move -> arc + context.ellipse(**ellipse_args, startangle=radians(280), endangle=radians(340)) + # Arc -> line + context.line_to(180, 50) + context.stroke(RED) + + context.begin_path() + context.move_to(180, 180) + context.line_to(180, 160) + # Line -> arc + context.ellipse(**ellipse_args, startangle=radians(10), endangle=radians(60)) + context.stroke(CORNFLOWERBLUE) + + await probe.redraw("Broken ellipse with connected lines should be drawn") + assert_reference(probe, "ellipse_path", threshold=0.04) + + async def test_rect(canvas, probe): "A rectangle can be drawn" diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index 60188db317..d729333af0 100644 --- a/testbed/tests/widgets/test_detailedlist.py +++ b/testbed/tests/widgets/test_detailedlist.py @@ -248,7 +248,7 @@ def add_row(event_widget, **kwargs): await probe.refresh_action() # It can take a couple of cycles for the refresh handler to fully execute; # impose a small delay to ensure it's been processed. - await probe.redraw("A refresh action has occurred") + await probe.redraw("A refresh action has occurred", delay=0.2) # New data has been added assert len(widget.data) == 101 diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index bf3dc3c41e..ef9afe4848 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -13,7 +13,7 @@ @pytest.fixture async def widget(): - skip_on_platforms("android", "iOS") + skip_on_platforms("iOS") return toga.Divider() diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 635ee25065..c71ee7e84c 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -85,6 +85,12 @@ async def test_clear_content(widget, probe, small_content): await probe.redraw("Widget content has been re-cleared") assert not probe.has_content + # Apply a style to guarantee a set_bounds() call has been made + # when there is no content. + widget.style.padding = 10 + await probe.redraw("Widget has definitely been refreshed") + assert not probe.has_content + widget.content = small_content await probe.redraw("Widget content has been restored") assert probe.has_content @@ -475,16 +481,23 @@ async def test_manual_scroll(widget, probe, content, on_scroll): async def test_no_content(widget, probe, content): "The content of the scroll container can be cleared" + original_width = probe.width + assert original_width > 100 widget.content = None await probe.redraw("Content of the scroll container has been cleared") + assert probe.width == original_width - # Force a refresh to see the impact of a set_bounds() when there's + # Force a resize to see the impact of a set_bounds() when there's # no inner content. - widget.refresh() - await probe.redraw("Scroll container layout has been refreshed") + widget.parent.add(other := toga.Box(style=Pack(flex=1))) + await probe.redraw("Scroll container size has been reduced") + reduced_width = probe.width + assert reduced_width == approx(original_width / 2, abs=1) widget.content = content await probe.redraw("Content of the scroll container has been restored") + assert probe.width == reduced_width - widget.refresh() - await probe.redraw("Scroll container layout has been refreshed") + widget.parent.remove(other) + await probe.redraw("Scroll container size has been restored") + assert probe.width == original_width diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 4aefd802e6..10752e90a5 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -487,7 +487,7 @@ async def test_cell_widget(widget, probe): warning_check = contextlib.nullcontext() else: warning_check = pytest.warns( - match=".* does not support the use of widgets in cells" + match=r".* does not support the use of widgets in cells" ) with warning_check: @@ -499,6 +499,8 @@ async def test_cell_widget(widget, probe): probe.assert_cell_content(0, 0, "A0") probe.assert_cell_content(0, 1, "B0") + await probe.redraw("Table row with a widget has been accessed", delay=0.1) + probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(1, 1, "B1") diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 26d97d151b..049d23acb3 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -160,7 +160,7 @@ async def test_on_confirm(widget, probe): await probe.redraw("Typed newline") # The handler has been invoked - assert handler.call_count == 1 + handler.assert_called_once_with(widget) async def test_validation(widget, probe): diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 10dcb6dd18..4dc852ed45 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -111,7 +111,14 @@ async def widget(on_load): else: raise - return widget + yield widget + + if toga.platform.current_platform == "linux": + # On Gtk, ensure that the WebView is garbage collection before the next test + # case. This prevents a segfault at GC time likely coming from the test suite + # running in a thread and Gtk WebViews sharing resources between instances. + del widget + gc.collect() async def test_set_url(widget, probe, on_load): diff --git a/toga/setup.cfg b/toga/setup.cfg index 64ca095ad1..47e639c815 100644 --- a/toga/setup.cfg +++ b/toga/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/tox.ini b/tox.ini index 308b6dc1f6..f25cf8f399 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,14 @@ -# The leading comma generates the "py-..." environments. -[testenv:py{,38,39,310,311,312}-{android,cocoa,core,gtk,iOS,web,winforms}] +# The leading comma generates the "py" environment. +[testenv:py{,38,39,310,311,312}] skip_install = True setenv = - android: subdir = android - cocoa: subdir = cocoa - core: subdir = core - gtk: subdir = gtk - iOS: subdir = iOS - web: subdir = web - winforms: subdir = winforms - - core: TOGA_BACKEND = toga_dummy - !core: TOGA_BACKEND = toga_{env:subdir} - gtk: test_command_prefix = xvfb-run -a -s "-screen 0 2048x1536x24" -changedir = {env:subdir} + TOGA_BACKEND = toga_dummy +changedir = core allowlist_externals = bash - gtk: xvfb-run commands = # TOGA_INSTALL_COMMAND is set to a bash command by the CI workflow. - {env:TOGA_INSTALL_COMMAND:python -m pip install ../core[dev] ../dummy .} + {env:TOGA_INSTALL_COMMAND:python -m pip install .[dev] ../dummy} {env:test_command_prefix:} coverage run -m pytest -vv {posargs} coverage combine coverage report --rcfile ../pyproject.toml diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index fdb71cba10..4d8090c05d 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,4 +1,5 @@ import toga +from toga.command import GROUP_BREAK, SECTION_BREAK from toga_web.libs import create_element, js from toga_web.window import Window @@ -22,7 +23,7 @@ def create(self): self.interface.commands.add( # ---- Help menu ---------------------------------- toga.Command( - lambda _: self.interface.about(), + self._menu_about, "About " + formal_name, group=toga.Group.HELP, ), @@ -33,7 +34,9 @@ def create(self): ), ) - # Create the menus. + # Create the menus. This is done before main window content to ensure + # the
for the menubar is inserted before the
for the + # main window. self.create_menus() # Call user code to populate the main window @@ -63,9 +66,9 @@ def create_menus(self): submenu = None for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: + elif cmd == SECTION_BREAK: # TODO - add a section break pass else: @@ -78,7 +81,7 @@ def create_menus(self): content=cmd.text, disabled=not cmd.enabled, ) - menu_item.onclick = cmd.action + menu_item.onclick = cmd._impl.dom_click submenu.append(menu_item) @@ -102,8 +105,10 @@ def create_menus(self): else: help_menu_container.appendChild(submenu) + menubar_id = f"{self.interface.app_id}-header" self.menubar = create_element( "header", + id=menubar_id, classes=["toga"], children=[ create_element( @@ -123,8 +128,15 @@ def create_menus(self): ], ) - # Menubar exists at the app level. - self.native.appendChild(self.menubar) + # If there's an existing menubar, replace it. + old_menubar = js.document.getElementById(menubar_id) + if old_menubar: + old_menubar.replaceWith(self.menubar) + else: + self.native.append(self.menubar) + + def _menu_about(self, widget, **kwargs): + self.interface.about() def main_loop(self): self.create() diff --git a/web/src/toga_web/command.py b/web/src/toga_web/command.py index 43dc630c02..3378a4e760 100644 --- a/web/src/toga_web/command.py +++ b/web/src/toga_web/command.py @@ -6,6 +6,9 @@ def __init__(self, interface): self.interface = interface self.native = [] + def dom_click(self, event): + self.interface.action() + def set_enabled(self, value): pass # enabled = self.interface.enabled diff --git a/web/src/toga_web/dialogs.py b/web/src/toga_web/dialogs.py index 0e81878a30..2fba1d7cbe 100644 --- a/web/src/toga_web/dialogs.py +++ b/web/src/toga_web/dialogs.py @@ -41,7 +41,7 @@ def dialog_close(self, event): self.native.hide() self.native.parentElement.removeChild(self.native) - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -52,7 +52,7 @@ def __init__(self, interface, title, message, on_result=None): interface.window.factory.not_implemented("Window.question_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -63,7 +63,7 @@ def __init__(self, interface, title, message, on_result=None): interface.window.factory.not_implemented("Window.confirm_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -74,7 +74,7 @@ def __init__(self, interface, title, message, on_result=None): interface.window.factory.not_implemented("Window.error_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -85,7 +85,7 @@ def __init__(self, interface, title, message, on_result=None, **kwargs): interface.window.factory.not_implemented("Window.stack_trace_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -104,7 +104,7 @@ def __init__( interface.window.factory.not_implemented("Window.save_file_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -123,7 +123,7 @@ def __init__( interface.window.factory.not_implemented("Window.open_file_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) @@ -141,5 +141,5 @@ def __init__( interface.window.factory.not_implemented("Window.select_folder_dialog()") - self.on_result(self, None) + self.on_result(None) self.interface.future.set_result(None) diff --git a/web/src/toga_web/widgets/button.py b/web/src/toga_web/widgets/button.py index 6491076fe3..633c27b318 100644 --- a/web/src/toga_web/widgets/button.py +++ b/web/src/toga_web/widgets/button.py @@ -7,7 +7,7 @@ def create(self): self.native.onclick = self.dom_onclick def dom_onclick(self, event): - self.interface.on_press(None) + self.interface.on_press() def get_text(self): return self.native.innerHTML diff --git a/web/src/toga_web/widgets/switch.py b/web/src/toga_web/widgets/switch.py index 748ee24ab5..558f837661 100644 --- a/web/src/toga_web/widgets/switch.py +++ b/web/src/toga_web/widgets/switch.py @@ -9,7 +9,7 @@ def create(self): self.native.addEventListener("sl-change", create_proxy(self.dom_onchange)) def dom_onchange(self, event): - self.interface.on_change(None) + self.interface.on_change() def get_text(self): return self.native.innerHTML @@ -24,4 +24,4 @@ def set_value(self, value): old_value = self.get_value() self.native.checked = value if value != old_value: - self.interface.on_change(self.interface) + self.interface.on_change() diff --git a/web/src/toga_web/widgets/textinput.py b/web/src/toga_web/widgets/textinput.py index 638aa9c612..cd63750cf6 100644 --- a/web/src/toga_web/widgets/textinput.py +++ b/web/src/toga_web/widgets/textinput.py @@ -9,7 +9,7 @@ def create(self): def dom_keyup(self, event): if event.key == "Enter": - self.interface.on_confirm(None) + self.interface.on_confirm() def set_readonly(self, value): self.native.readOnly = value diff --git a/web/tests/__init__.py b/web/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/tests/test_implementation.py b/web/tests/test_implementation.py deleted file mode 100644 index 51631ff090..0000000000 --- a/web/tests/test_implementation.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "toga_web") - ) - ) -) diff --git a/winforms/setup.cfg b/winforms/setup.cfg index 56967a1644..e4a72d66fa 100644 --- a/winforms/setup.cfg +++ b/winforms/setup.cfg @@ -12,7 +12,7 @@ author_email = russell@keith-magee.com maintainer = BeeWare Team maintainer_email = team@beeware.org classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index a94c9a7b94..9bca588af9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -6,12 +6,14 @@ import System.Windows.Forms as WinForms from System import Environment, Threading +from System.ComponentModel import InvalidEnumArgumentException from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher import toga from toga import Key +from toga.command import GROUP_BREAK, SECTION_BREAK from .keys import toga_to_winforms_key from .libs.proactor import WinformsProactorEventLoop @@ -23,13 +25,12 @@ class MainWindow(Window): def winforms_FormClosing(self, sender, event): # Differentiate between the handling that occurs when the user # requests the app to exit, and the actual application exiting. - if not self.interface.app._impl._is_exiting: + if not self.interface.app._impl._is_exiting: # pragma: no branch # If there's an event handler, process it. The decision to # actually exit the app will be processed in the on_exit handler. # If there's no exit handler, assume the close/exit can proceed. - if self.interface.app.on_exit: - self.interface.app.on_exit(self.interface.app) - event.Cancel = True + self.interface.app.on_exit() + event.Cancel = True class App: @@ -71,7 +72,7 @@ def create(self): # SetProcessDpiAwareness(True) if (win_version.Major == 6 and win_version.Minor == 3) or ( win_version.Major == 10 and win_version.Build < 15063 - ): + ): # pragma: no cover windll.shcore.SetProcessDpiAwareness(True) print( "WARNING: Your Windows version doesn't support DPI-independent rendering. " @@ -82,7 +83,7 @@ def create(self): elif win_version.Major == 10 and win_version.Build >= 15063: windll.user32.SetProcessDpiAwarenessContext(-2) # Any other version of windows should use SetProcessDPIAware() - else: + else: # pragma: no cover windll.user32.SetProcessDPIAware() self.native.EnableVisualStyles() @@ -95,84 +96,74 @@ def create(self): # encouraged. try: ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12 - except AttributeError: + except AttributeError: # pragma: no cover print( "WARNING: Your Windows .NET install does not support TLS1.2. " "You may experience difficulties accessing some web server content." ) try: ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls13 - except AttributeError: + except AttributeError: # pragma: no cover print( "WARNING: Your Windows .NET install does not support TLS1.3. " "You may experience difficulties accessing some web server content." ) - self.interface.commands.add( - toga.Command( - lambda _: self.interface.about(), - f"About {self.interface.name}", - group=toga.Group.HELP, - ), - toga.Command(None, "Preferences", group=toga.Group.FILE), - # Quit should always be the last item, in a section on its own - toga.Command( - lambda _: self.interface.exit(), - "Exit " + self.interface.name, - shortcut=Key.MOD_1 + "q", - group=toga.Group.FILE, - section=sys.maxsize, - ), - toga.Command( - lambda _: self.interface.visit_homepage(), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=toga.Group.HELP, - ), - ) - self._create_app_commands() - # Call user code to populate the main window self.interface._startup() + self._create_app_commands() self.create_menus() self.interface.main_window._impl.set_app(self) def create_menus(self): - self._menu_items = {} + if self.interface.main_window is None: # pragma: no branch + # The startup method may create commands before creating the window, so + # we'll call create_menus again after it returns. + return + + window = self.interface.main_window._impl + menubar = window.native.MainMenuStrip + if menubar: + menubar.Items.Clear() + else: + # The menu bar doesn't need to be positioned, because its `Dock` property + # defaults to `Top`. + menubar = WinForms.MenuStrip() + window.native.Controls.Add(menubar) + window.native.MainMenuStrip = menubar + menubar.SendToBack() # In a dock, "back" means "top". + + # The File menu should come before all user-created menus. self._menu_groups = {} + toga.Group.FILE.order = -1 - toga.Group.FILE.order = 0 - menubar = WinForms.MenuStrip() submenu = None for cmd in self.interface.commands: - if cmd == toga.GROUP_BREAK: + if cmd == GROUP_BREAK: submenu = None - elif cmd == toga.SECTION_BREAK: + elif cmd == SECTION_BREAK: submenu.DropDownItems.Add("-") else: submenu = self._submenu(cmd.group, menubar) - item = WinForms.ToolStripMenuItem(cmd.text) + item.Click += WeakrefCallable(cmd._impl.winforms_Click) + if cmd.shortcut is not None: + try: + item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) + except ( + ValueError, + InvalidEnumArgumentException, + ) as e: # pragma: no cover + # Make this a non-fatal warning, because different backends may + # accept different shortcuts. + print(f"WARNING: invalid shortcut {cmd.shortcut!r}: {e}") - if cmd.action: - item.Click += WeakrefCallable(cmd._impl.winforms_handler) item.Enabled = cmd.enabled - if cmd.shortcut is not None: - shortcut_keys = toga_to_winforms_key(cmd.shortcut) - item.ShortcutKeys = shortcut_keys - item.ShowShortcutKeys = True - cmd._impl.native.append(item) - - self._menu_items[item] = cmd submenu.DropDownItems.Add(item) - # The menu bar doesn't need to be positioned, because its `Dock` property - # defaults to `Top`. - self.interface.main_window._impl.native.Controls.Add(menubar) - self.interface.main_window._impl.native.MainMenuStrip = menubar - self.interface.main_window._impl.resize_content() + window.resize_content() def _submenu(self, group, menubar): try: @@ -195,16 +186,36 @@ def _submenu(self, group, menubar): return submenu def _create_app_commands(self): - # No extra menus - pass - - def open_document(self, fileURL): - """Add a new document to this app.""" - print( - "STUB: If you want to handle opening documents, implement App.open_document(fileURL)" + self.interface.commands.add( + # About should be the last item in the Help menu, in a section on its own. + toga.Command( + lambda _: self.interface.about(), + f"About {self.interface.formal_name}", + group=toga.Group.HELP, + section=sys.maxsize, + ), + # + toga.Command(None, "Preferences", group=toga.Group.FILE), + # + # On Windows, the Exit command doesn't usually contain the app name. It + # should be the last item in the File menu, in a section on its own. + toga.Command( + lambda _: self.interface.on_exit(), + "Exit", + shortcut=Key.MOD_1 + "q", + group=toga.Group.FILE, + section=sys.maxsize, + ), + # + toga.Command( + lambda _: self.interface.visit_homepage(), + "Visit homepage", + enabled=self.interface.home_page is not None, + group=toga.Group.HELP, + ), ) - def winforms_thread_exception(self, sender, winforms_exc): + def winforms_thread_exception(self, sender, winforms_exc): # pragma: no cover # The PythonException returned by Winforms doesn't give us # easy access to the underlying Python stacktrace; so we # reconstruct it from the string message. @@ -231,13 +242,13 @@ def winforms_thread_exception(self, sender, winforms_exc): print(py_exc.Message) @classmethod - def print_stack_trace(cls, stack_trace_line): + def print_stack_trace(cls, stack_trace_line): # pragma: no cover for level in stack_trace_line.split("', '"): for line in level.split("\\n"): if line: print(line) - def run_app(self): + def run_app(self): # pragma: no cover # Enable coverage tracing on this non-Python-created thread # (https://github.com/nedbat/coveragepy/issues/686). if threading._trace_hook: @@ -270,36 +281,30 @@ def main_loop(self): # If it's non-None, raise it, as it indicates the underlying # app thread had a problem; this is effectibely a re-raise over # a thread boundary. - if self._exception: + if self._exception: # pragma: no cover raise self._exception def show_about_dialog(self): message_parts = [] - if self.interface.name is not None: - if self.interface.version is not None: - message_parts.append( - "{name} v{version}".format( - name=self.interface.name, - version=self.interface.version, - ) - ) - else: - message_parts.append(f"{self.interface.name}") - elif self.interface.version is not None: - message_parts.append(f"v{self.interface.version}") + if self.interface.version is not None: + message_parts.append( + f"{self.interface.formal_name} v{self.interface.version}" + ) + else: + message_parts.append(self.interface.formal_name) if self.interface.author is not None: message_parts.append(f"Author: {self.interface.author}") if self.interface.description is not None: message_parts.append(f"\n{self.interface.description}") self.interface.main_window.info_dialog( - f"About {self.interface.name}", "\n".join(message_parts) + f"About {self.interface.formal_name}", "\n".join(message_parts) ) def beep(self): SystemSounds.Beep.Play() - def exit(self): + def exit(self): # pragma: no cover self._is_exiting = True self.native.Exit() @@ -309,7 +314,8 @@ def set_main_window(self, window): def get_current_window(self): for window in self.interface.windows: if WinForms.Form.ActiveForm == window._impl.native: - return window._impl.native + return window._impl + return None def set_current_window(self, window): window._impl.native.Activate() @@ -333,8 +339,9 @@ def hide_cursor(self): self._cursor_visible = False -class DocumentApp(App): +class DocumentApp(App): # pragma: no cover def _create_app_commands(self): + super()._create_app_commands() self.interface.commands.add( toga.Command( lambda w: self.open_file, @@ -344,11 +351,3 @@ def _create_app_commands(self): section=0, ), ) - - def open_document(self, fileURL): - """Open a new document in this app. - - Args: - fileURL (str): The URL/path to the file to add as a document. - """ - self.interface.factory.not_implemented("DocumentApp.open_document()") diff --git a/winforms/src/toga_winforms/colors.py b/winforms/src/toga_winforms/colors.py index 8609bbebf2..4a7b89d08f 100644 --- a/winforms/src/toga_winforms/colors.py +++ b/winforms/src/toga_winforms/colors.py @@ -1,5 +1,5 @@ from System.Drawing import Color -from travertino.colors import NAMED_COLOR, TRANSPARENT +from travertino.colors import TRANSPARENT CACHE = {TRANSPARENT: Color.Transparent} @@ -8,8 +8,6 @@ def native_color(c): try: color = CACHE[c] except KeyError: - if isinstance(c, str): - c = NAMED_COLOR[c] color = Color.FromArgb( int(c.rgba.a * 255), int(c.rgba.r), int(c.rgba.g), int(c.rgba.b) ) diff --git a/winforms/src/toga_winforms/command.py b/winforms/src/toga_winforms/command.py index c06c9f87d9..562eb7fb2e 100644 --- a/winforms/src/toga_winforms/command.py +++ b/winforms/src/toga_winforms/command.py @@ -3,10 +3,10 @@ def __init__(self, interface): self.interface = interface self.native = [] + def winforms_Click(self, sender, event): + return self.interface.action() + def set_enabled(self, value): if self.native: for widget in self.native: widget.Enabled = self.interface.enabled - - def winforms_handler(self, sender, event): - return self.interface.action(None) diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 061ef38d83..dcddf391f6 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -52,7 +52,7 @@ def apply_layout(self, layout_width, layout_height): ) def add_content(self, widget): - # The default appears to be to add new controls to the back of the Z-order. + # The default is to add new controls to the back of the Z-order. self.native_content.Controls.Add(widget.native) widget.native.BringToFront() diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 5b82d9e5ca..5e57104e66 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -26,7 +26,7 @@ def start_inner_loop(self, callback, *args): asyncio.get_event_loop().start_inner_loop(callback, *args) def set_result(self, result): - self.on_result(None, result) + self.on_result(result) self.interface.future.set_result(result) diff --git a/winforms/src/toga_winforms/images.py b/winforms/src/toga_winforms/images.py index 359c760715..9417fc56e1 100644 --- a/winforms/src/toga_winforms/images.py +++ b/winforms/src/toga_winforms/images.py @@ -33,6 +33,11 @@ def get_width(self): def get_height(self): return self.native.Height + def get_data(self): + stream = MemoryStream() + self.native.Save(stream, ImageFormat.Png) + return stream.ToArray() + def save(self, path): path = Path(path) try: diff --git a/winforms/src/toga_winforms/keys.py b/winforms/src/toga_winforms/keys.py index 5fabd663f0..33208d0657 100644 --- a/winforms/src/toga_winforms/keys.py +++ b/winforms/src/toga_winforms/keys.py @@ -1,72 +1,87 @@ import operator +import re from functools import reduce -from string import ascii_uppercase +from string import ascii_lowercase import System.Windows.Forms as WinForms from toga.keys import Key -WINFORMS_NON_PRINTABLES_MAP = { +WINFORMS_MODIFIERS = { Key.MOD_1: WinForms.Keys.Control, Key.MOD_2: WinForms.Keys.Alt, + Key.SHIFT: WinForms.Keys.Shift, } -WINFORMS_NON_PRINTABLES_MAP.update( - { - getattr(Key, modifier.upper()): getattr(WinForms.Keys, modifier.title()) - for modifier in ["shift", "up", "down", "left", "right", "home"] - } -) -WINFORMS_KEYS_MAP = { - Key.PLUS.value: WinForms.Keys.Oemplus, - Key.MINUS.value: WinForms.Keys.OemMinus, +WINFORMS_KEYS = { + "+": WinForms.Keys.Oemplus, + "-": WinForms.Keys.OemMinus, } -WINFORMS_KEYS_MAP.update( - { - getattr(Key, letter).value: getattr(WinForms.Keys, letter) - for letter in ascii_uppercase - } +WINFORMS_KEYS.update( + {str(digit): getattr(WinForms.Keys, f"D{digit}") for digit in range(10)} +) + +SHIFTED_KEYS = {symbol: number for symbol, number in zip("!@#$%^&*()", "1234567890")} +SHIFTED_KEYS.update( + {lower.upper(): lower for lower in ascii_lowercase}, ) def toga_to_winforms_key(key): + # Convert a Key object into string form. + try: + key = key.value + except AttributeError: + pass + codes = [] - for modifier, modifier_code in WINFORMS_NON_PRINTABLES_MAP.items(): + for modifier, modifier_code in WINFORMS_MODIFIERS.items(): if modifier.value in key: codes.append(modifier_code) key = key.replace(modifier.value, "") - key_code = WINFORMS_KEYS_MAP.get(key, None) - if key_code is not None: - codes.append(key_code) - return reduce(operator.or_, codes) - - -TOGA_KEYS_MAP = {w: t for t, w in WINFORMS_KEYS_MAP.items()} -TOGA_KEYS_MAP.update( - { - getattr(WinForms.Keys, modifier.title()): getattr(Key, modifier.upper()) - for modifier in ["shift", "up", "down", "left", "right", "home"] - } -) + if lower := SHIFTED_KEYS.get(key): + key = lower + codes.append(WinForms.Keys.Shift) -def toga_key(event): - """Convert a Cocoa NSKeyEvent into a Toga event.""" try: - key = TOGA_KEYS_MAP[event.KeyCode] + codes.append(WINFORMS_KEYS[key]) except KeyError: - key = WINFORMS_NON_PRINTABLES_MAP + if match := re.fullmatch(r"<(.+)>", key): + key = match[1] + try: + codes.append(getattr(WinForms.Keys, key.title())) + except AttributeError: # pragma: no cover + raise ValueError(f"unknown key: {key!r}") from None + + return reduce(operator.or_, codes) + + +def winforms_to_toga_key(code): modifiers = set() - # if event.Capslock?: - # modifiers.add(Key.CAPSLOCK) - if event.Shift: - modifiers.add(Key.SHIFT) - if event.Control: - modifiers.add(Key.MOD_1) - if event.Alt: - modifiers.add(Key.MOD_2) - # if event.Windows?: - # modifiers.add(Key.MOD_3) - - return {"key": key, "modifiers": modifiers} + code_names = str(code).split(", ") + for toga_mod, code in WINFORMS_MODIFIERS.items(): + try: + code_names.remove(str(code)) + except ValueError: + pass + else: + modifiers.add(toga_mod) + + assert len(code_names) == 1 + for toga_value, code in WINFORMS_KEYS.items(): + if str(code) == code_names[0]: + break + else: + toga_value = code_names[0].lower() + if len(toga_value) > 1: + toga_value = f"<{toga_value}>" + + if (Key.SHIFT in modifiers) and (toga_value not in ascii_lowercase): + for symbol, number in SHIFTED_KEYS.items(): + if toga_value == number: + toga_value = symbol + modifiers.remove(Key.SHIFT) + + return {"key": Key(toga_value), "modifiers": modifiers} diff --git a/winforms/src/toga_winforms/libs/proactor.py b/winforms/src/toga_winforms/libs/proactor.py index 1aa868baca..4cf3a25176 100644 --- a/winforms/src/toga_winforms/libs/proactor.py +++ b/winforms/src/toga_winforms/libs/proactor.py @@ -38,9 +38,9 @@ def run_forever(self, app): # in Lib/ascynio/base_events.py) # === START BaseEventLoop.run_forever() setup === self._check_closed() - if self.is_running(): + if self.is_running(): # pragma: no cover raise RuntimeError("This event loop is already running") - if events._get_running_loop() is not None: + if events._get_running_loop() is not None: # pragma: no cover raise RuntimeError( "Cannot run the event loop while another loop is running" ) @@ -71,13 +71,17 @@ def run_forever(self, app): def enqueue_tick(self): # Queue a call to tick in 5ms. - if not self.app._is_exiting: + if not self.app._is_exiting: # pragma: no branch self.task = Action[Task](self.tick) Task.Delay(5).ContinueWith(self.task) def tick(self, *args, **kwargs): """Cause a single iteration of the event loop to run on the main GUI thread.""" - if not self.app._is_exiting: + + # This function doesn't report as covered, probably because it runs on a + # non-Python-created thread (see App.run_app). But it must actually be covered, + # otherwise nothing would work. + if not self.app._is_exiting: # pragma: no cover action = Action(self.run_once_recurring) self.app.app_dispatcher.Invoke(action) @@ -99,7 +103,7 @@ def run_once_recurring(self): # Perform one tick of the event loop. self._run_once() - if self._stopping: + if self._stopping: # pragma: no cover # If we're stopping, we can do the "finally" handling from # the BaseEventLoop run_forever(). # === START BaseEventLoop.run_forever() finally handling === @@ -123,5 +127,5 @@ def run_once_recurring(self): callback(*args) # Exceptions thrown by this method will be silently ignored. - except BaseException: + except BaseException: # pragma: no cover traceback.print_exc() diff --git a/winforms/src/toga_winforms/libs/wrapper.py b/winforms/src/toga_winforms/libs/wrapper.py index 958db4cc66..4ad877d3a4 100644 --- a/winforms/src/toga_winforms/libs/wrapper.py +++ b/winforms/src/toga_winforms/libs/wrapper.py @@ -13,10 +13,10 @@ class WeakrefCallable: def __init__(self, function): try: self.ref = weakref.WeakMethod(function) - except TypeError: + except TypeError: # pragma: no cover self.ref = weakref.ref(function) def __call__(self, *args, **kwargs): function = self.ref() - if function: + if function: # pragma: no branch return function(*args, **kwargs) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index b4c8068aae..c966dfd219 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from decimal import ROUND_HALF_EVEN, ROUND_UP, Decimal +from decimal import ROUND_HALF_EVEN, Decimal from System.Drawing import ( Color, @@ -144,15 +144,10 @@ def remove_child(self, child): child.container = None def refresh(self): - intrinsic = self.interface.intrinsic - intrinsic.width = intrinsic.height = None + # Default values; may be overwritten by rehint(). + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) self.rehint() - assert intrinsic.width is not None - assert intrinsic.height is not None - intrinsic.width = self.scale_out(intrinsic.width, ROUND_UP) - intrinsic.height = self.scale_out(intrinsic.height, ROUND_UP) - - @abstractmethod def rehint(self): - ... + pass diff --git a/winforms/src/toga_winforms/widgets/box.py b/winforms/src/toga_winforms/widgets/box.py index 6025c443a8..44321cfdc7 100644 --- a/winforms/src/toga_winforms/widgets/box.py +++ b/winforms/src/toga_winforms/widgets/box.py @@ -1,5 +1,4 @@ import System.Windows.Forms as WinForms -from travertino.size import at_least from .base import Widget @@ -7,7 +6,3 @@ class Box(Widget): def create(self): self.native = WinForms.Panel() - - def rehint(self): - self.interface.intrinsic.width = at_least(0) - self.interface.intrinsic.height = at_least(0) diff --git a/winforms/src/toga_winforms/widgets/button.py b/winforms/src/toga_winforms/widgets/button.py index 372a2c06b5..c1c59ae416 100644 --- a/winforms/src/toga_winforms/widgets/button.py +++ b/winforms/src/toga_winforms/widgets/button.py @@ -1,3 +1,5 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms from travertino.size import at_least @@ -14,7 +16,7 @@ def create(self): self.native.Click += WeakrefCallable(self.winforms_click) def winforms_click(self, sender, event): - self.interface.on_press(None) + self.interface.on_press() def get_text(self): value = self.native.Text @@ -31,7 +33,9 @@ def set_text(self, text): self.native.Text = text def rehint(self): - # self.native.Size = Size(0, 0) - # print("REHINT Button", self, self.native.PreferredSize) - self.interface.intrinsic.width = at_least(self.native.PreferredSize.Width) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.PreferredSize.Width), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index 1d8d6ba8ab..3f4576655e 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -1,6 +1,7 @@ -from math import degrees, pi +from math import degrees import System.Windows.Forms as WinForms +from System import Array from System.Drawing import ( Bitmap, Graphics, @@ -21,7 +22,7 @@ from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream -from toga.widgets.canvas import Baseline, FillRule +from toga.widgets.canvas import Baseline, FillRule, arc_to_bezier, sweepangle from toga_winforms.colors import native_color from ..libs.wrapper import WeakrefCallable @@ -37,35 +38,39 @@ def __init__(self): def clear_paths(self): self.paths = [] - self.start_point = None - self.at_start_point = False + self.add_path() @property def current_path(self): - if len(self.paths) == 0: - self.add_path() return self.paths[-1] - def add_path(self): + def add_path(self, start_point=None): self.paths.append(GraphicsPath()) + self.start_point = start_point # Because the GraphicsPath API works in terms of segments rather than points, it has - # nowhere to save the starting point of each figure before we use it. In all other + # no equivalent to move_to, and we must save that point manually. In all other # situations, we can get the last point from the GraphicsPath itself. # # default_x and default_y should be set as described in the HTML spec under "ensure # there is a subpath". def get_last_point(self, default_x, default_y): - if self.at_start_point: - self.at_start_point = False - return self.start_point - elif self.current_path.PointCount: + if self.current_path.PointCount: return self.current_path.GetLastPoint() - else: - # Since we're returning start_point for immediate use, we don't set - # at_start_point here. - self.start_point = PointF(default_x, default_y) + elif self.start_point: return self.start_point + else: + return PointF(default_x, default_y) + + def print_path(self, path=None): # pragma: no cover + if path is None: + path = self.current_path + print( + "\n".join( + str((ptype, point.X, point.Y)) + for ptype, point in zip(path.PathTypes, path.PathPoints) + ) + ) class Canvas(Box): @@ -94,7 +99,6 @@ def winforms_paint(self, panel, event, *args): def winforms_resize(self, *args): self.interface.on_resize( - None, width=self.scale_out(self.native.Width), height=self.scale_out(self.native.Height), ) @@ -103,12 +107,12 @@ def winforms_mouse_down(self, obj, mouse_event): x, y = map(self.scale_out, (mouse_event.X, mouse_event.Y)) if mouse_event.Button == WinForms.MouseButtons.Left: if mouse_event.Clicks == 2: - self.interface.on_activate(None, x, y) + self.interface.on_activate(x, y) else: - self.interface.on_press(None, x, y) + self.interface.on_press(x, y) self.dragging = True elif mouse_event.Button == WinForms.MouseButtons.Right: - self.interface.on_alt_press(None, x, y) + self.interface.on_alt_press(x, y) self.dragging = True else: # pragma: no cover pass @@ -118,9 +122,9 @@ def winforms_mouse_move(self, obj, mouse_event): return x, y = map(self.scale_out, (mouse_event.X, mouse_event.Y)) if mouse_event.Button == WinForms.MouseButtons.Left: - self.interface.on_drag(None, x, y) + self.interface.on_drag(x, y) elif mouse_event.Button == WinForms.MouseButtons.Right: - self.interface.on_alt_drag(None, x, y) + self.interface.on_alt_drag(x, y) else: # pragma: no cover pass @@ -128,9 +132,9 @@ def winforms_mouse_up(self, obj, mouse_event): self.dragging = False x, y = map(self.scale_out, (mouse_event.X, mouse_event.Y)) if mouse_event.Button == WinForms.MouseButtons.Left: - self.interface.on_release(None, x, y) + self.interface.on_release(x, y) elif mouse_event.Button == WinForms.MouseButtons.Right: - self.interface.on_alt_release(None, x, y) + self.interface.on_alt_release(x, y) else: # pragma: no cover pass @@ -154,17 +158,15 @@ def begin_path(self, draw_context, **kwargs): # We don't use current_path.CloseFigure, because that causes the dash pattern to # start on the last segment of the path rather than the first one. def close_path(self, draw_context, **kwargs): - start = draw_context.start_point - if start: + if draw_context.current_path.PointCount: + start = draw_context.current_path.PathPoints[0] draw_context.current_path.AddLine( - draw_context.get_last_point(start.X, start.Y), start + draw_context.current_path.GetLastPoint(), start ) self.move_to(start.X, start.Y, draw_context) def move_to(self, x, y, draw_context, **kwargs): - draw_context.current_path.StartFigure() - draw_context.start_point = PointF(x, y) - draw_context.at_start_point = True + draw_context.add_path(PointF(x, y)) def line_to(self, x, y, draw_context, **kwargs): draw_context.current_path.AddLine( @@ -203,16 +205,18 @@ def quadratic_curve_to(self, cpx, cpy, x, y, draw_context, **kwargs): def arc( self, x, y, radius, startangle, endangle, anticlockwise, draw_context, **kwargs ): - sweepangle = endangle - startangle - if anticlockwise: - if sweepangle > 0: - sweepangle -= 2 * pi - else: - if sweepangle < 0: - sweepangle += 2 * pi - - rect = RectangleF(x - radius, y - radius, 2 * radius, 2 * radius) - draw_context.current_path.AddArc(rect, degrees(startangle), degrees(sweepangle)) + self.ellipse( + x, + y, + radius, + radius, + 0, + startangle, + endangle, + anticlockwise, + draw_context, + **kwargs, + ) def ellipse( self, @@ -227,33 +231,32 @@ def ellipse( draw_context, **kwargs, ): - # Transformations apply not to individual points, but to entire GraphicsPath - # objects, so we must create a separate one for this shape. - draw_context.add_path() - - # The current transform will be applied when the path is filled or stroked, so - # make sure we don't apply it now. - self.push_context(draw_context) - draw_context.matrix.Reset() - - self.translate(x, y, draw_context) - self.rotate(rotation, draw_context) - if radiusx >= radiusy: - self.scale(1, radiusy / radiusx, draw_context) - self.arc(0, 0, radiusx, startangle, endangle, anticlockwise, draw_context) - else: - self.scale(radiusx / radiusy, 1, draw_context) - self.arc(0, 0, radiusy, startangle, endangle, anticlockwise, draw_context) - - draw_context.current_path.Transform(draw_context.matrix) + matrix = Matrix() + matrix.Translate(x, y) + matrix.Rotate(degrees(rotation)) + matrix.Scale(radiusx, radiusy) + matrix.Rotate(degrees(startangle)) + + points = Array[PointF]( + [ + PointF(x, y) + for x, y in arc_to_bezier( + sweepangle(startangle, endangle, anticlockwise) + ) + ] + ) + matrix.TransformPoints(points) - # Set up a fresh GraphicsPath for the next operation. - self.pop_context(draw_context) - draw_context.add_path() + start = draw_context.start_point + if start and not draw_context.current_path.PointCount: + draw_context.current_path.AddLine(start, start) + draw_context.current_path.AddBeziers(points) def rect(self, x, y, width, height, draw_context, **kwargs): + draw_context.add_path() rect = RectangleF(x, y, width, height) draw_context.current_path.AddRectangle(rect) + draw_context.add_path() # Drawing Paths @@ -266,7 +269,7 @@ def fill(self, color, fill_rule, draw_context, **kwargs): path.FillMode = FillMode.Winding path.Transform(draw_context.matrix) draw_context.graphics.FillPath(brush, path) - draw_context.paths.clear() + draw_context.clear_paths() def stroke(self, color, line_width, line_dash, draw_context, **kwargs): pen = Pen(native_color(color), self.scale_in(line_width, rounding=None)) @@ -276,7 +279,7 @@ def stroke(self, color, line_width, line_dash, draw_context, **kwargs): for path in draw_context.paths: path.Transform(draw_context.matrix) draw_context.graphics.DrawPath(pen, path) - draw_context.paths.clear() + draw_context.clear_paths() # Transformations diff --git a/winforms/src/toga_winforms/widgets/dateinput.py b/winforms/src/toga_winforms/widgets/dateinput.py index adb5077d00..4567889ea2 100644 --- a/winforms/src/toga_winforms/widgets/dateinput.py +++ b/winforms/src/toga_winforms/widgets/dateinput.py @@ -1,8 +1,8 @@ import datetime +from decimal import ROUND_UP import System.Windows.Forms as WinForms from System import DateTime as WinDateTime -from travertino.size import at_least from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -42,8 +42,9 @@ def set_max_date(self, value): self.native.MaxDate = native_date(value) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) def winforms_value_changed(self, sender, event): - self.interface.on_change(self.interface) + self.interface.on_change() diff --git a/winforms/src/toga_winforms/widgets/divider.py b/winforms/src/toga_winforms/widgets/divider.py index 0fa666c9aa..86379a121a 100644 --- a/winforms/src/toga_winforms/widgets/divider.py +++ b/winforms/src/toga_winforms/widgets/divider.py @@ -1,3 +1,5 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms from travertino.size import at_least @@ -26,8 +28,14 @@ def set_direction(self, value): def rehint(self): if self.get_direction() == self.interface.HORIZONTAL: - self.interface.intrinsic.width = at_least(self.native.Width) - self.interface.intrinsic.height = self.native.Height + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.Width), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.Height, ROUND_UP + ) else: - self.interface.intrinsic.width = self.native.Width - self.interface.intrinsic.height = at_least(self.native.Height) + self.interface.intrinsic.width = self.scale_out(self.native.Width, ROUND_UP) + self.interface.intrinsic.height = self.scale_out( + at_least(self.native.Height), ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/imageview.py b/winforms/src/toga_winforms/widgets/imageview.py index 4795c1daf4..99b11f688d 100644 --- a/winforms/src/toga_winforms/widgets/imageview.py +++ b/winforms/src/toga_winforms/widgets/imageview.py @@ -1,3 +1,5 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms from System.Drawing import Bitmap @@ -32,8 +34,8 @@ def rehint(self): width, height, aspect_ratio = rehint_imageview( self.interface.image, self.interface.style, self.dpi_scale ) - self.interface.intrinsic.width = width - self.interface.intrinsic.height = height + self.interface.intrinsic.width = self.scale_out(width, ROUND_UP) + self.interface.intrinsic.height = self.scale_out(height, ROUND_UP) if aspect_ratio is not None: self.native.SizeMode = WinForms.PictureBoxSizeMode.Zoom else: diff --git a/winforms/src/toga_winforms/widgets/label.py b/winforms/src/toga_winforms/widgets/label.py index 66c0702ae0..c50f59b5ae 100644 --- a/winforms/src/toga_winforms/widgets/label.py +++ b/winforms/src/toga_winforms/widgets/label.py @@ -1,3 +1,5 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms from travertino.size import at_least @@ -21,8 +23,9 @@ def set_text(self, value): self.native.Text = value def rehint(self): - # Width & height of a label is known and fixed. - # self.native.Size = Size(0, 0) - # print("REHINT label", self, self.native.PreferredSize) - self.interface.intrinsic.width = at_least(self.native.PreferredSize.Width) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.PreferredSize.Width), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index 681e99a2ac..7111d47c1e 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -61,6 +61,7 @@ def set_value(self, value): self._set_placeholder_visible(False) self.native.Text = value + # This method is necessary to override the TextInput base class. def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) @@ -68,7 +69,7 @@ def rehint(self): def winforms_text_changed(self, sender, event): # Showing and hiding the placeholder should not cause an interface event. if not self._placeholder_visible: - self.interface.on_change(None) + self.interface.on_change() def _set_placeholder_visible(self, visible): # Changing ForeColor causes a native TextChanged event, so the order of these diff --git a/winforms/src/toga_winforms/widgets/numberinput.py b/winforms/src/toga_winforms/widgets/numberinput.py index 9b677d2d60..a2246af087 100644 --- a/winforms/src/toga_winforms/widgets/numberinput.py +++ b/winforms/src/toga_winforms/widgets/numberinput.py @@ -1,9 +1,8 @@ import sys -from decimal import Decimal, InvalidOperation +from decimal import ROUND_UP, Decimal, InvalidOperation import System.Windows.Forms as WinForms from System import Convert, String -from travertino.size import at_least from toga.widgets.numberinput import _clean_decimal from toga_winforms.libs.fonts import HorizontalTextAlignment @@ -30,7 +29,7 @@ def create(self): self.native.TextChanged += WeakrefCallable(self.winforms_text_changed) def winforms_text_changed(self, sender, event): - self.interface.on_change(None) + self.interface.on_change() def get_readonly(self): return self.native.ReadOnly @@ -68,5 +67,6 @@ def set_alignment(self, value): self.native.TextAlign = HorizontalTextAlignment(value) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index 15bc0e5813..a6137949bc 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -1,5 +1,4 @@ from System.Windows.Forms import TabControl, TabPage -from travertino.size import at_least from ..container import Container from ..libs.wrapper import WeakrefCallable @@ -58,7 +57,7 @@ def set_current_tab_index(self, current_tab_index): self.native.SelectedIndex = current_tab_index def winforms_selected(self, sender, event): - self.interface.on_select(None) + self.interface.on_select() def winforms_client_size_changed(self, sender, event): for panel in self.panels: @@ -67,7 +66,3 @@ def winforms_client_size_changed(self, sender, event): def resize_content(self, panel): size = panel.native_parent.ClientSize panel.resize_content(size.Width, size.Height) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/widgets/progressbar.py b/winforms/src/toga_winforms/widgets/progressbar.py index 40628875db..40efc73221 100644 --- a/winforms/src/toga_winforms/widgets/progressbar.py +++ b/winforms/src/toga_winforms/widgets/progressbar.py @@ -1,5 +1,6 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms -from travertino.size import at_least from .base import Widget @@ -78,7 +79,6 @@ def set_value(self, value): self.native.Value = int(value * self.TOGA_SCALE) def rehint(self): - # Height must be non-zero - # Set a sensible min-width - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index fc7c88b61a..66bf51fc21 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -3,7 +3,6 @@ from System.Drawing import Point from System.Windows.Forms import Panel, SystemInformation from travertino.node import Node -from travertino.size import at_least from toga_winforms.container import Container @@ -41,7 +40,7 @@ def create(self): self.native.MouseWheel += WeakrefCallable(self.winforms_scroll) def winforms_scroll(self, sender, event): - self.interface.on_scroll(None) + self.interface.on_scroll() def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) @@ -92,7 +91,7 @@ def get_horizontal(self): def set_horizontal(self, value): self.horizontal = value if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() if self.interface.content: self.interface.content.refresh() @@ -102,7 +101,7 @@ def get_vertical(self): def set_vertical(self, value): self.vertical = value if not value: - self.interface.on_scroll(None) + self.interface.on_scroll() if self.interface.content: self.interface.content.refresh() @@ -127,8 +126,4 @@ def set_position(self, horizontal_position, vertical_position): self.scale_in(horizontal_position), self.scale_in(vertical_position), ) - self.interface.on_scroll(None) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + self.interface.on_scroll() diff --git a/winforms/src/toga_winforms/widgets/selection.py b/winforms/src/toga_winforms/widgets/selection.py index 76453965eb..432cabbc2a 100644 --- a/winforms/src/toga_winforms/widgets/selection.py +++ b/winforms/src/toga_winforms/widgets/selection.py @@ -1,7 +1,7 @@ from contextlib import contextmanager +from decimal import ROUND_UP import System.Windows.Forms as WinForms -from travertino.size import at_least from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -35,7 +35,7 @@ def suspend_notifications(self): def on_change(self): if self._send_notifications: - self.interface.on_change(None) + self.interface.on_change() def clear(self): self.native.Items.Clear() @@ -77,5 +77,6 @@ def get_selected_index(self): return None if index == -1 else index def rehint(self): - self.interface.intrinsic.width = at_least(self.native.PreferredSize.Width) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/slider.py b/winforms/src/toga_winforms/widgets/slider.py index 0d5c0cd547..7d6c3481f1 100644 --- a/winforms/src/toga_winforms/widgets/slider.py +++ b/winforms/src/toga_winforms/widgets/slider.py @@ -1,5 +1,6 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms -from travertino.size import at_least from toga.widgets.slider import IntSliderImpl @@ -34,10 +35,10 @@ def winforms_value_chaned(self, sender, event): self.on_change() def winforms_mouse_down(self, sender, event): - self.interface.on_press(None) + self.interface.on_press() def winforms_mouse_up(self, sender, event): - self.interface.on_release(None) + self.interface.on_release() def get_int_value(self): return self.native.Value @@ -55,5 +56,6 @@ def set_ticks_visible(self, visible): self.native.TickStyle = BOTTOM_RIGHT_TICK_STYLE if visible else NONE_TICK_STYLE def rehint(self): - self.interface.intrinsic.width = at_least(self.native.PreferredSize.Width) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index f2605fd539..812ef3aebe 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -3,7 +3,6 @@ Orientation, SplitContainer as NativeSplitContainer, ) -from travertino.size import at_least from toga.constants import Direction @@ -82,7 +81,3 @@ def resize_content(self, **kwargs): for panel in self.panels: size = panel.native_parent.ClientSize panel.resize_content(size.Width, size.Height, **kwargs) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/widgets/switch.py b/winforms/src/toga_winforms/widgets/switch.py index 33f6702d62..16792bb01e 100644 --- a/winforms/src/toga_winforms/widgets/switch.py +++ b/winforms/src/toga_winforms/widgets/switch.py @@ -1,3 +1,5 @@ +from decimal import ROUND_UP + import System.Windows.Forms as WinForms from travertino.size import at_least @@ -11,7 +13,7 @@ def create(self): self.native.CheckedChanged += WeakrefCallable(self.winforms_checked_changed) def winforms_checked_changed(self, sender, event): - self.interface.on_change(None) + self.interface.on_change() def get_text(self): value = self.native.Text @@ -34,5 +36,9 @@ def set_value(self, value): self.native.Checked = value def rehint(self): - self.interface.intrinsic.width = at_least(self.native.PreferredSize.Width) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.width = self.scale_out( + at_least(self.native.PreferredSize.Width), ROUND_UP + ) + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 5b8c1a9b43..bb4689f7d1 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -1,7 +1,6 @@ from warnings import warn import System.Windows.Forms as WinForms -from travertino.size import at_least import toga @@ -110,13 +109,13 @@ def winforms_cache_virtual_items(self, sender, e): self._cache.append(self._new_item(i + self._first_item)) def winforms_item_selection_changed(self, sender, e): - self.interface.on_select(None) + self.interface.on_select() def winforms_double_click(self, sender, e): hit_test = self.native.HitTest(e.X, e.Y) item = hit_test.Item if item is not None: - self.interface.on_activate(None, row=self._data[item.Index]) + self.interface.on_activate(row=self._data[item.Index]) else: # pragma: no cover # Double clicking outside of an item apparently doesn't raise the event, but # that isn't guaranteed by the documentation. @@ -160,7 +159,7 @@ def icon(attr): def text(attr): val = getattr(item, attr, None) if isinstance(val, toga.Widget): - warn("This backend does not support the use of widgets in cells") + warn("Winforms does not support the use of widgets in cells") val = None if isinstance(val, tuple): val = val[1] @@ -217,10 +216,6 @@ def get_selection(self): def scroll_to_row(self, index): self.native.EnsureVisible(index) - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def remove_column(self, index): self.native.Columns.RemoveAt(index) self.update_data() diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index 08ea69b593..68654f3c46 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -1,8 +1,8 @@ from ctypes import c_uint, windll from ctypes.wintypes import HWND, WPARAM +from decimal import ROUND_UP import System.Windows.Forms as WinForms -from travertino.size import at_least from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment @@ -71,25 +71,23 @@ def set_color(self, color): self.native.ForeColor = self.native.DefaultForeColor def rehint(self): - # Height of a text input is known and fixed. - # Width must be > 100 - # print("REHINT TextInput", self, self.native.PreferredSize) - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) def winforms_text_changed(self, sender, event): - self.interface.on_change(self.interface) + self.interface.on_change() self.interface._validate() def winforms_key_press(self, sender, event): if ord(event.KeyChar) == int(WinForms.Keys.Enter): - self.interface.on_confirm(self.interface) + self.interface.on_confirm() def winforms_got_focus(self, sender, event): - self.interface.on_gain_focus(self.interface) + self.interface.on_gain_focus() def winforms_lost_focus(self, sender, event): - self.interface.on_lose_focus(self.interface) + self.interface.on_lose_focus() def is_valid(self): return self.error_provider.GetError(self.native) == "" diff --git a/winforms/src/toga_winforms/widgets/timeinput.py b/winforms/src/toga_winforms/widgets/timeinput.py index eb28fb3d58..3f37853eb1 100644 --- a/winforms/src/toga_winforms/widgets/timeinput.py +++ b/winforms/src/toga_winforms/widgets/timeinput.py @@ -1,8 +1,8 @@ import datetime +from decimal import ROUND_UP import System.Windows.Forms as WinForms from System import DateTime as WinDateTime -from travertino.size import at_least from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -46,8 +46,9 @@ def set_max_time(self, value): self.native.MaxDate = native_time(value) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = self.native.PreferredSize.Height + self.interface.intrinsic.height = self.scale_out( + self.native.PreferredSize.Height, ROUND_UP + ) def winforms_value_changed(self, sender, event): - self.interface.on_change(self.interface) + self.interface.on_change() diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 9f38f225a5..99a480fb09 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -9,7 +9,6 @@ ) from System.Drawing import Color from System.Threading.Tasks import Task, TaskScheduler -from travertino.size import at_least import toga from toga.widgets.webview import JavaScriptResult @@ -116,7 +115,7 @@ def winforms_initialization_completed(self, sender, args): raise RuntimeError(args.InitializationException) def winforms_navigation_completed(self, sender, args): - self.interface.on_webview_load(self.interface) + self.interface.on_webview_load() if self.loaded_future: self.loaded_future.set_result(None) @@ -174,7 +173,3 @@ def execute(): self.run_after_initialization(execute) return result - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index e887613531..b76b36a66a 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,7 +1,9 @@ import System.Windows.Forms as WinForms -from System.Drawing import Point, Size +from System.Drawing import Bitmap, Graphics, Point, Size +from System.Drawing.Imaging import ImageFormat +from System.IO import MemoryStream -from toga import GROUP_BREAK, SECTION_BREAK +from toga.command import GROUP_BREAK, SECTION_BREAK from .container import Container from .libs.wrapper import WeakrefCallable @@ -47,6 +49,7 @@ def create_toolbar(self): # defaults to `Top`. self.toolbar_native = WinForms.ToolStrip() self.native.Controls.Add(self.toolbar_native) + self.toolbar_native.BringToFront() # In a dock, "front" means "bottom". for cmd in self.interface.toolbar: if cmd == GROUP_BREAK: @@ -54,14 +57,13 @@ def create_toolbar(self): elif cmd == SECTION_BREAK: item = WinForms.ToolStripSeparator() else: + item = WinForms.ToolStripMenuItem(cmd.text) + if cmd.tooltip is not None: + item.ToolTipText = cmd.tooltip if cmd.icon is not None: - native_icon = cmd.icon._impl.native - item = WinForms.ToolStripMenuItem( - cmd.text, native_icon.ToBitmap() - ) - else: - item = WinForms.ToolStripMenuItem(cmd.text) - item.Click += WeakrefCallable(cmd._impl.winforms_handler) + item.Image = cmd.icon._impl.native.ToBitmap() + item.Enabled = cmd.enabled + item.Click += WeakrefCallable(cmd._impl.winforms_Click) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) @@ -80,10 +82,17 @@ def set_position(self, position): def get_size(self): size = self.native.Size - return tuple(map(self.scale_out, (size.Width, size.Height))) + return ( + self.scale_out(size.Width - self.decor_width()), + self.scale_out(size.Height - self.decor_height()), + ) def set_size(self, size): - self.native.Size = Size(*map(self.scale_in, size)) + width, height = size + self.native.Size = Size( + self.scale_in(width) + self.decor_width(), + self.scale_in(height) + self.decor_height(), + ) def set_app(self, app): icon_impl = app.interface.icon._impl @@ -97,16 +106,13 @@ def set_title(self, title): def refreshed(self): super().refreshed() - - # Enforce a minimum window size. This takes into account the title bar and - # borders, which are included in Size but not in ClientSize. - decor_size = self.native.Size - self.native.ClientSize layout = self.interface.content.layout - min_client_size = Size( - self.scale_in(layout.min_width), - self.scale_in(layout.min_height) + self.top_bars_height(), + self.native.MinimumSize = Size( + self.scale_in(layout.min_width) + self.decor_width(), + self.scale_in(layout.min_height) + + self.top_bars_height() + + self.decor_height(), ) - self.native.MinimumSize = decor_size + min_client_size def show(self): if self.interface.content is not None: @@ -133,7 +139,7 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True else: # See _is_closing comment in __init__. - self.interface.on_close(None) + self.interface.on_close() event.Cancel = True def set_full_screen(self, is_full_screen): @@ -151,6 +157,15 @@ def close(self): self._is_closing = True self.native.Close() + # "Decor" includes the title bar and the (usually invisible) resize borders. It does + # not include the menu bar and toolbar, which are included in the ClientSize (see + # top_bars_height). + def decor_width(self): + return self.native.Size.Width - self.native.ClientSize.Width + + def decor_height(self): + return self.native.Size.Height - self.native.ClientSize.Height + def top_bars_height(self): vertical_shift = 0 if self.toolbar_native: @@ -166,3 +181,18 @@ def resize_content(self): self.native.ClientSize.Width, self.native.ClientSize.Height - vertical_shift, ) + + def get_image_data(self): + size = Size(self.native_content.Size.Width, self.native_content.Size.Height) + bitmap = Bitmap(size.Width, size.Height) + graphics = Graphics.FromImage(bitmap) + + graphics.CopyFromScreen( + self.native_content.PointToScreen(Point.Empty), + Point(0, 0), + size, + ) + + stream = MemoryStream() + bitmap.Save(stream, ImageFormat.Png) + return stream.ToArray() diff --git a/winforms/tests/__init__.py b/winforms/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/winforms/tests/test_implementation.py b/winforms/tests/test_implementation.py deleted file mode 100644 index e8df0c4e38..0000000000 --- a/winforms/tests/test_implementation.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from toga_dummy import test_implementation - -globals().update( - test_implementation.create_impl_tests( - os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "src", "toga_winforms" - ) - ) - ) -) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 6059da9865..6813613105 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,16 +1,28 @@ +import ctypes from pathlib import Path +from time import sleep -import System.Windows.Forms as WinForms +import pytest +from System import EventArgs +from System.Drawing import Point +from System.Windows.Forms import Application, Cursor + +from toga_winforms.keys import toga_to_winforms_key, winforms_to_toga_key from .probe import BaseProbe +from .window import WindowProbe class AppProbe(BaseProbe): + supports_key = True + supports_key_mod3 = False + def __init__(self, app): super().__init__() self.app = app + self.main_window = app.main_window # The Winforms Application class is a singleton instance - assert self.app._impl.native == WinForms.Application + assert self.app._impl.native == Application @property def config_path(self): @@ -45,3 +57,104 @@ def logs_path(self): return ( Path.home() / "AppData" / "Local" / "Tiberius Yak" / "Toga Testbed" / "Logs" ) + + @property + def is_cursor_visible(self): + # Despite what the documentation says, Cursor.Current never returns null in + # Windows 10, whether the cursor is over the window or not. + # + # The following code is based on https://stackoverflow.com/a/12467292, but it + # only works when the cursor is over the window. + form = self.main_window._impl.native + Cursor.Position = Point( + form.Location.X + (form.Size.Width // 2), + form.Location.Y + (form.Size.Height // 2), + ) + + # A small delay is apparently required for the new position to take effect. + sleep(0.1) + + class POINT(ctypes.Structure): + _fields_ = [ + ("x", ctypes.c_long), + ("y", ctypes.c_long), + ] + + class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + + GetCursorInfo = ctypes.windll.user32.GetCursorInfo + GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] + + info = CURSORINFO() + info.cbSize = ctypes.sizeof(info) + if not GetCursorInfo(ctypes.byref(info)): + raise RuntimeError("GetCursorInfo failed") + + # `flags` is 0 or 1 in local testing, but the GitHub Actions runner always + # returns 2 ("the system is not drawing the cursor because the user is providing + # input through touch or pen instead of the mouse"). hCursor is more reliable. + return info.hCursor is not None + + def is_full_screen(self, window): + return WindowProbe(self.app, window).is_full_screen + + def content_size(self, window): + return WindowProbe(self.app, window).content_size + + def _menu_item(self, path): + item = self.main_window._impl.native.MainMenuStrip + for i, label in enumerate(path): + children = getattr(item, "Items" if i == 0 else "DropDownItems") + child_labels = [child.Text for child in children] + try: + child_index = child_labels.index(label) + except ValueError: + raise AssertionError( + f"no item named {path[:i+1]}; options are {child_labels}" + ) from None + item = children[child_index] + + return item + + def _activate_menu_item(self, path): + self._menu_item(path).OnClick(EventArgs.Empty) + + def activate_menu_exit(self): + self._activate_menu_item(["File", "Exit"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + async def close_about_dialog(self): + await WindowProbe(self.app, self.main_window)._close_dialog("\n") + + def activate_menu_visit_homepage(self): + self._activate_menu_item(["Help", "Visit homepage"]) + + def assert_menu_item(self, path, *, enabled=True): + assert self._menu_item(path).Enabled == enabled + + def assert_system_menus(self): + self.assert_menu_item(["File", "Preferences"], enabled=False) + self.assert_menu_item(["File", "Exit"]) + + self.assert_menu_item(["Help", "Visit homepage"]) + self.assert_menu_item(["Help", "About Toga Testbed"]) + + def activate_menu_close_window(self): + pytest.xfail("This platform doesn't have a window management menu") + + def activate_menu_close_all_windows(self): + pytest.xfail("This platform doesn't have a window management menu") + + def activate_menu_minimize(self): + pytest.xfail("This platform doesn't have a window management menu") + + def keystroke(self, combination): + return winforms_to_toga_key(toga_to_winforms_key(combination)) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index 6f39139f2b..bc02d4bba2 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -48,3 +48,6 @@ async def type_character(self, char, *, shift=False, ctrl=False, alt=False): # same app. Unfortunately that makes it difficult to run tests in the # background. SendKeys.SendWait(key_code) + + def assert_image_size(self, image_size, size): + assert image_size == (size[0] * self.scale_factor, size[1] * self.scale_factor) diff --git a/winforms/tests_backend/widgets/canvas.py b/winforms/tests_backend/widgets/canvas.py index 43b84bfe21..c102b7d8f3 100644 --- a/winforms/tests_backend/widgets/canvas.py +++ b/winforms/tests_backend/widgets/canvas.py @@ -17,10 +17,6 @@ def reference_variant(self, reference): def get_image(self): return Image.open(BytesIO(self.impl.get_image_data())) - def assert_image_size(self, image, width, height): - assert image.width == width * self.scale_factor - assert image.height == height * self.scale_factor - async def mouse_press(self, x, y, **kwargs): self.native.OnMouseDown(self.mouse_event(x, y, **kwargs)) self.native.OnMouseUp(self.mouse_event(x, y, **kwargs)) @@ -32,10 +28,12 @@ async def mouse_activate(self, x, y, **kwargs): self.native.OnMouseUp(self.mouse_event(x, y, clicks=2, **kwargs)) async def mouse_drag(self, x1, y1, x2, y2, **kwargs): + # Without a mouse button pressed, a move event should be ignored. + move_event = self.mouse_event((x1 + x2) // 2, (y1 + y2) // 2, **kwargs) + self.native.OnMouseMove(move_event) + self.native.OnMouseDown(self.mouse_event(x1, y1, **kwargs)) - self.native.OnMouseMove( - self.mouse_event((x1 + x2) // 2, (y1 + y2) // 2, **kwargs) - ) + self.native.OnMouseMove(move_event) self.native.OnMouseUp(self.mouse_event(x2, y2, **kwargs)) async def alt_mouse_press(self, x, y): diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index f560aca484..5fc6ae3c94 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -1,7 +1,15 @@ import asyncio from unittest.mock import Mock -from System.Windows.Forms import Form, FormBorderStyle, FormWindowState +from System import EventArgs +from System.Windows.Forms import ( + Form, + FormBorderStyle, + FormWindowState, + MenuStrip, + ToolStrip, + ToolStripSeparator, +) from .probe import BaseProbe @@ -33,8 +41,11 @@ def close(self): @property def content_size(self): return ( - self.native.ClientSize.Width / self.scale_factor, - self.native.ClientSize.Height / self.scale_factor, + (self.native.ClientSize.Width) / self.scale_factor, + ( + (self.native.ClientSize.Height - self.impl.top_bars_height()) + / self.scale_factor + ), ) @property @@ -111,3 +122,29 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): else: dialog.native.SelectedPath = str(result[-1] if multiple_select else result) await self._close_dialog("\n") + + def _native_toolbar(self): + for control in self.native.Controls: + if isinstance(control, ToolStrip) and not isinstance(control, MenuStrip): + return control + else: + return None + + def has_toolbar(self): + return self._native_toolbar() is not None + + def _native_toolbar_item(self, index): + return self._native_toolbar().Items[index] + + def assert_is_toolbar_separator(self, index, section=False): + assert isinstance(self._native_toolbar_item(index), ToolStripSeparator) + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self._native_toolbar_item(index) + assert item.Text == label + assert item.ToolTipText == tooltip + assert (item.Image is not None) == has_icon + assert item.Enabled == enabled + + def press_toolbar_button(self, index): + self._native_toolbar_item(index).OnClick(EventArgs.Empty)