diff --git a/changes/2252.feature.rst b/changes/2252.feature.rst new file mode 100644 index 0000000000..42b21fd999 --- /dev/null +++ b/changes/2252.feature.rst @@ -0,0 +1 @@ +The typing for Toga's API surface was updated to be more precise. diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 09bfa38098..87fe6b4582 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import warnings +from pathlib import Path from .app import App, DocumentApp, DocumentMainWindow, MainWindow @@ -49,7 +52,7 @@ class NotImplementedWarning(RuntimeWarning): # single argument (the warning message). Use a factory method to avoid reproducing # the message format and the warn invocation. @classmethod - def warn(self, platform, feature): + def warn(cls, platform: str, feature: str) -> None: """Raise a warning that a feature isn't implemented on a platform.""" warnings.warn(NotImplementedWarning(f"[{platform}] Not implemented: {feature}")) @@ -116,7 +119,7 @@ def warn(self, platform, feature): ] -def _package_version(file, name): +def _package_version(file: Path | str | None, name: str) -> str: try: # Read version from SCM metadata # This will only exist in a development environment diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 5bef2bae82..79e972ca44 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -6,22 +6,14 @@ import sys import warnings import webbrowser -from collections.abc import ( - Collection, - ItemsView, - Iterator, - KeysView, - Mapping, - MutableSet, - ValuesView, -) +from collections.abc import Iterator from email.message import Message from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, MutableSet, Protocol from warnings import warn from weakref import WeakValueDictionary -from toga.command import Command, CommandSet +from toga.command import CommandSet from toga.documents import Document from toga.handlers import wrapped_handler from toga.hardware.camera import Camera @@ -32,10 +24,10 @@ from toga.screens import Screen from toga.types import Position, Size from toga.widgets.base import Widget -from toga.window import Window +from toga.window import OnCloseHandler, Window if TYPE_CHECKING: - from toga.icons import IconContent + from toga.icons import IconContentT from toga.types import PositionT, SizeT # Make sure deprecation warnings are shown by default @@ -43,7 +35,7 @@ class AppStartupMethod(Protocol): - def __call__(self, app: App, **kwargs: Any) -> Widget: + def __call__(self, app: App, /, **kwargs: Any) -> Widget: """The startup method of the app. Called during app startup to set the initial main window content. @@ -53,11 +45,10 @@ def __call__(self, app: App, **kwargs: Any) -> Widget: future versions. :returns: The widget to use as the main window content. """ - ... class OnExitHandler(Protocol): - def __call__(self, app: App, **kwargs: Any) -> bool: + def __call__(self, app: App, /, **kwargs: Any) -> bool: """A handler to invoke when the app is about to exit. The return value of this callback controls whether the app is allowed to exit. @@ -69,21 +60,19 @@ def __call__(self, app: App, **kwargs: Any) -> bool: :returns: ``True`` if the app is allowed to exit; ``False`` if the app is not allowed to exit. """ - ... class BackgroundTask(Protocol): - def __call__(self, app: App, **kwargs: Any) -> None: + def __call__(self, app: App, /, **kwargs: Any) -> object: """Code that should be executed as a background task. :param app: The app that is handling the background task. :param kwargs: Ensures compatibility with additional arguments introduced in future versions. """ - ... -class WindowSet(MutableSet): +class WindowSet(MutableSet[Window]): def __init__(self, app: App): """A collection of windows managed by an app. @@ -92,7 +81,7 @@ def __init__(self, app: App): :attr:`~toga.Window.app` property of the Window. """ self.app = app - self.elements = set() + self.elements: set[Window] = set() def add(self, window: Window) -> None: if not isinstance(window, Window): @@ -113,7 +102,7 @@ def discard(self, window: Window) -> None: # 2023-10: Backwards compatibility ###################################################################### - def __iadd__(self, window: Window) -> None: + def __iadd__(self, window: Window) -> WindowSet: # The standard set type does not have a += operator. warn( "Windows are automatically associated with the app; += is not required", @@ -122,7 +111,7 @@ def __iadd__(self, window: Window) -> None: ) return self - def __isub__(self, other: Window) -> None: + def __isub__(self, other: Window) -> WindowSet: # The standard set type does have a -= operator, but it takes sets rather than # individual items. warn( @@ -136,10 +125,10 @@ def __isub__(self, other: Window) -> None: # End backwards compatibility ###################################################################### - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[Window]: return iter(self.elements) - def __contains__(self, value: Window) -> bool: + def __contains__(self, value: object) -> bool: return value in self.elements def __len__(self) -> int: @@ -155,7 +144,7 @@ class WidgetRegistry: # values()) are all proxied to underlying data store. Private methods exist for # internal use, but those methods shouldn't be used by end-users. - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): self._registry = WeakValueDictionary(*args, **kwargs) def __len__(self) -> int: @@ -171,19 +160,15 @@ def __iter__(self) -> Iterator[Widget]: return self.values() def __repr__(self) -> str: - return ( - "{" - + ", ".join(f"{k!r}: {v!r}" for k, v in sorted(self._registry.items())) - + "}" - ) + return f"{{{', '.join(f'{k!r}: {v!r}' for k, v in sorted(self._registry.items()))}}}" - def items(self) -> ItemsView: + def items(self) -> Iterator[tuple[str, Widget]]: return self._registry.items() - def keys(self) -> KeysView: + def keys(self) -> Iterator[str]: return self._registry.keys() - def values(self) -> ValuesView: + def values(self) -> Iterator[Widget]: return self._registry.values() # Private methods for internal use @@ -213,8 +198,8 @@ def __init__( resizable: bool = True, minimizable: bool = True, content: Widget | None = None, - resizeable=None, # DEPRECATED - closeable=None, # DEPRECATED + resizeable: None = None, # DEPRECATED + closeable: None = None, # DEPRECATED ): """Create a new main window. @@ -262,7 +247,7 @@ def on_close(self) -> None: return None @on_close.setter - def on_close(self, handler: Any): + def on_close(self, handler: OnCloseHandler | None) -> None: if handler: raise ValueError( "Cannot set on_close handler for the main window. " @@ -288,7 +273,7 @@ def __init__( (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 doc: 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 :any:`toga.Position` or tuple of @@ -318,7 +303,10 @@ def _default_title(self) -> str: class App: #: The currently running :class:`~toga.App`. Since there can only be one running #: Toga app in a process, this is available as a class property via ``toga.App.app``. - app: App = None + app: App + _impl: Any + _camera: Camera + _location: Location def __init__( self, @@ -326,15 +314,15 @@ def __init__( app_id: str | None = None, app_name: str | None = None, *, - icon: IconContent | None = None, + icon: IconContentT | None = None, author: str | None = None, version: str | None = None, home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, - id=None, # DEPRECATED - windows=None, # DEPRECATED + id: None = None, # DEPRECATED + windows: None = None, # DEPRECATED ): """Create a new App instance. @@ -357,7 +345,7 @@ def __init__( 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. Defaults to + :param icon: The :any:`icon ` for the app. Defaults to :attr:`toga.Icon.APP_ICON`. :param author: The person or organization to be credited as the author of the app. If not provided, the metadata key ``Author`` will be used. @@ -447,7 +435,7 @@ def __init__( if app_id: self._app_id = app_id else: - self._app_id = self.metadata.get("App-ID", None) + self._app_id = self.metadata.get("App-ID") if self._app_id is None: raise RuntimeError("Toga application must have an app ID") @@ -485,24 +473,24 @@ def __init__( self.on_exit = on_exit - # We need the command set to exist so that startup et al can add commands; + # 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._main_window: MainWindow | None = None self._windows = WindowSet(self) - self._full_screen_windows = None + self._full_screen_windows: tuple[Window, ...] | None = None 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) + def _create_impl(self) -> None: + self.factory.App(interface=self) ###################################################################### # App properties @@ -547,12 +535,12 @@ def home_page(self) -> str | None: def icon(self) -> Icon: """The Icon for the app. - Can be specified as any valid :any:`icon content `. + Can be specified as any valid :any:`icon content `. """ return self._icon @icon.setter - def icon(self, icon_or_name: IconContent) -> None: + def icon(self, icon_or_name: IconContentT) -> None: if isinstance(icon_or_name, Icon): self._icon = icon_or_name else: @@ -631,23 +619,23 @@ def main_loop(self) -> None: self._impl.main_loop() @property - def main_window(self) -> MainWindow: + def main_window(self) -> MainWindow | None: """The main window for the app.""" return self._main_window @main_window.setter - def main_window(self, window: MainWindow) -> None: + def main_window(self, window: MainWindow | None) -> None: self._main_window = window self._impl.set_main_window(window) - def _verify_startup(self): + def _verify_startup(self) -> None: if not isinstance(self.main_window, MainWindow): raise ValueError( "Application does not have a main window. " "Does your startup() method assign a value to self.main_window?" ) - def _startup(self): + def _startup(self) -> None: # This is a wrapper around the user's startup method that performs any # post-setup validation. self.startup() @@ -684,7 +672,7 @@ def camera(self) -> Camera: return self._camera @property - def commands(self) -> MutableSet[Command]: + def commands(self) -> CommandSet: """The commands available in the app.""" return self._commands @@ -716,7 +704,7 @@ def screens(self) -> list[Screen]: return [screen.interface for screen in self._impl.get_screens()] @property - def widgets(self) -> Mapping[str, Widget]: + def widgets(self) -> WidgetRegistry: """The widgets managed by the app, over all windows. Can be used to look up widgets by ID over the entire app (e.g., @@ -729,7 +717,7 @@ def widgets(self) -> Mapping[str, Widget]: return self._widgets @property - def windows(self) -> Collection[Window]: + def windows(self) -> WindowSet: """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 @@ -783,7 +771,7 @@ def current_window(self) -> Window | None: return window.interface @current_window.setter - def current_window(self, window: Window): + def current_window(self, window: Window) -> None: """Set a window into current active focus.""" self._impl.set_current_window(window) @@ -829,7 +817,7 @@ def on_exit(self) -> OnExitHandler: @on_exit.setter def on_exit(self, handler: OnExitHandler | None) -> None: - def cleanup(app, should_exit): + def cleanup(app: App, should_exit: bool) -> None: if should_exit or handler is None: app.exit() @@ -851,7 +839,7 @@ def name(self) -> str: # Support WindowSet __iadd__ and __isub__ @windows.setter - def windows(self, windows): + def windows(self, windows: WindowSet) -> None: if windows is not self._windows: raise AttributeError("can't set attribute 'windows'") @@ -867,15 +855,15 @@ def __init__( app_id: str | None = None, app_name: str | None = None, *, - icon: IconContent | None = None, + icon: IconContentT | 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: dict[str, type[Document]] = None, + document_types: dict[str, type[Document]] | None = None, on_exit: OnExitHandler | None = None, - id=None, # DEPRECATED + id: None = None, # DEPRECATED ): """Create a document-based application. @@ -889,7 +877,7 @@ def __init__( raise ValueError("A document must manage at least one document type.") self._document_types = document_types - self._documents = [] + self._documents: list[Document] = [] super().__init__( formal_name=formal_name, @@ -905,10 +893,10 @@ def __init__( id=id, ) - def _create_impl(self): - return self.factory.DocumentApp(interface=self) + def _create_impl(self) -> None: + self.factory.DocumentApp(interface=self) - def _verify_startup(self): + def _verify_startup(self) -> None: # No post-startup validation required for DocumentApps pass @@ -934,7 +922,7 @@ def startup(self) -> None: Subclasses can override this method to define customized startup behavior. """ - def _open(self, path): + def _open(self, path: Path) -> None: """Internal utility method; open a new document in this app, and shows the document. :param path: The path to the document to be opened. diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 3963550b03..ba6bb5f956 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol +from collections.abc import Iterator +from typing import TYPE_CHECKING, Protocol from toga.handlers import wrapped_handler from toga.icons import Icon @@ -9,7 +10,7 @@ if TYPE_CHECKING: from toga.app import App - from toga.icons import IconContent + from toga.icons import IconContentT class Group: @@ -40,7 +41,7 @@ def __init__( # Prime the underlying value of _parent so that the setter has a current value # to work with - self._parent = None + self._parent: Group | None = None self.parent = parent @property @@ -49,7 +50,7 @@ def parent(self) -> Group | None: return self._parent @parent.setter - def parent(self, parent: Group | None): + def parent(self, parent: Group | None) -> None: if parent is None: self._parent = None elif parent == self: @@ -97,17 +98,17 @@ def is_child_of(self, parent: Group | None) -> bool: def __hash__(self) -> int: return hash(self.key) - def __lt__(self, other: Any) -> bool: + def __lt__(self, other: object) -> bool: if not isinstance(other, (Group, Command)): return False return self.key < other.key - def __gt__(self, other: Any) -> bool: + def __gt__(self, other: object) -> bool: if not isinstance(other, (Group, Command)): return False return other < self - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, (Group, Command)): return False return self.key == other.key @@ -121,7 +122,7 @@ def __repr__(self) -> str: return f"" @property - def key(self) -> tuple[(int, int, str)]: + def key(self) -> tuple[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: @@ -130,13 +131,13 @@ def key(self) -> tuple[(int, int, str)]: # 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 + APP: Group #: Application-level commands + FILE: Group #: File commands + EDIT: Group #: Editing commands + VIEW: Group #: Content appearance commands + COMMANDS: Group #: Default group for user-provided commands + WINDOW: Group #: Window management commands + HELP: Group #: Help commands Group.APP = Group("*", order=0) @@ -156,7 +157,6 @@ def __call__(self, command: Command, **kwargs) -> bool: :param kwargs: Ensures compatibility with additional arguments introduced in future versions. """ - ... class Command: @@ -167,7 +167,7 @@ def __init__( *, shortcut: str | Key | None = None, tooltip: str | None = None, - icon: IconContent | None = None, + icon: IconContentT | None = None, group: Group = Group.COMMANDS, section: int = 0, order: int = 0, @@ -184,7 +184,7 @@ def __init__( :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 :any:`icon content ` that can be used to decorate + :param icon: The :any:`icon content ` 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. @@ -208,10 +208,11 @@ def __init__( self.factory = get_platform_factory() self._impl = self.factory.Command(interface=self) + self._enabled = True self.enabled = enabled @property - def key(self) -> tuple[(int, int, str)]: + def key(self) -> tuple[tuple[int, int, str], ...]: """A unique tuple describing the path to this command. Each element in the tuple describes the (section, order, text) for the @@ -225,7 +226,7 @@ def enabled(self) -> bool: return self._enabled @enabled.setter - def enabled(self, value: bool): + def enabled(self, value: bool) -> None: self._enabled = value and getattr(self.action, "_raw", True) is not None self._impl.set_enabled(value) @@ -233,12 +234,12 @@ def enabled(self, value: bool): def icon(self) -> Icon | None: """The Icon for the command. - Can be specified as any valid :any:`icon content `. + Can be specified as any valid :any:`icon content `. """ return self._icon @icon.setter - def icon(self, icon_or_name: IconContent | None): + def icon(self, icon_or_name: IconContentT | None) -> None: if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: @@ -250,24 +251,24 @@ def action(self) -> ActionHandler | None: return self._action @action.setter - def action(self, action: ActionHandler | None): + def action(self, action: ActionHandler | None) -> None: """Set the action attached to the command Needs to be a valid ActionHandler or ``None`` """ self._action = wrapped_handler(self, action) - def __lt__(self, other: Any) -> bool: + def __lt__(self, other: object) -> bool: if not isinstance(other, (Group, Command)): return False return self.key < other.key - def __gt__(self, other: Any) -> bool: + def __gt__(self, other: object) -> bool: if not isinstance(other, (Group, Command)): return False return other < self - def __repr__(self) -> bool: + def __repr__(self) -> str: return ( f" bool: class Separator: - def __init__(self, group: Group = None): + def __init__(self, group: Group | None = None): """A representation of a separator between sections in a Group. :param group: The group that contains the separator. @@ -285,24 +286,27 @@ def __init__(self, group: Group = None): self.group = group def __repr__(self) -> str: - return f"" + return f"" - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if isinstance(other, Separator): return self.group == other.group return False class CommandSetChangeHandler(Protocol): - def __call__(self) -> None: - """A handler that will be invoked when a Command or Group is added to the CommandSet.""" - ... + def __call__(self, **kwargs) -> object: + """A handler that will be invoked when a Command or Group is added to the + CommandSet. + + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class CommandSet: def __init__( self, - on_change: CommandSetChangeHandler = None, + on_change: CommandSetChangeHandler | None = None, app: App | None = None, ): """ @@ -318,35 +322,35 @@ def __init__( :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. + own :any:`CommandSet`. """ self._app = app - self._commands = set() + self._commands: set[Command | Group] = set() self.on_change = on_change - def add(self, *commands: Command | Group): - if self.app and self.app is not None: + def add(self, *commands: Command | Group) -> None: + if self.app: self.app.commands.add(*commands) self._commands.update(commands) if self.on_change: self.on_change() - def clear(self): + def clear(self) -> None: self._commands = set() if self.on_change: self.on_change() @property - def app(self) -> App: + def app(self) -> App | None: return self._app def __len__(self) -> int: return len(self._commands) - def __iter__(self) -> Command | Separator: + def __iter__(self) -> Iterator[Command | Separator]: cmd_iter = iter(sorted(self._commands)) - def descendant(group, ancestor): + def descendant(group: Group, ancestor: Group) -> Group | None: # Return the immediate descendant of ancestor used by this group. if group.parent == ancestor: return group diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 1f7f3cf47b..8c87e451d0 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -4,13 +4,15 @@ class Direction(Enum): - "The direction a given property should act" + """The direction a given property should act.""" + HORIZONTAL = 0 VERTICAL = 1 class Baseline(Enum): - "The meaning of a Y coordinate when drawing text." + """The meaning of a Y coordinate when drawing text.""" + ALPHABETIC = auto() #: Alphabetic baseline of the first line TOP = auto() #: Top of text MIDDLE = auto() #: Middle of text @@ -18,7 +20,8 @@ class Baseline(Enum): class FillRule(Enum): - "The rule to use when filling paths." + """The rule to use when filling paths.""" + EVENODD = 0 NONZERO = 1 @@ -36,7 +39,7 @@ class FlashMode(Enum): OFF = 0 ON = 1 - def __str__(self): + def __str__(self) -> str: return self.name.title() diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index a4a02dd7c7..c107896249 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -16,7 +16,7 @@ def __init__( self, path: str | Path, document_type: str, - app: App = None, + app: App, ): """Create a new Document. @@ -27,7 +27,7 @@ def __init__( self._path = Path(path) self._document_type = document_type self._app = app - self._main_window = None + self._main_window: Window | None = None # Create the visual representation of the document self.create() @@ -46,7 +46,7 @@ def can_close(self) -> bool: """ return True - async def handle_close(self, window, **kwargs): + async def handle_close(self, window: Window, **kwargs: object) -> bool: """An ``on-close`` handler for the main window of this document that implements platform-specific document close behavior. @@ -82,7 +82,7 @@ def filename(self) -> Path: return self._path @property - def document_type(self) -> Path: + def document_type(self) -> str: """A human-readable description of the document type (read-only).""" return self._document_type @@ -92,12 +92,12 @@ def app(self) -> App: return self._app @property - def main_window(self) -> Window: + def main_window(self) -> Window | None: """The main window for the document.""" return self._main_window @main_window.setter - def main_window(self, window): + def main_window(self, window: Window) -> None: self._main_window = window def show(self) -> None: diff --git a/core/src/toga/fonts.py b/core/src/toga/fonts.py index bbb1042dce..f2a7016d49 100644 --- a/core/src/toga/fonts.py +++ b/core/src/toga/fonts.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + # Use the Travertino font definitions as-is from travertino import constants from travertino.constants import ( @@ -27,7 +29,7 @@ FONT_STYLES = {NORMAL, ITALIC, OBLIQUE} FONT_VARIANTS = {NORMAL, SMALL_CAPS} -_REGISTERED_FONT_CACHE = {} +_REGISTERED_FONT_CACHE: dict[tuple[str, str, str, str], str] = {} class Font(BaseFont): @@ -68,7 +70,14 @@ def __str__(self) -> str: return f"{self.family} {size}{weight}{variant}{style}" @staticmethod - def register(family, path, *, weight=NORMAL, style=NORMAL, variant=NORMAL): + def register( + family: str, + path: str | Path, + *, + weight: str = NORMAL, + style: str = NORMAL, + variant: str = NORMAL, + ) -> None: """Registers a file-based font. **Note:** This is not currently supported on macOS or iOS. @@ -84,7 +93,12 @@ def register(family, path, *, weight=NORMAL, style=NORMAL, variant=NORMAL): _REGISTERED_FONT_CACHE[font_key] = str(toga.App.app.paths.app / path) @staticmethod - def _registered_font_key(family, weight, style, variant): + def _registered_font_key( + family: str, + weight: str, + style: str, + variant: str, + ) -> tuple[str, str, str, str]: if weight not in constants.FONT_WEIGHTS: weight = NORMAL if style not in constants.FONT_STYLES: diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index c111473ae6..64a1ea2a74 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -1,17 +1,51 @@ +from __future__ import annotations + import asyncio import inspect import sys import traceback import warnings from abc import ABC +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generator, + NoReturn, + Protocol, + TypeVar, + Union, +) + +if TYPE_CHECKING: + if sys.version_info < (3, 10): + from typing_extensions import TypeAlias + else: + from typing import TypeAlias + + GeneratorReturnT = TypeVar("GeneratorReturnT") + HandlerGeneratorReturnT: TypeAlias = Generator[ + Union[float, None], object, GeneratorReturnT + ] + + HandlerSyncT: TypeAlias = Callable[..., object] + HandlerAsyncT: TypeAlias = Callable[..., Awaitable[object]] + HandlerGeneratorT: TypeAlias = Callable[..., HandlerGeneratorReturnT[object]] + HandlerT: TypeAlias = Union[HandlerSyncT, HandlerAsyncT, HandlerGeneratorT] + WrappedHandlerT: TypeAlias = Callable[..., object] class NativeHandler: - def __init__(self, handler): + def __init__(self, handler: Callable[..., object]): self.native = handler -async def long_running_task(interface, generator, cleanup): +async def long_running_task( + interface: object, + generator: HandlerGeneratorReturnT[object], + cleanup: HandlerSyncT | None, +) -> None: """Run a generator as an asynchronous coroutine.""" try: try: @@ -33,7 +67,13 @@ async def long_running_task(interface, generator, cleanup): traceback.print_exc() -async def handler_with_cleanup(handler, cleanup, interface, *args, **kwargs): +async def handler_with_cleanup( + handler: HandlerAsyncT, + cleanup: HandlerSyncT | None, + interface: object, + *args: object, + **kwargs: object, +) -> None: try: result = await handler(interface, *args, **kwargs) except Exception as e: @@ -48,7 +88,11 @@ async def handler_with_cleanup(handler, cleanup, interface, *args, **kwargs): traceback.print_exc() -def wrapped_handler(interface, handler, cleanup=None): +def wrapped_handler( + interface: object, + handler: HandlerT | NativeHandler | None, + cleanup: HandlerSyncT | None = None, +) -> WrappedHandlerT: """Wrap a handler provided by the user, so it can be invoked. If the handler is a NativeHandler, return the handler object contained in the @@ -70,7 +114,7 @@ def wrapped_handler(interface, handler, cleanup=None): if isinstance(handler, NativeHandler): return handler.native - def _handler(*args, **kwargs): + def _handler(*args: object, **kwargs: object) -> object: if asyncio.iscoroutinefunction(handler): asyncio.ensure_future( handler_with_cleanup(handler, cleanup, interface, *args, **kwargs) @@ -94,35 +138,47 @@ def _handler(*args, **kwargs): except Exception as e: print("Error in handler cleanup:", e, file=sys.stderr) traceback.print_exc() + return None _handler._raw = handler else: # A dummy no-op handler - def _handler(*args, **kwargs): + def _handler(*args: object, **kwargs: object) -> object: try: if cleanup: cleanup(interface, None) except Exception as e: print("Error in handler cleanup:", e, file=sys.stderr) traceback.print_exc() + return None _handler._raw = None return _handler +class OnResultT(Protocol): + def __call__( + self, result: Any, /, exception: Exception | None = None + ) -> object: ... + + class AsyncResult(ABC): - def __init__(self, on_result=None): + RESULT_TYPE: str + + def __init__(self, on_result: OnResultT | None = None) -> None: loop = asyncio.get_event_loop() self.future = loop.create_future() ###################################################################### # 2023-12: Backwards compatibility ###################################################################### + self.on_result: OnResultT | None if on_result: warnings.warn( - "Synchronous `on_result` handlers have been deprecated; use `await` on the asynchronous result", + "Synchronous `on_result` handlers have been deprecated; " + "use `await` on the asynchronous result", DeprecationWarning, ) @@ -133,26 +189,26 @@ def __init__(self, on_result=None): # End backwards compatibility. ###################################################################### - def set_result(self, result): + def set_result(self, result: object) -> None: if not self.future.cancelled(): self.future.set_result(result) if self.on_result: self.on_result(result) - def set_exception(self, exc): + def set_exception(self, exc: Exception) -> None: if not self.future.cancelled(): self.future.set_exception(exc) if self.on_result: self.on_result(None, exception=exc) - def __repr__(self): + def __repr__(self) -> str: return f"" - def __await__(self): + def __await__(self) -> Generator[Any, None, Any]: return self.future.__await__() # All the comparison dunder methods are disabled - def __bool__(self, other): + def __bool__(self, other: object) -> NoReturn: raise RuntimeError( f"Can't check {self.RESULT_TYPE} result directly; use await or an on_result handler" ) diff --git a/core/src/toga/hardware/camera.py b/core/src/toga/hardware/camera.py index 08d4ec430e..d0676727d0 100644 --- a/core/src/toga/hardware/camera.py +++ b/core/src/toga/hardware/camera.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from toga.constants import FlashMode from toga.handlers import AsyncResult, PermissionResult @@ -8,6 +8,7 @@ if TYPE_CHECKING: from toga.app import App + from toga.widgets.base import Widget class PhotoResult(AsyncResult): @@ -15,7 +16,7 @@ class PhotoResult(AsyncResult): class CameraDevice: - def __init__(self, impl): + def __init__(self, impl: Any): self._impl = impl @property @@ -33,7 +34,7 @@ def has_flash(self) -> bool: """Does the device have a flash?""" return self._impl.has_flash() - def __eq__(self, other) -> bool: + def __eq__(self, other: Widget) -> bool: return self.id == other.id def __repr__(self) -> str: diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index 1a52edd173..56f9801c0e 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -17,11 +17,12 @@ class LocationResult(AsyncResult): class OnLocationChangeHandler(Protocol): def __call__( self, + *, service: Location, location: toga.LatLng, altitude: float | None, **kwargs: Any, - ) -> None: + ) -> object: """A handler that will be invoked when the user's location changes. :param service: the location service that generated the update. @@ -30,7 +31,6 @@ def __call__( None if the altitude could not be determined. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... class Location: @@ -137,10 +137,10 @@ def on_change(self) -> OnLocationChangeHandler: return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: OnLocationChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) - def start_tracking(self): + def start_tracking(self) -> None: """Start monitoring the user's location for changes. An :any:`on_change` callback will be generated when the user's location @@ -156,7 +156,7 @@ def start_tracking(self): "App does not have permission to use location services" ) - def stop_tracking(self): + def stop_tracking(self) -> None: """Stop monitoring the user's location. :raises PermissionError: If the app has not requested and received permission to diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index 729ae4c09e..8d45474eb7 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -2,6 +2,7 @@ import sys import warnings +from collections.abc import Callable, Iterable from pathlib import Path from typing import TYPE_CHECKING @@ -14,21 +15,18 @@ else: from typing import TypeAlias - IconContent: TypeAlias = str | Path | toga.Icon + IconContentT: TypeAlias = str | Path | toga.Icon class cachedicon: - def __init__(self, f): + def __init__(self, f: Callable[..., Icon]): self.f = f self.__doc__ = f.__doc__ - def __get__(self, obj, owner): + def __get__(self, obj: object, owner: type[Icon]) -> Icon: # If you ask for Icon.CACHED_ICON, obj is None, and owner is the Icon class # If you ask for self.CACHED_ICON, obj is self, from which we can get the class. - if obj is None: - cls = owner - else: - cls = obj.__class__ + cls = owner if obj is None else obj.__class__ try: # Look for a __CACHED_ICON attribute on the class @@ -111,6 +109,7 @@ def __init__( else: resource_path = toga.App.app.paths.app + full_path: dict[str, Path] | Path if self.factory.Icon.SIZES: full_path = {} for size in self.factory.Icon.SIZES: @@ -154,7 +153,12 @@ def __init__( ) self._impl = self.DEFAULT_ICON._impl - def _full_path(self, size, extensions, resource_path): + def _full_path( + self, + size: str | None, + extensions: Iterable[str], + resource_path: Path, + ) -> Path: platform = toga.platform.current_platform if size: for extension in extensions: @@ -178,5 +182,5 @@ def _full_path(self, size, extensions, resource_path): raise FileNotFoundError(f"Can't find icon {self.path}") - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, Icon) and other._impl.path == self._impl.path diff --git a/core/src/toga/images.py b/core/src/toga/images.py index 0d374ce73c..eb4d9fc668 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -1,11 +1,12 @@ from __future__ import annotations import importlib +import os import sys import warnings from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, TypeVar from warnings import warn import toga @@ -23,18 +24,18 @@ if TYPE_CHECKING: if sys.version_info < (3, 10): - from typing_extensions import TypeAlias, TypeVar + from typing_extensions import TypeAlias else: - from typing import TypeAlias, TypeVar + from typing import TypeAlias # Define a type variable for generics where an Image type is required. ImageT = TypeVar("ImageT") # Define the types that can be used as Image content - PathLike: TypeAlias = str | Path - BytesLike: TypeAlias = bytes | bytearray | memoryview - ImageLike: TypeAlias = Any - ImageContent: TypeAlias = PathLike | BytesLike | ImageLike + PathLikeT: TypeAlias = str | os.PathLike + BytesLikeT: TypeAlias = bytes | bytearray | memoryview + ImageLikeT: TypeAlias = Any + ImageContentT: TypeAlias = PathLikeT | BytesLikeT | ImageLikeT # Define a type variable representing an image of an externally defined type. ExternalImageT = TypeVar("ExternalImageT") @@ -49,7 +50,7 @@ class ImageConverter(Protocol): image_class: type[ExternalImageT] @staticmethod - def convert_from_format(image_in_format: ExternalImageT) -> BytesLike: + def convert_from_format(image_in_format: ExternalImageT) -> BytesLikeT: """Convert from :any:`image_class` to data in a :ref:`known image format `. @@ -58,11 +59,10 @@ def convert_from_format(image_in_format: ExternalImageT) -> BytesLike: :param image_in_format: An instance of :any:`image_class` (or a subclass). :returns: The image data, in a :ref:`known image format `. """ - ... @staticmethod def convert_to_format( - data: BytesLike, + data: BytesLikeT, image_class: type[ExternalImageT], ) -> ExternalImageT: """Convert from data to :any:`image_class` or specified subclass. @@ -76,7 +76,6 @@ def convert_to_format( :param image_class: The class of image to return. :returns: The image, as an instance of the image class specified. """ - ... NOT_PROVIDED = object() @@ -85,15 +84,15 @@ def convert_to_format( class Image: def __init__( self, - src: ImageContent = NOT_PROVIDED, + src: ImageContentT = NOT_PROVIDED, *, - path=NOT_PROVIDED, # DEPRECATED - data=NOT_PROVIDED, # DEPRECATED + path: object = NOT_PROVIDED, # DEPRECATED + data: object = NOT_PROVIDED, # DEPRECATED ): """Create a new image. :param src: The source from which to load the image. Can be any valid - :any:`image content ` type. + :any:`image content ` type. :param path: **DEPRECATED** - Use ``src``. :param data: **DEPRECATED** - Use ``src``. :raises FileNotFoundError: If a path is provided, but that path does not exist. @@ -157,7 +156,7 @@ def __init__( @classmethod @lru_cache(maxsize=None) - def _converters(cls): + def _converters(cls) -> list[ImageConverter]: """Return list of registered image plugin converters. Only loaded once.""" converters = [] @@ -172,9 +171,9 @@ def _converters(cls): return converters @property - def size(self) -> (int, int): + def size(self) -> tuple[int, int]: """The size of the image, as a (width, height) tuple.""" - return (self._impl.get_width(), self._impl.get_height()) + return self._impl.get_width(), self._impl.get_height() @property def width(self) -> int: diff --git a/core/src/toga/keys.py b/core/src/toga/keys.py index bbaa8c1942..4e518cb50c 100644 --- a/core/src/toga/keys.py +++ b/core/src/toga/keys.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum @@ -158,7 +160,7 @@ 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): + def __add__(self, other: Key | str) -> str: """Allow two Keys to be concatenated, or a string to be concatenated to a Key. Produces a single string definition. @@ -173,6 +175,6 @@ def __add__(self, other): except AttributeError: return self.value + other - def __radd__(self, other): + def __radd__(self, other: str) -> str: """Same as add.""" return other + self.value diff --git a/core/src/toga/paths.py b/core/src/toga/paths.py index a8c38301dd..b73fa03924 100644 --- a/core/src/toga/paths.py +++ b/core/src/toga/paths.py @@ -1,4 +1,4 @@ -import importlib +import importlib.util import sys from pathlib import Path diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index 3c319385d8..d950e29149 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -1,11 +1,14 @@ +from __future__ import annotations + import importlib import os import sys from functools import lru_cache +from types import ModuleType -if sys.version_info >= (3, 10): +if sys.version_info >= (3, 10): # pragma: no cover from importlib.metadata import entry_points -else: +else: # pragma: no cover # Before Python 3.10, entry_points did not support the group argument; # so, the backport package must be used on older versions. from importlib_metadata import entry_points @@ -26,7 +29,7 @@ } -def get_current_platform(): +def get_current_platform() -> str | None: # Rely on `sys.getandroidapilevel`, which only exists on Android; see # https://github.com/beeware/Python-Android-support/issues/8 if hasattr(sys, "getandroidapilevel"): @@ -49,7 +52,7 @@ def find_backends(): @lru_cache(maxsize=1) -def get_platform_factory(): +def get_platform_factory() -> ModuleType: """Determine the current host platform and import the platform factory. If the ``TOGA_BACKEND`` environment variable is set, the factory will be loaded @@ -59,8 +62,7 @@ def get_platform_factory(): :returns: The factory for the host platform. """ - backend_value = os.environ.get("TOGA_BACKEND") - if backend_value: + if backend_value := os.environ.get("TOGA_BACKEND"): try: factory = importlib.import_module(f"{backend_value}.factory") except ModuleNotFoundError as e: diff --git a/core/src/toga/plugins/image_formats.py b/core/src/toga/plugins/image_formats.py index 40822049d4..4787a3267b 100644 --- a/core/src/toga/plugins/image_formats.py +++ b/core/src/toga/plugins/image_formats.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from toga.images import BytesLike + from toga.images import BytesLikeT # Presumably, other converter plugins will be included with, or only installed # alongside, the packages they're for. But since this is provided in Toga, we need to @@ -29,8 +29,8 @@ def convert_from_format(image_in_format: PIL.Image.Image) -> bytes: @staticmethod def convert_to_format( - data: BytesLike, - image_class: type(PIL.Image.Image), + data: BytesLikeT, + image_class: type[PIL.Image.Image], ) -> PIL.Image.Image: # PIL Images aren't designed to be subclassed, so no implementation is necessary # for a supplied format. diff --git a/core/src/toga/screens.py b/core/src/toga/screens.py index 3dc24b8f9c..4129bb90db 100644 --- a/core/src/toga/screens.py +++ b/core/src/toga/screens.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from toga.images import Image from toga.platform import get_platform_factory @@ -11,7 +11,7 @@ class Screen: - def __init__(self, _impl): + def __init__(self, _impl: Any): self._impl = _impl self.factory = get_platform_factory() diff --git a/core/src/toga/sources/__init__.py b/core/src/toga/sources/__init__.py index c0d20e7308..c12ec189f1 100644 --- a/core/src/toga/sources/__init__.py +++ b/core/src/toga/sources/__init__.py @@ -3,3 +3,14 @@ from .list_source import ListSource, Row # noqa: F401 from .tree_source import Node, TreeSource # noqa: F401 from .value_source import ValueSource # noqa: F401 + +__all__ = [ + "ListSource", + "Listener", + "Node", + "Row", + "Source", + "TreeSource", + "ValueSource", + "to_accessor", +] diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index c080ec8b95..e5f2194cd7 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Iterable, Mapping NON_ACCESSOR_CHARS = re.compile(r"[^\w ]") WHITESPACE = re.compile(r"\s+") @@ -45,8 +46,8 @@ def to_accessor(heading: str) -> str: def build_accessors( - headings: list[str], - accessors: list[str | None] | dict[str, str] | None, + headings: Iterable[str], + accessors: Iterable[str | None] | Mapping[str, str] | None, ) -> list[str]: """Convert a list of headings (with accessor overrides) to a finalised list of accessors. @@ -62,13 +63,14 @@ def build_accessors( :returns: The final list of accessors. """ - if accessors: - if isinstance(accessors, dict): + if accessors is not None: + if isinstance(accessors, Mapping): result = [ accessors[h] if h in accessors else to_accessor(h) for h in headings ] else: - if len(headings) != len(accessors): + # TODO: use zip(..., strict=True) instead once Python 3.9 support is dropped + if len(headings := list(headings)) != len(accessors := list(accessors)): raise ValueError("Number of accessors must match number of headings") result = [ diff --git a/core/src/toga/sources/base.py b/core/src/toga/sources/base.py index 06e9d539c0..8ea13d2d5b 100644 --- a/core/src/toga/sources/base.py +++ b/core/src/toga/sources/base.py @@ -8,35 +8,35 @@ class Listener(Protocol): data source. """ - def change(self, item): + def change(self, item: object) -> object: """A change has occurred in an item. :param item: The data object that has changed. """ - def insert(self, index: int, item): + def insert(self, index: int, item: object) -> object: """An item has been added to the data source. :param index: The 0-index position in the data. :param item: The data object that was added. """ - def remove(self, index: int, item): + def remove(self, index: int, item: object) -> object: """An item has been removed from the data source. :param index: The 0-index position in the data. :param item: The data object that was added. """ - def clear(self): + def clear(self) -> object: """All items have been removed from the data source.""" class Source: """A base class for data sources, providing an implementation of data notifications.""" - def __init__(self): - self._listeners = [] + def __init__(self) -> None: + self._listeners: list[Listener] = [] @property def listeners(self) -> list[Listener]: @@ -46,7 +46,7 @@ def listeners(self) -> list[Listener]: """ return self._listeners - def add_listener(self, listener: Listener): + def add_listener(self, listener: Listener) -> None: """Add a new listener to this data source. If the listener is already registered on this data source, the @@ -57,14 +57,14 @@ def add_listener(self, listener: Listener): if listener not in self._listeners: self._listeners.append(listener) - def remove_listener(self, listener: Listener): + def remove_listener(self, listener: Listener) -> None: """Remove a listener from this data source. :param listener: The listener to remove. """ self._listeners.remove(listener) - def notify(self, notification: str, **kwargs): + def notify(self, notification: str, **kwargs: object) -> None: """Notify all listeners an event has occurred. :param notification: The notification to emit. diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index b72e696fb9..666bc71925 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -1,12 +1,20 @@ from __future__ import annotations -from collections.abc import Iterable -from typing import Any +from collections.abc import Iterable, Mapping, Sequence +from typing import Generic, TypeVar from .base import Source +T = TypeVar("T") -def _find_item(candidates: list, data: Any, accessors: list[str], start, error: str): + +def _find_item( + candidates: Sequence[T], + data: object, + accessors: Sequence[str], + start: T | None, + error: str, +) -> T: """Find-by-value implementation helper; find an item matching ``data`` in ``candidates``, starting with item ``start``.""" if start is not None: @@ -16,7 +24,7 @@ def _find_item(candidates: list, data: Any, accessors: list[str], start, error: for item in candidates[start_index:]: try: - if isinstance(data, dict): + if isinstance(data, Mapping): found = all( getattr(item, attr) == value for attr, value in data.items() ) @@ -36,8 +44,8 @@ def _find_item(candidates: list, data: Any, accessors: list[str], start, error: raise ValueError(error) -class Row: - def __init__(self, **data): +class Row(Generic[T]): + def __init__(self, **data: T): """Create a new Row object. The keyword arguments specified in the constructor will be converted into @@ -51,7 +59,7 @@ def __init__(self, **data): for name, value in data.items(): setattr(self, name, value) - def __repr__(self): + def __repr__(self) -> str: descriptor = " ".join( f"{attr}={getattr(self, attr)!r}" for attr in sorted(self.__dict__) @@ -63,7 +71,7 @@ def __repr__(self): # Utility wrappers ###################################################################### - def __setattr__(self, attr: str, value): + def __setattr__(self, attr: str, value: T) -> None: """Set an attribute on the Row object, notifying the source of the change. :param attr: The attribute to change. @@ -74,11 +82,13 @@ def __setattr__(self, attr: str, value): if self._source is not None: self._source.notify("change", item=self) - def __delattr__(self, attr: str): + def __getattr__(self, attr: str) -> T: + return super().__getattr__(attr) + + def __delattr__(self, attr: str) -> None: """Remove an attribute from the Row object, notifying the source of the change. :param attr: The attribute to change. - :param value: The new attribute value. """ super().__delattr__(attr) if not attr.startswith("_"): @@ -87,7 +97,9 @@ def __delattr__(self, attr: str): class ListSource(Source): - def __init__(self, accessors: list[str], data: Iterable | None = None): + _data: list[Row] + + def __init__(self, accessors: Iterable[str], data: Iterable | None = None): """A data source to store an ordered list of multiple data values. :param accessors: A list of attribute names for accessing the value @@ -105,7 +117,7 @@ def __init__(self, accessors: list[str], data: Iterable | None = None): raise ValueError("ListSource must be provided a list of accessors") # Convert the data into row objects - if data: + if data is not None: self._data = [self._create_row(value) for value in data] else: self._data = [] @@ -122,7 +134,7 @@ def __getitem__(self, index: int) -> Row: """Returns the item at position ``index`` of the list.""" return self._data[index] - def __delitem__(self, index: int): + def __delitem__(self, index: int) -> None: """Deletes the item at position ``index`` of the list.""" row = self._data[index] del self._data[index] @@ -133,8 +145,8 @@ def __delitem__(self, index: int): ###################################################################### # This behavior is documented in list_source.rst. - def _create_row(self, data: Any) -> Row: - if isinstance(data, dict): + def _create_row(self, data: object) -> Row: + if isinstance(data, Mapping): row = Row(**data) elif hasattr(data, "__iter__") and not isinstance(data, str): row = Row(**dict(zip(self._accessors, data))) @@ -147,7 +159,7 @@ def _create_row(self, data: Any) -> Row: # Utility methods to make ListSources more list-like ###################################################################### - def __setitem__(self, index: int, value: Any): + def __setitem__(self, index: int, value: object) -> None: """Set the value of a specific item in the data source. :param index: The item to change @@ -158,12 +170,12 @@ def __setitem__(self, index: int, value: Any): self._data[index] = row self.notify("insert", index=index, item=row) - def clear(self): + def clear(self) -> None: """Clear all data from the data source.""" self._data = [] self.notify("clear") - def insert(self, index: int, data: Any): + def insert(self, index: int, data: object) -> Row: """Insert a row into the data source at a specific index. :param index: The index at which to insert the item. @@ -176,7 +188,7 @@ def insert(self, index: int, data: Any): self.notify("insert", index=index, item=row) return row - def append(self, data): + def append(self, data: object) -> Row: """Insert a row at the end of the data source. :param data: The data to append to the ListSource. This data will be converted @@ -185,7 +197,7 @@ def append(self, data): """ return self.insert(len(self), data) - def remove(self, row: Row): + def remove(self, row: Row) -> None: """Remove a row from the data source. :param row: The row to remove from the data source. @@ -206,7 +218,7 @@ def index(self, row: Row) -> int: """ return self._data.index(row) - def find(self, data: Any, start: None | None = None): + def find(self, data: object, start: Row | None = None) -> Row: """Find the first item in the data that matches all the provided attributes. diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index 8b4a5268f5..967a97267e 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -1,13 +1,18 @@ from __future__ import annotations -from typing import Any +from collections.abc import Iterator +from typing import Iterable, Mapping, TypeVar from .base import Source from .list_source import Row, _find_item +T = TypeVar("T") -class Node(Row): - def __init__(self, **data): + +class Node(Row[T]): + _source: TreeSource + + def __init__(self, **data: T): """Create a new Node object. The keyword arguments specified in the constructor will be converted into @@ -21,10 +26,10 @@ def __init__(self, **data): notified. """ super().__init__(**data) - self._children: list[Node] | None = None - self._parent: Node | None = None + self._children: list[Node[T]] | None = None + self._parent: Node[T] | None = None - def __repr__(self): + def __repr__(self) -> str: descriptor = " ".join( f"{attr}={getattr(self, attr)!r}" for attr in sorted(self.__dict__) @@ -41,13 +46,13 @@ def __repr__(self): # Methods required by the TreeSource interface ###################################################################### - def __getitem__(self, index: int) -> Node: + def __getitem__(self, index: int) -> Node[T]: if self._children is None: raise ValueError(f"{self} is a leaf node") return self._children[index] - def __delitem__(self, index: int): + def __delitem__(self, index: int) -> None: if self._children is None: raise ValueError(f"{self} is a leaf node") @@ -79,10 +84,10 @@ def can_have_children(self) -> bool: # Utility methods to make TreeSource more list-like ###################################################################### - def __iter__(self): + def __iter__(self) -> Iterator[Node[T]]: return iter(self._children or []) - def __setitem__(self, index: int, data: Any): + def __setitem__(self, index: int, data: object) -> None: """Set the value of a specific child in the Node. :param index: The index of the child to change @@ -100,7 +105,7 @@ def __setitem__(self, index: int, data: Any): self._children[index] = node self._source.notify("change", item=node) - def insert(self, index: int, data: Any, children: Any = None): + def insert(self, index: int, data: object, children: object = None) -> Node[T]: """Insert a node as a child of this node a specific index. :param index: The index at which to insert the new child. @@ -122,7 +127,7 @@ def insert(self, index: int, data: Any, children: Any = None): self._source.notify("insert", parent=self, index=index, item=node) return node - def append(self, data: Any, children: Any = None): + def append(self, data: object, children: object = None) -> Node[T]: """Append a node to the end of the list of children of this node. :param data: The data to append as a child of this node. This data will be @@ -132,7 +137,7 @@ def append(self, data: Any, children: Any = None): """ return self.insert(len(self), data=data, children=children) - def remove(self, child: Node): + def remove(self, child: Node[T]) -> None: """Remove a child node from this node. :param child: The child node to remove from this node. @@ -140,7 +145,7 @@ def remove(self, child: Node): # Index will raise ValueError if the node is a leaf del self[self.index(child)] - def index(self, child: Node): + def index(self, child: Node[T]) -> int: """The index of a specific node in children of this node. This search uses Node instances, and searches for an *instance* match. @@ -157,7 +162,7 @@ def index(self, child: Node): return self._children.index(child) - def find(self, data: Any, start: Node = None): + def find(self, data: object, start: Node[T] | None = None) -> Node[T]: """Find the first item in the child nodes of this node that matches all the provided attributes. @@ -189,7 +194,9 @@ def find(self, data: Any, start: Node = None): class TreeSource(Source): - def __init__(self, accessors: list[str], data: dict | list[tuple] | None = None): + _roots: list[Node] + + def __init__(self, accessors: Iterable[str], data: object | None = None): super().__init__() if isinstance(accessors, str) or not hasattr(accessors, "__iter__"): raise ValueError("accessors should be a list of attribute names") @@ -199,7 +206,7 @@ def __init__(self, accessors: list[str], data: dict | list[tuple] | None = None) if len(self._accessors) == 0: raise ValueError("TreeSource must be provided a list of accessors") - if data: + if data is not None: self._roots = self._create_nodes(parent=None, value=data) else: self._roots = [] @@ -214,7 +221,7 @@ def __len__(self) -> int: def __getitem__(self, index: int) -> Node: return self._roots[index] - def __delitem__(self, index: int): + def __delitem__(self, index: int) -> None: node = self._roots[index] del self._roots[index] node._source = None @@ -227,10 +234,10 @@ def __delitem__(self, index: int): def _create_node( self, parent: Node | None, - data: Any, - children: list | dict | None = None, - ): - if isinstance(data, dict): + data: object, + children: object | None = None, + ) -> Node: + if isinstance(data, Mapping): node = Node(**data) elif hasattr(data, "__iter__") and not isinstance(data, str): node = Node(**dict(zip(self._accessors, data))) @@ -245,8 +252,8 @@ def _create_node( return node - def _create_nodes(self, parent: Node | None, value: Any): - if isinstance(value, dict): + def _create_nodes(self, parent: Node | None, value: object) -> list[Node]: + if isinstance(value, Mapping): return [ self._create_node(parent=parent, data=data, children=children) for data, children in value.items() @@ -263,7 +270,7 @@ def _create_nodes(self, parent: Node | None, value: Any): # Utility methods to make TreeSources more list-like ###################################################################### - def __setitem__(self, index: int, data: Any): + def __setitem__(self, index: int, data: object) -> None: """Set the value of a specific root item in the data source. :param index: The root item to change @@ -278,17 +285,12 @@ def __setitem__(self, index: int, data: Any): self._roots[index] = root self.notify("change", item=root) - def clear(self): + def clear(self) -> None: """Clear all data from the data source.""" self._roots = [] self.notify("clear") - def insert( - self, - index: int, - data: Any, - children: Any = None, - ): + def insert(self, index: int, data: object, children: object = None) -> Node: """Insert a root node into the data source at a specific index. If the node is a leaf node, it will be converted into a non-leaf node. @@ -309,9 +311,10 @@ def insert( self._roots.insert(index, node) node._parent = None self.notify("insert", parent=None, index=index, item=node) + return node - def append(self, data: Any, children: Any = None): + def append(self, data: object, children: object | None = None) -> Node: """Append a root node at the end of the list of children of this source. If the node is a leaf node, it will be converted into a non-leaf node. @@ -324,7 +327,7 @@ def append(self, data: Any, children: Any = None): """ return self.insert(len(self), data=data, children=children) - def remove(self, node: Node): + def remove(self, node: Node) -> None: """Remove a node from the data source. This will also remove the node if it is a descendant of a root node. @@ -353,7 +356,7 @@ def index(self, node: Node) -> int: """ return self._roots.index(node) - def find(self, data: Any, start: Node = None): + def find(self, data: object, start: Node | None = None) -> Node: """Find the first item in the child nodes of the given node that matches all the provided attributes. diff --git a/core/src/toga/sources/value_source.py b/core/src/toga/sources/value_source.py index c77fcf5d3a..1d8f10178b 100644 --- a/core/src/toga/sources/value_source.py +++ b/core/src/toga/sources/value_source.py @@ -4,15 +4,15 @@ class ValueSource(Source): - def __init__(self, value=None, accessor="value"): + def __init__(self, value: object = None, accessor: str = "value"): super().__init__() self.accessor = accessor setattr(self, accessor, value) - def __str__(self): + def __str__(self) -> str: return str(getattr(self, self.accessor, None)) - def __setattr__(self, attr, value): + def __setattr__(self, attr: str, value: object) -> None: super().__setattr__(attr, value) if attr == getattr(self, "accessor", None): self.notify("change", item=value) diff --git a/core/src/toga/style/__init__.py b/core/src/toga/style/__init__.py index 4605b72b2f..33014fdd05 100644 --- a/core/src/toga/style/__init__.py +++ b/core/src/toga/style/__init__.py @@ -1,2 +1,7 @@ from toga.style.applicator import TogaApplicator # noqa: F401 from toga.style.pack import Pack # noqa: F401 + +__all__ = [ + "Pack", + "TogaApplicator", +] diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index 4f9a7552db..2b0c17ee75 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -1,14 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from toga.widgets.base import Widget + + class TogaApplicator: """Apply styles to a Toga widget.""" - def __init__(self, widget): + def __init__(self, widget: Widget): self.widget = widget - def refresh(self): + def refresh(self) -> None: # print("RE-EVALUATE LAYOUT", self.widget) self.widget.refresh() - def set_bounds(self): + def set_bounds(self) -> None: # print(" APPLY LAYOUT", self.widget, self.widget.layout) self.widget._impl.set_bounds( self.widget.layout.absolute_content_left, @@ -19,10 +27,10 @@ def set_bounds(self): for child in self.widget.children: child.applicator.set_bounds() - def set_text_alignment(self, alignment): + def set_text_alignment(self, alignment: str) -> None: self.widget._impl.set_alignment(alignment) - def set_hidden(self, hidden): + def set_hidden(self, hidden: bool) -> None: self.widget._impl.set_hidden(hidden) for child in self.widget.children: # If the parent is hidden, then so are all children. However, if the @@ -38,14 +46,14 @@ def set_hidden(self, hidden): # False False False child.applicator.set_hidden(hidden or child.style._hidden) - def set_font(self, font): + def set_font(self, font: object) -> None: # Changing the font of a widget can make the widget change size, # which in turn means we need to do a re-layout self.widget._impl.set_font(font) self.widget.refresh() - def set_color(self, color): + def set_color(self, color: object) -> None: self.widget._impl.set_color(color) - def set_background_color(self, color): + def set_background_color(self, color: object) -> None: self.widget._impl.set_background_color(color) diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 455eb2e70f..8743293968 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from travertino.constants import ( # noqa: F401 BOLD, BOTTOM, @@ -27,6 +31,7 @@ ) from travertino.declaration import BaseStyle, Choices from travertino.layout import BaseBox +from travertino.node import Node from travertino.size import BaseIntrinsicSize from toga.fonts import ( @@ -80,15 +85,15 @@ class IntrinsicSize(BaseIntrinsicSize): _depth = -1 - def _debug(self, *args): # pragma: no cover + def _debug(self, *args: str) -> None: # pragma: no cover print(" " * self.__class__._depth, *args) @property - def _hidden(self): - """Does this style declaration define an object that should be hidden""" + def _hidden(self) -> bool: + """Does this style declaration define an object that should be hidden.""" return self.visibility == HIDDEN - def apply(self, prop, value): + def apply(self, prop: str, value: object) -> None: if self._applicator: if prop == "text_align": if value is None: @@ -127,7 +132,7 @@ def apply(self, prop, value): # so perform a refresh. self._applicator.refresh() - def layout(self, node, viewport): + def layout(self, node: Node, viewport: Any) -> None: # self._debug("=" * 80) # self._debug(f"Layout root {node}, available {viewport.width}x{viewport.height}") self.__class__._depth = -1 @@ -147,12 +152,12 @@ def layout(self, node, viewport): def _layout_node( self, - node, - alloc_width, - alloc_height, - use_all_width, - use_all_height, - ): + node: Node, + alloc_width: int, + alloc_height: int, + use_all_width: bool, + use_all_height: bool, + ) -> None: self.__class__._depth += 1 # self._debug( # f"COMPUTE LAYOUT for {node} available " @@ -259,12 +264,12 @@ def _layout_node( def _layout_row_children( self, - node, - available_width, - available_height, - use_all_width, - use_all_height, - ): + node: Node, + available_width: int, + available_height: int, + use_all_width: bool, + use_all_height: bool, + ) -> tuple[int, int, int, int]: # self._debug(f"LAYOUT ROW CHILDREN {available_width=} {available_height=}") # Pass 1: Lay out all children with a hard-specified width, or an # intrinsic non-flexible width. While iterating, collect the flex @@ -543,12 +548,12 @@ def _layout_row_children( def _layout_column_children( self, - node, - available_width, - available_height, - use_all_width, - use_all_height, - ): + node: Node, + available_width: int, + available_height: int, + use_all_width: bool, + use_all_height: bool, + ) -> tuple[int, int, int, int]: # self._debug(f"LAYOUT COLUMN CHILDREN {available_width=} {available_height=}") # Pass 1: Lay out all children with a hard-specified height, or an # intrinsic non-flexible height. While iterating, collect the flex @@ -819,7 +824,7 @@ def _layout_column_children( return min_width, width, min_height, height - def __css__(self): + def __css__(self) -> str: css = [] # display if self.display == NONE: diff --git a/core/src/toga/types.py b/core/src/toga/types.py index eeacd34de8..b50553cb1f 100644 --- a/core/src/toga/types.py +++ b/core/src/toga/types.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, NamedTuple, Tuple +from typing import TYPE_CHECKING, NamedTuple import toga @@ -11,8 +11,8 @@ else: from typing import TypeAlias - PositionT: TypeAlias = toga.Position | Tuple[int, int] - SizeT: TypeAlias = toga.Size | Tuple[int, int] + PositionT: TypeAlias = toga.Position | tuple[int, int] + SizeT: TypeAlias = toga.Size | tuple[int, int] class LatLng(NamedTuple): @@ -24,7 +24,7 @@ class LatLng(NamedTuple): #: Longitude lng: float - def __str__(self): + def __str__(self) -> str: return f"({self.lat:6f}, {self.lng:6f})" diff --git a/core/src/toga/validators.py b/core/src/toga/validators.py index a68cb6e21e..83ae650919 100644 --- a/core/src/toga/validators.py +++ b/core/src/toga/validators.py @@ -30,7 +30,6 @@ def is_valid(self, input_string: str) -> bool: :param input_string: The string to validate. :returns: ``True`` if the input is valid. """ - ... class CountValidator: @@ -83,7 +82,6 @@ def count(self, input_string: str) -> int: :param input_string: The string to inspect for content of interest. :returns: The number of instances of content that the validator is looking for. """ - ... class LengthBetween(BooleanValidator): @@ -105,8 +103,8 @@ def __init__( ``True`` """ if error_message is None: - error_message = "Input should be between {} and {} characters".format( - min_length, max_length + error_message = ( + f"Input should be between {min_length} and {max_length} characters" ) super().__init__(error_message=error_message, allow_empty=allow_empty) @@ -146,9 +144,7 @@ def __init__( ``True`` """ if error_message is None: - error_message = "Input is too short (length should be at least {})".format( - length - ) + error_message = f"Input is too short (length should be at least {length})" super().__init__( min_length=length, max_length=None, @@ -170,9 +166,7 @@ def __init__( string is too long. """ if error_message is None: - error_message = "Input is too long (length should be at most {})".format( - length - ) + error_message = f"Input is too long (length should be at most {length})" super().__init__( min_length=None, max_length=length, @@ -294,7 +288,7 @@ def __init__( class MatchRegex(BooleanValidator): def __init__( self, - regex_string, + regex_string: str, error_message: str | None = None, allow_empty: bool = True, ): @@ -430,9 +424,7 @@ def __init__( else: expected_existence_message = "Input should contain at least one digit" expected_non_existence_message = "Input should not contain digits" - expected_count_message = "Input should contain exactly {} digits".format( - count - ) + expected_count_message = f"Input should contain exactly {count} digits" super().__init__( count=count, diff --git a/core/src/toga/widgets/activityindicator.py b/core/src/toga/widgets/activityindicator.py index 7681ce4fc6..102f44d129 100644 --- a/core/src/toga/widgets/activityindicator.py +++ b/core/src/toga/widgets/activityindicator.py @@ -2,14 +2,14 @@ from typing import Literal -from .base import Widget +from .base import StyleT, Widget class ActivityIndicator(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, running: bool = False, ): """Create a new ActivityIndicator widget. @@ -41,7 +41,7 @@ def enabled(self, value: bool) -> None: pass def focus(self) -> None: - "No-op; ActivityIndicator cannot accept input focus" + """No-op; ActivityIndicator cannot accept input focus.""" pass @property diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index bb12d78d96..8a545916d4 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -1,8 +1,9 @@ from __future__ import annotations from builtins import id as identifier -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypeVar +from travertino.declaration import BaseStyle from travertino.node import Node from toga.platform import get_platform_factory @@ -12,6 +13,8 @@ from toga.app import App from toga.window import Window +StyleT = TypeVar("StyleT", bound=BaseStyle) + class Widget(Node): _MIN_WIDTH = 100 @@ -20,7 +23,7 @@ class Widget(Node): def __init__( self, id: str | None = None, - style=None, + style: StyleT | None = None, ): """Create a base Toga widget. @@ -36,16 +39,16 @@ def __init__( ) self._id = str(id if id else identifier(self)) - self._window = None - self._app = None - self._impl = None + self._window: Window | None = None + self._app: App | None = None + self._impl: Any = None self.factory = get_platform_factory() def __repr__(self) -> str: return f"<{self.__class__.__name__}:0x{identifier(self):x}>" - def __lt__(self, other) -> bool: + def __lt__(self, other: Widget) -> bool: return self.id < other.id @property @@ -62,8 +65,7 @@ def tab_index(self) -> int | None: .. note:: - This is a beta feature. The ``tab_index`` API may change in - the future. + This is a beta feature. The ``tab_index`` API may change in the future. """ return self._impl.get_tab_index() @@ -71,7 +73,7 @@ def tab_index(self) -> int | None: def tab_index(self, tab_index: int) -> None: self._impl.set_tab_index(tab_index) - def _assert_can_have_children(self): + def _assert_can_have_children(self) -> None: if not self.can_have_children: raise ValueError(f"{type(self).__name__} cannot have children") diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index cc6a61b9ad..5f84160fb1 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -1,6 +1,8 @@ from __future__ import annotations -from .base import Widget +from collections.abc import Iterable + +from .base import StyleT, Widget class Box(Widget): @@ -10,8 +12,8 @@ class Box(Widget): def __init__( self, id: str | None = None, - style=None, - children: list[Widget] | None = None, + style: StyleT | None = None, + children: Iterable[Widget] | None = None, ): """Create a new Box container widget. @@ -26,8 +28,8 @@ def __init__( self._impl = self.factory.Box(interface=self) # Children need to be added *after* the impl has been created. - self._children = [] - if children: + self._children: list[Widget] = [] + if children is not None: self.add(*children) @property diff --git a/core/src/toga/widgets/button.py b/core/src/toga/widgets/button.py index d065425f42..c6ab49b7b1 100644 --- a/core/src/toga/widgets/button.py +++ b/core/src/toga/widgets/button.py @@ -5,37 +5,36 @@ import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget if TYPE_CHECKING: - from toga.icons import IconContent + from toga.icons import IconContentT class OnPressHandler(Protocol): - def __call__(self, widget: Button, **kwargs: Any) -> None: + def __call__(self, widget: Button, /, **kwargs: Any) -> object: """A handler that will be invoked when a button is pressed. :param widget: The button that was pressed. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... class Button(Widget): def __init__( self, text: str | None = None, - icon: IconContent | None = None, + icon: IconContentT | None = None, id: str | None = None, - style=None, - on_press: OnPressHandler | None = None, + style: StyleT | None = None, + on_press: toga.widgets.button.OnPressHandler | None = None, enabled: bool = True, ): """Create a new button widget. :param text: The text to display on the button. :param icon: The icon to display on the button. Can be specified as any valid - :any:`icon content `. + :any:`icon content `. :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. @@ -85,6 +84,7 @@ def text(self) -> str: @text.setter def text(self, value: str | None) -> None: + # \u200B: zero-width space if value is None or value == "\u200B": value = "" else: @@ -100,7 +100,7 @@ def text(self, value: str | None) -> None: def icon(self) -> toga.Icon | None: """The icon displayed on the button. - Can be specified as any valid :any:`icon content `. + Can be specified as any valid :any:`icon content `. If the button is currently displaying text, and an icon is assigned, the text will be replaced by the new icon. @@ -113,7 +113,7 @@ def icon(self) -> toga.Icon | None: return self._impl.get_icon() @icon.setter - def icon(self, value: IconContent | None) -> None: + def icon(self, value: IconContentT | None) -> None: if isinstance(value, toga.Icon): icon = value text = "" @@ -138,5 +138,5 @@ def on_press(self) -> OnPressHandler: return self._on_press @on_press.setter - def on_press(self, handler): + def on_press(self, handler: toga.widgets.button.OnPressHandler) -> None: self._on_press = wrapped_handler(self, handler) diff --git a/core/src/toga/widgets/canvas.py b/core/src/toga/widgets/canvas.py index bebf336f90..8adf19c1aa 100644 --- a/core/src/toga/widgets/canvas.py +++ b/core/src/toga/widgets/canvas.py @@ -2,19 +2,31 @@ import warnings from abc import ABC, abstractmethod +from collections.abc import Iterator from contextlib import contextmanager from math import cos, pi, sin, tan -from typing import TYPE_CHECKING, Protocol +from typing import ( + TYPE_CHECKING, + Any, + ContextManager, + Literal, + NoReturn, + Protocol, +) from travertino.colors import Color import toga from toga.colors import BLACK, color as parse_color from toga.constants import Baseline, FillRule -from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font +from toga.fonts import ( + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, + Font, +) from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget if TYPE_CHECKING: from toga.images import ImageT @@ -54,20 +66,20 @@ class DrawingObject(ABC): * :meth:`toga.widgets.canvas.WriteText ` """ - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}()" @abstractmethod - def _draw(self, impl, **kwargs): ... + def _draw(self, impl: Any, **kwargs: Any) -> None: ... class BeginPath(DrawingObject): - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.begin_path(**kwargs) class ClosePath(DrawingObject): - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.close_path(**kwargs) @@ -81,13 +93,13 @@ def __init__( self.color = color self.fill_rule = fill_rule - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(color={self.color!r}, " f"fill_rule={self.fill_rule})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.fill(self.color, self.fill_rule, **kwargs) @property @@ -95,7 +107,7 @@ def fill_rule(self) -> FillRule: return self._fill_rule @fill_rule.setter - def fill_rule(self, fill_rule: FillRule): + def fill_rule(self, fill_rule: FillRule) -> None: self._fill_rule = fill_rule @property @@ -103,7 +115,7 @@ def color(self) -> Color: return self._color @color.setter - def color(self, value: Color | str | None): + def color(self, value: Color | str | None) -> None: if value is None: self._color = parse_color(BLACK) else: @@ -115,10 +127,10 @@ def color(self, value: Color | str | None): # `context.fill()` used to be a context manager, but is now a primitive. # If you try to use the Fill drawing object as a context, raise an exception. - def __enter__(self): + def __enter__(self) -> NoReturn: raise RuntimeError("Context.fill() has been renamed Context.Fill().") - def __exit__(self): # pragma: no cover + def __exit__(self) -> None: # pragma: no cover # This method is required to make the object a context manager, but as the # __enter__ method raises an exception, the __exit__ can't be called. pass @@ -136,13 +148,13 @@ def __init__( self.line_width = line_width self.line_dash = line_dash - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(color={self.color!r}, " f"line_width={self.line_width}, line_dash={self.line_dash!r})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.stroke(self.color, self.line_width, self.line_dash, **kwargs) @property @@ -150,7 +162,7 @@ def color(self) -> Color: return self._color @color.setter - def color(self, value: Color | str | None): + def color(self, value: Color | str | None) -> None: if value is None: self._color = parse_color(BLACK) else: @@ -161,7 +173,7 @@ def line_width(self) -> float: return self._line_width @line_width.setter - def line_width(self, value: float): + def line_width(self, value: float) -> None: self._line_width = float(value) @property @@ -169,7 +181,7 @@ def line_dash(self) -> list[float] | None: return self._line_dash @line_dash.setter - def line_dash(self, value: list[float] | None): + def line_dash(self, value: list[float] | None) -> None: self._line_dash = value ########################################################################### @@ -178,10 +190,10 @@ def line_dash(self, value: list[float] | None): # `context.stroke()` used to be a context manager, but is now a primitive. # If you try to use the Stroke drawing object as a context, raise an exception. - def __enter__(self): + def __enter__(self) -> NoReturn: raise RuntimeError("Context.stroke() has been renamed Context.Stroke().") - def __exit__(self): # pragma: no cover + def __exit__(self) -> None: # pragma: no cover # This method is required to make the object a context manager, but as the # __enter__ method raises an exception, the __exit__ can't be called. pass @@ -192,10 +204,10 @@ def __init__(self, x: float, y: float): self.x = x self.y = y - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(x={self.x}, y={self.y})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.move_to(self.x, self.y, **kwargs) @@ -204,10 +216,10 @@ def __init__(self, x: float, y: float): self.x = x self.y = y - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(x={self.x}, y={self.y})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.line_to(self.x, self.y, **kwargs) @@ -222,14 +234,14 @@ def __init__( self.x = x self.y = y - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(cp1x={self.cp1x}, cp1y={self.cp1y}, " f"cp2x={self.cp2x}, cp2y={self.cp2y}, " f"x={self.x}, y={self.y})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.bezier_curve_to( self.cp1x, self.cp1y, self.cp2x, self.cp2y, self.x, self.y, **kwargs ) @@ -242,10 +254,10 @@ def __init__(self, cpx: float, cpy: float, x: float, y: float): self.x = x self.y = y - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(cpx={self.cpx}, cpy={self.cpy}, x={self.x}, y={self.y})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.quadratic_curve_to(self.cpx, self.cpy, self.x, self.y, **kwargs) @@ -266,14 +278,14 @@ def __init__( self.endangle = endangle self.anticlockwise = anticlockwise - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(x={self.x}, y={self.y}, " f"radius={self.radius}, startangle={self.startangle:.3f}, " f"endangle={self.endangle:.3f}, anticlockwise={self.anticlockwise})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.arc( self.x, self.y, @@ -306,7 +318,7 @@ def __init__( self.endangle = endangle self.anticlockwise = anticlockwise - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(x={self.x}, y={self.y}, " f"radiusx={self.radiusx}, radiusy={self.radiusy}, " @@ -314,7 +326,7 @@ def __repr__(self): f"endangle={self.endangle:.3f}, anticlockwise={self.anticlockwise})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.ellipse( self.x, self.y, @@ -335,13 +347,13 @@ def __init__(self, x: float, y: float, width: float, height: float): self.width = width self.height = height - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(x={self.x}, y={self.y}, " f"width={self.width}, height={self.height})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.rect(self.x, self.y, self.width, self.height, **kwargs) @@ -360,13 +372,13 @@ def __init__( self.font = font self.baseline = baseline - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(text={self.text!r}, x={self.x}, y={self.y}, " f"font={self.font!r}, baseline={self.baseline})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.write_text( str(self.text), self.x, self.y, self.font._impl, self.baseline, **kwargs ) @@ -376,7 +388,7 @@ def font(self) -> Font: return self._font @font.setter - def font(self, value: Font | None): + def font(self, value: Font | None) -> None: if value is None: self._font = Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE) else: @@ -387,10 +399,10 @@ class Rotate(DrawingObject): def __init__(self, radians: float): self.radians = radians - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(radians={self.radians:.3f})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.rotate(self.radians, **kwargs) @@ -399,10 +411,10 @@ def __init__(self, sx: float, sy: float): self.sx = sx self.sy = sy - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(sx={self.sx:.3f}, sy={self.sy:.3f})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.scale(self.sx, self.sy, **kwargs) @@ -411,15 +423,15 @@ def __init__(self, tx: float, ty: float): self.tx = tx self.ty = ty - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(tx={self.tx}, ty={self.ty})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.translate(self.tx, self.ty, **kwargs) class ResetTransform(DrawingObject): - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.reset_transform(**kwargs) @@ -436,13 +448,13 @@ class Context(DrawingObject): or use :any:`Canvas.context` to access the root context of the canvas. """ - def __init__(self, canvas: toga.Canvas, **kwargs): + def __init__(self, canvas: toga.Canvas, **kwargs: Any): # kwargs used to support multiple inheritance super().__init__(**kwargs) self._canvas = canvas - self.drawing_objects = [] + self.drawing_objects: list[DrawingObject] = [] - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.push_context(**kwargs) for obj in self.drawing_objects: obj._draw(impl, **kwargs) @@ -457,7 +469,7 @@ def canvas(self) -> Canvas: """The canvas that is associated with this drawing context.""" return self._canvas - def redraw(self): + def redraw(self) -> None: """Calls :any:`Canvas.redraw` on the parent Canvas.""" self.canvas.redraw() @@ -473,7 +485,7 @@ def __getitem__(self, index: int) -> DrawingObject: """Returns the drawing object at the given index.""" return self.drawing_objects[index] - def append(self, obj: DrawingObject): + def append(self, obj: DrawingObject) -> None: """Append a drawing object to the context. :param obj: The drawing object to add to the context. @@ -481,7 +493,7 @@ def append(self, obj: DrawingObject): self.drawing_objects.append(obj) self.redraw() - def insert(self, index: int, obj: DrawingObject): + def insert(self, index: int, obj: DrawingObject) -> None: """Insert a drawing object into the context at a specific index. :param index: The index at which the drawing object should be inserted. @@ -490,7 +502,7 @@ def insert(self, index: int, obj: DrawingObject): self.drawing_objects.insert(index, obj) self.redraw() - def remove(self, obj: DrawingObject): + def remove(self, obj: DrawingObject) -> None: """Remove a drawing object from the context. :param obj: The drawing object to remove. @@ -498,7 +510,7 @@ def remove(self, obj: DrawingObject): self.drawing_objects.remove(obj) self.redraw() - def clear(self): + def clear(self) -> None: """Remove all drawing objects from the context.""" self.drawing_objects.clear() self.redraw() @@ -507,7 +519,7 @@ def clear(self): # Path manipulation ########################################################################### - def begin_path(self): + def begin_path(self) -> BeginPath: """Start a new path in the canvas context. :returns: The ``BeginPath`` :any:`DrawingObject` for the operation. @@ -516,7 +528,7 @@ def begin_path(self): self.append(begin_path) return begin_path - def close_path(self): + def close_path(self) -> ClosePath: """Close the current path in the canvas context. This closes the current path as a simple drawing operation. It should be paired @@ -530,7 +542,7 @@ def close_path(self): self.append(close_path) return close_path - def move_to(self, x: float, y: float): + def move_to(self, x: float, y: float) -> MoveTo: """Moves the current point of the canvas context without drawing. :param x: The x coordinate of the new current point. @@ -541,7 +553,7 @@ def move_to(self, x: float, y: float): self.append(move_to) return move_to - def line_to(self, x: float, y: float): + def line_to(self, x: float, y: float) -> LineTo: """Draw a line segment ending at a point in the canvas context. :param x: The x coordinate for the end point of the line segment. @@ -560,7 +572,7 @@ def bezier_curve_to( cp2y: float, x: float, y: float, - ): + ) -> BezierCurveTo: """Draw a Bézier curve in the canvas context. A Bézier curve requires three points. The first two are control points; the @@ -580,7 +592,13 @@ def bezier_curve_to( self.append(bezier_curve_to) return bezier_curve_to - def quadratic_curve_to(self, cpx: float, cpy: float, x: float, y: float): + def quadratic_curve_to( + self, + cpx: float, + cpy: float, + x: float, + y: float, + ) -> QuadraticCurveTo: """Draw a quadratic curve in the canvas context. A quadratic curve requires two points. The first point is a control point; the @@ -608,7 +626,7 @@ def arc( startangle: float = 0.0, endangle: float = 2 * pi, anticlockwise: bool = False, - ): + ) -> Arc: """Draw a circular arc in the canvas context. A full circle will be drawn by default; an arc can be drawn by specifying a @@ -638,7 +656,7 @@ def ellipse( startangle: float = 0.0, endangle: float = 2 * pi, anticlockwise: bool = False, - ): + ) -> Ellipse: """Draw an elliptical arc in the canvas context. A full ellipse will be drawn by default; an arc can be drawn by specifying a @@ -671,7 +689,7 @@ def ellipse( self.append(ellipse) return ellipse - def rect(self, x: float, y: float, width: float, height: float): + def rect(self, x: float, y: float, width: float, height: float) -> Rect: """Draw a rectangle in the canvas context. :param x: The horizontal coordinate of the left of the rectangle. @@ -688,8 +706,8 @@ def fill( self, color: str = BLACK, fill_rule: FillRule = FillRule.NONZERO, - preserve=None, # DEPRECATED - ): + preserve: None = None, # DEPRECATED + ) -> Fill: """Fill the current path. The fill can use either the `Non-Zero @@ -717,7 +735,7 @@ def stroke( color: str = BLACK, line_width: float = 2.0, line_dash: list[float] | None = None, - ): + ) -> Stroke: """Draw the current path as a stroke. :param color: The color for the stroke. @@ -741,7 +759,7 @@ def write_text( y: float = 0.0, font: Font | None = None, baseline: Baseline = Baseline.ALPHABETIC, - ): + ) -> WriteText: """Write text at a given position in the canvas context. Drawing text is effectively a series of path operations, so the text will have @@ -762,7 +780,7 @@ def write_text( ########################################################################### # Transformations ########################################################################### - def rotate(self, radians: float): + def rotate(self, radians: float) -> Rotate: """Add a rotation to the canvas context. :param radians: The angle to rotate clockwise in radians. @@ -772,7 +790,7 @@ def rotate(self, radians: float): self.append(rotate) return rotate - def scale(self, sx: float, sy: float): + def scale(self, sx: float, sy: float) -> Scale: """Add a scaling transformation to the canvas context. :param sx: Scale factor for the X dimension. A negative value flips the @@ -785,7 +803,7 @@ def scale(self, sx: float, sy: float): self.append(scale) return scale - def translate(self, tx: float, ty: float): + def translate(self, tx: float, ty: float) -> Translate: """Add a translation to the canvas context. :param tx: Translation for the X dimension. @@ -796,7 +814,7 @@ def translate(self, tx: float, ty: float): self.append(translate) return translate - def reset_transform(self): + def reset_transform(self) -> ResetTransform: """Reset all transformations in the canvas context. :returns: A ``ResetTransform`` :any:`DrawingObject`. @@ -810,7 +828,7 @@ def reset_transform(self): ########################################################################### @contextmanager - def Context(self): + def Context(self) -> Iterator[Context]: """Construct and yield a new sub-:class:`~toga.widgets.canvas.Context` within this context. @@ -822,7 +840,11 @@ def Context(self): self.redraw() @contextmanager - def ClosedPath(self, x: float | None = None, y: float | None = None): + def ClosedPath( + self, + x: float | None = None, + y: float | None = None, + ) -> Iterator[ClosedPathContext]: """Construct and yield a new :class:`~toga.widgets.canvas.ClosedPath` sub-context that will draw a closed path, starting from an origin. @@ -846,7 +868,7 @@ def Fill( y: float | None = None, color: str = BLACK, fill_rule: FillRule = FillRule.NONZERO, - ): + ) -> Iterator[FillContext]: """Construct and yield a new :class:`~toga.widgets.canvas.Fill` sub-context within this context. @@ -886,7 +908,7 @@ def Stroke( color: str = BLACK, line_width: float = 2.0, line_dash: list[float] | None = None, - ): + ) -> Iterator[StrokeContext]: """Construct and yield a new :class:`~toga.widgets.canvas.Stroke` sub-context within this context. @@ -924,7 +946,7 @@ def Stroke( # 2023-07 Backwards incompatibility ########################################################################### - def new_path(self): + def new_path(self) -> BeginPath: """**DEPRECATED** - Use :meth:`~toga.widgets.canvas.Context.begin_path`.""" warnings.warn( "Context.new_path() has been renamed Context.begin_path()", @@ -976,10 +998,10 @@ def __init__( self.x = x self.y = y - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}(x={self.x}, y={self.y})" - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: """Used by parent to draw all objects that are part of the context.""" impl.push_context(**kwargs) impl.begin_path(**kwargs) @@ -1029,13 +1051,13 @@ def __init__( self.color = color self.fill_rule = fill_rule - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(x={self.x}, y={self.y}, " f"color={self.color!r}, fill_rule={self.fill_rule})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.push_context(**kwargs) impl.begin_path(**kwargs) if self.x is not None and self.y is not None: @@ -1062,7 +1084,7 @@ def color(self) -> Color: return self._color @color.setter - def color(self, value: Color | str | None): + def color(self, value: Color | str | None) -> None: if value is None: self._color = parse_color(BLACK) else: @@ -1102,13 +1124,13 @@ def __init__( self.line_width = line_width self.line_dash = line_dash - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(x={self.x}, y={self.y}, color={self.color!r}, " f"line_width={self.line_width}, line_dash={self.line_dash!r})" ) - def _draw(self, impl, **kwargs): + def _draw(self, impl: Any, **kwargs: Any) -> None: impl.push_context(**kwargs) impl.begin_path(**kwargs) @@ -1139,7 +1161,7 @@ def color(self) -> Color: return self._color @color.setter - def color(self, value): + def color(self, value: object) -> None: if value is None: self._color = parse_color(BLACK) else: @@ -1152,7 +1174,7 @@ def color(self, value): class OnTouchHandler(Protocol): - def __call__(self, widget: Canvas, x: int, y: int, **kwargs): + def __call__(self, widget: Canvas, x: int, y: int, /, **kwargs: Any) -> object: """A handler that will be invoked when a :any:`Canvas` is touched with a finger or mouse. @@ -1161,11 +1183,12 @@ def __call__(self, widget: Canvas, x: int, y: int, **kwargs): :param y: Y coordinate, relative to the top edge of the canvas. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... class OnResizeHandler(Protocol): - def __call__(self, widget: Canvas, width: int, height: int, **kwargs): + def __call__( + self, widget: Canvas, /, width: int, height: int, **kwargs: Any + ) -> object: """A handler that will be invoked when a :any:`Canvas` is resized. :param widget: The canvas that was resized. @@ -1173,7 +1196,6 @@ def __call__(self, widget: Canvas, width: int, height: int, **kwargs): :param height: The new height. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... ####################################################################################### @@ -1187,16 +1209,16 @@ class Canvas(Widget): def __init__( self, - id=None, - style=None, - on_resize: OnResizeHandler = None, - on_press: OnTouchHandler = None, - on_activate: OnTouchHandler = None, - on_release: OnTouchHandler = None, - on_drag: OnTouchHandler = None, - on_alt_press: OnTouchHandler = None, - on_alt_release: OnTouchHandler = None, - on_alt_drag: OnTouchHandler = None, + id: str | None = None, + style: StyleT | None = None, + on_resize: OnResizeHandler | None = None, + on_press: OnTouchHandler | None = None, + on_activate: OnTouchHandler | None = None, + on_release: OnTouchHandler | None = None, + on_drag: OnTouchHandler | None = None, + on_alt_press: OnTouchHandler | None = None, + on_alt_release: OnTouchHandler | None = None, + on_alt_drag: OnTouchHandler | None = None, ): """Create a new Canvas widget. @@ -1233,7 +1255,7 @@ def __init__( self.on_alt_drag = on_alt_drag @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? ScrollContainer widgets cannot be disabled; this property will always return True; any attempt to modify it will be ignored. @@ -1241,11 +1263,11 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; ScrollContainer cannot accept input focus" + def focus(self) -> None: + """No-op; ScrollContainer cannot accept input focus.""" pass @property @@ -1253,7 +1275,7 @@ def context(self) -> Context: """The root context for the canvas.""" return self._context - def redraw(self): + def redraw(self) -> None: """Redraw the Canvas. The Canvas will be automatically redrawn after adding or removing a drawing @@ -1262,7 +1284,7 @@ def redraw(self): """ self._impl.redraw() - def Context(self): + def Context(self) -> ContextManager[Context]: """Construct and yield a new sub-:class:`~toga.widgets.canvas.Context` within the root context of this Canvas. @@ -1270,7 +1292,11 @@ def Context(self): """ return self.context.Context() - def ClosedPath(self, x: float | None = None, y: float | None = None): + def ClosedPath( + self, + x: float | None = None, + y: float | None = None, + ) -> ContextManager[ClosedPathContext]: """Construct and yield a new :class:`~toga.widgets.canvas.ClosedPathContext` context in the root context of this canvas. @@ -1286,7 +1312,7 @@ def Fill( y: float | None = None, color: Color | str | None = BLACK, fill_rule: FillRule = FillRule.NONZERO, - ): + ) -> ContextManager[FillContext]: """Construct and yield a new :class:`~toga.widgets.canvas.FillContext` in the root context of this canvas. @@ -1312,7 +1338,7 @@ def Stroke( color: Color | str | None = BLACK, line_width: float = 2.0, line_dash: list[float] | None = None, - ): + ) -> ContextManager[StrokeContext]: """Construct and yield a new :class:`~toga.widgets.canvas.StrokeContext` in the root context of this canvas. @@ -1335,7 +1361,7 @@ def on_resize(self) -> OnResizeHandler: return self._on_resize @on_resize.setter - def on_resize(self, handler: OnResizeHandler): + def on_resize(self, handler: OnResizeHandler) -> None: self._on_resize = wrapped_handler(self, handler) @property @@ -1345,7 +1371,7 @@ def on_press(self) -> OnTouchHandler: return self._on_press @on_press.setter - def on_press(self, handler: OnTouchHandler): + def on_press(self, handler: OnTouchHandler) -> None: self._on_press = wrapped_handler(self, handler) @property @@ -1358,7 +1384,7 @@ def on_activate(self) -> OnTouchHandler: return self._on_activate @on_activate.setter - def on_activate(self, handler: OnTouchHandler): + def on_activate(self, handler: OnTouchHandler) -> None: self._on_activate = wrapped_handler(self, handler) @property @@ -1367,7 +1393,7 @@ def on_release(self) -> OnTouchHandler: return self._on_release @on_release.setter - def on_release(self, handler: OnTouchHandler): + def on_release(self, handler: OnTouchHandler) -> None: self._on_release = wrapped_handler(self, handler) @property @@ -1376,7 +1402,7 @@ def on_drag(self) -> OnTouchHandler: return self._on_drag @on_drag.setter - def on_drag(self, handler: OnTouchHandler): + def on_drag(self, handler: OnTouchHandler) -> None: self._on_drag = wrapped_handler(self, handler) @property @@ -1390,7 +1416,7 @@ def on_alt_press(self) -> OnTouchHandler: return self._on_alt_press @on_alt_press.setter - def on_alt_press(self, handler: OnTouchHandler): + def on_alt_press(self, handler: OnTouchHandler) -> None: self._on_alt_press = wrapped_handler(self, handler) @property @@ -1402,7 +1428,7 @@ def on_alt_release(self) -> OnTouchHandler: return self._on_alt_release @on_alt_release.setter - def on_alt_release(self, handler: OnTouchHandler): + def on_alt_release(self, handler: OnTouchHandler) -> None: self._on_alt_release = wrapped_handler(self, handler) @property @@ -1414,7 +1440,7 @@ def on_alt_drag(self) -> OnTouchHandler: return self._on_alt_drag @on_alt_drag.setter - def on_alt_drag(self, handler: OnTouchHandler): + def on_alt_drag(self, handler: OnTouchHandler) -> None: self._on_alt_drag = wrapped_handler(self, handler) ########################################################################### @@ -1425,7 +1451,7 @@ def measure_text( self, text: str, font: Font | None = None, - tight=None, # DEPRECATED + tight: None = None, # DEPRECATED ) -> tuple[float, float]: """Measure the size at which :meth:`~.Context.write_text` would render some text. @@ -1639,7 +1665,7 @@ def stroke( return self.Stroke(color=color, line_width=line_width, line_dash=line_dash) -def sweepangle(startangle, endangle, anticlockwise): +def sweepangle(startangle: float, endangle: float, anticlockwise: bool) -> float: """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.""" @@ -1664,7 +1690,7 @@ def sweepangle(startangle, endangle, anticlockwise): # Based on https://stackoverflow.com/a/30279817 -def arc_to_bezier(sweepangle): +def arc_to_bezier(sweepangle: float) -> list[tuple[float, float]]: """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 @@ -1707,7 +1733,7 @@ def arc_to_bezier(sweepangle): return result -def transform(x, y, matrix): +def transform(x: float, y: float, matrix: list[int]) -> tuple[float, float]: return ( x * matrix[0] + y * matrix[1], x * matrix[2] + y * matrix[3], diff --git a/core/src/toga/widgets/dateinput.py b/core/src/toga/widgets/dateinput.py index 53b050816b..7408f38ae2 100644 --- a/core/src/toga/widgets/dateinput.py +++ b/core/src/toga/widgets/dateinput.py @@ -2,10 +2,12 @@ import datetime import warnings +from typing import Any, Protocol +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget # This accommodates the ranges of all existing implementations: # * datetime.date: 1 - 9999 @@ -15,17 +17,26 @@ MAX_DATE = datetime.date(8999, 12, 31) +class OnChangeHandler(Protocol): + def __call__(self, widget: DateInput, /, **kwargs: Any) -> object: + """A handler that will be invoked when a change occurs. + + :param widget: The DateInput that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + class DateInput(Widget): _MIN_WIDTH = 200 def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, value: datetime.date | None = None, min: datetime.date | None = None, max: datetime.date | None = None, - on_change: callable | None = None, + on_change: toga.widgets.dateinput.OnChangeHandler | None = None, ): """Create a new DateInput widget. @@ -60,7 +71,18 @@ def value(self) -> datetime.date: """ return self._impl.get_value() - def _convert_date(self, value, *, check_range): + @value.setter + def value(self, value: object) -> None: + value = self._convert_date(value, check_range=False) + + if value < self.min: + value = self.min + elif value > self.max: + value = self.max + + self._impl.set_value(value) + + def _convert_date(self, value: object, *, check_range: bool) -> datetime.date: if value is None: value = datetime.date.today() elif isinstance(value, datetime.datetime): @@ -82,17 +104,6 @@ def _convert_date(self, value, *, check_range): return value - @value.setter - def value(self, value): - value = self._convert_date(value, check_range=False) - - if value < self.min: - value = self.min - elif value > self.max: - value = self.max - - self._impl.set_value(value) - @property def min(self) -> datetime.date: """The minimum allowable date (inclusive). A value of ``None`` will be converted @@ -106,7 +117,7 @@ def min(self) -> datetime.date: return self._impl.get_min_date() @min.setter - def min(self, value): + def min(self, value: object) -> None: if value is None: min = MIN_DATE else: @@ -131,7 +142,7 @@ def max(self) -> datetime.date: return self._impl.get_max_date() @max.setter - def max(self, value): + def max(self, value: object) -> None: if value is None: max = MAX_DATE else: @@ -144,18 +155,18 @@ def max(self, value): self.value = max @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the date value changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.dateinput.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) # 2023-05: Backwards compatibility class DatePicker(DateInput): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): warnings.warn("DatePicker has been renamed DateInput.", DeprecationWarning) for old_name, new_name in [ @@ -176,28 +187,28 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @property - def min_date(self): + def min_date(self) -> datetime.date: warnings.warn( "DatePicker.min_date has been renamed DateInput.min", DeprecationWarning ) return self.min @min_date.setter - def min_date(self, value): + def min_date(self, value: object) -> None: warnings.warn( "DatePicker.min_date has been renamed DateInput.min", DeprecationWarning ) self.min = value @property - def max_date(self): + def max_date(self) -> datetime.date: warnings.warn( "DatePicker.max_date has been renamed DateInput.max", DeprecationWarning ) return self.max @max_date.setter - def max_date(self, value): + def max_date(self, value: object) -> None: warnings.warn( "DatePicker.max_date has been renamed DateInput.max", DeprecationWarning ) diff --git a/core/src/toga/widgets/detailedlist.py b/core/src/toga/widgets/detailedlist.py index 2b4d818f9a..a5ec7937e0 100644 --- a/core/src/toga/widgets/detailedlist.py +++ b/core/src/toga/widgets/detailedlist.py @@ -1,29 +1,71 @@ from __future__ import annotations import warnings -from typing import Any +from collections.abc import Iterable +from typing import Any, Literal, Protocol, TypeVar +import toga from toga.handlers import wrapped_handler from toga.sources import ListSource, Row, Source -from .base import Widget +from .base import StyleT, Widget + +SourceT = TypeVar("SourceT", bound=Source) + + +class OnPrimaryActionHandler(Protocol): + def __call__(self, widget: DetailedList, row: Any, /, **kwargs: Any) -> object: + """A handler to invoke for the primary action. + + :param widget: The DetailedList that was invoked. + :param row: The current row for the detailed list. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnSecondaryActionHandler(Protocol): + def __call__(self, widget: DetailedList, row: Any, /, **kwargs: Any) -> object: + """A handler to invoke for the secondary action. + + :param widget: The DetailedList that was invoked. + :param row: The current row for the detailed list. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnRefreshHandler(Protocol): + def __call__(self, widget: DetailedList, /, **kwargs: Any) -> object: + """A handler to invoke when the detailed list is refreshed. + + :param widget: The DetailedList that was refreshed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnSelectHandler(Protocol): + def __call__(self, widget: DetailedList, /, **kwargs: Any) -> object: + """A handler to invoke when the detailed list is selected. + + :param widget: The DetailedList that was selected. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class DetailedList(Widget): def __init__( self, - id=None, - style=None, - data: Any = None, + id: str | None = None, + style: StyleT | None = None, + data: SourceT | Iterable | None = None, accessors: tuple[str, str, str] = ("title", "subtitle", "icon"), missing_value: str = "", primary_action: str | None = "Delete", - on_primary_action: callable = None, + on_primary_action: OnPrimaryActionHandler | None = None, secondary_action: str | None = "Action", - on_secondary_action: callable = None, - on_refresh: callable = None, - on_select: callable = None, - on_delete: callable = None, # DEPRECATED + on_secondary_action: OnSecondaryActionHandler | None = None, + on_refresh: OnRefreshHandler | None = None, + on_select: toga.widgets.detailedlist.OnSelectHandler | None = None, + on_delete: None = None, # DEPRECATED ): """Create a new DetailedList widget. @@ -63,12 +105,13 @@ def __init__( # Prime the attributes and handlers that need to exist when the widget is created. self._accessors = accessors + self._missing_value = missing_value self._primary_action = primary_action self._secondary_action = secondary_action - self._missing_value = missing_value - self._data = None self.on_select = None + self._data: SourceT | ListSource = None + self._impl = self.factory.DetailedList(interface=self) self.data = data @@ -78,7 +121,7 @@ def __init__( self.on_select = on_select @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? DetailedList widgets cannot be disabled; this property will always return True; any attempt to modify it will be ignored. @@ -86,15 +129,15 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; DetailedList cannot accept input focus" + def focus(self) -> None: + """No-op; DetailedList cannot accept input focus.""" pass @property - def data(self) -> ListSource: + def data(self) -> SourceT | ListSource: """The data to display in the table. When setting this property: @@ -110,7 +153,7 @@ def data(self) -> ListSource: return self._data @data.setter - def data(self, data: Any): + def data(self, data: SourceT | Iterable | None) -> None: if data is None: self._data = ListSource(data=[], accessors=self.accessors) elif isinstance(data, Source): @@ -121,11 +164,11 @@ def data(self, data: Any): self._data.add_listener(self._impl) self._impl.change_source(source=self._data) - def scroll_to_top(self): + def scroll_to_top(self) -> None: """Scroll the view so that the top of the list (first row) is visible.""" self.scroll_to_row(0) - def scroll_to_row(self, row: int): + def scroll_to_row(self, row: int) -> None: """Scroll the view so that the specified row index is visible. :param row: The index of the row to make visible. Negative values refer to the @@ -137,12 +180,12 @@ def scroll_to_row(self, row: int): else: self._impl.scroll_to_row(max(len(self.data) + row, 0)) - def scroll_to_bottom(self): + def scroll_to_bottom(self) -> None: """Scroll the view so that the bottom of the list (last row) is visible.""" self.scroll_to_row(-1) @property - def accessors(self) -> list[str]: + def accessors(self) -> tuple[str, str, str]: """The accessors used to populate the list (read-only)""" return self._accessors @@ -165,7 +208,7 @@ def selection(self) -> Row | None: return None @property - def on_primary_action(self) -> callable: + def on_primary_action(self) -> OnPrimaryActionHandler: """The handler to invoke when the user performs the primary action on a row of the DetailedList. @@ -178,12 +221,12 @@ def on_primary_action(self) -> callable: return self._on_primary_action @on_primary_action.setter - def on_primary_action(self, handler: callable): + def on_primary_action(self, handler: OnPrimaryActionHandler) -> None: self._on_primary_action = wrapped_handler(self, handler) self._impl.set_primary_action_enabled(handler is not None) @property - def on_secondary_action(self) -> callable: + def on_secondary_action(self) -> OnSecondaryActionHandler: """The handler to invoke when the user performs the secondary action on a row of the DetailedList. @@ -196,12 +239,12 @@ def on_secondary_action(self) -> callable: return self._on_secondary_action @on_secondary_action.setter - def on_secondary_action(self, handler: callable): + def on_secondary_action(self, handler: OnSecondaryActionHandler) -> None: self._on_secondary_action = wrapped_handler(self, handler) self._impl.set_secondary_action_enabled(handler is not None) @property - def on_refresh(self) -> callable: + def on_refresh(self) -> OnRefreshHandler: """The callback function to invoke when the user performs a refresh action (usually "pull down") on the DetailedList. @@ -211,19 +254,19 @@ def on_refresh(self) -> callable: return self._on_refresh @on_refresh.setter - def on_refresh(self, handler: callable): + def on_refresh(self, handler: OnRefreshHandler) -> None: self._on_refresh = wrapped_handler( self, handler, cleanup=self._impl.after_on_refresh ) self._impl.set_refresh_enabled(handler is not None) @property - def on_select(self) -> callable: + def on_select(self) -> OnSelectHandler: """The callback function that is invoked when a row of the DetailedList is selected.""" return self._on_select @on_select.setter - def on_select(self, handler: callable): + def on_select(self, handler: toga.widgets.detailedlist.OnSelectHandler) -> None: self._on_select = wrapped_handler(self, handler) ###################################################################### @@ -231,7 +274,7 @@ def on_select(self, handler: callable): ###################################################################### @property - def on_delete(self): + def on_delete(self) -> OnPrimaryActionHandler: """**DEPRECATED**; Use :any:`on_primary_action`""" warnings.warn( "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", @@ -240,7 +283,7 @@ def on_delete(self): return self.on_primary_action @on_delete.setter - def on_delete(self, handler): + def on_delete(self, handler: OnPrimaryActionHandler) -> None: warnings.warn( "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", DeprecationWarning, diff --git a/core/src/toga/widgets/divider.py b/core/src/toga/widgets/divider.py index c9103974c1..4b7edf62c1 100644 --- a/core/src/toga/widgets/divider.py +++ b/core/src/toga/widgets/divider.py @@ -1,8 +1,10 @@ from __future__ import annotations +from typing import Literal + from toga.constants import Direction -from .base import Widget +from .base import StyleT, Widget class Divider(Widget): @@ -11,8 +13,8 @@ class Divider(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, direction: Direction = HORIZONTAL, ): """Create a new divider line. @@ -32,7 +34,7 @@ def __init__( self.direction = direction @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? Divider widgets cannot be disabled; this property will always return True; any @@ -41,11 +43,11 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; Divider cannot accept input focus" + def focus(self) -> None: + """No-op; Divider cannot accept input focus.""" pass @property @@ -54,6 +56,6 @@ def direction(self) -> Direction: return self._impl.get_direction() @direction.setter - def direction(self, value): + def direction(self, value: object) -> None: self._impl.set_direction(value) self.refresh() diff --git a/core/src/toga/widgets/imageview.py b/core/src/toga/widgets/imageview.py index e269a2ce00..663b3d1071 100644 --- a/core/src/toga/widgets/imageview.py +++ b/core/src/toga/widgets/imageview.py @@ -1,18 +1,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from travertino.size import at_least import toga from toga.style.pack import NONE -from toga.widgets.base import Widget +from toga.widgets.base import StyleT, Widget if TYPE_CHECKING: - from toga.images import ImageContent, ImageT + from toga.images import ImageContentT, ImageT -def rehint_imageview(image, style, scale=1): +def rehint_imageview( + image: toga.Image, + style: StyleT, + scale: int = 1, +) -> tuple[int, int, float | None]: """Compute the size hints for an ImageView based on the image. This logic is common across all backends, so it's shared here. @@ -63,20 +67,18 @@ def rehint_imageview(image, style, scale=1): return width, height, aspect_ratio -# Note: remove PIL type annotation when plugin system is implemented for image format -# registration; replace with ImageT? class ImageView(Widget): def __init__( self, - image: ImageContent | None = None, - id=None, - style=None, + image: ImageContentT | None = None, + id: str | None = None, + style: StyleT | None = None, ): """ Create a new image view. :param image: The image to display. Can be any valid :any:`image content - ` type; or :any:`None` to display no image. + ` type; or :any:`None` to display no image. :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. @@ -88,7 +90,7 @@ def __init__( self.image = image @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? ImageView widgets cannot be disabled; this property will always return True; any @@ -97,11 +99,11 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; ImageView cannot accept input focus" + def focus(self) -> None: + """No-op; ImageView cannot accept input focus.""" pass @property @@ -109,12 +111,12 @@ def image(self) -> toga.Image | None: """The image to display. When setting an image, you can provide any valid :any:`image content - ` type; or :any:`None` to clear the image view. + ` type; or :any:`None` to clear the image view. """ return self._image @image.setter - def image(self, image): + def image(self, image: ImageContentT) -> None: if isinstance(image, toga.Image): self._image = image elif image is None: diff --git a/core/src/toga/widgets/label.py b/core/src/toga/widgets/label.py index 88a5fd1fad..83fb1ba7a6 100644 --- a/core/src/toga/widgets/label.py +++ b/core/src/toga/widgets/label.py @@ -1,14 +1,14 @@ from __future__ import annotations -from .base import Widget +from .base import StyleT, Widget class Label(Widget): def __init__( self, text: str, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, ): """Create a new text label. @@ -24,8 +24,8 @@ def __init__( self.text = text - def focus(self): - "No-op; Label cannot accept input focus" + def focus(self) -> None: + """No-op; Label cannot accept input focus.""" pass @property @@ -39,7 +39,7 @@ def text(self) -> str: return self._impl.get_text() @text.setter - def text(self, value): + def text(self, value: object) -> None: if value is None or value == "\u200B": text = "" else: diff --git a/core/src/toga/widgets/mapview.py b/core/src/toga/widgets/mapview.py index 5ebda6afca..dd69651b07 100644 --- a/core/src/toga/widgets/mapview.py +++ b/core/src/toga/widgets/mapview.py @@ -1,11 +1,12 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator from typing import Any, Protocol import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget class MapPin: @@ -27,10 +28,10 @@ def __init__( self._subtitle = subtitle # A pin isn't tied to a map at time of creation. - self.interface = None + self.interface: MapView | None = None self._native = None - def __repr__(self): + def __repr__(self) -> str: if self.subtitle: label = f"; {self.title} - {self.subtitle}" else: @@ -40,7 +41,7 @@ def __repr__(self): @property def location(self) -> toga.LatLng: - "The (latitude, longitude) where the pin is located." + """The (latitude, longitude) where the pin is located.""" return self._location @location.setter @@ -62,7 +63,7 @@ def title(self, title: str) -> None: @property def subtitle(self) -> str | None: - "The subtitle of the pin." + """The subtitle of the pin.""" return self._subtitle @subtitle.setter @@ -75,26 +76,26 @@ def subtitle(self, subtitle: str | None) -> None: class MapPinSet: - def __init__(self, interface, pins): + def __init__(self, interface: MapView, pins: Iterable[MapPin] | None): self.interface = interface - self._pins = set() + self._pins: set[MapPin] = set() - if pins: + if pins is not None: for item in pins: self.add(item) - def __repr__(self): + def __repr__(self) -> str: return f"" - def __iter__(self): + def __iter__(self) -> Iterator[MapPin]: """Return an iterator over the pins on the map.""" return iter(self._pins) - def __len__(self): + def __len__(self) -> int: """Return the number of pins being displayed.""" return len(self._pins) - def add(self, pin): + def add(self, pin: MapPin) -> None: """Add a new pin to the map. :param pin: The :any:`toga.MapPin` instance to add. @@ -103,7 +104,7 @@ def add(self, pin): self._pins.add(pin) self.interface._impl.add_pin(pin) - def remove(self, pin): + def remove(self, pin: MapPin) -> None: """Remove a pin from the map. :param pin: The :any:`toga.MapPin` instance to remove. @@ -112,7 +113,7 @@ def remove(self, pin): self._pins.remove(pin) pin.interface = None - def clear(self): + def clear(self) -> None: """Remove all pins from the map.""" for pin in self._pins: self.interface._impl.remove_pin(pin) @@ -120,24 +121,23 @@ def clear(self): class OnSelectHandler(Protocol): - def __call__(self, widget: MapView, *, pin: MapPin, **kwargs: Any) -> None: + def __call__(self, widget: MapView, /, *, pin: MapPin, **kwargs: Any) -> object: """A handler that will be invoked when the user selects a map pin. - :param widget: The button that was pressed. + :param widget: The MapView that was selected. :param pin: The pin that was selected. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... class MapView(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, location: toga.LatLng | tuple[float, float] | None = None, zoom: int = 11, - pins: list[MapPin] | None = None, + pins: Iterable[MapPin] | None = None, on_select: toga.widgets.mapview.OnSelectHandler | None = None, ): """Create a new MapView widget. @@ -155,7 +155,7 @@ def __init__( """ super().__init__(id=id, style=style) - self._impl = self.factory.MapView(interface=self) + self._impl: Any = self.factory.MapView(interface=self) self._pins = MapPinSet(self, pins) @@ -179,7 +179,7 @@ def location(self) -> toga.LatLng: return self._impl.get_location() @location.setter - def location(self, coordinates: toga.LatLng | tuple[float, float]): + def location(self, coordinates: toga.LatLng | tuple[float, float]) -> None: self._impl.set_location(toga.LatLng(*coordinates)) @property @@ -218,7 +218,7 @@ def zoom(self) -> int: return round(self._impl.get_zoom()) @zoom.setter - def zoom(self, value: int): + def zoom(self, value: int) -> None: value = int(value) if value < 0: value = 0 @@ -233,7 +233,7 @@ def pins(self) -> MapPinSet: return self._pins @property - def on_select(self) -> toga.widgets.mapview.OnSelectHandler: + def on_select(self) -> OnSelectHandler: """The handler to invoke when the user selects a pin on a map. **Note:** This is not currently supported on GTK or Windows. @@ -241,7 +241,7 @@ def on_select(self) -> toga.widgets.mapview.OnSelectHandler: return self._on_select @on_select.setter - def on_select(self, handler: toga.widgets.mapview.OnSelectHandler | None): + def on_select(self, handler: toga.widgets.mapview.OnSelectHandler | None) -> None: if handler and not getattr(self._impl, "SUPPORTS_ON_SELECT", True): self.factory.not_implemented("MapView.on_select") diff --git a/core/src/toga/widgets/multilinetextinput.py b/core/src/toga/widgets/multilinetextinput.py index d4db065abf..d987725c35 100644 --- a/core/src/toga/widgets/multilinetextinput.py +++ b/core/src/toga/widgets/multilinetextinput.py @@ -1,19 +1,31 @@ from __future__ import annotations +from typing import Any, Protocol + +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + + +class OnChangeHandler(Protocol): + def __call__(self, widget: MultilineTextInput, /, **kwargs: Any) -> object: + """A handler to invoke when the value is changed. + + :param widget: The MultilineTextInput that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class MultilineTextInput(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, value: str | None = None, readonly: bool = False, placeholder: str | None = None, - on_change: callable | None = None, + on_change: toga.widgets.multilinetextinput.OnChangeHandler | None = None, ): """Create a new multi-line text input widget. @@ -53,7 +65,7 @@ def placeholder(self) -> str: return self._impl.get_placeholder() @placeholder.setter - def placeholder(self, value): + def placeholder(self, value: object) -> None: self._impl.set_placeholder("" if value is None else str(value)) self.refresh() @@ -68,7 +80,7 @@ def readonly(self) -> bool: return self._impl.get_readonly() @readonly.setter - def readonly(self, value): + def readonly(self, value: object) -> None: self._impl.set_readonly(bool(value)) @property @@ -81,23 +93,25 @@ def value(self) -> str: return self._impl.get_value() @value.setter - def value(self, value): + def value(self, value: object) -> None: self._impl.set_value("" if value is None else str(value)) self.refresh() - def scroll_to_bottom(self): + def scroll_to_bottom(self) -> None: """Scroll the view to make the bottom of the text field visible.""" self._impl.scroll_to_bottom() - def scroll_to_top(self): + def scroll_to_top(self) -> None: """Scroll the view to make the top of the text field visible.""" self._impl.scroll_to_top() @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the value of the widget changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change( + self, handler: toga.widgets.multilinetextinput.OnChangeHandler + ) -> None: self._on_change = wrapped_handler(self, handler) diff --git a/core/src/toga/widgets/numberinput.py b/core/src/toga/widgets/numberinput.py index 83690fd36b..8a9907fe67 100644 --- a/core/src/toga/widgets/numberinput.py +++ b/core/src/toga/widgets/numberinput.py @@ -1,12 +1,23 @@ from __future__ import annotations import re +import sys import warnings from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import TYPE_CHECKING, Any, Protocol, Union +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + +if TYPE_CHECKING: + if sys.version_info < (3, 10): + from typing_extensions import TypeAlias + else: + from typing import TypeAlias + + NumberInputT: TypeAlias = Union[Decimal, int, float, str] # Implementation notes # ==================== @@ -23,7 +34,7 @@ NUMERIC_RE = re.compile(r"[^0-9\.-]") -def _clean_decimal(value, step=None): +def _clean_decimal(value: NumberInputT, step: NumberInputT | None = None) -> Decimal: # Decimal(3.7) yields "3.700000000...177". # However, Decimal(str(3.7)) yields "3.7". If the user provides a float, # convert to a string first. @@ -40,7 +51,7 @@ def _clean_decimal(value, step=None): return value -def _clean_decimal_str(value): +def _clean_decimal_str(value: str) -> str: """Clean a string value""" # Replace any character that isn't a number, `.` or `-` value = NUMERIC_RE.sub("", value) @@ -61,19 +72,28 @@ def _clean_decimal_str(value): return value +class OnChangeHandler(Protocol): + def __call__(self, widget: NumberInput, /, **kwargs: Any) -> object: + """A handler to invoke when the value is changed. + + :param widget: The NumberInput that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + class NumberInput(Widget): def __init__( self, - id=None, - style=None, - step: Decimal = 1, - min: Decimal | None = None, - max: Decimal | None = None, - value: Decimal | None = None, + id: str | None = None, + style: StyleT | None = None, + step: NumberInputT = 1, + min: NumberInputT | None = None, + max: NumberInputT | None = None, + value: NumberInputT | None = None, readonly: bool = False, - on_change: callable | None = None, - min_value: Decimal | None = None, # DEPRECATED - max_value: Decimal | None = None, # DEPRECATED + on_change: toga.widgets.numberinput.OnChangeHandler | None = None, + min_value: None = None, # DEPRECATED + max_value: None = None, # DEPRECATED ): """Create a new number input widget. @@ -88,8 +108,8 @@ def __init__( equal to this maximum. :param value: The initial value for the widget. :param readonly: Can the value of the widget be modified by the user? - :param on_change: A handler that will be invoked when the value of the - widget changes. + :param on_change: A handler that will be invoked when the value of the widget + changes. :param min_value: **DEPRECATED**; alias of ``min``. :param max_value: **DEPRECATED**; alias of ``max``. """ @@ -123,8 +143,8 @@ def __init__( # The initial setting of min requires calling get_value(), # which in turn interrogates min. Prime those values with # an empty starting value - self._min = None - self._max = None + self._min: Decimal | None = None + self._max: Decimal | None = None self.on_change = None self._impl = self.factory.NumberInput(interface=self) @@ -148,7 +168,7 @@ def readonly(self) -> bool: return self._impl.get_readonly() @readonly.setter - def readonly(self, value): + def readonly(self, value: object) -> None: self._impl.set_readonly(value) @property @@ -160,7 +180,7 @@ def step(self) -> Decimal: return self._step @step.setter - def step(self, step): + def step(self, step: NumberInputT) -> None: try: self._step = _clean_decimal(step) except (ValueError, TypeError, InvalidOperation): @@ -168,7 +188,7 @@ def step(self, step): self._impl.set_step(self._step) - # Re-assigning the min and max value forces the min/max to be requantized. + # Re-assigning the min and max value forces the min/max to be re-quantized. self.min = self.min self.max = self.max @@ -184,7 +204,7 @@ def min(self) -> Decimal | None: return self._min @min.setter - def min(self, new_min): + def min(self, new_min: NumberInputT | None) -> None: try: new_min = _clean_decimal(new_min, self.step) @@ -216,7 +236,7 @@ def max(self) -> Decimal | None: return self._max @max.setter - def max(self, new_max): + def max(self, new_max: NumberInputT | None) -> None: try: new_max = _clean_decimal(new_max, self.step) @@ -263,7 +283,7 @@ def value(self) -> Decimal | None: return value @value.setter - def value(self, value): + def value(self, value: NumberInputT | None) -> None: try: value = _clean_decimal(value, self.step) @@ -281,12 +301,12 @@ def value(self, value): self.refresh() @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the value of the widget changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.numberinput.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) ###################################################################### @@ -303,7 +323,7 @@ def min_value(self) -> Decimal | None: return self.min @min_value.setter - def min_value(self, value): + def min_value(self, value: NumberInputT | None) -> None: warnings.warn( "NumberInput.min_value has been renamed NumberInput.min", DeprecationWarning, @@ -320,7 +340,7 @@ def max_value(self) -> Decimal | None: return self.max @max_value.setter - def max_value(self, value): + def max_value(self, value: NumberInputT | None) -> None: warnings.warn( "NumberInput.max_value has been renamed NumberInput.max", DeprecationWarning, diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index ebc6621091..8716ebb7c6 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -1,38 +1,37 @@ from __future__ import annotations import sys +from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Protocol, overload import toga from toga.handlers import wrapped_handler from toga.platform import get_platform_factory -from .base import Widget +from .base import StyleT, Widget if TYPE_CHECKING: if sys.version_info < (3, 10): from typing_extensions import TypeAlias else: from typing import TypeAlias + from toga.icons import IconContentT - from toga.icons import IconContent - - OptionContainerContent: TypeAlias = ( + OptionContainerContentT: TypeAlias = ( tuple[str, Widget] - | tuple[str, Widget, IconContent | None] - | tuple[str, Widget, IconContent | None, bool] + | tuple[str, Widget, IconContentT | None] + | tuple[str, Widget, IconContentT | None, bool] | toga.OptionItem ) class OnSelectHandler(Protocol): - def __call__(self, widget: OptionContainer, **kwargs: Any) -> None: + def __call__(self, widget: OptionContainer, /, **kwargs: Any) -> None: """A handler that will be invoked when a new tab is selected in the OptionContainer. :param widget: The OptionContainer that had a selection change. :param kwargs: Ensures compatibility with arguments added in future versions. """ - ... class OptionItem: @@ -41,14 +40,14 @@ def __init__( text: str, content: Widget, *, - icon: IconContent | None = None, + icon: IconContentT | None = None, enabled: bool = True, ): """A tab of content in an OptionContainer. :param text: The text label for the new tab. :param content: The content widget to use for the new tab. - :param icon: The :any:`icon content ` to use to represent the tab. + :param icon: The :any:`icon content ` to use to represent the tab. :param enabled: Should the new tab be enabled? """ if content is None: @@ -60,9 +59,9 @@ def __init__( # will become the source of truth. Initially prime the attributes with None (so # that the attribute exists), then use the setter to enforce validation on the # provided values. - self._text = None - self._icon = None - self._enabled = None + self._text: str = None + self._icon: toga.Icon = None + self._enabled: bool = None self.text = text self.icon = icon @@ -70,8 +69,8 @@ def __init__( # Prime the attributes for properties that will be set when the OptionItem is # set as content. - self._interface = None - self._index = None + self._interface: OptionContainer = None + self._index: int = None @property def interface(self) -> OptionContainer: @@ -90,7 +89,7 @@ def enabled(self) -> bool: return self._interface._impl.is_option_enabled(self.index) @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: enable = bool(value) if hasattr(self, "_enabled"): self._enabled = enable @@ -112,7 +111,7 @@ def text(self) -> str: return self._interface._impl.get_option_text(self.index) @text.setter - def text(self, value): + def text(self, value: object) -> None: if value is None: raise ValueError("Item text cannot be None") @@ -129,7 +128,7 @@ def text(self, value): def icon(self) -> toga.Icon: """The Icon for the tab of content. - Can be specified as any valid :any:`icon content `. + Can be specified as any valid :any:`icon content `. If the platform does not support the display of icons, this property will return ``None`` regardless of any value provided. @@ -140,7 +139,7 @@ def icon(self) -> toga.Icon: return self._interface._impl.get_option_icon(self.index) @icon.setter - def icon(self, icon_or_name: IconContent | None): + def icon(self, icon_or_name: IconContentT | None) -> None: if get_platform_factory().OptionContainer.uses_icons: if icon_or_name is None: icon = None @@ -167,7 +166,7 @@ def content(self) -> Widget: """The content widget displayed in this tab of the OptionContainer.""" return self._content - def _preserve_option(self): + def _preserve_option(self) -> None: # Move the ground truth back to the OptionItem instance self._text = self.text self._icon = self.icon @@ -177,7 +176,7 @@ def _preserve_option(self): self._index = None self._interface = None - def _add_as_option(self, index, interface): + def _add_as_option(self, index: int, interface: OptionContainer) -> None: text = self._text del self._text @@ -198,11 +197,11 @@ def _add_as_option(self, index, interface): class OptionList: - def __init__(self, interface): + def __init__(self, interface: Any): self.interface = interface - self._options = [] + self._options: list[OptionItem] = [] - def __repr__(self): + def __repr__(self) -> str: items = ", ".join(repr(option.text) for option in self) return f"" @@ -210,11 +209,11 @@ def __getitem__(self, index: int | str | OptionItem) -> OptionItem: """Obtain a specific tab of content.""" return self._options[self.index(index)] - def __delitem__(self, index: int | str | OptionItem): + def __delitem__(self, index: int | str | OptionItem) -> None: """Same as :any:`remove`.""" self.remove(index) - def remove(self, index: int | str | OptionItem): + def remove(self, index: int | str | OptionItem) -> None: """Remove the specified tab of content. The currently selected item cannot be deleted. @@ -244,7 +243,7 @@ def __len__(self) -> int: """The number of tabs of content in the OptionContainer.""" return len(self._options) - def index(self, value: str | int | OptionItem): + def index(self, value: str | int | OptionItem) -> int: """Find the index of the tab that matches the given value. :param value: The value to look for. An integer is returned as-is; @@ -267,7 +266,7 @@ def index(self, value: str | int | OptionItem): def append( self, text_or_item: OptionItem, - ): ... + ) -> None: ... @overload def append( @@ -275,18 +274,18 @@ def append( text_or_item: str, content: Widget, *, - icon: IconContent | None = None, - enabled: bool = True, - ): ... + icon: IconContentT | None = None, + enabled: bool | None = True, + ) -> None: ... def append( self, - text_or_item: str, + text_or_item: str | OptionItem, content: Widget | None = None, *, - icon: IconContent | None = None, - enabled: bool = None, - ): + icon: IconContentT | None = None, + enabled: bool | None = None, + ) -> None: """Add a new tab of content to the OptionContainer. The new tab can be specified as an existing :any:`OptionItem` instance, or by @@ -296,7 +295,7 @@ def append( :param text_or_item: An :any:`OptionItem`; or, the text label for the new tab. :param content: The content widget to use for the new tab. - :param icon: The :any:`icon content ` to use to represent the tab. + :param icon: The :any:`icon content ` to use to represent the tab. :param enabled: Should the new tab be enabled? (Default: ``True``) """ self.insert(len(self), text_or_item, content, icon=icon, enabled=enabled) @@ -306,7 +305,7 @@ def insert( self, index: int | str | OptionItem, text_or_item: OptionItem, - ): ... + ) -> None: ... @overload def insert( @@ -315,9 +314,9 @@ def insert( text_or_item: str, content: Widget, *, - icon: IconContent | None = None, - enabled: bool = True, - ): ... + icon: IconContentT | None = None, + enabled: bool | None = True, + ) -> None: ... def insert( self, @@ -325,9 +324,9 @@ def insert( text_or_item: str | OptionItem, content: Widget | None = None, *, - icon: IconContent | None = None, + icon: IconContentT | None = None, enabled: bool | None = None, - ): + ) -> None: """Insert a new tab of content to the OptionContainer at the specified index. The new tab can be specified as an existing :any:`OptionItem` instance, or by @@ -338,7 +337,7 @@ def insert( :param index: The index where the new tab should be inserted. :param text_or_item: An :any:`OptionItem`; or, the text label for the new tab. :param content: The content widget to use for the new tab. - :param icon: The :any:`icon content ` to use to represent the tab. + :param icon: The :any:`icon content ` to use to represent the tab. :param enabled: Should the new tab be enabled? (Default: ``True``) """ if isinstance(text_or_item, OptionItem): @@ -379,9 +378,9 @@ def insert( class OptionContainer(Widget): def __init__( self, - id=None, - style=None, - content: list[OptionContainerContent] | None = None, + id: str | None = None, + style: StyleT | None = None, + content: Iterable[OptionContainerContentT] | None = None, on_select: toga.widgets.optioncontainer.OnSelectHandler | None = None, ): """Create a new OptionContainer. @@ -390,7 +389,7 @@ def __init__( :param style: A style object. If no style is provided, a default style will be applied to the widget. :param content: The initial :any:`OptionContainer content - ` to display in the OptionContainer. + ` to display in the OptionContainer. :param on_select: Initial :any:`on_select` handler. """ super().__init__(id=id, style=style) @@ -399,7 +398,7 @@ def __init__( self._impl = self.factory.OptionContainer(interface=self) - if content: + if content is not None: for item in content: if isinstance(item, OptionItem): self.content.append(item) @@ -434,12 +433,11 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - """No-op; OptionContainer cannot accept input focus""" - pass + def focus(self) -> None: + """No-op; OptionContainer cannot accept input focus.""" @property def content(self) -> OptionList: @@ -459,7 +457,7 @@ def current_tab(self) -> OptionItem | None: return self._content[index] @current_tab.setter - def current_tab(self, value): + def current_tab(self, value: OptionItem | str | int) -> None: index = self._content.index(value) if not self._impl.is_option_enabled(index): raise ValueError("A disabled tab cannot be made the current tab.") @@ -467,7 +465,7 @@ def current_tab(self, value): self._impl.set_current_tab_index(index) @Widget.app.setter - def app(self, app): + def app(self, app) -> None: # Invoke the superclass property setter Widget.app.fset(self, app) @@ -476,7 +474,7 @@ def app(self, app): item._content.app = app @Widget.window.setter - def window(self, window): + def window(self, window) -> None: # Invoke the superclass property setter Widget.window.fset(self, window) @@ -485,10 +483,10 @@ def window(self, window): item._content.window = window @property - def on_select(self) -> toga.widgets.optioncontainer.OnSelectHandler: + def on_select(self) -> OnSelectHandler: """The callback to invoke when a new tab of content is selected.""" return self._on_select @on_select.setter - def on_select(self, handler): + def on_select(self, handler: toga.widgets.optioncontainer.OnSelectHandler) -> None: self._on_select = wrapped_handler(self, handler) diff --git a/core/src/toga/widgets/passwordinput.py b/core/src/toga/widgets/passwordinput.py index 8044527841..48548b4935 100644 --- a/core/src/toga/widgets/passwordinput.py +++ b/core/src/toga/widgets/passwordinput.py @@ -6,5 +6,5 @@ class PasswordInput(TextInput): """Create a new password input widget.""" - def _create(self): + def _create(self) -> None: self._impl = self.factory.PasswordInput(interface=self) diff --git a/core/src/toga/widgets/progressbar.py b/core/src/toga/widgets/progressbar.py index 31bb1e80e5..49f2e67575 100644 --- a/core/src/toga/widgets/progressbar.py +++ b/core/src/toga/widgets/progressbar.py @@ -1,6 +1,8 @@ from __future__ import annotations -from .base import Widget +from typing import Literal, SupportsFloat + +from .base import StyleT, Widget class ProgressBar(Widget): @@ -8,10 +10,10 @@ class ProgressBar(Widget): def __init__( self, - id=None, - style=None, - max: float = 1.0, - value: float = 0.0, + id: str | None = None, + style: StyleT | None = None, + max: str | SupportsFloat = 1.0, + value: str | SupportsFloat = 0.0, running: bool = False, ): """Create a new Progress Bar widget. @@ -39,7 +41,7 @@ def __init__( self.start() @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? ProgressBar widgets cannot be disabled; this property will always return True; @@ -48,7 +50,7 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass @property @@ -69,7 +71,7 @@ def is_determinate(self) -> bool: """ return self.max is not None - def start(self): + def start(self) -> None: """Start the progress bar. If the progress bar is already started, this is a no-op. @@ -77,7 +79,7 @@ def start(self): if not self.is_running: self._impl.start() - def stop(self): + def stop(self) -> None: """Stop the progress bar. If the progress bar is already stopped, this is a no-op. @@ -98,7 +100,7 @@ def value(self) -> float: return self._impl.get_value() @value.setter - def value(self, value): + def value(self, value: str | SupportsFloat) -> None: if self.max is not None: value = max(0.0, min(self.max, float(value))) self._impl.set_value(value) @@ -112,7 +114,7 @@ def max(self) -> float | None: return self._impl.get_max() @max.setter - def max(self, value): + def max(self, value: str | SupportsFloat | None) -> None: if value is None: self._impl.set_max(None) elif float(value) > 0.0: diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 4d1a64d36e..030cbc1bf9 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -1,24 +1,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal, Protocol, SupportsInt from toga.handlers import wrapped_handler from toga.types import Position -from .base import Widget +from .base import StyleT, Widget if TYPE_CHECKING: from toga.types import PositionT +class OnScrollHandler(Protocol): + def __call__(self, widget: ScrollContainer, /, **kwargs: Any) -> object: + """A handler to invoke when the container is scrolled. + + :param widget: The ScrollContainer that was scrolled. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + class ScrollContainer(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, horizontal: bool = True, vertical: bool = True, - on_scroll: callable | None = None, + on_scroll: OnScrollHandler | None = None, content: Widget | None = None, ): """Create a new Scroll Container. @@ -33,8 +42,9 @@ def __init__( """ super().__init__(id=id, style=style) - self._content = None + self._content: Widget | None = None self.on_scroll = None + # Create a platform specific implementation of a Scroll Container self._impl = self.factory.ScrollContainer(interface=self) @@ -45,7 +55,7 @@ def __init__( self.on_scroll = on_scroll @Widget.app.setter - def app(self, app): + def app(self, app) -> None: # Invoke the superclass property setter Widget.app.fset(self, app) @@ -54,7 +64,7 @@ def app(self, app): self._content.app = app @Widget.window.setter - def window(self, window): + def window(self, window) -> None: # Invoke the superclass property setter Widget.window.fset(self, window) @@ -63,7 +73,7 @@ def window(self, window): self._content.window = window @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? ScrollContainer widgets cannot be disabled; this property will always return @@ -72,20 +82,20 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; ScrollContainer cannot accept input focus" + def focus(self) -> None: + """No-op; ScrollContainer cannot accept input focus.""" pass @property - def content(self) -> Widget: + def content(self) -> Widget | None: """The root content widget displayed inside the scroll container.""" return self._content @content.setter - def content(self, widget): + def content(self, widget: Widget | None) -> None: if self._content: # Clear the window before the app so that registry entries can be cleared self._content.window = None @@ -108,7 +118,7 @@ def vertical(self) -> bool: return self._impl.get_vertical() @vertical.setter - def vertical(self, value): + def vertical(self, value: object) -> None: self._impl.set_vertical(bool(value)) if self._content: self._content.refresh() @@ -119,18 +129,18 @@ def horizontal(self) -> bool: return self._impl.get_horizontal() @horizontal.setter - def horizontal(self, value): + def horizontal(self, value: object) -> None: self._impl.set_horizontal(bool(value)) if self._content: self._content.refresh() @property - def on_scroll(self) -> callable: + def on_scroll(self) -> OnScrollHandler: """Handler to invoke when the user moves a scroll bar.""" return self._on_scroll @on_scroll.setter - def on_scroll(self, on_scroll): + def on_scroll(self, on_scroll: OnScrollHandler) -> None: self._on_scroll = wrapped_handler(self, on_scroll) @property @@ -155,7 +165,7 @@ def horizontal_position(self) -> int: return self._impl.get_horizontal_position() @horizontal_position.setter - def horizontal_position(self, horizontal_position): + def horizontal_position(self, horizontal_position: SupportsInt) -> None: if not self.horizontal: raise ValueError( "Cannot set horizontal position when horizontal scrolling is not enabled." @@ -185,7 +195,7 @@ def vertical_position(self) -> int: return self._impl.get_vertical_position() @vertical_position.setter - def vertical_position(self, vertical_position): + def vertical_position(self, vertical_position: SupportsInt) -> None: if not self.vertical: raise ValueError( "Cannot set vertical position when vertical scrolling is not enabled." diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 83a9ae3bf1..9b9fb5d565 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -1,24 +1,38 @@ from __future__ import annotations import warnings +from collections.abc import Iterable +from typing import Any, Protocol, TypeVar +import toga from toga.handlers import wrapped_handler from toga.sources import ListSource, Source -from .base import Widget +from .base import StyleT, Widget + +SourceT = TypeVar("SourceT", bound=Source) + + +class OnChangeHandler(Protocol): + def __call__(self, widget: Selection, /, **kwargs: Any) -> object: + """A handler to invoke when the value is changed. + + :param widget: The Selection that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class Selection(Widget): def __init__( self, - id=None, - style=None, - items: list | ListSource | None = None, + id: str | None = None, + style: StyleT | None = None, + items: SourceT | Iterable | None = None, accessor: str | None = None, - value: None = None, - on_change: callable | None = None, - enabled=True, - on_select: callable | None = None, # DEPRECATED + value: object | None = None, + on_change: toga.widgets.selection.OnChangeHandler | None = None, + enabled: bool = True, + on_select: None = None, # DEPRECATED ): """Create a new Selection widget. @@ -52,6 +66,8 @@ def __init__( # End backwards compatibility. ###################################################################### + self._items: SourceT | ListSource + self.on_change = None # needed for _impl initialization self._impl = self.factory.Selection(interface=self) @@ -64,7 +80,7 @@ def __init__( self.enabled = enabled @property - def items(self) -> ListSource: + def items(self) -> SourceT | ListSource: """The items to display in the selection. When setting this property: @@ -81,7 +97,7 @@ def items(self) -> ListSource: return self._items @items.setter - def items(self, items): + def items(self, items: SourceT | Iterable | None) -> None: if self._accessor is None: accessors = ["value"] else: @@ -113,7 +129,7 @@ def items(self, items): self.refresh() - def _title_for_item(self, item): + def _title_for_item(self, item: Any) -> str: """Internal utility method; return the display title for an item""" if self._accessor: title = getattr(item, self._accessor) @@ -123,7 +139,7 @@ def _title_for_item(self, item): return str(title).split("\n")[0] @property - def value(self): + def value(self) -> object | None: """The currently selected item. Returns None if there are no items in the selection. @@ -151,7 +167,7 @@ def value(self): return item @value.setter - def value(self, value): + def value(self, value: object) -> None: try: if self._accessor is None: item = self._items.find(dict(value=value)) @@ -164,13 +180,13 @@ def value(self, value): raise ValueError(f"{value!r} is not a current item in the selection") @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """Handler to invoke when the value of the selection is changed, either by the user or programmatically.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.selection.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) ###################################################################### @@ -178,7 +194,7 @@ def on_change(self, handler): ###################################################################### @property - def on_select(self) -> callable: + def on_select(self) -> OnChangeHandler: """**DEPRECATED**: Use ``on_change``""" warnings.warn( "Selection.on_select has been renamed Selection.on_change.", @@ -187,7 +203,7 @@ def on_select(self) -> callable: return self.on_change @on_select.setter - def on_select(self, handler): + def on_select(self, handler: toga.widgets.selection.OnChangeHandler) -> None: warnings.warn( "Selection.on_select has been renamed Selection.on_change.", DeprecationWarning, diff --git a/core/src/toga/widgets/slider.py b/core/src/toga/widgets/slider.py index 98e559483f..efddce2312 100644 --- a/core/src/toga/widgets/slider.py +++ b/core/src/toga/widgets/slider.py @@ -3,26 +3,55 @@ import warnings from abc import ABC, abstractmethod from contextlib import contextmanager +from typing import Any, Protocol, SupportsFloat +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + + +class OnChangeHandler(Protocol): + def __call__(self, widget: Slider, /, **kwargs: Any) -> object: + """A handler to invoke when the value is changed. + + :param widget: The Slider that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnPressHandler(Protocol): + def __call__(self, widget: Slider, /, **kwargs: Any) -> object: + """A handler to invoke when the slider is pressed. + + :param widget: The Slider that was pressed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnReleaseHandler(Protocol): + def __call__(self, widget: Slider, /, **kwargs: Any) -> object: + """A handler to invoke when the slider is pressed. + + :param widget: The Slider that was released. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class Slider(Widget): def __init__( self, id: str | None = None, - style=None, + style: StyleT | None = None, value: float | None = None, - min: float = None, # Default to 0.0 when range is removed - max: float = None, # Default to 1.0 when range is removed + min: float | None = None, # Default to 0.0 when range is removed + max: float | None = None, # Default to 1.0 when range is removed tick_count: int | None = None, - on_change: callable | None = None, - on_press: callable | None = None, - on_release: callable | None = None, + on_change: toga.widgets.slider.OnChangeHandler | None = None, + on_press: toga.widgets.slider.OnPressHandler | None = None, + on_release: OnReleaseHandler | None = None, enabled: bool = True, - range: tuple[float, float] | None = None, # DEPRECATED + range: None = None, # DEPRECATED ): """Create a new Slider widget. @@ -90,7 +119,7 @@ def __init__( # Backends are inconsistent about when they produce events for programmatic changes, # so we deal with those in the interface layer. @contextmanager - def _programmatic_change(self): + def _programmatic_change(self) -> float: old_value = self.value on_change = self._on_change self.on_change = None @@ -111,7 +140,7 @@ def value(self) -> float: return self._impl.get_value() @value.setter - def value(self, value): + def value(self, value: float) -> None: if value < self.min: value = self.min elif value > self.max: @@ -119,10 +148,10 @@ def value(self, value): with self._programmatic_change(): self._set_value(value) - def _set_value(self, value): + def _set_value(self, value: SupportsFloat) -> None: self._impl.set_value(self._round_value(float(value))) - def _round_value(self, value): + def _round_value(self, value: float) -> float: step = self.tick_step if step is not None: # Round to the nearest tick. @@ -139,7 +168,7 @@ def min(self) -> float: return self._impl.get_min() @min.setter - def min(self, value): + def min(self, value: SupportsFloat) -> None: with self._programmatic_change() as old_value: # Some backends will clip the current value within the range automatically, # but do it ourselves to be certain. In discrete mode, setting self.value also @@ -163,7 +192,7 @@ def max(self) -> float: return self._impl.get_max() @max.setter - def max(self, value): + def max(self, value: SupportsFloat) -> None: with self._programmatic_change() as old_value: # Some backends will clip the current value within the range automatically, # but do it ourselves to be certain. In discrete mode, setting self.value also @@ -199,7 +228,7 @@ def tick_count(self) -> int | None: return self._impl.get_tick_count() @tick_count.setter - def tick_count(self, tick_count): + def tick_count(self, tick_count: float | None) -> None: if (tick_count is not None) and (tick_count < 2): raise ValueError("tick count must be at least 2") with self._programmatic_change() as old_value: @@ -226,7 +255,7 @@ def tick_step(self) -> float | None: return (self.max - self.min) / (self.tick_count - 1) @property - def tick_value(self) -> int | None: + def tick_value(self) -> float | None: """Value of the slider, measured in ticks. * If the slider is continuous, this property returns ``None``. @@ -235,25 +264,25 @@ def tick_value(self) -> int | None: :raises ValueError: If set to anything inconsistent with the rules above. """ - if self.tick_count is not None: + if self.tick_count is not None and self.tick_step is not None: return round((self.value - self.min) / self.tick_step) + 1 else: return None @tick_value.setter - def tick_value(self, tick_value): + def tick_value(self, tick_value: int | None) -> None: if self.tick_count is None: if tick_value is not None: raise ValueError("cannot set tick value when tick count is None") else: - if tick_value is None: + if tick_value is None or self.tick_step is None: raise ValueError( "cannot set tick value to None when tick count is not None" ) self.value = self.min + (tick_value - 1) * self.tick_step @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """Handler to invoke when the value of the slider is changed, either by the user or programmatically. @@ -262,25 +291,25 @@ def on_change(self) -> callable: return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.slider.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) @property - def on_press(self) -> callable: + def on_press(self) -> OnPressHandler: """Handler to invoke when the user presses the slider before changing it.""" return self._on_press @on_press.setter - def on_press(self, handler): + def on_press(self, handler: toga.widgets.slider.OnPressHandler) -> None: self._on_press = wrapped_handler(self, handler) @property - def on_release(self) -> callable: + def on_release(self) -> OnReleaseHandler: """Handler to invoke when the user releases the slider after changing it.""" return self._on_release @on_release.setter - def on_release(self, handler): + def on_release(self, handler: OnReleaseHandler) -> None: self._on_release = wrapped_handler(self, handler) ###################################################################### @@ -303,10 +332,10 @@ def range(self) -> tuple[float, float]: "Slider.range has been deprecated in favor of Slider.min and Slider.max", DeprecationWarning, ) - return (self.min, self.max) + return self.min, self.max @range.setter - def range(self, range): + def range(self, range: tuple[float, float]) -> None: warnings.warn( "Slider.range has been deprecated in favor of Slider.min and Slider.max", DeprecationWarning, @@ -317,29 +346,31 @@ def range(self, range): class SliderImpl(ABC): + interface: Any + @abstractmethod - def get_value(self): ... + def get_value(self) -> float: ... @abstractmethod - def set_value(self, value): ... + def set_value(self, value: float) -> None: ... @abstractmethod - def get_min(self): ... + def get_min(self) -> float: ... @abstractmethod - def set_min(self, value): ... + def set_min(self, value: float) -> None: ... @abstractmethod - def get_max(self): ... + def get_max(self) -> float: ... @abstractmethod - def set_max(self, value): ... + def set_max(self, value: float) -> None: ... @abstractmethod - def get_tick_count(self): ... + def get_tick_count(self) -> int | None: ... @abstractmethod - def set_tick_count(self, tick_count): ... + def set_tick_count(self, tick_count: int | None) -> None: ... class IntSliderImpl(SliderImpl): @@ -348,41 +379,41 @@ class IntSliderImpl(SliderImpl): # Number of steps to use to approximate a continuous slider. CONTINUOUS_MAX = 10000 - def __init__(self): + def __init__(self) -> None: super().__init__() # Dummy values used during initialization. - self.value = 0 - self.min = 0 - self.max = 1 + self.value: int | float = 0 + self.min: int | float = 0 + self.max: int | float = 1 self.discrete = False - def get_value(self): + def get_value(self) -> float: return self.value - def set_value(self, value): + def set_value(self, value: float) -> None: span = self.max - self.min self.set_int_value( 0 if span == 0 else round((value - self.min) / span * self.get_int_max()) ) self.value = value # Cache the original value so we can round-trip it. - def get_min(self): + def get_min(self) -> float: return self.min - def set_min(self, value): + def set_min(self, value: float) -> None: self.min = value - def get_max(self): + def get_max(self) -> float: return self.max - def set_max(self, value): + def set_max(self, value: float) -> None: self.max = value - def get_tick_count(self): + def get_tick_count(self) -> int | None: return (self.get_int_max() + 1) if self.discrete else None - def set_tick_count(self, tick_count): + def set_tick_count(self, tick_count: int | None) -> None: if tick_count is None: self.discrete = False self.set_int_max(self.CONTINUOUS_MAX) @@ -393,22 +424,22 @@ def set_tick_count(self, tick_count): # Instead of calling the event handler directly, implementations should call this # method. - def on_change(self): + def on_change(self) -> None: span = self.max - self.min self.value = self.min + (self.get_int_value() / self.get_int_max() * span) self.interface.on_change() @abstractmethod - def get_int_value(self): ... + def get_int_value(self) -> int: ... @abstractmethod - def set_int_value(self, value): ... + def set_int_value(self, value: int) -> None: ... @abstractmethod - def get_int_max(self): ... + def get_int_max(self) -> int: ... @abstractmethod - def set_int_max(self, max): ... + def set_int_max(self, max: int) -> None: ... @abstractmethod - def set_ticks_visible(self, visible): ... + def set_ticks_visible(self, visible: bool) -> None: ... diff --git a/core/src/toga/widgets/splitcontainer.py b/core/src/toga/widgets/splitcontainer.py index 79b29e3789..04589bc737 100644 --- a/core/src/toga/widgets/splitcontainer.py +++ b/core/src/toga/widgets/splitcontainer.py @@ -1,8 +1,21 @@ from __future__ import annotations +import sys +from typing import TYPE_CHECKING + +from toga.app import App from toga.constants import Direction +from toga.window import Window + +from .base import StyleT, Widget -from .base import Widget +if TYPE_CHECKING: + if sys.version_info < (3, 10): + from typing_extensions import TypeAlias + else: + from typing import TypeAlias + + SplitContainerContentT: TypeAlias = Widget | tuple[Widget, float] | None class SplitContainer(Widget): @@ -11,10 +24,10 @@ class SplitContainer(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, direction: Direction = Direction.VERTICAL, - content: tuple[Widget | None | tuple, Widget | None | tuple] = (None, None), + content: tuple[SplitContainerContentT, SplitContainerContentT] = (None, None), ): """Create a new SplitContainer. @@ -25,11 +38,14 @@ def __init__( :attr:`~toga.constants.Direction.HORIZONTAL` or :attr:`~toga.constants.Direction.VERTICAL`; defaults to :attr:`~toga.constants.Direction.VERTICAL` - :param content: Initial :any:`content` of the container. Defaults to both panels - being empty. + :param content: Initial :any:`SplitContainer content ` + of the container. Defaults to both panels being empty. """ super().__init__(id=id, style=style) - self._content = (None, None) + self._content: tuple[SplitContainerContentT, SplitContainerContentT] = ( + None, + None, + ) # Create a platform specific implementation of a SplitContainer self._impl = self.factory.SplitContainer(interface=self) @@ -47,17 +63,15 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; SplitContainer cannot accept input focus" + def focus(self) -> None: + """No-op; SplitContainer cannot accept input focus.""" pass - # The inner tuple's full type is tuple[Widget | None, float], but that would make - # the documentation unreadable. @property - def content(self) -> tuple[Widget | None | tuple, Widget | None | tuple]: + def content(self) -> tuple[SplitContainerContentT, SplitContainerContentT]: """The widgets displayed in the SplitContainer. This property accepts a sequence of exactly 2 elements, each of which can be @@ -75,7 +89,9 @@ def content(self) -> tuple[Widget | None | tuple, Widget | None | tuple]: return self._content @content.setter - def content(self, content): + def content( + self, content: tuple[SplitContainerContentT, SplitContainerContentT] + ) -> None: try: if len(content) != 2: raise TypeError() @@ -119,7 +135,7 @@ def content(self, content): self.refresh() @Widget.app.setter - def app(self, app): + def app(self, app: App | None) -> None: # Invoke the superclass property setter Widget.app.fset(self, app) @@ -129,7 +145,7 @@ def app(self, app): content.app = app @Widget.window.setter - def window(self, window): + def window(self, window: Window | None) -> None: # Invoke the superclass property setter Widget.window.fset(self, window) @@ -140,10 +156,10 @@ def window(self, window): @property def direction(self) -> Direction: - """The direction of the split""" + """The direction of the split.""" return self._impl.get_direction() @direction.setter - def direction(self, value): + def direction(self, value: object) -> None: self._impl.set_direction(value) self.refresh() diff --git a/core/src/toga/widgets/switch.py b/core/src/toga/widgets/switch.py index b5aa934603..17c0dfb354 100644 --- a/core/src/toga/widgets/switch.py +++ b/core/src/toga/widgets/switch.py @@ -1,17 +1,29 @@ from __future__ import annotations +from typing import Any, Protocol + +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + + +class OnChangeHandler(Protocol): + def __call__(self, widget: Switch, /, **kwargs: Any) -> object: + """A handler to invoke when the value is changed. + + :param widget: The Switch that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class Switch(Widget): def __init__( self, - text, - id=None, - style=None, - on_change: callable | None = None, + text: str, + id: str | None = None, + style: StyleT | None = None, + on_change: toga.widgets.switch.OnChangeHandler | None = None, value: bool = False, enabled: bool = True, ): @@ -56,7 +68,7 @@ def text(self) -> str: return self._impl.get_text() @text.setter - def text(self, value): + def text(self, value: object) -> None: if value is None or value == "\u200B": value = "" else: @@ -68,12 +80,12 @@ def text(self, value): self.refresh() @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the value of the switch changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.switch.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) @property @@ -85,9 +97,9 @@ def value(self) -> bool: return self._impl.get_value() @value.setter - def value(self, value): + def value(self, value: object) -> None: self._impl.set_value(bool(value)) - def toggle(self): + def toggle(self) -> None: """Reverse the current value of the switch.""" self.value = not self.value diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index c63de105c6..d0e991e0fb 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -1,28 +1,51 @@ from __future__ import annotations import warnings -from typing import Any +from collections.abc import Iterable +from typing import Any, Literal, Protocol, TypeVar +import toga from toga.handlers import wrapped_handler from toga.sources import ListSource, Row, Source from toga.sources.accessors import build_accessors, to_accessor -from .base import Widget +from .base import StyleT, Widget + +SourceT = TypeVar("SourceT", bound=Source) + + +class OnSelectHandler(Protocol): + def __call__(self, widget: Table, /, **kwargs: Any) -> object: + """A handler to invoke when the table is selected. + + :param widget: The Table that was selected. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnActivateHandler(Protocol): + def __call__(self, widget: Table, /, row: Any, **kwargs: Any) -> object: + """A handler to invoke when the table is activated. + + :param widget: The Table that was activated. + :param row: The Table Row that was activated. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class Table(Widget): def __init__( self, - headings: list[str] | None = None, - id=None, - style=None, - data: Any = None, - accessors: list[str] | None = None, + headings: Iterable[str] | None = None, + id: str | None = None, + style: StyleT | None = None, + data: SourceT | Iterable | None = None, + accessors: Iterable[str] | None = None, multiple_select: bool = False, - on_select: callable | None = None, - on_activate: callable | None = None, + on_select: toga.widgets.table.OnSelectHandler | None = None, + on_activate: toga.widgets.table.OnActivateHandler | None = None, missing_value: str = "", - on_double_click=None, # DEPRECATED + on_double_click: None = None, # DEPRECATED ): """Create a new Table widget. @@ -73,12 +96,16 @@ def __init__( # End backwards compatibility. ###################################################################### + self._headings: list[str] | None + self._accessors: list[str] + self._data: SourceT | ListSource + if headings is not None: self._headings = [heading.split("\n")[0] for heading in headings] self._accessors = build_accessors(self._headings, accessors) elif accessors is not None: self._headings = None - self._accessors = accessors + self._accessors = list(accessors) else: raise ValueError( "Cannot create a table without either headings or accessors" @@ -99,7 +126,7 @@ def __init__( self.on_activate = on_activate @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? Table widgets cannot be disabled; this property will always return True; any attempt to modify it will be ignored. @@ -107,15 +134,15 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; Table cannot accept input focus" + def focus(self) -> None: + """No-op; Table cannot accept input focus.""" pass @property - def data(self) -> ListSource: + def data(self) -> SourceT | ListSource: """The data to display in the table. When setting this property: @@ -131,7 +158,7 @@ def data(self) -> ListSource: return self._data @data.setter - def data(self, data: Any): + def data(self, data: SourceT | Iterable | None) -> None: if data is None: self._data = ListSource(accessors=self._accessors, data=[]) elif isinstance(data, Source): @@ -166,11 +193,11 @@ def selection(self) -> list[Row] | Row | None: else: return self.data[selection] - def scroll_to_top(self): + def scroll_to_top(self) -> None: """Scroll the view so that the top of the list (first row) is visible.""" self.scroll_to_row(0) - def scroll_to_row(self, row: int): + def scroll_to_row(self, row: int) -> None: """Scroll the view so that the specified row index is visible. :param row: The index of the row to make visible. Negative values refer to the @@ -182,34 +209,34 @@ def scroll_to_row(self, row: int): else: self._impl.scroll_to_row(max(len(self.data) + row, 0)) - def scroll_to_bottom(self): + def scroll_to_bottom(self) -> None: """Scroll the view so that the bottom of the list (last row) is visible.""" self.scroll_to_row(-1) @property - def on_select(self) -> callable: + def on_select(self) -> OnSelectHandler: """The callback function that is invoked when a row of the table is selected.""" return self._on_select @on_select.setter - def on_select(self, handler: callable): + def on_select(self, handler: toga.widgets.table.OnSelectHandler) -> None: self._on_select = wrapped_handler(self, handler) @property - def on_activate(self) -> callable: + def on_activate(self) -> OnActivateHandler: """The callback function that is invoked when a row of the table is activated, usually with a double click or similar action.""" return self._on_activate @on_activate.setter - def on_activate(self, handler): + def on_activate(self, handler: toga.widgets.table.OnActivateHandler) -> None: self._on_activate = wrapped_handler(self, handler) - def add_column(self, heading: str, accessor: str | None = None): + def add_column(self, heading: str, accessor: str | None = None) -> None: """**DEPRECATED**: use :meth:`~toga.Table.append_column`""" self.insert_column(len(self._accessors), heading, accessor=accessor) - def append_column(self, heading: str, accessor: str | None = None): + def append_column(self, heading: str, accessor: str | None = None) -> None: """Append a column to the end of the table. :param heading: The heading for the new column. @@ -222,9 +249,9 @@ def append_column(self, heading: str, accessor: str | None = None): def insert_column( self, index: int | str, - heading: str | None, + heading: str, accessor: str | None = None, - ): + ) -> None: """Insert an additional column into the table. :param index: The index at which to insert the column, or the accessor of the @@ -257,7 +284,7 @@ def insert_column( self._impl.insert_column(index, heading, accessor) - def remove_column(self, column: int | str): + def remove_column(self, column: int | str) -> None: """Remove a table column. :param column: The index of the column to remove, or the accessor of the column @@ -302,7 +329,7 @@ def missing_value(self) -> str: ###################################################################### @property - def on_double_click(self): + def on_double_click(self) -> OnActivateHandler: """**DEPRECATED**: Use ``on_activate``""" warnings.warn( "Table.on_double_click has been renamed Table.on_activate.", @@ -311,7 +338,7 @@ def on_double_click(self): return self.on_activate @on_double_click.setter - def on_double_click(self, handler): + def on_double_click(self, handler: toga.widgets.table.OnActivateHandler) -> None: warnings.warn( "Table.on_double_click has been renamed Table.on_activate.", DeprecationWarning, diff --git a/core/src/toga/widgets/textinput.py b/core/src/toga/widgets/textinput.py index 5c207e7edc..cea1d1e44a 100644 --- a/core/src/toga/widgets/textinput.py +++ b/core/src/toga/widgets/textinput.py @@ -1,8 +1,48 @@ from __future__ import annotations +from collections.abc import Callable, Iterable +from typing import Any, Protocol + +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + + +class OnChangeHandler(Protocol): + def __call__(self, widget: TextInput, /, **kwargs: Any) -> object: + """A handler to invoke when the text input is changed. + + :param widget: The TextInput that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnConfirmHandler(Protocol): + def __call__(self, widget: TextInput, /, **kwargs: Any) -> object: + """A handler to invoke when the text input is confirmed. + + :param widget: The TextInput that was confirmed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnGainFocusHandler(Protocol): + def __call__(self, widget: TextInput, /, **kwargs: Any) -> object: + """A handler to invoke when the text input gains focus. + + :param widget: The TextInput that gained focus. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnLoseFocusHandler(Protocol): + def __call__(self, widget: TextInput, /, **kwargs: Any) -> object: + """A handler to invoke when the text input loses focus. + + :param widget: The TextInput that lost focus. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class TextInput(Widget): @@ -10,35 +50,34 @@ class TextInput(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, value: str | None = None, readonly: bool = False, placeholder: str | None = None, - on_change: callable | None = None, - on_confirm: callable | None = None, - on_gain_focus: callable | None = None, - on_lose_focus: callable | None = None, - validators: list[callable] | None = None, + on_change: toga.widgets.textinput.OnChangeHandler | None = None, + on_confirm: OnConfirmHandler | None = None, + on_gain_focus: OnGainFocusHandler | None = None, + on_lose_focus: OnLoseFocusHandler | None = None, + validators: Iterable[Callable[[str], bool]] | None = None, ): """ :param id: The ID for the widget. - :param style: A style object. If no style is provided, a default style - will be applied to the widget. + :param style: A style object. If no style is provided, a default style will be + applied to the widget. :param value: The initial content to display in the widget. :param readonly: Can the value of the widget be modified by the user? - :param placeholder: The content to display as a placeholder when there - is no user content to display. - :param on_change: A handler that will be invoked when the value of - the widget changes. - :param on_confirm: A handler that will be invoked when the user accepts - the value of the input (usually by pressing Return on the keyboard). - :param on_gain_focus: A handler that will be invoked when the widget - gains input focus. - :param on_lose_focus: A handler that will be invoked when the widget - loses input focus. - :param validators: A list of validators to run on the value of the - input. + :param placeholder: The content to display as a placeholder when there is no + user content to display. + :param on_change: A handler that will be invoked when the value of the widget + changes. + :param on_confirm: A handler that will be invoked when the user accepts the + value of the input (usually by pressing Return on the keyboard). + :param on_gain_focus: A handler that will be invoked when the widget gains + input focus. + :param on_lose_focus: A handler that will be invoked when the widget loses + input focus. + :param validators: A list of validators to run on the value of the input. """ super().__init__(id=id, style=style) @@ -64,7 +103,7 @@ def __init__( self.on_lose_focus = on_lose_focus self.on_gain_focus = on_gain_focus - def _create(self): + def _create(self) -> None: self._impl = self.factory.TextInput(interface=self) @property @@ -78,7 +117,7 @@ def readonly(self) -> bool: return self._impl.get_readonly() @readonly.setter - def readonly(self, value): + def readonly(self, value: object) -> None: self._impl.set_readonly(bool(value)) @property @@ -91,7 +130,7 @@ def placeholder(self) -> str: return self._impl.get_placeholder() @placeholder.setter - def placeholder(self, value): + def placeholder(self, value: object) -> None: self._impl.set_placeholder("" if value is None else str(value)) self.refresh() @@ -109,7 +148,7 @@ def value(self) -> str: return self._impl.get_value() @value.setter - def value(self, value: str): + def value(self, value: object) -> None: if value is None: v = "" else: @@ -123,16 +162,16 @@ def is_valid(self) -> bool: return self._impl.is_valid() @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the value of the widget changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.textinput.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) @property - def validators(self) -> list[callable]: + def validators(self) -> list[Callable[[str], bool]]: """The list of validators being used to check input on the widget. Changing the list of validators will cause validation to be performed. @@ -140,35 +179,35 @@ def validators(self) -> list[callable]: return self._validators @validators.setter - def validators(self, validators): + def validators(self, validators: Iterable[Callable[[str], bool]] | None) -> None: replacing = hasattr(self, "_validators") if validators is None: self._validators = [] else: - self._validators = validators + self._validators = list(validators) if replacing: self._validate() @property - def on_gain_focus(self) -> callable: + def on_gain_focus(self) -> OnGainFocusHandler: """The handler to invoke when the widget gains input focus.""" return self._on_gain_focus @on_gain_focus.setter - def on_gain_focus(self, handler): + def on_gain_focus(self, handler: OnGainFocusHandler) -> None: self._on_gain_focus = wrapped_handler(self, handler) @property - def on_lose_focus(self) -> callable: + def on_lose_focus(self) -> OnLoseFocusHandler: """The handler to invoke when the widget loses input focus.""" return self._on_lose_focus @on_lose_focus.setter - def on_lose_focus(self, handler): + def on_lose_focus(self, handler: OnLoseFocusHandler) -> None: self._on_lose_focus = wrapped_handler(self, handler) - def _validate(self): + def _validate(self) -> None: """Validate the current value of the widget. If a problem is found, the widget will be put into an error state. @@ -181,18 +220,18 @@ def _validate(self): else: self._impl.clear_error() - def _value_changed(self): + def _value_changed(self) -> None: """Validate the current value of the widget and invoke the on_change handler.""" self._validate() self.on_change() @property - def on_confirm(self) -> callable: + def on_confirm(self) -> OnConfirmHandler: """The handler to invoke when the user accepts the value of the widget, usually by pressing return/enter on the keyboard. """ return self._on_confirm @on_confirm.setter - def on_confirm(self, handler): + def on_confirm(self, handler: OnConfirmHandler) -> None: self._on_confirm = wrapped_handler(self, handler) diff --git a/core/src/toga/widgets/timeinput.py b/core/src/toga/widgets/timeinput.py index f1beee1041..0a23987bb0 100644 --- a/core/src/toga/widgets/timeinput.py +++ b/core/src/toga/widgets/timeinput.py @@ -2,21 +2,32 @@ import datetime import warnings +from typing import Any, Protocol +import toga from toga.handlers import wrapped_handler -from .base import Widget +from .base import StyleT, Widget + + +class OnChangeHandler(Protocol): + def __call__(self, widget: TimeInput, /, **kwargs: Any) -> object: + """A handler to invoke when the time input is changed. + + :param widget: The TimeInput that was changed. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ class TimeInput(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, value: datetime.time | None = None, min: datetime.time | None = None, max: datetime.time | None = None, - on_change: callable | None = None, + on_change: toga.widgets.timeinput.OnChangeHandler | None = None, ): """Create a new TimeInput widget. @@ -51,7 +62,18 @@ def value(self) -> datetime.time: """ return self._impl.get_value() - def _convert_time(self, value): + @value.setter + def value(self, value: object) -> None: + value = self._convert_time(value) + + if value < self.min: + value = self.min + elif value > self.max: + value = self.max + + self._impl.set_value(value) + + def _convert_time(self, value: object) -> datetime.time: if value is None: value = datetime.datetime.now().time() elif isinstance(value, datetime.datetime): @@ -65,17 +87,6 @@ def _convert_time(self, value): return value.replace(microsecond=0) - @value.setter - def value(self, value): - value = self._convert_time(value) - - if value < self.min: - value = self.min - elif value > self.max: - value = self.max - - self._impl.set_value(value) - @property def min(self) -> datetime.time: """The minimum allowable time (inclusive). A value of ``None`` will be converted @@ -87,7 +98,7 @@ def min(self) -> datetime.time: return self._impl.get_min_time() @min.setter - def min(self, value): + def min(self, value: object) -> None: if value is None: min = datetime.time(0, 0, 0) else: @@ -110,7 +121,7 @@ def max(self) -> datetime.time: return self._impl.get_max_time() @max.setter - def max(self, value): + def max(self, value: object) -> None: if value is None: max = datetime.time(23, 59, 59) else: @@ -123,18 +134,18 @@ def max(self, value): self.value = max @property - def on_change(self) -> callable: + def on_change(self) -> OnChangeHandler: """The handler to invoke when the time value changes.""" return self._on_change @on_change.setter - def on_change(self, handler): + def on_change(self, handler: toga.widgets.timeinput.OnChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) # 2023-05: Backwards compatibility class TimePicker(TimeInput): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): warnings.warn("TimePicker has been renamed TimeInput", DeprecationWarning) for old_name, new_name in [ @@ -155,28 +166,28 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @property - def min_time(self): + def min_time(self) -> datetime.time: warnings.warn( "TimePicker.min_time has been renamed TimeInput.min", DeprecationWarning ) return self.min @min_time.setter - def min_time(self, value): + def min_time(self, value: object) -> None: warnings.warn( "TimePicker.min_time has been renamed TimeInput.min", DeprecationWarning ) self.min = value @property - def max_time(self): + def max_time(self) -> datetime.time: warnings.warn( "TimePicker.max_time has been renamed TimeInput.max", DeprecationWarning ) return self.max @max_time.setter - def max_time(self, value): + def max_time(self, value: object) -> None: warnings.warn( "TimePicker.max_time has been renamed TimeInput.max", DeprecationWarning ) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 55608225b6..4c2b2c616e 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -1,28 +1,51 @@ from __future__ import annotations import warnings -from typing import Any +from collections.abc import Iterable +from typing import Any, Literal, Protocol, TypeVar +import toga from toga.handlers import wrapped_handler from toga.sources import Node, Source, TreeSource from toga.sources.accessors import build_accessors, to_accessor +from toga.style import Pack from .base import Widget +SourceT = TypeVar("SourceT", bound=Source) + + +class OnSelectHandler(Protocol): + def __call__(self, widget: Tree, /, **kwargs: Any) -> object: + """A handler to invoke when the tree is selected. + + :param widget: The Tree that was selected. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + +class OnActivateHandler(Protocol): + def __call__(self, widget: Tree, /, **kwargs: Any) -> object: + """A handler to invoke when the tree is activated. + + :param widget: The Tree that was activated. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + class Tree(Widget): def __init__( self, - headings: list[str] | None = None, - id=None, - style=None, - data: Any = None, - accessors: list[str] | None = None, + headings: Iterable[str] | None = None, + id: str | None = None, + style: Pack | None = None, + data: SourceT | object | None = None, + accessors: Iterable[str] | None = None, multiple_select: bool = False, - on_select: callable | None = None, - on_activate: callable | None = None, + on_select: toga.widgets.tree.OnSelectHandler | None = None, + on_activate: toga.widgets.tree.OnActivateHandler | None = None, missing_value: str = "", - on_double_click=None, # DEPRECATED + on_double_click: None = None, # DEPRECATED ): """Create a new Tree widget. @@ -73,12 +96,15 @@ def __init__( # End backwards compatibility. ###################################################################### + self._headings: list[str] | None + self._data: SourceT | TreeSource + if headings is not None: self._headings = [heading.split("\n")[0] for heading in headings] self._accessors = build_accessors(self._headings, accessors) elif accessors is not None: self._headings = None - self._accessors = accessors + self._accessors = list(accessors) else: raise ValueError( "Cannot create a tree without either headings or accessors" @@ -98,7 +124,7 @@ def __init__( self.on_activate = on_activate @property - def enabled(self) -> bool: + def enabled(self) -> Literal[True]: """Is the widget currently enabled? i.e., can the user interact with the widget? Tree widgets cannot be disabled; this property will always return True; any attempt to modify it will be ignored. @@ -106,15 +132,15 @@ def enabled(self) -> bool: return True @enabled.setter - def enabled(self, value): + def enabled(self, value: object) -> None: pass - def focus(self): - "No-op; Tree cannot accept input focus" + def focus(self) -> None: + """No-op; Tree cannot accept input focus.""" pass @property - def data(self) -> TreeSource: + def data(self) -> SourceT | TreeSource: """The data to display in the tree. When setting this property: @@ -130,7 +156,7 @@ def data(self) -> TreeSource: return self._data @data.setter - def data(self, data: Any): + def data(self, data: SourceT | object | None) -> None: if data is None: self._data = TreeSource(accessors=self._accessors, data=[]) elif isinstance(data, Source): @@ -159,7 +185,7 @@ def selection(self) -> list[Node] | Node | None: """ return self._impl.get_selection() - def expand(self, node: Node | None = None): + def expand(self, node: Node | None = None) -> None: """Expand the specified node of the tree. If no node is provided, all nodes of the tree will be expanded. @@ -176,7 +202,7 @@ def expand(self, node: Node | None = None): else: self._impl.expand_node(node) - def collapse(self, node: Node | None = None): + def collapse(self, node: Node | None = None) -> None: """Collapse the specified node of the tree. If no node is provided, all nodes of the tree will be collapsed. @@ -191,7 +217,7 @@ def collapse(self, node: Node | None = None): else: self._impl.collapse_node(node) - def append_column(self, heading: str, accessor: str | None = None): + def append_column(self, heading: str, accessor: str | None = None) -> None: """Append a column to the end of the tree. :param heading: The heading for the new column. @@ -204,9 +230,9 @@ def append_column(self, heading: str, accessor: str | None = None): def insert_column( self, index: int | str, - heading: str | None, + heading: str, accessor: str | None = None, - ): + ) -> None: """Insert an additional column into the tree. :param index: The index at which to insert the column, or the accessor of the @@ -239,7 +265,7 @@ def insert_column( self._impl.insert_column(index, heading, accessor) - def remove_column(self, column: int | str): + def remove_column(self, column: int | str) -> None: """Remove a tree column. :param column: The index of the column to remove, or the accessor of the column @@ -261,7 +287,7 @@ def remove_column(self, column: int | str): self._impl.remove_column(index) @property - def headings(self) -> list[str]: + def headings(self) -> list[str] | None: """The column headings for the tree (read-only)""" return self._headings @@ -278,22 +304,22 @@ def missing_value(self) -> str: return self._missing_value @property - def on_select(self) -> callable: + def on_select(self) -> OnSelectHandler: """The callback function that is invoked when a row of the tree is selected.""" return self._on_select @on_select.setter - def on_select(self, handler: callable): + def on_select(self, handler: toga.widgets.tree.OnSelectHandler) -> None: self._on_select = wrapped_handler(self, handler) @property - def on_activate(self) -> callable: + def on_activate(self) -> OnActivateHandler: """The callback function that is invoked when a row of the tree is activated, usually with a double click or similar action.""" return self._on_activate @on_activate.setter - def on_activate(self, handler): + def on_activate(self, handler: toga.widgets.tree.OnActivateHandler) -> None: self._on_activate = wrapped_handler(self, handler) ###################################################################### @@ -301,7 +327,7 @@ def on_activate(self, handler): ###################################################################### @property - def on_double_click(self): + def on_double_click(self) -> OnActivateHandler: """**DEPRECATED**: Use ``on_activate``""" warnings.warn( "Tree.on_double_click has been renamed Tree.on_activate.", @@ -310,7 +336,7 @@ def on_double_click(self): return self.on_activate @on_double_click.setter - def on_double_click(self, handler): + def on_double_click(self, handler: toga.widgets.tree.OnActivateHandler) -> None: warnings.warn( "Tree.on_double_click has been renamed Tree.on_activate.", DeprecationWarning, diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index f2282efe73..740d830af2 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -1,24 +1,34 @@ from __future__ import annotations import asyncio +from typing import Any, Protocol -from toga.handlers import AsyncResult, wrapped_handler +from toga.handlers import AsyncResult, OnResultT, wrapped_handler -from .base import Widget +from .base import StyleT, Widget class JavaScriptResult(AsyncResult): RESULT_TYPE = "JavaScript" +class OnWebViewLoadHandler(Protocol): + def __call__(self, widget: WebView, /, **kwargs: Any) -> object: + """A handler to invoke when the WebView is loaded. + + :param widget: The WebView that was loaded. + :param kwargs: Ensures compatibility with arguments added in future versions. + """ + + class WebView(Widget): def __init__( self, - id=None, - style=None, + id: str | None = None, + style: StyleT | None = None, url: str | None = None, user_agent: str | None = None, - on_webview_load: callable | None = None, + on_webview_load: OnWebViewLoadHandler | None = None, ): """Create a new WebView widget. @@ -32,7 +42,6 @@ def __init__( :param on_webview_load: A handler that will be invoked when the web view finishes loading. """ - super().__init__(id=id, style=style) self._impl = self.factory.WebView(interface=self) @@ -42,11 +51,9 @@ def __init__( self.on_webview_load = on_webview_load self.url = url - def _set_url(self, url, future): + def _set_url(self, url: str | None, future: asyncio.Future | None) -> None: # Utility method for validating and setting the URL with a future. - if (url is not None) and not ( - url.startswith("https://") or url.startswith("http://") - ): + if (url is not None) and not url.startswith(("https://", "http://")): raise ValueError("WebView can only display http:// and https:// URLs") self._impl.set_url(url, future=future) @@ -62,10 +69,10 @@ def url(self) -> str | None: return self._impl.get_url() @url.setter - def url(self, value): + def url(self, value: str | None) -> None: self._set_url(value, future=None) - async def load_url(self, url: str): + async def load_url(self, url: str) -> asyncio.Future: """Load a URL, and wait until the next :any:`on_webview_load` event. **Note:** On Android, this method will return immediately. @@ -78,7 +85,7 @@ async def load_url(self, url: str): return await loaded_future @property - def on_webview_load(self) -> callable: + def on_webview_load(self) -> OnWebViewLoadHandler: """The handler to invoke when the web view finishes loading. Rendering web content is a complex, multi-threaded process. Although a page @@ -95,7 +102,7 @@ def on_webview_load(self) -> callable: return self._on_webview_load @on_webview_load.setter - def on_webview_load(self, handler): + def on_webview_load(self, handler: OnWebViewLoadHandler) -> None: if handler and not getattr(self._impl, "SUPPORTS_ON_WEBVIEW_LOAD", True): self.factory.not_implemented("WebView.on_webview_load") @@ -111,10 +118,10 @@ def user_agent(self) -> str: return self._impl.get_user_agent() @user_agent.setter - def user_agent(self, value): + def user_agent(self, value: str) -> None: self._impl.set_user_agent(value) - def set_content(self, root_url: str, content: str): + def set_content(self, root_url: str, content: str) -> None: """Set the HTML content of the WebView. **Note:** On Android and Windows, the ``root_url`` argument is ignored. Calling @@ -126,7 +133,11 @@ def set_content(self, root_url: str, content: str): """ self._impl.set_content(root_url, content) - def evaluate_javascript(self, javascript, on_result=None) -> JavaScriptResult: + def evaluate_javascript( + self, + javascript: str, + on_result: OnResultT | None = None, + ) -> JavaScriptResult: """Evaluate a JavaScript expression. **This is an asynchronous method**. There is no guarantee that the JavaScript diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 651068d6b5..725e5c4a9e 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -2,22 +2,18 @@ import warnings from builtins import id as identifier -from collections.abc import Mapping, MutableSet +from collections.abc import Iterator from pathlib import Path from typing import ( TYPE_CHECKING, Any, - ItemsView, - Iterator, - KeysView, Literal, Protocol, TypeVar, - ValuesView, overload, ) -from toga.command import Command, CommandSet +from toga.command import CommandSet from toga.handlers import AsyncResult, wrapped_handler from toga.images import Image from toga.platform import get_platform_factory @@ -35,7 +31,7 @@ class FilteredWidgetRegistry: # A class that exposes a mapping lookup interface, filtered to widgets from a single # window. The underlying data store is on the app. - def __init__(self, window): + def __init__(self, window: Window) -> None: self._window = window def __len__(self) -> int: @@ -60,51 +56,49 @@ def __iter__(self) -> Iterator[Widget]: def __repr__(self) -> str: return "{" + ", ".join(f"{k!r}: {v!r}" for k, v in sorted(self.items())) + "}" - def items(self) -> ItemsView: + def items(self) -> Iterator[tuple[str, Widget]]: for item in self._window.app.widgets.items(): if item[1].window == self._window: yield item - def keys(self) -> KeysView: + def keys(self) -> Iterator[str]: for item in self._window.app.widgets.items(): if item[1].window == self._window: yield item[0] - def values(self) -> ValuesView: + def values(self) -> Iterator[Widget]: for item in self._window.app.widgets.items(): if item[1].window == self._window: yield item[1] class OnCloseHandler(Protocol): - def __call__(self, window: Window, **kwargs: Any) -> bool: + def __call__(self, window: Window, /, **kwargs: Any) -> bool: """A handler to invoke when a window is about to close. The return value of this callback controls whether the window is allowed to close. This can be used to prevent a window closing with unsaved changes, etc. :param window: The window instance that is closing. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. - :returns: ``True`` if the window is allowed to close; ``False`` if the window is not - allowed to close. + :param kwargs: Ensures compatibility with arguments added in future versions. + :returns: ``True`` if the window is allowed to close; ``False`` if the window + is not allowed to close. """ - ... -T = TypeVar("T") +_DialogResultT = TypeVar("_DialogResultT") -class DialogResultHandler(Protocol[T]): - def __call__(self, window: Window, result: T, **kwargs: Any) -> None: +class DialogResultHandler(Protocol[_DialogResultT]): + def __call__( + self, window: Window, result: _DialogResultT, /, **kwargs: Any + ) -> object: """A handler to invoke when a dialog is closed. :param window: The window that opened the dialog. + :param kwargs: Ensures compatibility with arguments added in future versions. :param result: The result returned by the dialog. - :param kwargs: Ensures compatibility with additional arguments introduced in - future versions. """ - ... class Dialog(AsyncResult): @@ -130,8 +124,8 @@ def __init__( minimizable: bool = True, on_close: OnCloseHandler | None = None, content: Widget | None = None, - resizeable=None, # DEPRECATED - closeable=None, # DEPRECATED + resizeable: None = None, # DEPRECATED + closeable: None = None, # DEPRECATED ) -> None: """Create a new Window. @@ -174,8 +168,8 @@ def __init__( from toga import App self._id = str(id if id else identifier(self)) - self._impl = None - self._content = None + self._impl: Any = None + self._content: Widget | None = None self._is_full_screen = False self._closed = False @@ -194,7 +188,8 @@ def __init__( ) # Add the window to the app - self._app = None + # _app will only be None until the window is added to the app below + self._app: App = None if App.app is None: raise RuntimeError("Cannot create a Window before creating an App") App.app.windows.add(self) @@ -208,7 +203,7 @@ def __init__( self.on_close = on_close - def __lt__(self, other) -> bool: + def __lt__(self, other: Window) -> bool: return self.id < other.id ###################################################################### @@ -330,12 +325,12 @@ def content(self, widget: Widget) -> None: widget.refresh() @property - def toolbar(self) -> MutableSet[Command]: + def toolbar(self) -> CommandSet: """Toolbar for the window.""" return self._toolbar @property - def widgets(self) -> Mapping[str, Widget]: + def widgets(self) -> FilteredWidgetRegistry: """The widgets contained in the window. Can be used to look up widgets by ID (e.g., ``window.widgets["my_id"]``). @@ -464,7 +459,7 @@ def as_image(self, format: type[ImageT] = Image) -> ImageT: ###################################################################### @property - def on_close(self) -> OnCloseHandler: + def on_close(self) -> OnCloseHandler | None: """The handler to invoke if the user attempts to close the window.""" return self._on_close @@ -605,7 +600,7 @@ def stack_trace_dialog( title: str, message: str, content: str, - retry: Literal[True] = False, + retry: Literal[True] = True, on_result: DialogResultHandler[bool] | None = None, ) -> Dialog: ... @@ -616,7 +611,7 @@ def stack_trace_dialog( message: str, content: str, retry: bool = False, - on_result: DialogResultHandler[bool | None] | None = None, + on_result: DialogResultHandler[bool] | DialogResultHandler[None] | None = None, ) -> Dialog: ... def stack_trace_dialog( @@ -625,7 +620,7 @@ def stack_trace_dialog( message: str, content: str, retry: bool = False, - on_result: DialogResultHandler[bool | None] | None = None, + on_result: DialogResultHandler[bool] | DialogResultHandler[None] | None = None, ) -> Dialog: """Open a dialog to display a large block of text, such as a stack trace. @@ -687,7 +682,7 @@ def save_file_dialog( # Convert suggested filename to a path (if it isn't already), # and break it into a filename and a directory suggested_path = Path(suggested_filename) - initial_directory = suggested_path.parent + initial_directory: Path | None = suggested_path.parent if initial_directory == Path("."): initial_directory = None filename = suggested_path.name @@ -708,8 +703,8 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: Literal[False] = False, - on_result: DialogResultHandler[Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: DialogResultHandler[Path] | DialogResultHandler[None] | None = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... @overload @@ -719,8 +714,10 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: Literal[True] = True, - on_result: DialogResultHandler[list[Path] | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] | DialogResultHandler[None] | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... @overload @@ -730,8 +727,13 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] + | DialogResultHandler[Path] + | DialogResultHandler[None] + | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... def open_file_dialog( @@ -740,8 +742,13 @@ def open_file_dialog( initial_directory: Path | str | None = None, file_types: list[str] | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] + | DialogResultHandler[Path] + | DialogResultHandler[None] + | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: """Prompt the user to select a file (or files) to open. @@ -797,8 +804,8 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: Literal[False] = False, - on_result: DialogResultHandler[Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: DialogResultHandler[Path] | DialogResultHandler[None] | None = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... @overload @@ -807,8 +814,10 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: Literal[True] = True, - on_result: DialogResultHandler[list[Path] | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] | DialogResultHandler[None] | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... @overload @@ -817,8 +826,13 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] + | DialogResultHandler[Path] + | DialogResultHandler[None] + | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: ... def select_folder_dialog( @@ -826,8 +840,13 @@ def select_folder_dialog( title: str, initial_directory: Path | str | None = None, multiple_select: bool = False, - on_result: DialogResultHandler[list[Path] | Path | None] | None = None, - multiselect=None, # DEPRECATED + on_result: ( + DialogResultHandler[list[Path]] + | DialogResultHandler[Path] + | DialogResultHandler[None] + | None + ) = None, + multiselect: None = None, # DEPRECATED ) -> Dialog: """Prompt the user to select a directory (or directories). @@ -878,7 +897,6 @@ def select_folder_dialog( ###################################################################### # 2023-08: Backwards compatibility ###################################################################### - @property def resizeable(self) -> bool: """**DEPRECATED** Use :attr:`resizable`""" @@ -896,3 +914,7 @@ def closeable(self) -> bool: DeprecationWarning, ) return self._closable + + ###################################################################### + # End Backwards compatibility + ###################################################################### diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index 284d584c69..b6e3d79a0e 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -70,7 +70,7 @@ of content can be modified after initial construction: icecream = toga.Box() container.content.append("Ice Cream", icecream, toga.Icon("icecream")) -OptionContainer content can also be specified by using :any:`OptionItem` instances +OptionContainer content can also be specified by using :any:`toga.OptionItem` instances instead of tuples. This enables you to be explicit when setting an icon or enabled status; it also allows you to set the initial enabled status *without* setting an icon: @@ -119,7 +119,7 @@ item, you can specify an item using: # Delete tab labeled "Pasta" del container.content["Pasta"] -* A reference to an :any:`OptionItem`: +* A reference to an :any:`toga.OptionItem`: .. code-block:: python @@ -174,16 +174,16 @@ Notes Reference --------- -.. c:type:: OptionContainerContent +.. c:type:: OptionContainerContentT An item of :any:`OptionContainer` content can be: * a 2-tuple, containing the title for the tab, and the content widget; - * a 3-tuple, containing the title, content widget, and :any:`icon ` + * a 3-tuple, containing the title, content widget, and :any:`icon ` for the tab; - * a 4-tuple, containing the title, content widget, :any:`icon ` for + * a 4-tuple, containing the title, content widget, :any:`icon ` for the tab, and enabled status; or - * an :any:`OptionItem` instance. + * an :any:`toga.OptionItem` instance. .. autoclass:: toga.OptionContainer :exclude-members: app, window @@ -193,4 +193,6 @@ Reference .. autoclass:: toga.widgets.optioncontainer.OptionList :special-members: __getitem__, __delitem__ +.. autoclass:: toga.widgets.optioncontainer.OptionItem + .. autoprotocol:: toga.widgets.optioncontainer.OnSelectHandler diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index cb068354f1..53020130d2 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -60,3 +60,5 @@ Reference .. autoclass:: toga.ScrollContainer :exclude-members: window, app + +.. autoprotocol:: toga.widgets.scrollcontainer.OnScrollHandler diff --git a/docs/reference/api/containers/splitcontainer.rst b/docs/reference/api/containers/splitcontainer.rst index 48c0ea8e13..6d04683daf 100644 --- a/docs/reference/api/containers/splitcontainer.rst +++ b/docs/reference/api/containers/splitcontainer.rst @@ -49,7 +49,7 @@ Usage left_container = toga.Box() right_container = toga.ScrollContainer() - split = toga.SplitContainer(content=[left_container, right_container]) + split = toga.SplitContainer(content=(left_container, right_container)) Content can be specified when creating the widget, or after creation by assigning the ``content`` attribute. The direction of the split can also be configured, either @@ -65,7 +65,7 @@ at time of creation, or by setting the ``direction`` attribute: left_container = toga.Box() right_container = toga.ScrollContainer() - split.content = [left_container, right_container] + split.content = (left_container, right_container) By default, the space of the SplitContainer will be evenly divided between the two panels. To specify an uneven split, you can provide a flex value when specifying @@ -80,7 +80,7 @@ and right panels. left_container = toga.Box() right_container = toga.ScrollContainer() - split.content = [(left_container, 3), (right_container, 2)] + split.content = ((left_container, 3), (right_container, 2)) This only specifies the initial split; the split can be modified by the user once it is displayed. @@ -88,5 +88,12 @@ once it is displayed. Reference --------- +.. c:type:: SplitContainerContentT + + An item of :any:`SplitContainer` content can be: + + * a :class:`~toga.Widget`; or + * a 2-tuple, containing a :class:`~toga.Widget`, and an :any:`int` flex value + .. autoclass:: toga.SplitContainer :exclude-members: HORIZONTAL, VERTICAL, window, app diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index 90cdfad228..3f517d4ef7 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -80,7 +80,7 @@ permission error). In this case, an error will be raised. Reference --------- -.. c:type:: IconContent +.. c:type:: IconContentT When specifying an :any:`Icon`, you can provide: diff --git a/docs/reference/api/resources/images.rst b/docs/reference/api/resources/images.rst index 59aa204451..498d2ccee8 100644 --- a/docs/reference/api/resources/images.rst +++ b/docs/reference/api/resources/images.rst @@ -30,7 +30,7 @@ Usage widget configured to use an Icon (probably :class:`~toga.Button`), *not* an ``on_press`` handler on an :class:`~toga.Image` or :class:`~toga.ImageView`. -An image can be constructed from a :any:`wide range of sources `: +An image can be constructed from a :any:`wide range of sources `: .. code-block:: python @@ -86,13 +86,13 @@ Notes Reference --------- -.. c:type:: ImageContent +.. c:type:: ImageContentT When specifying content for an :any:`Image`, you can provide: * a string specifying an absolute or relative path to a file in a :ref:`known image format `; - * an absolute or relative :any:`pathlib.Path` object describing a file in a + * an absolute or relative :class:`~pathlib.Path` object describing a file in a :ref:`known image format `; * a "blob of bytes" data type (:any:`bytes`, :any:`bytearray`, or :any:`memoryview`) containing raw image data in a :ref:`known image format `; diff --git a/docs/reference/api/widgets/dateinput.rst b/docs/reference/api/widgets/dateinput.rst index 2854869094..c0da1d954d 100644 --- a/docs/reference/api/widgets/dateinput.rst +++ b/docs/reference/api/widgets/dateinput.rst @@ -60,3 +60,5 @@ Reference --------- .. autoclass:: toga.DateInput + +.. autoprotocol:: toga.widgets.dateinput.OnChangeHandler diff --git a/docs/reference/api/widgets/detailedlist.rst b/docs/reference/api/widgets/detailedlist.rst index 55693e2bb2..306d858e58 100644 --- a/docs/reference/api/widgets/detailedlist.rst +++ b/docs/reference/api/widgets/detailedlist.rst @@ -146,3 +146,8 @@ Reference --------- .. autoclass:: toga.DetailedList + +.. autoprotocol:: toga.widgets.detailedlist.OnPrimaryActionHandler +.. autoprotocol:: toga.widgets.detailedlist.OnSecondaryActionHandler +.. autoprotocol:: toga.widgets.detailedlist.OnRefreshHandler +.. autoprotocol:: toga.widgets.detailedlist.OnSelectHandler diff --git a/docs/reference/api/widgets/multilinetextinput.rst b/docs/reference/api/widgets/multilinetextinput.rst index 35fbe4f176..8c69cd4cd2 100644 --- a/docs/reference/api/widgets/multilinetextinput.rst +++ b/docs/reference/api/widgets/multilinetextinput.rst @@ -71,3 +71,5 @@ Reference --------- .. autoclass:: toga.MultilineTextInput + +.. autoprotocol:: toga.widgets.multilinetextinput.OnChangeHandler diff --git a/docs/reference/api/widgets/numberinput.rst b/docs/reference/api/widgets/numberinput.rst index 68bde9474d..77b532a693 100644 --- a/docs/reference/api/widgets/numberinput.rst +++ b/docs/reference/api/widgets/numberinput.rst @@ -53,11 +53,13 @@ Usage widget = toga.NumberInput(min_value=1, max_value=10, step=0.001) widget.value = 2.718 -NumberInput's properties can accept integers, floats, and strings containing -numbers, but they always return :any:`decimal.Decimal` objects to ensure -precision is retained. +NumberInput's properties can accept :class:`~decimal.Decimal`, :any:`int`, :any:`float`, +or :any:`str` containing numbers, but they always return :class:`~decimal.Decimal` +objects to ensure precision is retained. Reference --------- .. autoclass:: toga.NumberInput + +.. autoprotocol:: toga.widgets.numberinput.OnChangeHandler diff --git a/docs/reference/api/widgets/slider.rst b/docs/reference/api/widgets/slider.rst index a2569d5bdb..c021103806 100644 --- a/docs/reference/api/widgets/slider.rst +++ b/docs/reference/api/widgets/slider.rst @@ -69,3 +69,7 @@ Reference --------- .. autoclass:: toga.Slider + +.. autoprotocol:: toga.widgets.slider.OnChangeHandler +.. autoprotocol:: toga.widgets.slider.OnPressHandler +.. autoprotocol:: toga.widgets.slider.OnReleaseHandler diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index d4081b9141..38d8d87e74 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -84,3 +84,5 @@ Reference --------- .. autoclass:: toga.Switch + +.. autoprotocol:: toga.widgets.switch.OnChangeHandler diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 66f6c8dfbd..a910c688d9 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -135,3 +135,6 @@ Reference --------- .. autoclass:: toga.Table + +.. autoprotocol:: toga.widgets.table.OnSelectHandler +.. autoprotocol:: toga.widgets.table.OnActivateHandler diff --git a/docs/reference/api/widgets/textinput.rst b/docs/reference/api/widgets/textinput.rst index f95e64d242..49512eb092 100644 --- a/docs/reference/api/widgets/textinput.rst +++ b/docs/reference/api/widgets/textinput.rst @@ -94,3 +94,8 @@ Reference --------- .. autoclass:: toga.TextInput + +.. autoprotocol:: toga.widgets.textinput.OnChangeHandler +.. autoprotocol:: toga.widgets.textinput.OnConfirmHandler +.. autoprotocol:: toga.widgets.textinput.OnGainFocusHandler +.. autoprotocol:: toga.widgets.textinput.OnLoseFocusHandler diff --git a/docs/reference/api/widgets/timeinput.rst b/docs/reference/api/widgets/timeinput.rst index c6d8527b72..1218cc4f45 100644 --- a/docs/reference/api/widgets/timeinput.rst +++ b/docs/reference/api/widgets/timeinput.rst @@ -64,3 +64,5 @@ Reference --------- .. autoclass:: toga.TimeInput + +.. autoprotocol:: toga.widgets.timeinput.OnChangeHandler diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index 9bb05f5706..cba03f43e1 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -152,3 +152,6 @@ Reference --------- .. autoclass:: toga.Tree + +.. autoprotocol:: toga.widgets.tree.OnSelectHandler +.. autoprotocol:: toga.widgets.tree.OnActivateHandler diff --git a/docs/reference/api/widgets/webview.rst b/docs/reference/api/widgets/webview.rst index 0927cfc2f2..ccc035fa9d 100644 --- a/docs/reference/api/widgets/webview.rst +++ b/docs/reference/api/widgets/webview.rst @@ -109,3 +109,5 @@ Reference --------- .. autoclass:: toga.WebView + +.. autoprotocol:: toga.widgets.webview.OnWebViewLoadHandler diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 2f5cb2a368..6cf24d63b1 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -42,14 +42,14 @@ An operating system-managed container of widgets. Usage ----- -A window is the top-level container that the operating system uses to display widgets. A -window may also have other decorations, such as a title bar or toolbar. +A window is the top-level container that the operating system uses to display widgets. +A window may also have other decorations, such as a title bar or toolbar. When first created, a window is not visible. To display it, call the :meth:`~toga.Window.show` method. The window has content, which will usually be a container widget of some kind. The -content of the window can be changed by re-assigning its `content` attribute to a +content of the window can be changed by re-assigning its ``content`` attribute to a different widget. .. code-block:: python @@ -96,5 +96,6 @@ Reference .. autoclass:: toga.Window +.. autoprotocol:: toga.window.Dialog .. autoprotocol:: toga.window.OnCloseHandler .. autoprotocol:: toga.window.DialogResultHandler diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index aa4837f1be..6992245f2c 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -3,6 +3,7 @@ accessors amongst App apps +Async awaitable backend backends diff --git a/dummy/src/toga_dummy/plugins/image_formats.py b/dummy/src/toga_dummy/plugins/image_formats.py index d58686b739..923ceb418c 100644 --- a/dummy/src/toga_dummy/plugins/image_formats.py +++ b/dummy/src/toga_dummy/plugins/image_formats.py @@ -6,7 +6,7 @@ import toga if TYPE_CHECKING: - from toga.images import BytesLike + from toga.images import BytesLikeT class CustomImage: @@ -26,7 +26,7 @@ def convert_from_format(image_in_format: CustomImage): @staticmethod def convert_to_format( - data: BytesLike, + data: BytesLikeT, image_class: type[CustomImage], ) -> CustomImage: image = image_class() @@ -45,7 +45,7 @@ def convert_from_format(image_in_format: Any): @staticmethod def convert_to_format( - data: BytesLike, + data: BytesLikeT, image_class: type[Any], ) -> Any: raise Exception("Converter should be disabled")