From e3f393619bfb90cf808a06dc810f7f97532ef037 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Mon, 3 Apr 2023 14:59:39 +0100 Subject: [PATCH 1/6] Delay imports in api modules if toolkit will be imported (#1233) This is uses module `__getattr__` and `__dir__` based on [PEP 562](https://peps.python.org/pep-0562/) which is available now we are dropping Python 3.6 support. Basic strategy is to handle two classes of imports - those which come directly via the toolkit object, and those which are via an import relative to the api module. This allows us to eventually deprecate and remove stub modules which do nothing more than import using the toolkit, but we don't do that in this PR. We test by checking that every public attribute returned by `dir()` can be accessed. Includes a few drive-by fixes to avoid toolkit selection where where possible. The most significant is adding formal `Interface` classes for `ActionManager` and its subclasses (and using those on `ApplicationWindow`). It also cleans up a deprecated trait on `ApplicationWindow`. --- docs/source/toolkits.rst | 15 +- pyface/action/action_manager.py | 9 +- pyface/action/action_manager_item.py | 2 +- pyface/action/api.py | 74 ++++++- pyface/action/group.py | 3 +- pyface/action/i_action_manager.py | 199 +++++++++++++++++ pyface/action/i_menu_bar_manager.py | 33 +++ pyface/action/i_menu_manager.py | 45 ++++ pyface/action/i_status_bar_manager.py | 43 ++++ pyface/action/i_tool_bar_manager.py | 50 +++++ pyface/action/tests/test_api.py | 35 +++ pyface/action/window_action.py | 5 +- pyface/api.py | 202 ++++++++++-------- pyface/base_toolkit.py | 6 +- pyface/data_view/api.py | 60 +++++- pyface/fields/api.py | 53 ++++- pyface/fields/tests/test_api.py | 35 +++ pyface/i_application_window.py | 36 ++-- pyface/i_image_resource.py | 3 +- pyface/tasks/action/api.py | 3 +- pyface/tasks/action/dock_pane_toggle_group.py | 3 +- pyface/tasks/action/task_action.py | 10 +- pyface/tasks/action/task_toggle_group.py | 7 +- .../tasks/action/task_window_toggle_group.py | 5 +- pyface/tasks/api.py | 81 +++++-- pyface/tasks/task.py | 3 +- pyface/tasks/task_window.py | 33 ++- pyface/tasks/tests/test_api.py | 35 +++ pyface/tests/test_api.py | 14 +- pyface/timer/api.py | 51 ++++- pyface/timer/tests/test_api.py | 35 +++ pyface/ui/qt/action/menu_bar_manager.py | 5 +- pyface/ui/qt/action/menu_manager.py | 4 +- pyface/ui/qt/action/status_bar_manager.py | 5 +- pyface/ui/qt/action/tool_bar_manager.py | 7 +- pyface/ui/qt/application_window.py | 42 +--- pyface/ui/wx/action/menu_bar_manager.py | 3 + pyface/ui/wx/action/menu_manager.py | 4 +- pyface/ui/wx/action/status_bar_manager.py | 5 +- pyface/ui/wx/action/tool_bar_manager.py | 9 +- pyface/ui/wx/application_window.py | 49 +---- pyface/ui/wx/init.py | 4 + pyface/wizard/api.py | 63 +++++- 43 files changed, 1109 insertions(+), 279 deletions(-) create mode 100644 pyface/action/i_action_manager.py create mode 100644 pyface/action/i_menu_bar_manager.py create mode 100644 pyface/action/i_menu_manager.py create mode 100644 pyface/action/i_status_bar_manager.py create mode 100644 pyface/action/i_tool_bar_manager.py create mode 100644 pyface/action/tests/test_api.py create mode 100644 pyface/fields/tests/test_api.py create mode 100644 pyface/tasks/tests/test_api.py create mode 100644 pyface/timer/tests/test_api.py diff --git a/docs/source/toolkits.rst b/docs/source/toolkits.rst index 5bc91eaa2..9960000c8 100644 --- a/docs/source/toolkits.rst +++ b/docs/source/toolkits.rst @@ -23,9 +23,10 @@ ways: from tratis.etsconfig.api import ETSConfig ETSConfig.toolkit = 'qt' - This must be done _before_ any widget imports in your application, including - importing :py:mod:`pyface.api`. Precisely, this must be set before the - first import of :py:mod:`pyface.toolkit`. + This must be done _before_ any widget imports in your application. + Precisely, this must be set before the first call to + :py:func:`pyface.base_toolkit.find_toolkit` (which usually happens + as a side-effect of importing :py:mod:`pyface.toolkit`). If for some reason Pyface can't load a deliberately specified toolkit, then it will raise an exception. @@ -35,6 +36,14 @@ toolkits, in that order, and then any other toolkits that it knows about other than ``null``. If all of those fail, then it will try to load the ``null`` toolkit. +Pyface tries to defer toolkit selection as long as possible until it is +actually needed because importing a toolkit tends to be slow and have +significant side-effects. Very occasionally an application or test suite may +need to ensure that the toolkit has been selected (for example, to enable +"ui" dispatch from background threads in Traits). This can be achieved by +either importing :py:mod:`pyface.toolkit` or, more directly, by calling +:py:func:`pyface.base_toolkit.find_toolkit`. + Once selected, the toolkit infrastructure is largely transparent to the application. diff --git a/pyface/action/action_manager.py b/pyface/action/action_manager.py index 29dda760c..d5d0de280 100644 --- a/pyface/action/action_manager.py +++ b/pyface/action/action_manager.py @@ -45,7 +45,7 @@ class ActionManager(HasTraits): enabled = Bool(True) #: All of the contribution groups in the manager. - groups = Property(List(Group)) + groups = Property(List(Instance(Group)), observe='_groups.items') #: The manager's unique identifier (if it has one). id = Str() @@ -61,7 +61,7 @@ class ActionManager(HasTraits): # Private interface ---------------------------------------------------- #: All of the contribution groups in the manager. - _groups = List(Group) + _groups = List(Instance(Group)) # ------------------------------------------------------------------------ # 'object' interface. @@ -272,6 +272,8 @@ def walk_group(self, group, fn): Parameters ---------- + group : Group + The group to walk. fn : callable A callable to apply to the tree of groups and items. """ @@ -290,6 +292,9 @@ def walk_item(self, item, fn): Parameters ---------- + item : item + The item to walk. This may be a submenu or similar in addition to + simple Action items. fn : callable A callable to apply to the tree of items and subgroups. """ diff --git a/pyface/action/action_manager_item.py b/pyface/action/action_manager_item.py index b4cf6b675..a407b99e5 100644 --- a/pyface/action/action_manager_item.py +++ b/pyface/action/action_manager_item.py @@ -32,7 +32,7 @@ class ActionManagerItem(HasTraits): id = Str() #: The group the item belongs to. - parent = Instance("pyface.action.api.Group") + parent = Instance("pyface.action.group.Group") #: Is the item enabled? enabled = Bool(True) diff --git a/pyface/action/api.py b/pyface/action/api.py index ad10c0ef5..82e830e87 100644 --- a/pyface/action/api.py +++ b/pyface/action/api.py @@ -65,10 +65,11 @@ """ +# Imports which don't select the toolkit as a side-effect. + from .action import Action from .action_controller import ActionController from .action_event import ActionEvent -from .action_item import ActionItem from .action_manager import ActionManager from .action_manager_item import ActionManagerItem from .field_action import FieldAction @@ -80,16 +81,71 @@ ExitAction, GUIApplicationAction, ) +from .i_action_manager import IActionManager +from .i_menu_bar_manager import IMenuBarManager +from .i_menu_manager import IMenuManager +from .i_status_bar_manager import IStatusBarManager +from .i_tool_bar_manager import IToolBarManager from .listening_action import ListeningAction -from .menu_manager import MenuManager -from .menu_bar_manager import MenuBarManager -from .status_bar_manager import StatusBarManager -from .tool_bar_manager import ToolBarManager from .traitsui_widget_action import TraitsUIWidgetAction from .window_action import CloseWindowAction, WindowAction -# This part of the module handles widgets that are still wx specific. This -# will all be removed when everything has been ported to PyQt and pyface -# becomes toolkit agnostic. -from traits.etsconfig.api import ETSConfig +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'MenuManager': "menu_manager", + 'MenuBarManager': "menu_bar_manager", + 'StatusBarManager': "status_bar_manager", + 'ToolBarManager': "tool_bar_manager", +} + +# These are pyface.* imports that have selection as a side-effect +# TODO: refactor to delay imports where possible +_relative_imports = { + 'ActionItem': "action_item", +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _relative_imports: + from importlib import import_module + source = _relative_imports[name] + module = import_module(f"pyface.action.{source}") + result = getattr(module, name) + + elif name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"action.{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +# the list of available names we report for introspection purposes +_extra_names = set(_toolkit_imports) | set(_relative_imports) + + +def __dir__(): + return sorted(set(globals()) | _extra_names) diff --git a/pyface/action/group.py b/pyface/action/group.py index bd5cb1da6..3830d8f86 100644 --- a/pyface/action/group.py +++ b/pyface/action/group.py @@ -18,7 +18,6 @@ from pyface.action.action import Action -from pyface.action.action_item import ActionItem class Group(HasTraits): @@ -151,6 +150,8 @@ def insert(self, index, item): action, and that is inserted. If the item is a callable, then an Action is created for the callable, and then that is handled as above. """ + from pyface.action.action_item import ActionItem + if isinstance(item, Action): item = ActionItem(action=item) elif callable(item): diff --git a/pyface/action/i_action_manager.py b/pyface/action/i_action_manager.py new file mode 100644 index 000000000..f813cd051 --- /dev/null +++ b/pyface/action/i_action_manager.py @@ -0,0 +1,199 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +"""Interface for all action managers.""" + + +from traits.api import Bool, Constant, Event, Instance, Interface, List, Str + +from pyface.action.action_controller import ActionController +from pyface.action.group import Group + + +class IActionManager(Interface): + """ Abstract base class for all action managers. + + An action manager contains a list of groups, with each group containing a + list of items. + + There are currently three concrete sub-classes: + + 1) 'MenuBarManager' + 2) 'MenuManager' + 3) 'ToolBarManager' + + """ + + # 'ActionManager' interface -------------------------------------------- + + #: The Id of the default group. + DEFAULT_GROUP = Constant("additions") + + #: The action controller (if any) used to control how actions are performed. + controller = Instance(ActionController) + + #: Is the action manager enabled? + enabled = Bool(True) + + #: All of the contribution groups in the manager. + groups = List(Instance(Group)) + + #: The manager's unique identifier (if it has one). + id = Str() + + #: Is the action manager visible? + visible = Bool(True) + + # Events ---- + + #: fixme: We probably need more granular events than this! + changed = Event() + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __init__(self, *items, **traits): + """ Creates a new action manager. + + Parameters + ---------- + items : collection of strings, Group, or ActionManagerItem instances + Positional arguments are interpreted as Items or Groups + managed by the action manager. + traits : additional traits + Traits to be passed to the usual Traits ``__init__``. + + Notes + ----- + + If a Group is passed as a positional agrument then it is added to the + manager and any subsequent Items arguments are appended to the Group + until another Group is encountered. + + If a string is passed, a Group is created with id set to the string. + """ + + # ------------------------------------------------------------------------ + # 'IActionManager' interface. + # ------------------------------------------------------------------------ + + def append(self, item): + """ Append an item to the manager. + + Parameters + ---------- + item : string, Group instance or ActionManagerItem instance + The item to append. + + Notes + ----- + + If the item is a group, the Group is appended to the manager's list + of groups. It the item is a string, then a group is created with + the string as the ``id`` and the new group is appended to the list + of groups. If the item is an ActionManagerItem then the item is + appended to the manager's default group. + """ + + def destroy(self): + """ Called when the manager is no longer required. + + By default this method simply calls 'destroy' on all of the manager's + groups. + """ + + def insert(self, index, item): + """ Insert an item into the manager at the specified index. + + Parameters + ---------- + index : int + The position at which to insert the object + item : string, Group instance or ActionManagerItem instance + The item to insert. + + Notes + ----- + + If the item is a group, the Group is inserted into the manager's list + of groups. It the item is a string, then a group is created with + the string as the ``id`` and the new group is inserted into the list + of groups. If the item is an ActionManagerItem then the item is + inserted into the manager's defualt group. + """ + + def find_group(self, id): + """ Find a group with a specified Id. + + Parameters + ---------- + id : str + The id of the group to find. + + Returns + ------- + group : Group instance + The group which matches the id, or None if no such group exists. + """ + + def find_item(self, path): + """ Find an item using a path. + + Parameters + ---------- + path : str + A '/' separated list of contribution Ids. + + Returns + ------- + item : ActionManagerItem or None + Returns the matching ActionManagerItem, or None if any component + of the path is not found. + """ + + def walk(self, fn): + """ Walk the manager applying a function at every item. + + The components are walked in pre-order. + + Parameters + ---------- + fn : callable + A callable to apply to the tree of groups and items, starting with + the manager. + """ + + def walk_group(self, group, fn): + """ Walk a group applying a function at every item. + + The components are walked in pre-order. + + Parameters + ---------- + group : Group + The group to walk. + fn : callable + A callable to apply to the tree of groups and items. + """ + + def walk_item(self, item, fn): + """ Walk an item (may be a sub-menu manager remember!). + + The components are walked in pre-order. + + Parameters + ---------- + item : item + The item to walk. This may be a submenu or similar in addition to + simple Action items. + fn : callable + A callable to apply to the tree of items and subgroups. + """ diff --git a/pyface/action/i_menu_bar_manager.py b/pyface/action/i_menu_bar_manager.py new file mode 100644 index 000000000..33b08ee11 --- /dev/null +++ b/pyface/action/i_menu_bar_manager.py @@ -0,0 +1,33 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +""" The interface for a menu bar manager. """ + +from pyface.action.i_action_manager import IActionManager + + +class IMenuBarManager(IActionManager): + """ The interface for a menu bar manager. """ + + # ------------------------------------------------------------------------ + # 'MenuBarManager' interface. + # ------------------------------------------------------------------------ + + def create_menu_bar(self, parent, controller=None): + """ Creates a menu bar representation of the manager. + + Parameters + ---------- + parent : toolkit control + The toolkit control that owns the menubar. + controller : ActionController + An optional ActionController for all items in the menubar. + """ diff --git a/pyface/action/i_menu_manager.py b/pyface/action/i_menu_manager.py new file mode 100644 index 000000000..9469fa7d9 --- /dev/null +++ b/pyface/action/i_menu_manager.py @@ -0,0 +1,45 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Instance, Str + +from pyface.action.action import Action +from pyface.action.i_action_manager import IActionManager + + +class IMenuManager(IActionManager): + """ A menu manager realizes itself in a menu control. + + This could be a sub-menu or a context (popup) menu. + """ + + # 'IMenuManager' interface ---------------------------------------------# + + # The menu manager's name (if the manager is a sub-menu, this is what its + # label will be). + name = Str() + + # The default action for tool button when shown in a toolbar (Qt only) + action = Instance(Action) + + # ------------------------------------------------------------------------ + # 'IMenuManager' interface. + # ------------------------------------------------------------------------ + + def create_menu(self, parent, controller=None): + """ Creates a menu representation of the manager. + + Parameters + ---------- + parent : toolkit control + The toolkit control that owns the menu. + controller : ActionController + An optional ActionController for all items in the menu. + """ diff --git a/pyface/action/i_status_bar_manager.py b/pyface/action/i_status_bar_manager.py new file mode 100644 index 000000000..c2d43def0 --- /dev/null +++ b/pyface/action/i_status_bar_manager.py @@ -0,0 +1,43 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +from traits.api import Any, Bool, Interface, List, Str + + +class IStatusBarManager(Interface): + """ The interface for a status bar manager. """ + + # The message displayed in the first field of the status bar. + message = Str() + + # The messages to be displayed in the status bar fields. + messages = List(Str) + + # The toolkit-specific control that represents the status bar. + status_bar = Any() + + # Whether to show a size grip on the status bar. + size_grip = Bool(False) + + # Whether the status bar is visible. + visible = Bool(True) + + def create_status_bar(self, parent): + """ Creates a status bar. + + Parameters + ---------- + parent : toolkit control + The toolkit control that owns the status bar. + """ + + def destroy(self): + """ Destroys the status bar. """ diff --git a/pyface/action/i_tool_bar_manager.py b/pyface/action/i_tool_bar_manager.py new file mode 100644 index 000000000..f98863c13 --- /dev/null +++ b/pyface/action/i_tool_bar_manager.py @@ -0,0 +1,50 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Bool, Str, Tuple + +from pyface.action.i_action_manager import IActionManager +from pyface.ui_traits import Orientation + + +class IToolBarManager(IActionManager): + """ The interface for a tool bar manager. """ + + # 'IToolBarManager' interface ------------------------------------------- + + # The size of tool images (width, height). + image_size = Tuple((16, 16)) + + # The toolbar name (used to distinguish multiple toolbars). + name = Str("ToolBar") + + # The orientation of the toolbar. + orientation = Orientation("horizontal") + + # Should we display the name of each tool bar tool under its image? + show_tool_names = Bool(True) + + # Should we display the horizontal divider? + show_divider = Bool(True) + + # ------------------------------------------------------------------------ + # 'ToolBarManager' interface. + # ------------------------------------------------------------------------ + + def create_tool_bar(self, parent, controller=None): + """ Creates a tool bar. + + Parameters + ---------- + parent : toolkit control + The toolkit control that owns the toolbar. + controller : ActionController + An optional ActionController for all items in the toolbar. + """ diff --git a/pyface/action/tests/test_api.py b/pyface/action/tests/test_api.py new file mode 100644 index 000000000..27523b7e2 --- /dev/null +++ b/pyface/action/tests/test_api.py @@ -0,0 +1,35 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Test for pyface.api """ + +import unittest + + +class TestApi(unittest.TestCase): + """ Test importable items in any environment.""" + + def test_api_importable(self): + # make sure api is importable with the most minimal + # required dependencies, including in the absence of toolkit backends. + from pyface.action import api # noqa: F401 + + def test_public_attrs(self): + # make sure everything advertised by dir() is available except optional + from pyface.action import api + + attrs = [ + name + for name in dir(api) + if not name.startswith('_') + ] + for attr in attrs: + with self.subTest(attr=attr): + self.assertIsNotNone(getattr(api, attr, None)) diff --git a/pyface/action/window_action.py b/pyface/action/window_action.py index 7bfd4a1bd..73979bcdf 100644 --- a/pyface/action/window_action.py +++ b/pyface/action/window_action.py @@ -13,11 +13,10 @@ """ Abstract base class for all window actions. """ -from pyface.window import Window from traits.api import Instance, Property - from pyface.action.listening_action import ListeningAction +from pyface.i_window import IWindow class WindowAction(ListeningAction): @@ -30,7 +29,7 @@ class WindowAction(ListeningAction): # 'WindowAction' interface ----------------------------------------------- #: The window that the action is associated with. - window = Instance(Window) + window = Instance(IWindow) # ------------------------------------------------------------------------ # Protected interface. diff --git a/pyface/api.py b/pyface/api.py index 0ea18758b..aea9f7d3c 100644 --- a/pyface/api.py +++ b/pyface/api.py @@ -16,6 +16,7 @@ - :class:`~.ApplicationWindow` - :attr:`~.clipboard` - :class:`~.Clipboard` +- :func:`~.find_toolkit` - :class:`~.GUI` - :class:`~.GUIApplication` - :class:`~.ImageResource` @@ -24,6 +25,7 @@ - :class:`~.SplitApplicationWindow` - :class:`~.SplitPanel` - :class:`~.SystemMetrics` +- :class:`~.Toolkit` - :class:`~.Window` - :class:`~.Widget` @@ -134,87 +136,15 @@ """ -import logging as _logging +# Imports which don't select the toolkit as a side-effect. -from .about_dialog import AboutDialog from .application import Application -from .application_window import ApplicationWindow -from .beep import beep -from .clipboard import clipboard, Clipboard -from .confirmation_dialog import confirm, ConfirmationDialog +from .base_toolkit import Toolkit, find_toolkit from .color import Color from .constant import OK, CANCEL, YES, NO -from .dialog import Dialog -from .directory_dialog import DirectoryDialog -from .drop_handler import BaseDropHandler, FileDropHandler -from .file_dialog import FileDialog from .filter import Filter from .font import Font -from .gui import GUI from .gui_application import GUIApplication -from .heading_text import HeadingText -from .image_cache import ImageCache -from .image_resource import ImageResource -from .key_pressed_event import KeyPressedEvent -from .layered_panel import LayeredPanel -from .message_dialog import error, information, warning, MessageDialog -from .progress_dialog import ProgressDialog - -from .util._optional_dependencies import optional_import as _optional_import - -# Excuse numpy dependency, otherwise re-raise -with _optional_import( - "numpy", - msg="ArrayImage not available due to missing numpy.", - logger=_logging.getLogger(__name__)): - - # We need to manually try importing numpy because the ``ArrayImage`` - # import will end up raising a ``TraitError`` exception instead of an - # ``ImportError``, which isnt caught by ``_optional_import``. - import numpy - - from .array_image import ArrayImage - - del numpy - -# Excuse pillow dependency, otherwise re-raise -with _optional_import( - "pillow", - msg="PILImage not available due to missing pillow.", - logger=_logging.getLogger(__name__)): - from .pil_image import PILImage - -# Excuse pygments dependency (for Qt), otherwise re-raise -with _optional_import( - "pygments", - msg="PythonEditor not available due to missing pygments.", - logger=_logging.getLogger(__name__)): - from .python_editor import PythonEditor - -with _optional_import( - "pygments", - msg="PythonShell not available due to missing pygments.", - logger=_logging.getLogger(__name__)): - from .python_shell import PythonShell - -from .sorter import Sorter -from .single_choice_dialog import choose_one, SingleChoiceDialog -from .splash_screen import SplashScreen -from .split_application_window import SplitApplicationWindow -from .split_dialog import SplitDialog -from .split_panel import SplitPanel -from .system_metrics import SystemMetrics -from .ui_traits import ( - Alignment, Border, HasBorder, HasMargin, Image, Margin, Orientation, - Position, PyfaceColor, PyfaceFont -) -from .window import Window -from .widget import Widget - -# ---------------------------------------------------------------------------- -# Public Interfaces -# ---------------------------------------------------------------------------- - from .i_about_dialog import IAboutDialog from .i_application_window import IApplicationWindow from .i_clipboard import IClipboard @@ -241,27 +171,121 @@ from .i_system_metrics import ISystemMetrics from .i_widget import IWidget from .i_window import IWindow +from .sorter import Sorter +from .ui_traits import ( + Alignment, Border, HasBorder, HasMargin, Image, Margin, Orientation, + Position, PyfaceColor, PyfaceFont +) # ---------------------------------------------------------------------------- -# Legacy and Wx-specific imports. +# Deferred imports # ---------------------------------------------------------------------------- -# These widgets currently only have Wx implementations -# will return Unimplemented for Qt. +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'AboutDialog': 'about_dialog', + 'ApplicationWindow': "application_window", + 'BaseDropHandler': "drop_handler", + 'beep': "beep", + 'Clipboard': "clipboard", + 'ConfirmationDialog': "confirmation_dialog", + 'Dialog': "dialog", + 'DirectoryDialog': "directory_dialog", + 'FileDropHandler': "drop_handler", + 'FileDialog': "file_dialog", + 'GUI': 'gui', + 'HeadingText': "heading_text", + 'ImageCache': "image_cache", + 'ImageResource': "image_resource", + 'KeyPressedEvent': "key_pressed_event", + 'LayeredPanel': "layered_panel", + 'MessageDialog': "message_dialog", + 'ProgressDialog': "progress_dialog", + 'SingleChoiceDialog': "single_choice_dialog", + 'SplashScreen': "splash_screen", + 'SystemMetrics': "system_metrics", + 'Window': "window", + 'Widget': "widget", + + # Wx-only (or legacy) imports + 'ExpandablePanel': "expandable_panel", + 'ImageWidget': "image_widget", + 'MDIApplicationWindow': "mdi_application_window", + 'MDIWindowMenu': "mdi_window_menu", + 'MultiToolbarWindow': "multi_toolbar_window", +} + +# These are pyface.* imports that have selection as a side-effect +# TODO: refactor to delay imports where possible +_relative_imports = { + 'choose_one': "single_choice_dialog", + 'clipboard': "clipboard", + 'confirm': "confirmation_dialog", + 'error': "message_dialog", + 'information': "message_dialog", + 'SplitApplicationWindow': "split_application_window", + 'SplitDialog': "split_dialog", + 'SplitPanel': "split_panel", + 'warning': "message_dialog", +} +_optional_imports = { + 'ArrayImage': ("numpy", "array_image"), + 'PILImage': ("pillow", "pil_image"), + 'PythonEditor': ("pygments", "python_editor"), + 'PythonShell': ("pygments", "python_shell"), +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _relative_imports: + from importlib import import_module + source = _relative_imports[name] + module = import_module(f"pyface.{source}") + result = getattr(module, name) + + elif name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"{source}:{name}") + + elif name in _optional_imports: + import logging + from pyface.toolkit import toolkit_object + from pyface.util._optional_dependencies import optional_import + dependency, source = _optional_imports[name] + with optional_import( + dependency, + msg=f"{name} is not available due to missing {dependency}.", + logger=logging.getLogger(__name__), + ): + result = toolkit_object(f"{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result -from .expandable_panel import ExpandablePanel -from .image_widget import ImageWidget -from .mdi_application_window import MDIApplicationWindow -from .mdi_window_menu import MDIWindowMenu -from .multi_toolbar_window import MultiToolbarWindow -# This code isn't toolkit widget code, but is wx-specific -from traits.etsconfig.api import ETSConfig +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- -if ETSConfig.toolkit == "wx": +# the list of available names we report for introspection purposes +_extra_names = ( + set(_toolkit_imports) | set(_relative_imports) | set(_optional_imports) +) - # Fix for broken Pycrust introspect module. - # XXX move this somewhere better? - CJW 2017 - from .util import fix_introspect_bug -del ETSConfig +def __dir__(): + return sorted(set(globals()) | _extra_names) diff --git a/pyface/base_toolkit.py b/pyface/base_toolkit.py index ad53b1591..657e1e984 100644 --- a/pyface/base_toolkit.py +++ b/pyface/base_toolkit.py @@ -231,7 +231,11 @@ def import_toolkit(toolkit_name, entry_point="pyface.toolkits"): raise RuntimeError(msg) from toolkit_exception -def find_toolkit(entry_point, toolkits=None, priorities=default_priorities): +def find_toolkit( + entry_point="pyface.toolkits", + toolkits=None, + priorities=default_priorities, +): """ Find a toolkit that works. If ETSConfig is set, then attempt to find a matching toolkit. Otherwise diff --git a/pyface/data_view/api.py b/pyface/data_view/api.py index 7d19218cc..e4ef206e3 100644 --- a/pyface/data_view/api.py +++ b/pyface/data_view/api.py @@ -65,24 +65,64 @@ """ -from pyface.data_view.abstract_data_exporter import AbstractDataExporter # noqa: 401 -from pyface.data_view.abstract_data_model import AbstractDataModel # noqa: 401 -from pyface.data_view.abstract_value_type import AbstractValueType # noqa: 401 -from pyface.data_view.data_formats import ( # noqa: 401 +from pyface.data_view.abstract_data_exporter import AbstractDataExporter +from pyface.data_view.abstract_data_model import AbstractDataModel +from pyface.data_view.abstract_value_type import AbstractValueType +from pyface.data_view.data_formats import ( csv_column_format, csv_format, csv_row_format, from_csv, from_csv_column, from_csv_row, from_json, from_npy, html_format, npy_format, standard_text_format, to_csv, to_csv_column, to_csv_row, to_json, to_npy, table_format, text_column_format, text_row_format ) -from pyface.data_view.data_view_errors import ( # noqa: 401 +from pyface.data_view.data_view_errors import ( DataViewError, DataViewGetError, DataViewSetError ) -from pyface.data_view.data_view_widget import DataViewWidget # noqa: 401 -from pyface.data_view.data_wrapper import DataWrapper # noqa: 401 -from pyface.data_view.i_data_view_widget import IDataViewWidget # noqa: 401 -from pyface.data_view.i_data_wrapper import ( # noqa: 401 +from pyface.data_view.i_data_view_widget import IDataViewWidget +from pyface.data_view.i_data_wrapper import ( DataFormat, IDataWrapper, text_format ) -from pyface.data_view.index_manager import ( # noqa: 401 +from pyface.data_view.index_manager import ( AbstractIndexManager, IntIndexManager, TupleIndexManager ) + + +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'DataViewWidget': 'data_view_widget', + 'DataWrapper': 'data_wrapper', +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"data_view.{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +def __dir__(): + return sorted(set(globals()) | set(_toolkit_imports)) diff --git a/pyface/fields/api.py b/pyface/fields/api.py index eebe2c632..95fb0ee7c 100644 --- a/pyface/fields/api.py +++ b/pyface/fields/api.py @@ -38,10 +38,49 @@ from .i_time_field import ITimeField from .i_toggle_field import IToggleField -from .combo_field import ComboField -from .spin_field import SpinField -from .text_field import TextField -from .time_field import TimeField -from .toggle_field import ( - CheckBoxField, RadioButtonField, ToggleButtonField, -) + +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'CheckBoxField': 'toggle_field', + 'ComboField': 'combo_field', + 'RadioButtonField': 'toggle_field', + 'SpinField': 'spin_field', + 'TextField': 'text_field', + 'TimeField': 'time_field', + 'ToggleButtonField': 'toggle_field', +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"fields.{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +def __dir__(): + return sorted(set(globals()) | set(_toolkit_imports)) diff --git a/pyface/fields/tests/test_api.py b/pyface/fields/tests/test_api.py new file mode 100644 index 000000000..0b37df01e --- /dev/null +++ b/pyface/fields/tests/test_api.py @@ -0,0 +1,35 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Test for pyface.api """ + +import unittest + + +class TestApi(unittest.TestCase): + """ Test importable items in any environment.""" + + def test_api_importable(self): + # make sure api is importable with the most minimal + # required dependencies, including in the absence of toolkit backends. + from pyface.fields import api # noqa: F401 + + def test_public_attrs(self): + # make sure everything advertised by dir() is available except optional + from pyface.fields import api + + attrs = [ + name + for name in dir(api) + if not name.startswith('_') + ] + for attr in attrs: + with self.subTest(attr=attr): + self.assertIsNotNone(getattr(api, attr, None)) diff --git a/pyface/i_application_window.py b/pyface/i_application_window.py index 2a5470d7d..ee8061853 100644 --- a/pyface/i_application_window.py +++ b/pyface/i_application_window.py @@ -13,8 +13,9 @@ from traits.api import HasTraits, Instance, List - -from pyface.action.api import MenuBarManager, StatusBarManager, ToolBarManager +from pyface.action.i_menu_bar_manager import IMenuBarManager +from pyface.action.i_status_bar_manager import IStatusBarManager +from pyface.action.i_tool_bar_manager import IToolBarManager from pyface.i_window import IWindow from pyface.ui_traits import Image @@ -37,18 +38,14 @@ class IApplicationWindow(IWindow): #: The window icon. The default is toolkit specific. icon = Image() - #: The menu bar manager (None iff there is no menu bar). - menu_bar_manager = Instance(MenuBarManager) - - #: The status bar manager (None iff there is no status bar). - status_bar_manager = Instance(StatusBarManager) + #: The menu bar manager for the window. + menu_bar_manager = Instance(IMenuBarManager) - #: The tool bar manager (None iff there is no tool bar). - tool_bar_manager = Instance(ToolBarManager) + #: The status bar manager for the window. + status_bar_manager = Instance(IStatusBarManager) - #: If the underlying toolkit supports multiple toolbars, you can use this - #: list instead of the single ToolBarManager instance above. - tool_bar_managers = List(ToolBarManager) + #: The collection of tool bar managers for the window. + tool_bar_managers = List(Instance(IToolBarManager)) # ------------------------------------------------------------------------ # Protected 'IApplicationWindow' interface. @@ -116,6 +113,18 @@ class MApplicationWindow(HasTraits): Implements: destroy(), _create_trim_widgets() """ + #: The icon to display in the application window title bar. + icon = Image() + + #: The menu bar manager for the window. + menu_bar_manager = Instance(IMenuBarManager) + + #: The status bar manager for the window. + status_bar_manager = Instance(IStatusBarManager) + + #: The collection of tool bar managers for the window. + tool_bar_managers = List(Instance(IToolBarManager)) + # ------------------------------------------------------------------------ # 'IWidget' interface. # ------------------------------------------------------------------------ @@ -126,9 +135,6 @@ def destroy(self): if self.menu_bar_manager is not None: self.menu_bar_manager.destroy() - if self.tool_bar_manager is not None: - self.tool_bar_manager.destroy() - if self.status_bar_manager is not None: self.status_bar_manager.destroy() diff --git a/pyface/i_image_resource.py b/pyface/i_image_resource.py index c69315284..5df954c05 100644 --- a/pyface/i_image_resource.py +++ b/pyface/i_image_resource.py @@ -14,7 +14,6 @@ from traits.api import HasTraits, List, Str from pyface.i_image import IImage -from pyface.resource_manager import resource_manager from pyface.resource.resource_path import resource_module, resource_path @@ -130,6 +129,8 @@ def _get_ref(self, size=None): """ if self._ref is None: + from pyface.resource_manager import resource_manager + self._ref = resource_manager.locate_image( self.name, self.search_path, size ) diff --git a/pyface/tasks/action/api.py b/pyface/tasks/action/api.py index 5e4a33d97..151e59932 100644 --- a/pyface/tasks/action/api.py +++ b/pyface/tasks/action/api.py @@ -78,7 +78,8 @@ SMenuBar, SToolBar, ) -from .schema_addition import SchemaAddition +# deprecated: will be removed in a future Pyface release +from pyface.action.schema.schema_addition import SchemaAddition from .task_action import ( CentralPaneAction, DockPaneAction, diff --git a/pyface/tasks/action/dock_pane_toggle_group.py b/pyface/tasks/action/dock_pane_toggle_group.py index 603cdfc59..701c2f8f8 100644 --- a/pyface/tasks/action/dock_pane_toggle_group.py +++ b/pyface/tasks/action/dock_pane_toggle_group.py @@ -10,7 +10,6 @@ """ A Group for toggling the visibility of a task's dock panes. """ -from pyface.action.api import Action, ActionItem, Group from traits.api import ( cached_property, Instance, @@ -21,6 +20,7 @@ ) +from pyface.action.api import Action, Group from pyface.tasks.i_dock_pane import IDockPane @@ -123,6 +123,7 @@ def get_manager(self): def _dock_panes_updated(self, event): """Recreate the group items when dock panes have been added/removed. """ + from pyface.action.action_item import ActionItem # Remove the previous group items. self.destroy() diff --git a/pyface/tasks/action/task_action.py b/pyface/tasks/action/task_action.py index 986e1722a..401eb28bc 100644 --- a/pyface/tasks/action/task_action.py +++ b/pyface/tasks/action/task_action.py @@ -12,7 +12,9 @@ from traits.api import Instance, Property, Str, cached_property -from pyface.tasks.api import Editor, Task, TaskPane +from pyface.tasks.i_editor import IEditor +from pyface.tasks.task import Task +from pyface.tasks.i_task_pane import ITaskPane from pyface.action.listening_action import ListeningAction @@ -74,7 +76,7 @@ class CentralPaneAction(TaskAction): # CentralPaneAction interface -----------------------------------------# #: The central pane with which the action is associated. - central_pane = Property(Instance(TaskPane), observe="task") + central_pane = Property(Instance(ITaskPane), observe="task") # ------------------------------------------------------------------------ # Protected interface. @@ -101,7 +103,7 @@ class DockPaneAction(TaskAction): # DockPaneAction interface --------------------------------------------- #: The dock pane with which the action is associated. Set by the framework. - dock_pane = Property(Instance(TaskPane), observe="task") + dock_pane = Property(Instance(ITaskPane), observe="task") #: The ID of the dock pane with which the action is associated. dock_pane_id = Str() @@ -132,7 +134,7 @@ class EditorAction(CentralPaneAction): #: The active editor in the central pane with which the action is associated. active_editor = Property( - Instance(Editor), observe="central_pane.active_editor" + Instance(IEditor), observe="central_pane.active_editor" ) # ------------------------------------------------------------------------ diff --git a/pyface/tasks/action/task_toggle_group.py b/pyface/tasks/action/task_toggle_group.py index 97c4d8001..362b959c6 100644 --- a/pyface/tasks/action/task_toggle_group.py +++ b/pyface/tasks/action/task_toggle_group.py @@ -9,12 +9,11 @@ # Thanks for using Enthought open source! -from pyface.action.api import Action, ActionItem, Group +from pyface.action.api import Action, Group from traits.api import Any, List, Instance, Property, Str, observe from pyface.tasks.task import Task -from pyface.tasks.task_window import TaskWindow class TaskToggleAction(Action): @@ -89,13 +88,15 @@ class TaskToggleGroup(Group): manager = Any() #: The window that contains the group. - window = Instance(TaskWindow) + window = Instance("pyface.tasks.task_window.TaskWindow") # ------------------------------------------------------------------------ # Private interface. # ------------------------------------------------------------------------ def _get_items(self): + from pyface.action.api import ActionItem + items = [] if len(self.window.tasks) > 1: # at least two tasks, so something to toggle diff --git a/pyface/tasks/action/task_window_toggle_group.py b/pyface/tasks/action/task_window_toggle_group.py index 210450bb9..9a6d70129 100644 --- a/pyface/tasks/action/task_window_toggle_group.py +++ b/pyface/tasks/action/task_window_toggle_group.py @@ -9,9 +9,10 @@ # Thanks for using Enthought open source! -from pyface.action.api import Action, ActionItem, Group from traits.api import Any, Instance, List, Property, Str, observe +from pyface.action.api import Action, Group + class TaskWindowToggleAction(Action): """ An action for activating an application window. @@ -94,6 +95,8 @@ def destroy(self): # ------------------------------------------------------------------------- def _get_items(self): + from pyface.action.action_item import ActionItem + items = [] for window in self.application.windows: active = window == self.application.active_window diff --git a/pyface/tasks/api.py b/pyface/tasks/api.py index bab3d2653..36059a2f5 100644 --- a/pyface/tasks/api.py +++ b/pyface/tasks/api.py @@ -57,14 +57,6 @@ """ -from .advanced_editor_area_pane import AdvancedEditorAreaPane -from .split_editor_area_pane import SplitEditorAreaPane -from .dock_pane import DockPane -from .editor import Editor -from .editor_area_pane import EditorAreaPane -from .enaml_dock_pane import EnamlDockPane -from .enaml_editor import EnamlEditor -from .enaml_task_pane import EnamlTaskPane from .i_dock_pane import IDockPane from .i_editor import IEditor from .i_editor_area_pane import IEditorAreaPane @@ -79,9 +71,72 @@ HSplitter, VSplitter, ) -from .task_pane import TaskPane -from .task_window import TaskWindow from .task_window_layout import TaskWindowLayout -from .traits_dock_pane import TraitsDockPane -from .traits_editor import TraitsEditor -from .traits_task_pane import TraitsTaskPane + + +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'AdvancedEditorAreaPane': "advanced_editor_area_pane", + 'DockPane': "dock_pane", + 'EditorAreaPane': "editor_area_pane", + 'Editor': "editor", + 'SplitEditorAreaPane': "split_editor_area_pane", + 'TaskPane': "task_pane", +} + +# These are pyface.* imports that have selection as a side-effect +# TODO: refactor to delay imports where possible +_relative_imports = { + 'EnamlDockPane': "enaml_dock_pane", + 'EnamlEditor': "enaml_editor", + 'EnamlTaskPane': "enaml_task_pane", + 'TaskWindow': "task_window", + 'TraitsDockPane': "traits_dock_pane", + 'TraitsEditor': "traits_editor", + 'TraitsTaskPane': "traits_task_pane", +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _relative_imports: + from importlib import import_module + source = _relative_imports[name] + module = import_module(f"pyface.tasks.{source}") + result = getattr(module, name) + + elif name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"tasks.{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +# the list of available names we report for introspection purposes +_extra_names = set(_toolkit_imports) | set(_relative_imports) + + +def __dir__(): + return sorted(set(globals()) | _extra_names) diff --git a/pyface/tasks/task.py b/pyface/tasks/task.py index b091d3be6..753ff21be 100644 --- a/pyface/tasks/task.py +++ b/pyface/tasks/task.py @@ -8,7 +8,6 @@ # # Thanks for using Enthought open source! -from pyface.action.api import StatusBarManager from traits.api import Callable, HasTraits, Instance, List, Str @@ -49,7 +48,7 @@ class Task(HasTraits): menu_bar = Instance(MenuBarSchema) #: The (optional) status bar for the task. - status_bar = Instance(StatusBarManager) + status_bar = Instance("pyface.action.status_bar_manager.StatusBarManager") #: The list of tool bars for the tasks. tool_bars = List(ToolBarSchema) diff --git a/pyface/tasks/task_window.py b/pyface/tasks/task_window.py index 854c20b95..6d9fe3aa6 100644 --- a/pyface/tasks/task_window.py +++ b/pyface/tasks/task_window.py @@ -11,8 +11,6 @@ import logging -from pyface.action.api import MenuBarManager, StatusBarManager, ToolBarManager -from pyface.api import ApplicationWindow from traits.api import ( Bool, Callable, @@ -26,13 +24,17 @@ ) +from pyface.action.i_menu_bar_manager import IMenuBarManager +from pyface.action.i_status_bar_manager import IStatusBarManager +from pyface.action.i_tool_bar_manager import IToolBarManager +from pyface.application_window import ApplicationWindow from pyface.tasks.action.task_action_manager_builder import ( TaskActionManagerBuilder, ) from pyface.tasks.i_dock_pane import IDockPane from pyface.tasks.i_task_pane import ITaskPane +from pyface.tasks.i_task_window_backend import ITaskWindowBackend from pyface.tasks.task import Task, TaskLayout -from pyface.tasks.task_window_backend import TaskWindowBackend from pyface.tasks.task_window_layout import TaskWindowLayout # Logging. @@ -81,7 +83,7 @@ class TaskWindow(ApplicationWindow): _active_state = Instance("pyface.tasks.task_window.TaskState") _states = List(Instance("pyface.tasks.task_window.TaskState")) _title = Str() - _window_backend = Instance(TaskWindowBackend) + _window_backend = Instance(ITaskWindowBackend) # ------------------------------------------------------------------------ # 'Widget' interface. @@ -403,6 +405,7 @@ def _get_state(self, id_or_task): # Trait initializers --------------------------------------------------- def __window_backend_default(self): + from pyface.tasks.task_window_backend import TaskWindowBackend return TaskWindowBackend(window=self) # Trait property getters/setters --------------------------------------- @@ -448,15 +451,29 @@ class TaskState(HasStrictTraits): with an attached Task. """ + #: The Task that the state comes from. task = Instance(Task) + + #: The layout of panes in the TaskWindow. layout = Instance(TaskLayout) + + #: Whether the task state has been initialized. initialized = Bool(False) + #: The central pane of the TaskWindow central_pane = Instance(ITaskPane) - dock_panes = List(IDockPane) - menu_bar_manager = Instance(MenuBarManager) - status_bar_manager = Instance(StatusBarManager) - tool_bar_managers = List(ToolBarManager) + + #: The list of all dock panes. + dock_panes = List(Instance(IDockPane)) + + #: The TaskWindow's menu bar manager instance. + menu_bar_manager = Instance(IMenuBarManager) + + #: The TaskWindow's status bar instance. + status_bar_manager = Instance(IStatusBarManager) + + #: The TaskWindow's tool bar instances. + tool_bar_managers = List(Instance(IToolBarManager)) def get_dock_pane(self, id): """ Returns the dock pane with the specified id, or None if no such dock diff --git a/pyface/tasks/tests/test_api.py b/pyface/tasks/tests/test_api.py new file mode 100644 index 000000000..048795a57 --- /dev/null +++ b/pyface/tasks/tests/test_api.py @@ -0,0 +1,35 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Test for pyface.api """ + +import unittest + + +class TestApi(unittest.TestCase): + """ Test importable items in any environment.""" + + def test_api_importable(self): + # make sure api is importable with the most minimal + # required dependencies, including in the absence of toolkit backends. + from pyface.tasks import api # noqa: F401 + + def test_public_attrs(self): + # make sure everything advertised by dir() is available except optional + from pyface.tasks import api + + attrs = [ + name + for name in dir(api) + if not name.startswith('_') + ] + for attr in attrs: + with self.subTest(attr=attr): + self.assertIsNotNone(getattr(api, attr, None)) diff --git a/pyface/tests/test_api.py b/pyface/tests/test_api.py index 52df08136..d6de78b37 100644 --- a/pyface/tests/test_api.py +++ b/pyface/tests/test_api.py @@ -26,6 +26,19 @@ def test_api_importable(self): # required dependencies, including in the absence of toolkit backends. from pyface import api # noqa: F401 + def test_public_attrs(self): + # make sure everything advertised by dir() is available except optional + from pyface import api + + attrs = [ + name + for name in dir(api) + if not name.startswith('_') or name in api._optional_imports + ] + for attr in attrs: + with self.subTest(attr=attr): + self.assertIsNotNone(getattr(api, attr, None)) + @unittest.skipIf(not is_qt, "This test is for qt.") class TestApiQt(unittest.TestCase): @@ -191,7 +204,6 @@ def test_importable_items(self): clipboard, confirm, error, - fix_introspect_bug, information, warning, # Interfaces diff --git a/pyface/timer/api.py b/pyface/timer/api.py index 1832ca818..d944eafeb 100644 --- a/pyface/timer/api.py +++ b/pyface/timer/api.py @@ -27,6 +27,53 @@ """ -from .do_later import do_later, do_after, DoLaterTimer from .i_timer import ICallbackTimer, IEventTimer, ITimer -from .timer import CallbackTimer, EventTimer, Timer + + +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +# These are pyface.undo.* imports that have selection as a side-effect +# TODO: refactor to delay imports where possible +_relative_imports = { + 'do_later': 'do_later', + 'do_after': 'do_later', + 'DoLaterTimer': 'do_later', + 'CallbackTimer': 'timer', + 'EventTimer': 'timer', + 'Timer': 'timer', +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _relative_imports: + from importlib import import_module + source = _relative_imports[name] + module = import_module(f"pyface.timer.{source}") + result = getattr(module, name) + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +def __dir__(): + return sorted(set(globals()) | set(_relative_imports)) diff --git a/pyface/timer/tests/test_api.py b/pyface/timer/tests/test_api.py new file mode 100644 index 000000000..392dd7e50 --- /dev/null +++ b/pyface/timer/tests/test_api.py @@ -0,0 +1,35 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Test for pyface.api """ + +import unittest + + +class TestApi(unittest.TestCase): + """ Test importable items in any environment.""" + + def test_api_importable(self): + # make sure api is importable with the most minimal + # required dependencies, including in the absence of toolkit backends. + from pyface.timer import api # noqa: F401 + + def test_public_attrs(self): + # make sure everything advertised by dir() is available except optional + from pyface.timer import api + + attrs = [ + name + for name in dir(api) + if not name.startswith('_') + ] + for attr in attrs: + with self.subTest(attr=attr): + self.assertIsNotNone(getattr(api, attr, None)) diff --git a/pyface/ui/qt/action/menu_bar_manager.py b/pyface/ui/qt/action/menu_bar_manager.py index adf6cb7a6..3fefdf028 100644 --- a/pyface/ui/qt/action/menu_bar_manager.py +++ b/pyface/ui/qt/action/menu_bar_manager.py @@ -17,13 +17,14 @@ import sys +from traits.api import provides from pyface.qt import QtGui - - from pyface.action.action_manager import ActionManager +from pyface.action.i_menu_bar_manager import IMenuBarManager +@provides(IMenuBarManager) class MenuBarManager(ActionManager): """ A menu bar manager realizes itself in errr, a menu bar control. """ diff --git a/pyface/ui/qt/action/menu_manager.py b/pyface/ui/qt/action/menu_manager.py index 8a1f5641b..d9bf02a61 100644 --- a/pyface/ui/qt/action/menu_manager.py +++ b/pyface/ui/qt/action/menu_manager.py @@ -18,15 +18,17 @@ from pyface.qt import QtCore, QtGui -from traits.api import Instance, List, Str +from traits.api import Instance, List, Str, provides from pyface.action.action_manager import ActionManager from pyface.action.action_manager_item import ActionManagerItem from pyface.action.action_item import _Tool, Action +from pyface.action.i_menu_manager import IMenuManager from pyface.action.group import Group +@provides(IMenuManager) class MenuManager(ActionManager, ActionManagerItem): """ A menu manager realizes itself in a menu control. diff --git a/pyface/ui/qt/action/status_bar_manager.py b/pyface/ui/qt/action/status_bar_manager.py index 72492bfde..5c792d8af 100644 --- a/pyface/ui/qt/action/status_bar_manager.py +++ b/pyface/ui/qt/action/status_bar_manager.py @@ -17,9 +17,12 @@ from pyface.qt import QtGui -from traits.api import Any, Bool, HasTraits, List, Property, Str +from traits.api import Any, Bool, HasTraits, List, Property, Str, provides +from pyface.action.i_status_bar_manager import IStatusBarManager + +@provides(IStatusBarManager) class StatusBarManager(HasTraits): """ A status bar manager realizes itself in a status bar control. """ diff --git a/pyface/ui/qt/action/tool_bar_manager.py b/pyface/ui/qt/action/tool_bar_manager.py index d60bdb07f..15ad782d1 100644 --- a/pyface/ui/qt/action/tool_bar_manager.py +++ b/pyface/ui/qt/action/tool_bar_manager.py @@ -17,13 +17,16 @@ from pyface.qt import QtCore, QtGui -from traits.api import Bool, Enum, Instance, List, Str, Tuple +from traits.api import Bool, Instance, List, Str, Tuple, provides from pyface.image_cache import ImageCache from pyface.action.action_manager import ActionManager +from pyface.action.i_tool_bar_manager import IToolBarManager +from pyface.ui_traits import Orientation +@provides(IToolBarManager) class ToolBarManager(ActionManager): """ A tool bar manager realizes itself in errr, a tool bar control. """ @@ -42,7 +45,7 @@ class ToolBarManager(ActionManager): name = Str("ToolBar") # The orientation of the toolbar. - orientation = Enum("horizontal", "vertical") + orientation = Orientation("horizontal") # Should we display the name of each tool bar tool under its image? show_tool_names = Bool(True) diff --git a/pyface/ui/qt/application_window.py b/pyface/ui/qt/application_window.py index 5528f5576..2efb0e8a6 100644 --- a/pyface/ui/qt/application_window.py +++ b/pyface/ui/qt/application_window.py @@ -18,17 +18,10 @@ import sys +from traits.api import observe, provides, Str from pyface.qt import QtGui - - -from pyface.action.api import MenuBarManager, StatusBarManager -from pyface.action.api import ToolBarManager -from traits.api import Instance, List, observe, provides, Str - - from pyface.i_application_window import IApplicationWindow, MApplicationWindow -from pyface.ui_traits import Image from .image_resource import ImageResource from .window import Window @@ -39,23 +32,6 @@ class ApplicationWindow(MApplicationWindow, Window): IApplicationWindow interface for the API documentation. """ - # 'IApplicationWindow' interface --------------------------------------- - - #: The icon to display in the application window title bar. - icon = Image() - - #: The menu bar manager for the window. - menu_bar_manager = Instance(MenuBarManager) - - #: The status bar manager for the window. - status_bar_manager = Instance(StatusBarManager) - - #: DEPRECATED: The tool bar manager for the window. - tool_bar_manager = Instance(ToolBarManager) - - #: The collection of tool bar managers for the window. - tool_bar_managers = List(ToolBarManager) - # 'IWindow' interface -------------------------------------------------# #: The window title. @@ -90,7 +66,7 @@ def _create_status_bar(self, parent): status_bar.setVisible(self.status_bar_manager.visible) def _create_tool_bar(self, parent): - tool_bar_managers = self._get_tool_bar_managers() + tool_bar_managers = self.tool_bar_managers visible = self.control.isVisible() for tool_bar_manager in tool_bar_managers: # Add the tool bar and make sure it is visible. @@ -150,18 +126,6 @@ def _create_control(self, parent): # Private interface. # ------------------------------------------------------------------------ - def _get_tool_bar_managers(self): - """ Return all tool bar managers specified for the window. """ - - # fixme: V3 remove the old-style single toolbar option! - if self.tool_bar_manager is not None: - tool_bar_managers = [self.tool_bar_manager] - - else: - tool_bar_managers = self.tool_bar_managers - - return tool_bar_managers - # Trait change handlers ------------------------------------------------ # QMainWindow takes ownership of the menu bar and the status bar upon @@ -180,7 +144,7 @@ def _status_bar_manager_updated(self, event): event.old.destroy() self._create_status_bar(self.control) - @observe("tool_bar_manager, tool_bar_managers.items") + @observe("tool_bar_managers.items") def _update_tool_bar_managers(self, event): if self.control is not None: # Remove the old toolbars. diff --git a/pyface/ui/wx/action/menu_bar_manager.py b/pyface/ui/wx/action/menu_bar_manager.py index 143d91bac..efe8d8d49 100644 --- a/pyface/ui/wx/action/menu_bar_manager.py +++ b/pyface/ui/wx/action/menu_bar_manager.py @@ -15,10 +15,13 @@ import wx +from traits.api import provides from pyface.action.action_manager import ActionManager +from pyface.action.i_menu_bar_manager import IMenuBarManager +@provides(IMenuBarManager) class MenuBarManager(ActionManager): """ A menu bar manager realizes itself in errr, a menu bar control. """ diff --git a/pyface/ui/wx/action/menu_manager.py b/pyface/ui/wx/action/menu_manager.py index 2ed052fac..eef36da3f 100644 --- a/pyface/ui/wx/action/menu_manager.py +++ b/pyface/ui/wx/action/menu_manager.py @@ -16,14 +16,16 @@ import wx -from traits.api import Str, Bool +from traits.api import Str, Bool, provides from pyface.action.action_manager import ActionManager from pyface.action.action_manager_item import ActionManagerItem from pyface.action.group import Group +from pyface.action.i_menu_manager import IMenuManager +@provides(IMenuManager) class MenuManager(ActionManager, ActionManagerItem): """ A menu manager realizes itself in a menu control. diff --git a/pyface/ui/wx/action/status_bar_manager.py b/pyface/ui/wx/action/status_bar_manager.py index a268492f5..a766a7778 100644 --- a/pyface/ui/wx/action/status_bar_manager.py +++ b/pyface/ui/wx/action/status_bar_manager.py @@ -16,9 +16,12 @@ import wx -from traits.api import Any, HasTraits, List, Property, Str +from traits.api import Any, HasTraits, List, Property, Str, provides +from pyface.action.i_status_bar_manager import IStatusBarManager + +@provides(IStatusBarManager) class StatusBarManager(HasTraits): """ A status bar manager realizes itself in a status bar control. """ diff --git a/pyface/ui/wx/action/tool_bar_manager.py b/pyface/ui/wx/action/tool_bar_manager.py index b2fb60912..41584639f 100644 --- a/pyface/ui/wx/action/tool_bar_manager.py +++ b/pyface/ui/wx/action/tool_bar_manager.py @@ -15,15 +15,16 @@ import wx - -from traits.api import Bool, Enum, Instance, Str, Tuple - +from traits.api import Bool, Instance, Str, Tuple, provides from pyface.wx.aui import aui as AUI from pyface.image_cache import ImageCache from pyface.action.action_manager import ActionManager +from pyface.action.i_tool_bar_manager import IToolBarManager +from pyface.ui_traits import Orientation +@provides(IToolBarManager) class ToolBarManager(ActionManager): """ A tool bar manager realizes itself in errr, a tool bar control. """ @@ -42,7 +43,7 @@ class ToolBarManager(ActionManager): name = Str("ToolBar") # The orientation of the toolbar. - orientation = Enum("horizontal", "vertical") + orientation = Orientation("horizontal") # Should we display the name of each tool bar tool under its image? show_tool_names = Bool(True) diff --git a/pyface/ui/wx/application_window.py b/pyface/ui/wx/application_window.py index 659f9c7af..dcac63a49 100644 --- a/pyface/ui/wx/application_window.py +++ b/pyface/ui/wx/application_window.py @@ -14,14 +14,9 @@ import wx -from traits.api import Instance, List, Str, observe, provides - -from pyface.action.api import MenuBarManager, StatusBarManager -from pyface.action.api import ToolBarManager -from pyface.i_application_window import ( - IApplicationWindow, MApplicationWindow, -) -from pyface.ui_traits import Image +from traits.api import Str, observe, provides + +from pyface.i_application_window import IApplicationWindow, MApplicationWindow from pyface.wx.aui import aui, PyfaceAuiManager from .image_resource import ImageResource from .window import Window @@ -33,30 +28,8 @@ class ApplicationWindow(MApplicationWindow, Window): IApplicationWindow interface for the API documentation. """ - # 'IApplicationWindow' interface --------------------------------------- - - icon = Image() - - menu_bar_manager = Instance(MenuBarManager) - - status_bar_manager = Instance(StatusBarManager) - - tool_bar_manager = Instance(ToolBarManager) - - # If the underlying toolkit supports multiple toolbars then you can use - # this list instead. - tool_bar_managers = List(ToolBarManager) - # 'IWindow' interface -------------------------------------------------# - # fixme: We can't set the default value of the actual 'size' trait here as - # in the toolkit-specific event handlers for window size and position - # changes, we set the value of the shadow '_size' trait. The problem is - # that by doing that traits never knows that the trait has been set and - # hence always returns the default value! Using a trait initializer method - # seems to work however (e.g. 'def _size_default'). Hmmmm.... - ## size = (800, 600) - title = Str("Pyface") # ------------------------------------------------------------------------ @@ -79,7 +52,7 @@ def _create_status_bar(self, parent): self.control.SetStatusBar(status_bar) def _create_tool_bar(self, parent): - tool_bar_managers = self._get_tool_bar_managers() + tool_bar_managers = self.tool_bar_managers if len(tool_bar_managers) > 0: for tool_bar_manager in reversed(tool_bar_managers): tool_bar = tool_bar_manager.create_tool_bar(parent, aui=True) @@ -174,18 +147,6 @@ def _get_tool_bar_pane_info(self, tool_bar): return info - def _get_tool_bar_managers(self): - """ Return all tool bar managers specified for the window. """ - - # fixme: V3 remove the old-style single toolbar option! - if self.tool_bar_manager is not None: - tool_bar_managers = [self.tool_bar_manager] - - else: - tool_bar_managers = self.tool_bar_managers - - return tool_bar_managers - def _wx_enable_tool_bar(self, tool_bar, enabled): """ Enable/Disablea tool bar. """ @@ -225,7 +186,7 @@ def _status_bar_manager_changed(self, old, new): old.destroy() self._create_status_bar(self.control) - @observe("tool_bar_manager, tool_bar_managers.items") + @observe("tool_bar_managers.items") def _update_tool_bar_managers(self, event): if self.control is not None: self._create_tool_bar(self.control) diff --git a/pyface/ui/wx/init.py b/pyface/ui/wx/init.py index bc7c08898..a94f1ead8 100644 --- a/pyface/ui/wx/init.py +++ b/pyface/ui/wx/init.py @@ -36,3 +36,7 @@ if ui_handler is None: # Tell the traits notification handlers to use this UI handler set_ui_handler(GUI.invoke_later) + +# Fix for broken Pycrust introspect module. Imported to patch pycrust. +# CJW: is this still needed? Has been in for years. +from pyface.util import fix_introspect_bug # noqa: F401, E402 diff --git a/pyface/wizard/api.py b/pyface/wizard/api.py index 3f1954541..777b39faa 100644 --- a/pyface/wizard/api.py +++ b/pyface/wizard/api.py @@ -27,14 +27,65 @@ """ +from .chained_wizard_controller import ChainedWizardController from .i_wizard_page import IWizardPage -from .wizard_page import WizardPage - from .i_wizard import IWizard -from .wizard import Wizard - from .i_wizard_controller import IWizardController from .wizard_controller import WizardController -from .chained_wizard import ChainedWizard -from .chained_wizard_controller import ChainedWizardController + +# ---------------------------------------------------------------------------- +# Deferred imports +# ---------------------------------------------------------------------------- + +# These imports have the side-effect of performing toolkit selection + +_toolkit_imports = { + 'Wizard': 'wizard', + 'WizardPage': 'wizard_page', +} + +# These are pyface.* imports that have selection as a side-effect +_relative_imports = { + 'ChainedWizard': "chained_wizard", +} + + +def __getattr__(name): + """Lazily load attributes with side-effects + + In particular, lazily load toolkit backend names. For efficiency, lazily + loaded objects are injected into the module namespace + """ + # sentinel object for no result + not_found = object() + result = not_found + + if name in _relative_imports: + from importlib import import_module + source = _relative_imports[name] + module = import_module(f"pyface.wizard.{source}") + result = getattr(module, name) + + elif name in _toolkit_imports: + from pyface.toolkit import toolkit_object + source = _toolkit_imports[name] + result = toolkit_object(f"wizard.{source}:{name}") + + if result is not_found: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = result + return result + + +# ---------------------------------------------------------------------------- +# Introspection support +# ---------------------------------------------------------------------------- + +# the list of available names we report for introspection purposes +_extra_names = set(_toolkit_imports) | set(_relative_imports) + + +def __dir__(): + return sorted(set(globals()) | _extra_names) From acec6ce3d3d5511fa853fe07db9cdd168751d839 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Mon, 3 Apr 2023 16:58:21 +0100 Subject: [PATCH 2/6] Update Qt APIs to be more Qt5/Qt6-ish (#1236) In particular this includes: - removing PyQt4 support (PySide is long-gone), but this means no more Qt4 (Fixes #510) - `QtSvgWidgets`, and backwards compatibility imports in QtSvg on Qt6 platforms (Fixes #1235) - `QtWidgets` is now available, and we should probably shift imports there instead of QtGui over time (but not in this PR) (work towards #1052) --- pyface/qt/QtCore.py | 14 +------------ pyface/qt/QtGui.py | 12 +---------- pyface/qt/QtNetwork.py | 5 +---- pyface/qt/QtOpenGL.py | 5 +---- pyface/qt/QtOpenGLWidgets.py | 24 ++++++++++++++++++++++ pyface/qt/QtScript.py | 8 ++------ pyface/qt/QtSvg.py | 9 ++++---- pyface/qt/QtSvgWidgets.py | 24 ++++++++++++++++++++++ pyface/qt/QtTest.py | 5 +---- pyface/qt/QtWebKit.py | 5 +---- pyface/qt/QtWidgets.py | 22 ++++++++++++++++++++ pyface/qt/__init__.py | 3 +-- pyface/ui/qt/code_editor/code_widget.py | 8 -------- pyface/ui/qt/data_view/data_view_widget.py | 6 +++--- pyface/ui/qt/tests/test_qt_imports.py | 3 +++ pyface/ui/qt/workbench/split_tab_widget.py | 12 +---------- 16 files changed, 91 insertions(+), 74 deletions(-) create mode 100644 pyface/qt/QtOpenGLWidgets.py create mode 100644 pyface/qt/QtSvgWidgets.py create mode 100644 pyface/qt/QtWidgets.py diff --git a/pyface/qt/QtCore.py b/pyface/qt/QtCore.py index 1636ef1cc..801fcba10 100644 --- a/pyface/qt/QtCore.py +++ b/pyface/qt/QtCore.py @@ -9,19 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtCore import * - - from PyQt4.QtCore import pyqtProperty as Property - from PyQt4.QtCore import pyqtSignal as Signal - from PyQt4.QtCore import pyqtSlot as Slot - from PyQt4.Qt import QCoreApplication - from PyQt4.Qt import Qt - - __version__ = QT_VERSION_STR - __version_info__ = tuple(map(int, QT_VERSION_STR.split("."))) - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtCore import * from PyQt5.QtCore import pyqtProperty as Property diff --git a/pyface/qt/QtGui.py b/pyface/qt/QtGui.py index 25d6c82c8..632cb247d 100644 --- a/pyface/qt/QtGui.py +++ b/pyface/qt/QtGui.py @@ -9,17 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.Qt import QKeySequence, QTextCursor - from PyQt4.QtGui import * - - # forward-compatible font weights - # see https://doc.qt.io/qt-5/qfont.html#Weight-enum - QFont.Weight.ExtraLight = 12 - QFont.Weight.Medium = 57 - QFont.Weight.ExtraBold = 81 - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtGui import * from PyQt5.QtWidgets import * from PyQt5.QtPrintSupport import * diff --git a/pyface/qt/QtNetwork.py b/pyface/qt/QtNetwork.py index e392f878f..0b3b2658d 100644 --- a/pyface/qt/QtNetwork.py +++ b/pyface/qt/QtNetwork.py @@ -9,10 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtNetwork import * - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtNetwork import * elif qt_api == "pyqt6": diff --git a/pyface/qt/QtOpenGL.py b/pyface/qt/QtOpenGL.py index a7794f654..9d9b6437f 100644 --- a/pyface/qt/QtOpenGL.py +++ b/pyface/qt/QtOpenGL.py @@ -9,10 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtOpenGL import * - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtOpenGL import * from PyQt5.QtWidgets import QOpenGLWidget diff --git a/pyface/qt/QtOpenGLWidgets.py b/pyface/qt/QtOpenGLWidgets.py new file mode 100644 index 000000000..87314a5a2 --- /dev/null +++ b/pyface/qt/QtOpenGLWidgets.py @@ -0,0 +1,24 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from . import qt_api + +if qt_api == "pyqt5": + from PyQt5.QtOpenGL import * + from PyQt5.QtWidgets import QOpenGLWidget + +elif qt_api == "pyqt6": + from PyQt6.QtOpenGLWidgets import * + +elif qt_api == "pyside6": + from PySide6.QtOpenGLWidgets import * + +else: + from PySide2.QtOpenGL import * + from PySide2.QtWidgets import QOpenGLWidget diff --git a/pyface/qt/QtScript.py b/pyface/qt/QtScript.py index 0a9079f06..841087ecc 100644 --- a/pyface/qt/QtScript.py +++ b/pyface/qt/QtScript.py @@ -7,12 +7,8 @@ # is also available online at http://www.enthought.com/licenses/BSD.txt # # Thanks for using Enthought open source! -from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtScript import * -else: - import warnings +import warnings - warnings.warn(DeprecationWarning("QtScript is not supported in PyQt5/PySide2")) +warnings.warn(DeprecationWarning("QtScript is not supported after Qt4")) diff --git a/pyface/qt/QtSvg.py b/pyface/qt/QtSvg.py index 4c13c77aa..5a3160370 100644 --- a/pyface/qt/QtSvg.py +++ b/pyface/qt/QtSvg.py @@ -9,17 +9,18 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtSvg import * - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtSvg import * elif qt_api == "pyqt6": from PyQt6.QtSvg import * + # Backwards compatibility imports + from PyQt6.QtSvgWidgets import * elif qt_api == "pyside6": from PySide6.QtSvg import * + # Backwards compatibility imports + from PySide6.QtSvgWidgets import * else: from PySide2.QtSvg import * diff --git a/pyface/qt/QtSvgWidgets.py b/pyface/qt/QtSvgWidgets.py new file mode 100644 index 000000000..8ed4d4565 --- /dev/null +++ b/pyface/qt/QtSvgWidgets.py @@ -0,0 +1,24 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from . import qt_api + +if qt_api == "pyqt5": + # Forwards compatibility imports + from PyQt5.QtSvg import * + +elif qt_api == "pyqt6": + from PyQt6.QtSvgWidgets import * + +elif qt_api == "pyside6": + from PySide6.QtSvgWidgets import * + +else: + # Forwards compatibility imports + from PySide2.QtSvg import * diff --git a/pyface/qt/QtTest.py b/pyface/qt/QtTest.py index db3fe2410..e1544b838 100644 --- a/pyface/qt/QtTest.py +++ b/pyface/qt/QtTest.py @@ -9,10 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtTest import * - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtTest import * elif qt_api == "pyqt6": diff --git a/pyface/qt/QtWebKit.py b/pyface/qt/QtWebKit.py index c2b186eb9..8ca944811 100644 --- a/pyface/qt/QtWebKit.py +++ b/pyface/qt/QtWebKit.py @@ -9,10 +9,7 @@ # Thanks for using Enthought open source! from . import qt_api -if qt_api == "pyqt": - from PyQt4.QtWebKit import * - -elif qt_api == "pyqt5": +if qt_api == "pyqt5": from PyQt5.QtWidgets import * try: diff --git a/pyface/qt/QtWidgets.py b/pyface/qt/QtWidgets.py new file mode 100644 index 000000000..73656afdf --- /dev/null +++ b/pyface/qt/QtWidgets.py @@ -0,0 +1,22 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from . import qt_api + +if qt_api == "pyqt5": + from PyQt5.QtWidgets import * + +elif qt_api == "pyqt6": + from PyQt6.QtWidgets import * + +elif qt_api == "pyside6": + from PySide6.QtWidgets import * + +else: + from PySide2.QtWidgets import * diff --git a/pyface/qt/__init__.py b/pyface/qt/__init__.py index fd35ae346..07fd0a8a8 100644 --- a/pyface/qt/__init__.py +++ b/pyface/qt/__init__.py @@ -18,7 +18,6 @@ ("pyside6", "PySide6"), ("pyqt5", "PyQt5"), ("pyqt6", "PyQt6"), - ("pyqt", "PyQt4"), ] api_names, modules = zip(*QtAPIs) @@ -56,7 +55,7 @@ raise RuntimeError(msg) # useful constants -is_qt4 = qt_api in {"pyqt"} +is_qt4 = False is_qt5 = qt_api in {"pyqt5", "pyside2"} is_qt6 = qt_api in {"pyqt6", "pyside6"} is_pyqt = qt_api in {"pyqt", "pyqt5", "pyqt6"} diff --git a/pyface/ui/qt/code_editor/code_widget.py b/pyface/ui/qt/code_editor/code_widget.py index 132bdb765..eee2a54ec 100644 --- a/pyface/ui/qt/code_editor/code_widget.py +++ b/pyface/ui/qt/code_editor/code_widget.py @@ -844,14 +844,6 @@ def _update_replace_all_enabled(self, text): if __name__ == "__main__": - def set_trace(): - from PyQt4.QtCore import pyqtRemoveInputHook - - pyqtRemoveInputHook() - import pdb - - pdb.Pdb().set_trace(sys._getframe().f_back) - app = QtGui.QApplication(sys.argv) window = AdvancedCodeWidget(None) diff --git a/pyface/ui/qt/data_view/data_view_widget.py b/pyface/ui/qt/data_view/data_view_widget.py index a240e8a03..e04610540 100644 --- a/pyface/ui/qt/data_view/data_view_widget.py +++ b/pyface/ui/qt/data_view/data_view_widget.py @@ -12,10 +12,10 @@ from traits.api import Callable, Enum, Instance, observe, provides -from pyface.qt.QtCore import QAbstractItemModel -from pyface.qt.QtGui import ( - QAbstractItemView, QItemSelection, QItemSelectionModel, QTreeView +from pyface.qt.QtCore import ( + QAbstractItemModel, QItemSelection, QItemSelectionModel ) +from pyface.qt.QtGui import QAbstractItemView, QTreeView from pyface.data_view.i_data_view_widget import ( IDataViewWidget, MDataViewWidget ) diff --git a/pyface/ui/qt/tests/test_qt_imports.py b/pyface/ui/qt/tests/test_qt_imports.py index 3327c61a6..821c99de3 100644 --- a/pyface/ui/qt/tests/test_qt_imports.py +++ b/pyface/ui/qt/tests/test_qt_imports.py @@ -21,10 +21,13 @@ def test_imports(self): import pyface.qt.QtGui # noqa: F401 import pyface.qt.QtNetwork # noqa: F401 import pyface.qt.QtOpenGL # noqa: F401 + import pyface.qt.QtOpenGLWidgets # noqa: F401 import pyface.qt.QtSvg # noqa: F401 + import pyface.qt.QtSvgWidgets # noqa: F401 import pyface.qt.QtTest # noqa: F401 import pyface.qt.QtMultimedia # noqa: F401 import pyface.qt.QtMultimediaWidgets # noqa: F401 + import pyface.qt.QtWidgets # noqa: F401 @unittest.skipIf(sys.version_info > (3, 6), "WebKit is not available") def test_import_web_kit(self): diff --git a/pyface/ui/qt/workbench/split_tab_widget.py b/pyface/ui/qt/workbench/split_tab_widget.py index 3536ae13a..83d858ace 100644 --- a/pyface/ui/qt/workbench/split_tab_widget.py +++ b/pyface/ui/qt/workbench/split_tab_widget.py @@ -19,8 +19,7 @@ import sys import warnings - -from pyface.qt import QtCore, QtGui, qt_api +from pyface.qt import QtCore, QtGui from pyface.image_resource import ImageResource @@ -300,15 +299,6 @@ def _set_focus(self): def _focus_changed(self, old, new): """ Handle a change in focus that affects the current tab. """ - # It is possible for the C++ layer of this object to be deleted between - # the time when the focus change signal is emitted and time when the - # slots are dispatched by the Qt event loop. This may be a bug in PyQt4. - if qt_api == "pyqt": - import sip - - if sip.isdeleted(self): - return - if self._repeat_focus_changes: self.focus_changed.emit(old, new) From e45b1e863f91d507059e548ca99530dfad71c34f Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 4 Apr 2023 14:34:50 +0100 Subject: [PATCH 3/6] Fix screen sizes (#1237) Improve system metrics to account for multiple screens where possible. Fixes #1232 and fixes #721 --- pyface/i_system_metrics.py | 9 ++++-- pyface/tests/test_system_metrics.py | 7 +++++ pyface/ui/qt/system_metrics.py | 47 ++++++++++++++++------------- pyface/ui/wx/system_metrics.py | 15 ++++++--- 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/pyface/i_system_metrics.py b/pyface/i_system_metrics.py index c86c28245..42382cbdc 100644 --- a/pyface/i_system_metrics.py +++ b/pyface/i_system_metrics.py @@ -11,7 +11,7 @@ """ The interface to system metrics (screen width and height etc). """ -from traits.api import HasTraits, Int, Interface, Tuple +from traits.api import HasTraits, Int, Interface, List, Tuple class ISystemMetrics(Interface): @@ -19,12 +19,15 @@ class ISystemMetrics(Interface): # 'ISystemMetrics' interface ------------------------------------------- - #: The width of the screen in pixels. + #: The width of the main screen in pixels. screen_width = Int() - #: The height of the screen in pixels. + #: The height of the main screen in pixels. screen_height = Int() + #: The height and width of each screen in pixels + screen_sizes = List(Tuple(Int, Int)) + #: Background color of a standard dialog window as a tuple of RGB values #: between 0.0 and 1.0. # FIXME v3: Why isn't this a traits colour? diff --git a/pyface/tests/test_system_metrics.py b/pyface/tests/test_system_metrics.py index fd95d7a9a..2cdc93be0 100644 --- a/pyface/tests/test_system_metrics.py +++ b/pyface/tests/test_system_metrics.py @@ -26,6 +26,13 @@ def test_height(self): height = self.metrics.screen_height self.assertGreaterEqual(height, 0) + def test_screen_sizes(self): + screens = self.metrics.screen_sizes + self.assertTrue(all( + (isinstance(screen, tuple) and len(screen) == 2) + for screen in screens + )) + def test_background_color(self): color = self.metrics.dialog_background_color self.assertIn(len(color), [3, 4]) diff --git a/pyface/ui/qt/system_metrics.py b/pyface/ui/qt/system_metrics.py index 4f230453a..06ade0135 100644 --- a/pyface/ui/qt/system_metrics.py +++ b/pyface/ui/qt/system_metrics.py @@ -11,14 +11,10 @@ # This software is provided without warranty under the terms of the BSD license. # However, when used with the GPL version of PyQt the additional terms described in the PyQt GPL exception also apply - -from pyface.qt import QtGui, is_qt4 - - -from traits.api import HasTraits, Int, Property, provides, Tuple - +from traits.api import HasTraits, Int, List, Property, provides, Tuple from pyface.i_system_metrics import ISystemMetrics, MSystemMetrics +from pyface.qt import QtGui @provides(ISystemMetrics) @@ -29,10 +25,17 @@ class SystemMetrics(MSystemMetrics, HasTraits): # 'ISystemMetrics' interface ------------------------------------------- + #: The width of the main screen in pixels. screen_width = Property(Int) + #: The height of the main screen in pixels. screen_height = Property(Int) + #: The height and width of each screen in pixels + screen_sizes = Property(List(Tuple(Int, Int))) + + #: Background color of a standard dialog window as a tuple of RGB values + #: between 0.0 and 1.0. dialog_background_color = Property(Tuple) # ------------------------------------------------------------------------ @@ -40,26 +43,28 @@ class SystemMetrics(MSystemMetrics, HasTraits): # ------------------------------------------------------------------------ def _get_screen_width(self): - # QDesktopWidget.screenGeometry() is deprecated and Qt docs - # suggest using screens() instead, but screens in not available in qt - # see issue: enthought/pyface#721 - if not is_qt4: - return QtGui.QApplication.instance().screens()[0].availableGeometry().width() + screens = self.screen_sizes + if len(screens) == 0: + return 0 else: - return QtGui.QApplication.instance().desktop().availableGeometry().width() + return screens[0][0] def _get_screen_height(self): - # QDesktopWidget.screenGeometry(int screen) is deprecated and Qt docs - # suggest using screens() instead, but screens in not available in qt - # see issue: enthought/pyface#721 - if not is_qt4: - return ( - QtGui.QApplication.instance().screens()[0].availableGeometry().height() - ) + screens = self.screen_sizes + if len(screens) == 0: + return 0 else: - return ( - QtGui.QApplication.instance().desktop().availableGeometry().height() + return screens[0][1] + + def _get_screen_sizes(self): + screens = QtGui.QApplication.instance().screens() + return [ + ( + screen.availableGeometry().width(), + screen.availableGeometry().height(), ) + for screen in screens + ] def _get_dialog_background_color(self): color = ( diff --git a/pyface/ui/wx/system_metrics.py b/pyface/ui/wx/system_metrics.py index 16c308f16..78730b445 100644 --- a/pyface/ui/wx/system_metrics.py +++ b/pyface/ui/wx/system_metrics.py @@ -15,12 +15,9 @@ import sys - import wx - -from traits.api import HasTraits, Int, Property, provides, Tuple - +from traits.api import HasTraits, Int, List, Property, provides, Tuple from pyface.i_system_metrics import ISystemMetrics, MSystemMetrics @@ -33,10 +30,17 @@ class SystemMetrics(MSystemMetrics, HasTraits): # 'ISystemMetrics' interface ------------------------------------------- + #: The width of the main screen in pixels. screen_width = Property(Int) + #: The height of the main screen in pixels. screen_height = Property(Int) + #: The height and width of each screen in pixels + screen_sizes = Property(List(Tuple(Int, Int))) + + #: Background color of a standard dialog window as a tuple of RGB values + #: between 0.0 and 1.0. dialog_background_color = Property(Tuple) # ------------------------------------------------------------------------ @@ -49,6 +53,9 @@ def _get_screen_width(self): def _get_screen_height(self): return wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y) + def _get_screen_sizes(self): + return [(self.screen_width, self.screen_height)] + def _get_dialog_background_color(self): if sys.platform == "darwin": # wx lies. From 987a0611b942db3adebf28497cd517fbc6df53a8 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 6 Apr 2023 12:49:30 +0100 Subject: [PATCH 4/6] Import optional imports from relative module rather than toolkit (#1240) This fixes an issue seen in TraitsUI bleeding-edge tests. It also fixes CI for PySide 6.5 and also for EDM on MacOS. --- .github/actions/install-qt-support/action.yml | 1 + etstool.py | 3 ++- pyface/api.py | 5 +++-- pyface/tests/test_api.py | 2 +- pyface/tests/test_array_image.py | 3 ++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/actions/install-qt-support/action.yml b/.github/actions/install-qt-support/action.yml index 15e7a22a6..66b98ab7e 100644 --- a/.github/actions/install-qt-support/action.yml +++ b/.github/actions/install-qt-support/action.yml @@ -20,6 +20,7 @@ runs: sudo apt-get install libxcb-render-util0 sudo apt-get install libxcb-xinerama0 sudo apt-get install libxcb-shape0 + sudo apt-get install libxcb-cursor0 sudo apt-get install pulseaudio sudo apt-get install libpulse-mainloop-glib0 # Needed to work around https://bugreports.qt.io/browse/PYSIDE-1547 diff --git a/etstool.py b/etstool.py index 6d661394f..4272abdf6 100644 --- a/etstool.py +++ b/etstool.py @@ -95,7 +95,6 @@ "importlib_resources>=1.1.0", "traits" + TRAITS_VERSION_REQUIRES, "traitsui", - "numpy", "pygments", "coverage", "flake8", @@ -112,8 +111,10 @@ ) if b'AVX2' in result.stdout.split(): dependencies.add('pillow_simd') + dependencies.add('numpy') else: dependencies.add('pillow_simd') + dependencies.add('numpy') source_dependencies = { diff --git a/pyface/api.py b/pyface/api.py index aea9f7d3c..2fbfa2e1c 100644 --- a/pyface/api.py +++ b/pyface/api.py @@ -259,8 +259,8 @@ def __getattr__(name): result = toolkit_object(f"{source}:{name}") elif name in _optional_imports: + from importlib import import_module import logging - from pyface.toolkit import toolkit_object from pyface.util._optional_dependencies import optional_import dependency, source = _optional_imports[name] with optional_import( @@ -268,7 +268,8 @@ def __getattr__(name): msg=f"{name} is not available due to missing {dependency}.", logger=logging.getLogger(__name__), ): - result = toolkit_object(f"{source}:{name}") + module = import_module(f"pyface.{source}") + result = getattr(module, name) if result is not_found: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/pyface/tests/test_api.py b/pyface/tests/test_api.py index d6de78b37..abc42b55d 100644 --- a/pyface/tests/test_api.py +++ b/pyface/tests/test_api.py @@ -33,7 +33,7 @@ def test_public_attrs(self): attrs = [ name for name in dir(api) - if not name.startswith('_') or name in api._optional_imports + if not (name.startswith('_') or name in api._optional_imports) ] for attr in attrs: with self.subTest(attr=attr): diff --git a/pyface/tests/test_array_image.py b/pyface/tests/test_array_image.py index 479d2a011..79f5560f7 100644 --- a/pyface/tests/test_array_image.py +++ b/pyface/tests/test_array_image.py @@ -13,7 +13,8 @@ from traits.testing.optional_dependencies import numpy as np, requires_numpy -from ..array_image import ArrayImage +if np is not None: + from ..array_image import ArrayImage @requires_numpy From 032808988200c30d66c79214381d1ac75e12d79c Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 6 Apr 2023 13:10:20 +0100 Subject: [PATCH 5/6] Refactor fields (#1234) This is a refactor of the Field class to split it into display and editable variants: base `Fields` may not be editable, `EditaleFields` add editability (ie. the value can be changed by the user). Includes tests and documentation, including fixes to make the documentation actually compile. Adds a LabelField and ImageField to demonstrate the non-editable Fields. --------- Co-authored-by: Mark Dickinson --- docs/source/conf.py | 9 +-- docs/source/fields.rst | 103 +++++++++++++++++------- pyface/fields/api.py | 14 ++++ pyface/fields/i_combo_field.py | 9 +-- pyface/fields/i_editable_field.py | 62 ++++++++++++++ pyface/fields/i_field.py | 61 +++++++++----- pyface/fields/i_image_field.py | 36 +++++++++ pyface/fields/i_label_field.py | 67 +++++++++++++++ pyface/fields/i_spin_field.py | 31 +++++-- pyface/fields/i_text_field.py | 14 ++-- pyface/fields/i_time_field.py | 20 +---- pyface/fields/i_toggle_field.py | 8 +- pyface/fields/image_field.py | 16 ++++ pyface/fields/label_field.py | 16 ++++ pyface/fields/tests/field_mixin.py | 9 ++- pyface/fields/tests/test_image_field.py | 39 +++++++++ pyface/fields/tests/test_label_field.py | 43 ++++++++++ pyface/fields/tests/test_spin_field.py | 8 ++ pyface/fields/tests/test_text_field.py | 13 +-- pyface/ui/qt/fields/combo_field.py | 28 +++++-- pyface/ui/qt/fields/editable_field.py | 37 +++++++++ pyface/ui/qt/fields/field.py | 28 ++++++- pyface/ui/qt/fields/image_field.py | 52 ++++++++++++ pyface/ui/qt/fields/label_field.py | 49 +++++++++++ pyface/ui/qt/fields/spin_field.py | 27 +++---- pyface/ui/qt/fields/text_field.py | 5 +- pyface/ui/qt/fields/time_field.py | 4 +- pyface/ui/qt/fields/toggle_field.py | 14 +++- pyface/ui/qt/util/alignment.py | 67 +++++++++++++++ pyface/ui/wx/fields/combo_field.py | 4 +- pyface/ui/wx/fields/editable_field.py | 25 ++++++ pyface/ui/wx/fields/field.py | 21 ++++- pyface/ui/wx/fields/image_field.py | 51 ++++++++++++ pyface/ui/wx/fields/label_field.py | 47 +++++++++++ pyface/ui/wx/fields/spin_field.py | 14 +++- pyface/ui/wx/fields/text_field.py | 4 +- pyface/ui/wx/fields/time_field.py | 4 +- pyface/ui/wx/fields/toggle_field.py | 4 +- pyface/ui/wx/util/alignment.py | 41 ++++++++++ pyface/wx/image_control.py | 59 ++++++++++++++ 40 files changed, 1004 insertions(+), 159 deletions(-) create mode 100644 pyface/fields/i_editable_field.py create mode 100644 pyface/fields/i_image_field.py create mode 100644 pyface/fields/i_label_field.py create mode 100644 pyface/fields/image_field.py create mode 100644 pyface/fields/label_field.py create mode 100644 pyface/fields/tests/test_image_field.py create mode 100644 pyface/fields/tests/test_label_field.py create mode 100644 pyface/ui/qt/fields/editable_field.py create mode 100644 pyface/ui/qt/fields/image_field.py create mode 100644 pyface/ui/qt/fields/label_field.py create mode 100644 pyface/ui/qt/util/alignment.py create mode 100644 pyface/ui/wx/fields/editable_field.py create mode 100644 pyface/ui/wx/fields/image_field.py create mode 100644 pyface/ui/wx/fields/label_field.py create mode 100644 pyface/ui/wx/util/alignment.py create mode 100644 pyface/wx/image_control.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 8b58d78e6..a16a56067 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,9 +11,7 @@ # All configuration values have a default value; values that are commented out # serve to show the default value. -import os -import runpy -import sys +import importlib.metadata # General configuration # --------------------- @@ -45,10 +43,7 @@ # The default replacements for |version| and |release|, also used in various # other places throughout the built documents. -version_py = os.path.join('..', '..', 'pyface', '_version.py') -version_content = runpy.run_path(version_py) -version = ".".join(version_content["version"].split(".", 2)[:2]) -release = version +version = release = importlib.metadata.version("pyface") # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/source/fields.rst b/docs/source/fields.rst index ac6d12639..2bb7c4049 100644 --- a/docs/source/fields.rst +++ b/docs/source/fields.rst @@ -9,16 +9,81 @@ to a Traits interface, so that you can use them in a cross-toolkit manner. Where possible, these classes share a common API for the same functionality. In particular, all classes have a -:py:attr:`~pyface.fields.i_field.IField.value` trait which holds the (usually -user-editable) value displayed in the field. Code using the field can listen -to changes in this trait to react to the user entering a new value for the -field without needing to know anything about the underlying toolkit event -signalling mechanisms. +:py:attr:`~pyface.fields.i_field.IField.value` trait which holds the value +displayed in the field. -All fields also provide a trait for setting the -:py:attr:`~pyface.fields.i_field.IField.context_menu` of the field. Context -menus should be :py:class:`~pyface.action.menu_manager.MenuManager` -instances. +Fields where the value is user-editable rather than simply displayed implement +the :py:attr:`~pyface.fields.i_editable_field.IEditableField` interface. Code +using the field can listen to changes in the value trait to react to the user +entering a new value for the field without needing to know anything about the +underlying toolkit event signalling mechanisms. + +Fields inherit from :py:class:`~pyface.i_widget.IWidget` and +:py:class:`~pyface.i_layout_item.ILayoutItem` which have a number of +additional traits with useful features: + +:py:attr:`~pyface.i_widget.IWidget.tooltip` + A tooltip for the widget, which should hold string. + +:py:attr:`~pyface.i_widget.IWidget.context_menu` + A context menu for the widget, which should hold an + :py:class:`~pyface.action.i_menu_manager.IMenuManager` instance. + +:py:attr:`~pyface.i_layout_item.ILayoutItem.minimum_size` + A tuple holding the minimum size of a layout widget. + +:py:attr:`~pyface.i_layout_item.ILayoutItem.maximum_size` + A tuple holding the minimum size of a layout widget. + +:py:attr:`~pyface.i_layout_item.ILayoutItem.stretch` + A tuple holding information about the distribution of addtional space into + the widget when growing in a layout. Higher numbers mean proportinally + more space. + +:py:attr:`~pyface.i_layout_item.ILayoutItem.size_policy` + A tuple holding information about how the widget can grow and shrink. + +:py:attr:`~pyface.fields.i_field.IField.alignment` + A value holding the horizontal alignment of the contents of the field. + +ComboField +========== + +The :py:class:`~pyface.fields.i_combo_field.IComboField` interface has an arbitrary +:py:attr:`~pyface.fields.i_combo_field.IComboField.value` that must come from a list +of valid :py:attr:`~pyface.fields.i_combo_field.IComboField.values`. For non-text +values, a :py:attr:`~pyface.fields.i_combo_field.IComboField.formatter` function +should be provided, defaulting to :py:func:`str`. + +LabelField +========== + +The :py:class:`~pyface.fields.i_label_field.ILabelField` interface has a string +for the :py:attr:`~pyface.fields.i_label_field.ILabelField.value` which is not +user-editable. + +In the Qt backend they can have an image for an +:py:attr:`~pyface.fields.i_label_field.ILabelField.icon`. + +ImageField +========== + +The :py:class:`~pyface.fields.i_image_field.IImageField` interface has an +:py:class:`~pyface.i_image.IImage` for its +:py:attr:`~pyface.fields.i_image_field.IImageField.value` which is not +user-editable. + +SpinField +========= + +The :py:class:`~pyface.fields.i_spin_field.ISpinField` interface has an integer +for the :py:attr:`~pyface.fields.i_spin_field.ISpinField.value`, and also +requires a range to be set, either via setting the min/max values as a tuple to +the :py:attr:`~pyface.fields.i_spin_field.ISpinField.bounds` trait, or by setting +values individually to :py:attr:`~pyface.fields.i_spin_field.ISpinField.minimum` +and :py:attr:`~pyface.fields.i_spin_field.ISpinField.maximum`. The +:py:attr:`~pyface.fields.i_spin_field.ISpinField.wrap` trait determines whether +the spinner wraps around at the extreme values. TextField ========= @@ -41,26 +106,6 @@ the Qt backend has a number of other options as well). The text field can be set to read-only mode via the :py:attr:`~pyface.fields.i_text_field.ITextField.read_only` trait. -SpinField -========= - -The :py:class:`~pyface.fields.i_spin_field.ISpinField` interface has an integer -for the :py:attr:`~pyface.fields.i_spin_field.ISpinField.value`, and also -requires a range to be set, either via setting the min/max values as a tuple to -the :py:attr:`~pyface.fields.i_spin_field.ISpinField.range` trait, or by setting -values individually to :py:attr:`~pyface.fields.i_spin_field.ISpinField.minimum` -and :py:attr:`~pyface.fields.i_spin_field.ISpinField.maximum`. - -ComboField -========== - -The :py:class:`~pyface.fields.i_combo_field.IComboField` interface has an arbitrary -:py:attr:`~pyface.fields.i_combo_field.IComboField.value` that must come from a list -of valid :py:attr:`~pyface.fields.i_combo_field.IComboField.values`. For non-text -values, a :py:attr:`~pyface.fields.i_combo_field.IComboField.formatter` function -should be provided - this defaults to either :py:func:`str` (Python 3+) or -:py:func:`unicode` (Python 2). - TimeField ========== diff --git a/pyface/fields/api.py b/pyface/fields/api.py index 95fb0ee7c..4287c9038 100644 --- a/pyface/fields/api.py +++ b/pyface/fields/api.py @@ -14,6 +14,10 @@ - :class:`~.CheckBoxField` - :class:`~.ComboField` +- :class:`~.EditableField` +- :class:`~.Field` +- :class:`~.ImageField` +- :class:`~.LabelField` - :class:`~.RadioButtonField` - :class:`~.SpinField` - :class:`~.TextField` @@ -23,7 +27,10 @@ Interfaces ---------- - :class:`~.IComboField` +- :class:`~.IEditableField` - :class:`~.IField` +- :class:`~.IImageField` +- :class:`~.ILabelField` - :class:`~.ISpinField` - :class:`~.ITextField` - :class:`~.ITimeField` @@ -32,7 +39,10 @@ """ from .i_combo_field import IComboField +from .i_editable_field import IEditableField from .i_field import IField +from .i_image_field import IImageField +from .i_label_field import ILabelField from .i_spin_field import ISpinField from .i_text_field import ITextField from .i_time_field import ITimeField @@ -48,6 +58,10 @@ _toolkit_imports = { 'CheckBoxField': 'toggle_field', 'ComboField': 'combo_field', + 'EditableField': 'editable_field', + 'Field': 'field', + 'ImageField': 'image_field', + 'LabelField': 'label_field', 'RadioButtonField': 'toggle_field', 'SpinField': 'spin_field', 'TextField': 'text_field', diff --git a/pyface/fields/i_combo_field.py b/pyface/fields/i_combo_field.py index 2220b85c7..18839fafd 100644 --- a/pyface/fields/i_combo_field.py +++ b/pyface/fields/i_combo_field.py @@ -13,10 +13,10 @@ from traits.api import Callable, HasTraits, Enum, List -from pyface.fields.i_field import IField +from pyface.fields.i_editable_field import IEditableField -class IComboField(IField): +class IComboField(IEditableField): """ The combo field interface. This is for comboboxes holding a list of values. @@ -64,7 +64,6 @@ def __init__(self, values, **traits): def _initialize_control(self): super()._initialize_control() self._set_control_values(self.values) - self._set_control_value(self.value) def _add_event_listeners(self): """ Set up toolkit-specific bindings for events """ @@ -72,13 +71,9 @@ def _add_event_listeners(self): self.observe( self._values_updated, "values.items,formatter", dispatch="ui" ) - if self.control is not None: - self._observe_control_value() def _remove_event_listeners(self): """ Remove toolkit-specific bindings for events """ - if self.control is not None: - self._observe_control_value(remove=True) self.observe( self._values_updated, "values.items,formatter", diff --git a/pyface/fields/i_editable_field.py b/pyface/fields/i_editable_field.py new file mode 100644 index 000000000..6e28bb14e --- /dev/null +++ b/pyface/fields/i_editable_field.py @@ -0,0 +1,62 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The editable field interface. """ + + +from traits.api import HasTraits + +from .i_field import IField + + +class IEditableField(IField): + """ The editable field interface. + + A editable field is a widget that displays a user-editable value. + """ + + +class MEditableField(HasTraits): + """The editable field mix-in. + + Classes which use this mixin should implement _observe_control_value to + connect a toolkit handler that calls _update_value. + """ + + # ------------------------------------------------------------------------ + # IWidget interface + # ------------------------------------------------------------------------ + + def _add_event_listeners(self): + """ Set up toolkit-specific bindings for events """ + super()._add_event_listeners() + self._observe_control_value() + + def _remove_event_listeners(self): + """ Remove toolkit-specific bindings for events """ + self._observe_control_value(remove=True) + super()._remove_event_listeners() + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _update_value(self, value): + """ Handle a change to the value from user interaction + + This is a method suitable for calling from a toolkit event handler. + """ + self.value = self._get_control_value() + + # Toolkit control interface --------------------------------------------- + + def _observe_control_value(self, remove=False): + """ Toolkit specific method to change the control value observer. """ + raise NotImplementedError() diff --git a/pyface/fields/i_field.py b/pyface/fields/i_field.py index 4380bf1d4..06e029110 100644 --- a/pyface/fields/i_field.py +++ b/pyface/fields/i_field.py @@ -14,6 +14,7 @@ from traits.api import Any, HasTraits from pyface.i_layout_widget import ILayoutWidget +from pyface.ui_traits import Alignment class IField(ILayoutWidget): @@ -26,6 +27,9 @@ class IField(ILayoutWidget): #: The value held by the field. value = Any() + #: The alignment of the field's content. + alignment = Alignment() + class MField(HasTraits): """ The field mix-in. """ @@ -33,44 +37,52 @@ class MField(HasTraits): #: The value held by the field. value = Any() + #: The alignment of the text in the field. + alignment = Alignment() + # ------------------------------------------------------------------------ # IWidget interface # ------------------------------------------------------------------------ + def create(self, parent=None): + """ Creates the toolkit specific control. + + This method should create the control and assign it to the + :py:attr:``control`` trait. + """ + super().create(parent=parent) + + self.show(self.visible) + self.enable(self.enabled) + + def _initialize_control(self): + """ Perform any post-creation initialization for the control. + """ + super()._initialize_control() + self._set_control_value(self.value) + if self.alignment != 'default': + self._set_control_alignment(self.alignment) + def _add_event_listeners(self): """ Set up toolkit-specific bindings for events """ super()._add_event_listeners() self.observe(self._value_updated, "value", dispatch="ui") + self.observe(self._alignment_updated, "alignment", dispatch="ui") def _remove_event_listeners(self): """ Remove toolkit-specific bindings for events """ self.observe( self._value_updated, "value", dispatch="ui", remove=True ) + self.observe( + self._alignment_updated, "alignment", dispatch="ui", remove=True + ) super()._remove_event_listeners() # ------------------------------------------------------------------------ # Private interface # ------------------------------------------------------------------------ - def create(self, parent=None): - """ Creates the toolkit specific control. - - This method should create the control and assign it to the - :py:attr:``control`` trait. - """ - super().create(parent=parent) - - self.show(self.visible) - self.enable(self.enabled) - - def _update_value(self, value): - """ Handle a change to the value from user interaction - - This is a method suitable for calling from a toolkit event handler. - """ - self.value = self._get_control_value() - def _get_control(self): """ If control is not passed directly, get it from the trait. """ control = self.control @@ -88,8 +100,12 @@ def _set_control_value(self, value): """ Toolkit specific method to set the control's value. """ raise NotImplementedError() - def _observe_control_value(self, remove=False): - """ Toolkit specific method to change the control value observer. """ + def _get_control_alignment(self): + """ Toolkit specific method to get the control's read_only state. """ + raise NotImplementedError() + + def _set_control_alignment(self, alignment): + """ Toolkit specific method to set the control's alignment. """ raise NotImplementedError() # Trait change handlers ------------------------------------------------- @@ -98,3 +114,8 @@ def _value_updated(self, event): value = event.new if self.control is not None: self._set_control_value(value) + + def _alignment_updated(self, event): + alignment = event.new + if self.control is not None: + self._set_control_alignment(alignment) diff --git a/pyface/fields/i_image_field.py b/pyface/fields/i_image_field.py new file mode 100644 index 000000000..3e45509fb --- /dev/null +++ b/pyface/fields/i_image_field.py @@ -0,0 +1,36 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The image field interface. """ + +from traits.api import Any, HasTraits + +from pyface.fields.i_field import IField +from pyface.ui_traits import Image + + +class IImageField(IField): + """ The image field interface. + + This is for a field that edits a IImage value. + """ + + #: The current value of the image field + value = Image() + + +class MImageField(HasTraits): + """ Mixin class for ImageField implementations """ + + #: The current value of the image field + value = Image() + + #: The toolkit image to display + _toolkit_value = Any() diff --git a/pyface/fields/i_label_field.py b/pyface/fields/i_label_field.py new file mode 100644 index 000000000..437145917 --- /dev/null +++ b/pyface/fields/i_label_field.py @@ -0,0 +1,67 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The label field interface. """ + + +from traits.api import HasTraits, Str + +from pyface.fields.i_field import IField +from pyface.ui_traits import Image + + +class ILabelField(IField): + """ The label field interface. """ + + #: The value held by the field. + value = Str() + + #: The icon to display with the toggle. + icon = Image() + + +class MLabelField(HasTraits): + """ The text field mix-in. """ + + #: The value held by the field. + value = Str() + + #: The icon to display with the toggle. + icon = Image() + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _initialize_control(self): + super()._initialize_control() + self._set_control_icon(self.icon) + + def _add_event_listeners(self): + """ Set up toolkit-specific bindings for events """ + super()._add_event_listeners() + self.observe(self._icon_updated, "icon", dispatch="ui") + + def _remove_event_listeners(self): + """ Remove toolkit-specific bindings for events """ + self.observe(self._icon_updated, "icon", dispatch="ui", remove=True) + super()._remove_event_listeners() + + # Toolkit control interface --------------------------------------------- + + def _set_control_icon(self, icon): + """ Toolkit specific method to set the control's icon. """ + raise NotImplementedError() + + # Trait change handlers ------------------------------------------------- + + def _icon_updated(self, event): + if self.control is not None: + self._set_control_icon(self.icon) diff --git a/pyface/fields/i_spin_field.py b/pyface/fields/i_spin_field.py index 37127e4a4..35d9e43ad 100644 --- a/pyface/fields/i_spin_field.py +++ b/pyface/fields/i_spin_field.py @@ -11,12 +11,12 @@ """ The spin field interface. """ -from traits.api import HasTraits, Int, Property, Range, Tuple +from traits.api import Bool, HasTraits, Int, Property, Range, Tuple -from pyface.fields.i_field import IField +from pyface.fields.i_editable_field import IEditableField -class ISpinField(IField): +class ISpinField(IEditableField): """ The spin field interface. This is for spinners holding integer values. @@ -34,6 +34,9 @@ class ISpinField(IField): #: The maximum value maximum = Property(Int, observe="bounds") + #: Whether the values wrap around at maximum and minimum. + wrap = Bool() + class MSpinField(HasTraits): @@ -49,6 +52,9 @@ class MSpinField(HasTraits): #: The maximum value for the spinner maximum = Property(Int, observe="bounds") + #: Whether the values wrap around at maximum and minimum. + wrap = Bool() + # ------------------------------------------------------------------------ # object interface # ------------------------------------------------------------------------ @@ -69,21 +75,24 @@ def _initialize_control(self): super()._initialize_control() self._set_control_bounds(self.bounds) self._set_control_value(self.value) + self._set_control_wrap(self.wrap) def _add_event_listeners(self): """ Set up toolkit-specific bindings for events """ super()._add_event_listeners() self.observe(self._bounds_updated, "bounds", dispatch="ui") + self.observe(self._wrap_updated, "wrap", dispatch="ui") if self.control is not None: self._observe_control_value() def _remove_event_listeners(self): """ Remove toolkit-specific bindings for events """ - if self.control is not None: - self._observe_control_value(remove=True) self.observe( self._bounds_updated, "bounds", dispatch="ui", remove=True ) + self.observe( + self._wrap_updated, "wrap", dispatch="ui", remove=True + ) super()._remove_event_listeners() # Toolkit control interface --------------------------------------------- @@ -96,6 +105,14 @@ def _set_control_bounds(self, bounds): """ Toolkit specific method to set the control's bounds. """ raise NotImplementedError() + def _get_control_wrap(self): + """ Toolkit specific method to get whether the control wraps. """ + raise NotImplementedError + + def _set_control_wrap(self, wrap): + """ Toolkit specific method to set whether the control wraps. """ + raise NotImplementedError + # Trait property handlers ----------------------------------------------- def _get_minimum(self): @@ -130,3 +147,7 @@ def _value_default(self): def _bounds_updated(self, event): if self.control is not None: self._set_control_bounds(self.bounds) + + def _wrap_updated(self, event): + if self.control is not None: + self._set_control_wrap(event.new) diff --git a/pyface/fields/i_text_field.py b/pyface/fields/i_text_field.py index eff0066bc..0e6427642 100644 --- a/pyface/fields/i_text_field.py +++ b/pyface/fields/i_text_field.py @@ -13,10 +13,10 @@ from traits.api import Bool, Enum, HasTraits, Str -from pyface.fields.i_field import IField +from pyface.fields.i_editable_field import IEditableField -class ITextField(IField): +class ITextField(IEditableField): """ The text field interface. """ #: The value held by the field. @@ -58,13 +58,11 @@ class MTextField(HasTraits): # ------------------------------------------------------------------------ def _initialize_control(self): + super()._initialize_control() self._set_control_echo(self.echo) - self._set_control_value(self.value) self._set_control_placeholder(self.placeholder) self._set_control_read_only(self.read_only) - super()._initialize_control() - def _add_event_listeners(self): """ Set up toolkit-specific bindings for events """ super()._add_event_listeners() @@ -74,17 +72,15 @@ def _add_event_listeners(self): self.observe(self._read_only_updated, "read_only", dispatch="ui") if self.control is not None: if self.update_text == "editing_finished": + self._observe_control_value(remove=True) self._observe_control_editing_finished() - else: - self._observe_control_value() def _remove_event_listeners(self): """ Remove toolkit-specific bindings for events """ if self.control is not None: if self.update_text == "editing_finished": self._observe_control_editing_finished(remove=True) - else: - self._observe_control_value(remove=True) + self._observe_control_value() self.observe( self._update_text_updated, "update_text", diff --git a/pyface/fields/i_time_field.py b/pyface/fields/i_time_field.py index 18cf40a24..3d63d79b7 100644 --- a/pyface/fields/i_time_field.py +++ b/pyface/fields/i_time_field.py @@ -14,10 +14,10 @@ from traits.api import HasTraits, Time -from pyface.fields.i_field import IField +from pyface.fields.i_editable_field import IEditableField -class ITimeField(IField): +class ITimeField(IEditableField): """ The time field interface. This is for a field that edits a datetime.time value. @@ -37,22 +37,6 @@ class MTimeField(HasTraits): # Private interface # ------------------------------------------------------------------------ - def _initialize_control(self): - super(MTimeField, self)._initialize_control() - self._set_control_value(self.value) - - def _add_event_listeners(self): - """ Set up toolkit-specific bindings for events """ - super(MTimeField, self)._add_event_listeners() - if self.control is not None: - self._observe_control_value() - - def _remove_event_listeners(self): - """ Remove toolkit-specific bindings for events """ - if self.control is not None: - self._observe_control_value(remove=True) - super(MTimeField, self)._remove_event_listeners() - # Trait defaults -------------------------------------------------------- def _value_default(self): diff --git a/pyface/fields/i_toggle_field.py b/pyface/fields/i_toggle_field.py index 017b4f7bd..be68a1228 100644 --- a/pyface/fields/i_toggle_field.py +++ b/pyface/fields/i_toggle_field.py @@ -13,11 +13,11 @@ from traits.api import Bool, HasTraits, Str -from pyface.fields.i_field import IField +from pyface.fields.i_editable_field import IEditableField from pyface.ui_traits import Image -class IToggleField(IField): +class IToggleField(IEditableField): """ The toggle field interface. This is for a toggle between two states, represented by a boolean value. @@ -62,13 +62,9 @@ def _add_event_listeners(self): super()._add_event_listeners() self.observe(self._text_updated, "text", dispatch="ui") self.observe(self._icon_updated, "icon", dispatch="ui") - if self.control is not None: - self._observe_control_value() def _remove_event_listeners(self): """ Remove toolkit-specific bindings for events """ - if self.control is not None: - self._observe_control_value(remove=True) self.observe(self._text_updated, "text", dispatch="ui", remove=True) self.observe(self._icon_updated, "icon", dispatch="ui", remove=True) super()._remove_event_listeners() diff --git a/pyface/fields/image_field.py b/pyface/fields/image_field.py new file mode 100644 index 000000000..3f756a098 --- /dev/null +++ b/pyface/fields/image_field.py @@ -0,0 +1,16 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The image field widget. """ + +# Import the toolkit specific version. +from pyface.toolkit import toolkit_object + +ImageField = toolkit_object("fields.image_field:ImageField") diff --git a/pyface/fields/label_field.py b/pyface/fields/label_field.py new file mode 100644 index 000000000..d1d4dba11 --- /dev/null +++ b/pyface/fields/label_field.py @@ -0,0 +1,16 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The label field widget. """ + +# Import the toolkit specific version. +from pyface.toolkit import toolkit_object + +LabelField = toolkit_object("fields.label_field:LabelField") diff --git a/pyface/fields/tests/field_mixin.py b/pyface/fields/tests/field_mixin.py index 132cfcc0c..80d4421bd 100644 --- a/pyface/fields/tests/field_mixin.py +++ b/pyface/fields/tests/field_mixin.py @@ -14,4 +14,11 @@ class FieldMixin(LayoutWidgetMixin): """ Mixin which provides standard methods for all fields. """ - pass + + def test_text_field_alignment(self): + self._create_widget_control() + + self.widget.alignment = 'right' + self.gui.process_events() + + self.assertEqual(self.widget._get_control_alignment(), 'right') diff --git a/pyface/fields/tests/test_image_field.py b/pyface/fields/tests/test_image_field.py new file mode 100644 index 000000000..7b32c6919 --- /dev/null +++ b/pyface/fields/tests/test_image_field.py @@ -0,0 +1,39 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +import unittest + +from traits.testing.optional_dependencies import numpy as np, requires_numpy + +from ..image_field import ImageField +from .field_mixin import FieldMixin + + +@requires_numpy +class ImageFieldMixin(FieldMixin, unittest.TestCase): + + def setUp(self): + super().setUp() + self.data = np.full((32, 64, 4), 0xee, dtype='uint8') + + def _create_widget_simple(self, **traits): + traits.setdefault("tooltip", "Dummy") + return ImageField(**traits) + + # Tests ------------------------------------------------------------------ + + def test_image_field(self): + self._create_widget_control() + + self.widget.value = 'splash' + self.gui.process_events() + + self.assertIsNotNone(self.widget._get_control_value()) diff --git a/pyface/fields/tests/test_label_field.py b/pyface/fields/tests/test_label_field.py new file mode 100644 index 000000000..219c0d1ab --- /dev/null +++ b/pyface/fields/tests/test_label_field.py @@ -0,0 +1,43 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +import unittest + + +from pyface.image_resource import ImageResource +from ..label_field import LabelField +from .field_mixin import FieldMixin + + +class TestLabelField(FieldMixin, unittest.TestCase): + + def _create_widget_simple(self, **traits): + traits.setdefault("value", "Label") + traits.setdefault("tooltip", "Dummy") + return LabelField(**traits) + + # Tests ------------------------------------------------------------------ + + def test_label_field(self): + self._create_widget_control() + + self.widget.value = "Test" + self.gui.process_events() + + self.assertEqual(self.widget._get_control_value(), "Test") + + def test_label_field_image(self): + self._create_widget_control() + image = ImageResource("question") + + # XXX can't validate icon values currently, so just a smoke test + self.widget.image = image + self.gui.process_events() diff --git a/pyface/fields/tests/test_spin_field.py b/pyface/fields/tests/test_spin_field.py index ec7cda343..2ada52e93 100644 --- a/pyface/fields/tests/test_spin_field.py +++ b/pyface/fields/tests/test_spin_field.py @@ -51,3 +51,11 @@ def test_spin_field_bounds(self): self.assertEqual(self.widget._get_control_bounds(), (10, 50)) self.assertEqual(self.widget._get_control_value(), 10) self.assertEqual(self.widget.value, 10) + + def test_spin_field_wrap(self): + self._create_widget_control() + + self.widget.wrap = True + self.gui.process_events() + + self.assertTrue(self.widget._get_control_wrap()) diff --git a/pyface/fields/tests/test_text_field.py b/pyface/fields/tests/test_text_field.py index 53230b4e4..f995e01e4 100644 --- a/pyface/fields/tests/test_text_field.py +++ b/pyface/fields/tests/test_text_field.py @@ -52,7 +52,7 @@ def test_text_field_echo(self): self.assertEqual(self.widget._get_control_echo(), "password") @unittest.skipIf( - is_wx, "Can't change password mode for wx after control " "creation." + is_wx, "Can't change password mode for wx after control creation." ) def test_text_field_echo_change(self): self._create_widget_control() @@ -71,17 +71,6 @@ def test_text_field_placeholder(self): self.assertEqual(self.widget._get_control_placeholder(), "test") def test_text_field_readonly(self): - self.widget.read_only = True - self._create_widget_control() - - self.gui.process_events() - - self.assertEqual(self.widget._get_control_read_only(), True) - - @unittest.skipIf( - is_wx, "Can't change read_only mode for wx after control " "creation." - ) - def test_text_field_readonly_change(self): self._create_widget_control() self.widget.read_only = True diff --git a/pyface/ui/qt/fields/combo_field.py b/pyface/ui/qt/fields/combo_field.py index c2ce4dc16..afb15c1b8 100644 --- a/pyface/ui/qt/fields/combo_field.py +++ b/pyface/ui/qt/fields/combo_field.py @@ -19,11 +19,14 @@ from pyface.fields.i_combo_field import IComboField, MComboField from pyface.qt.QtCore import Qt from pyface.qt.QtGui import QComboBox -from .field import Field +from pyface.ui.qt.util.alignment import ( + alignment_to_qalignment, qalignment_to_alignment +) +from .editable_field import EditableField @provides(IComboField) -class ComboField(MComboField, Field): +class ComboField(MComboField, EditableField): """ The Qt-specific implementation of the combo field class """ # ------------------------------------------------------------------------ @@ -41,11 +44,6 @@ def _create_control(self, parent): # Private interface # ------------------------------------------------------------------------ - def _update_value(self, value): - """ Handle a change to the value from user interaction - """ - self.value = self._get_control_value() - # Toolkit control interface --------------------------------------------- def _get_control_value(self): @@ -101,3 +99,19 @@ def _set_control_values(self, values): self._set_control_value(current_value) else: self._set_control_value(self.value) + + def _get_control_alignment(self): + """ Toolkit specific method to get the control's alignment. """ + # only works if combobox is ieditable, which currently is always False + line_edit = self.control.lineEdit() + if line_edit is not None: + return qalignment_to_alignment(line_edit.alignment()) + else: + # no widget; cheat + return self.alignment + + def _set_control_alignment(self, alignment): + """ Toolkit specific method to set the control's alignment. """ + line_edit = self.control.lineEdit() + if line_edit is not None: + return line_edit.setAlignment(alignment_to_qalignment(alignment)) diff --git a/pyface/ui/qt/fields/editable_field.py b/pyface/ui/qt/fields/editable_field.py new file mode 100644 index 000000000..0de04eb58 --- /dev/null +++ b/pyface/ui/qt/fields/editable_field.py @@ -0,0 +1,37 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Qt-specific implementation of the text field class """ + +from traits.api import provides + +from pyface.fields.i_editable_field import IEditableField, MEditableField +from pyface.ui.qt.fields.field import Field + + +@provides(IEditableField) +class EditableField(MEditableField, Field): + """ The Qt-specific implementation of the field class + + This is an abstract class which is not meant to be instantiated. Because + many concrete QWidgets provide a `value` property, the default control + observer targets this. + """ + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _observe_control_value(self, remove=False): + """ Toolkit specific method to change the control value observer. """ + if remove: + self.control.valueChanged[int].disconnect(self._update_value) + else: + self.control.valueChanged[int].connect(self._update_value) diff --git a/pyface/ui/qt/fields/field.py b/pyface/ui/qt/fields/field.py index dc3b00aa2..bed8ee3d8 100644 --- a/pyface/ui/qt/fields/field.py +++ b/pyface/ui/qt/fields/field.py @@ -15,14 +15,40 @@ from pyface.fields.i_field import IField, MField from pyface.ui.qt.layout_widget import LayoutWidget +from pyface.ui.qt.util.alignment import ( + alignment_to_qalignment, qalignment_to_alignment +) @provides(IField) class Field(MField, LayoutWidget): """ The Qt-specific implementation of the field class - This is an abstract class which is not meant to be instantiated. + This is an abstract class which is not meant to be instantiated. Because + many concrete QWidgets provide a `value` property, the default getters and + setters target this. """ #: The value held by the field. value = Any() + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _get_control_value(self): + """ Toolkit specific method to get the control's value. """ + return self.control.value() + + def _set_control_value(self, value): + """ Toolkit specific method to set the control's value. """ + self.control.setValue(value) + + def _get_control_alignment(self): + """ Toolkit specific method to get the control's alignment. """ + # default implementation + return qalignment_to_alignment(self.control.alignment()) + + def _set_control_alignment(self, alignment): + """ Toolkit specific method to set the control's alignment. """ + self.control.setAlignment(alignment_to_qalignment(alignment)) diff --git a/pyface/ui/qt/fields/image_field.py b/pyface/ui/qt/fields/image_field.py new file mode 100644 index 000000000..04cfc8daf --- /dev/null +++ b/pyface/ui/qt/fields/image_field.py @@ -0,0 +1,52 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Qt-specific implementation of the text field class """ + +from pyface.qt.QtGui import QLabel, QPixmap + +from traits.api import provides + +from pyface.ui.qt.fields.field import Field +from pyface.fields.i_image_field import IImageField, MImageField + + +@provides(IImageField) +class ImageField(MImageField, Field): + """ The Qt-specific implementation of the image field class + """ + + # ------------------------------------------------------------------------ + # IWidget interface + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + """ Create the toolkit-specific control that represents the widget. """ + control = QLabel(parent) + return control + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _get_control_value(self): + """ Toolkit specific method to get the control's value. """ + # XXX we should really have a ToolkitImage subclass of Image and + # which would be the correct subclass to return. + return self.control.pixmap() + + def _set_control_value(self, value): + """ Toolkit specific method to set the control's value. """ + if value is None: + self._toolkit_value = None + self.control.setPixmap(QPixmap()) + else: + self._toolkit_value = self.value.create_bitmap() + self.control.setPixmap(self._toolkit_value) diff --git a/pyface/ui/qt/fields/label_field.py b/pyface/ui/qt/fields/label_field.py new file mode 100644 index 000000000..8c4ab136b --- /dev/null +++ b/pyface/ui/qt/fields/label_field.py @@ -0,0 +1,49 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Qt-specific implementation of the label field class """ + + +from traits.api import provides + +from pyface.fields.i_label_field import ILabelField, MLabelField +from pyface.qt.QtGui import QLabel, QPixmap +from .field import Field + + +@provides(ILabelField) +class LabelField(MLabelField, Field): + """ The Qt-specific implementation of the label field class """ + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + """ Create the toolkit-specific control that represents the widget. """ + control = QLabel(parent) + return control + + # Toolkit control interface --------------------------------------------- + + def _get_control_value(self): + """ Toolkit specific method to get the control's value. """ + return self.control.text() + + def _set_control_value(self, value): + """ Toolkit specific method to set the control's value. """ + return self.control.setText(value) + + def _set_control_icon(self, icon): + """ Toolkit specific method to set the control's icon. """ + if icon is not None: + self.control.setPixmap(self.icon.create_bitmap()) + else: + self.control.setPixmap(QPixmap()) diff --git a/pyface/ui/qt/fields/spin_field.py b/pyface/ui/qt/fields/spin_field.py index ce1e52db3..e011d9a12 100644 --- a/pyface/ui/qt/fields/spin_field.py +++ b/pyface/ui/qt/fields/spin_field.py @@ -18,11 +18,11 @@ from pyface.fields.i_spin_field import ISpinField, MSpinField from pyface.qt.QtGui import QSpinBox -from .field import Field +from .editable_field import EditableField @provides(ISpinField) -class SpinField(MSpinField, Field): +class SpinField(MSpinField, EditableField): """ The Qt-specific implementation of the spin field class """ # ------------------------------------------------------------------------ @@ -39,21 +39,6 @@ def _create_control(self, parent): # Private interface # ------------------------------------------------------------------------ - def _get_control_value(self): - """ Toolkit specific method to get the control's value. """ - return self.control.value() - - def _set_control_value(self, value): - """ Toolkit specific method to set the control's value. """ - self.control.setValue(value) - - def _observe_control_value(self, remove=False): - """ Toolkit specific method to change the control value observer. """ - if remove: - self.control.valueChanged[int].disconnect(self._update_value) - else: - self.control.valueChanged[int].connect(self._update_value) - def _get_control_bounds(self): """ Toolkit specific method to get the control's bounds. """ return (self.control.minimum(), self.control.maximum()) @@ -61,3 +46,11 @@ def _get_control_bounds(self): def _set_control_bounds(self, bounds): """ Toolkit specific method to set the control's bounds. """ self.control.setRange(*bounds) + + def _get_control_wrap(self): + """ Toolkit specific method to get whether the control wraps. """ + return self.control.wrapping() + + def _set_control_wrap(self, wrap): + """ Toolkit specific method to set whether the control wraps. """ + self.control.setWrapping(wrap) diff --git a/pyface/ui/qt/fields/text_field.py b/pyface/ui/qt/fields/text_field.py index d5e195511..5675b691a 100644 --- a/pyface/ui/qt/fields/text_field.py +++ b/pyface/ui/qt/fields/text_field.py @@ -15,8 +15,7 @@ from pyface.fields.i_text_field import ITextField, MTextField from pyface.qt.QtGui import QLineEdit -from .field import Field - +from .editable_field import EditableField ECHO_TO_QT_ECHO_MODE = { "normal": QLineEdit.EchoMode.Normal, @@ -33,7 +32,7 @@ @provides(ITextField) -class TextField(MTextField, Field): +class TextField(MTextField, EditableField): """ The Qt-specific implementation of the text field class """ #: Display typed text, or one of several hidden "password" modes. diff --git a/pyface/ui/qt/fields/time_field.py b/pyface/ui/qt/fields/time_field.py index 0823e9fd2..07d96df2a 100644 --- a/pyface/ui/qt/fields/time_field.py +++ b/pyface/ui/qt/fields/time_field.py @@ -17,11 +17,11 @@ from pyface.fields.i_time_field import ITimeField, MTimeField from pyface.ui.qt.util.datetime import pytime_to_qtime, qtime_to_pytime -from .field import Field +from .editable_field import EditableField @provides(ITimeField) -class TimeField(MTimeField, Field): +class TimeField(MTimeField, EditableField): """ The Qt-specific implementation of the time field class """ # ------------------------------------------------------------------------ diff --git a/pyface/ui/qt/fields/toggle_field.py b/pyface/ui/qt/fields/toggle_field.py index fb6b60733..facb5d2f1 100644 --- a/pyface/ui/qt/fields/toggle_field.py +++ b/pyface/ui/qt/fields/toggle_field.py @@ -17,11 +17,11 @@ from pyface.qt.QtGui import ( QCheckBox, QIcon, QPushButton, QRadioButton ) -from .field import Field +from .editable_field import EditableField @provides(IToggleField) -class ToggleField(MToggleField, Field): +class ToggleField(MToggleField, EditableField): """ The Qt-specific implementation of the toggle field class """ # ------------------------------------------------------------------------ @@ -60,6 +60,16 @@ def _observe_control_value(self, remove=False): else: self.control.toggled.connect(self._update_value) + def _get_control_alignment(self): + """ Toolkit specific method to get the control's alignment. """ + # dummy implementation + return self.alignment + + def _set_control_alignment(self, alignment): + """ Toolkit specific method to set the control's alignment. """ + # use stylesheet for button alignment + self.control.setStyleSheet(f"text-align: {alignment}") + class CheckBoxField(ToggleField): """ The Qt-specific implementation of the checkbox class """ diff --git a/pyface/ui/qt/util/alignment.py b/pyface/ui/qt/util/alignment.py new file mode 100644 index 000000000..e9b701664 --- /dev/null +++ b/pyface/ui/qt/util/alignment.py @@ -0,0 +1,67 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Qt alignment helper functions """ + +from pyface.qt.QtCore import Qt + + +ALIGNMENT_TO_QALIGNMENT = { + "default": Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + "left": Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + "center": Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, + "right": Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, +} +QALIGNMENT_TO_ALIGNMENT = { + 0: "default", + int(Qt.AlignmentFlag.AlignLeft): "left", + int(Qt.AlignmentFlag.AlignHCenter): "center", + int(Qt.AlignmentFlag.AlignRight): "right", +} +ALIGNMENT_MASK = ( + Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignHCenter + | Qt.AlignmentFlag.AlignRight +) + + +def alignment_to_qalignment(alignment): + """Convert an Alignment trait value to a Qt Alignment + + Parameters + ---------- + alignment : str + An Alignment trait value, one of "default", "left", "center", or + "right". + + Returns + ------- + qalignment : Qt.AlignmentFlag + A Qt.AlignmentFlag value + """ + return ALIGNMENT_TO_QALIGNMENT[alignment] + + +def qalignment_to_alignment(alignment): + """Convert a Qt Alignment value to an Alignment trait + + Parameters + ---------- + qalignment : Qt.AlignmentFlag + A Qt.AlignmentFlag value + + Returns + ------- + alignment : str + An Alignment trait value, one of "default", "left", "center", or + "right". + """ + h_alignment = int(alignment & ALIGNMENT_MASK) + return QALIGNMENT_TO_ALIGNMENT[h_alignment] diff --git a/pyface/ui/wx/fields/combo_field.py b/pyface/ui/wx/fields/combo_field.py index 73bf80b2a..9193c6418 100644 --- a/pyface/ui/wx/fields/combo_field.py +++ b/pyface/ui/wx/fields/combo_field.py @@ -16,11 +16,11 @@ from traits.api import provides from pyface.fields.i_combo_field import IComboField, MComboField -from .field import Field +from .editable_field import EditableField @provides(IComboField) -class ComboField(MComboField, Field): +class ComboField(MComboField, EditableField): """ The Wx-specific implementation of the combo field class """ # ------------------------------------------------------------------------ diff --git a/pyface/ui/wx/fields/editable_field.py b/pyface/ui/wx/fields/editable_field.py new file mode 100644 index 000000000..6ead41ef8 --- /dev/null +++ b/pyface/ui/wx/fields/editable_field.py @@ -0,0 +1,25 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Wx-specific implementation of the text field class """ + + +from traits.api import provides + +from pyface.fields.i_editable_field import IEditableField, MEditableField +from pyface.ui.wx.fields.field import Field + + +@provides(IEditableField) +class EditableField(MEditableField, Field): + """ The Wx-specific implementation of the EditableField class + + This is an abstract class which is not meant to be instantiated. + """ diff --git a/pyface/ui/wx/fields/field.py b/pyface/ui/wx/fields/field.py index d2f484297..8a7f5ee0c 100644 --- a/pyface/ui/wx/fields/field.py +++ b/pyface/ui/wx/fields/field.py @@ -11,10 +11,13 @@ """ The Wx-specific implementation of the text field class """ -from traits.api import Any, provides +from traits.api import provides from pyface.fields.i_field import IField, MField from pyface.ui.wx.layout_widget import LayoutWidget +from pyface.ui.wx.util.alignment import ( + get_alignment_style, set_alignment_style +) @provides(IField) @@ -24,5 +27,17 @@ class Field(MField, LayoutWidget): This is an abstract class which is not meant to be instantiated. """ - #: The value held by the field. - value = Any() + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _get_control_alignment(self): + """ Toolkit specific method to get the control's read_only state. """ + return get_alignment_style(self.control.GetWindowStyle()) + + def _set_control_alignment(self, alignment): + """ Toolkit specific method to set the control's read_only state. """ + old_style = self.control.GetWindowStyle() + new_style = set_alignment_style(alignment, old_style) + self.control.SetWindowStyle(new_style) + self.control.Refresh() diff --git a/pyface/ui/wx/fields/image_field.py b/pyface/ui/wx/fields/image_field.py new file mode 100644 index 000000000..5da484250 --- /dev/null +++ b/pyface/ui/wx/fields/image_field.py @@ -0,0 +1,51 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Qt-specific implementation of the text field class """ + +from traits.api import provides + +from pyface.ui.wx.fields.field import Field +from pyface.fields.i_image_field import IImageField, MImageField +from pyface.wx.image_control import ImageControl + + +@provides(IImageField) +class ImageField(MImageField, Field): + """ The Wx-specific implementation of the image field class + + """ + + # ------------------------------------------------------------------------ + # IWidget interface + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + """ Create the toolkit-specific control that represents the widget. """ + control = ImageControl(parent) + return control + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + def _get_control_value(self): + """ Toolkit specific method to get the control's value. """ + # XXX we should really have a ToolkitImage subclass of Image and + # which would be the correct subclass to return. + return self.control.GetBitmap() + + def _set_control_value(self, value): + """ Toolkit specific method to set the control's value. """ + if value is None: + self._toolkit_value = None + else: + self._toolkit_value = self.value.create_bitmap() + self.control.SetBitmap(self._toolkit_value) diff --git a/pyface/ui/wx/fields/label_field.py b/pyface/ui/wx/fields/label_field.py new file mode 100644 index 000000000..46a69aff4 --- /dev/null +++ b/pyface/ui/wx/fields/label_field.py @@ -0,0 +1,47 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The Wx-specific implementation of the label field class """ + +import wx + +from traits.api import provides + +from pyface.fields.i_label_field import ILabelField, MLabelField +from .field import Field + + +@provides(ILabelField) +class LabelField(MLabelField, Field): + """ The Wx-specific implementation of the label field class """ + + def _create_control(self, parent): + """ Create the toolkit-specific control that represents the widget. """ + control = wx.StaticText(parent) + return control + + # ------------------------------------------------------------------------ + # Private interface + # ------------------------------------------------------------------------ + + # Toolkit control interface --------------------------------------------- + + def _get_control_value(self): + """ Toolkit specific method to get the control's value. """ + return self.control.GetLabel() + + def _set_control_value(self, value): + """ Toolkit specific method to set the control's value. """ + self.control.SetLabel(value) + + def _set_control_icon(self, icon): + """ Toolkit specific method to set the control's icon. """ + # don't support icons on Wx for now + pass diff --git a/pyface/ui/wx/fields/spin_field.py b/pyface/ui/wx/fields/spin_field.py index 78e02fc47..37ed16bc2 100644 --- a/pyface/ui/wx/fields/spin_field.py +++ b/pyface/ui/wx/fields/spin_field.py @@ -19,11 +19,11 @@ from traits.api import provides from pyface.fields.i_spin_field import ISpinField, MSpinField -from .field import Field +from .editable_field import EditableField @provides(ISpinField) -class SpinField(MSpinField, Field): +class SpinField(MSpinField, EditableField): """ The Wx-specific implementation of the spin field class """ # ------------------------------------------------------------------------ @@ -65,3 +65,13 @@ def _get_control_bounds(self): def _set_control_bounds(self, bounds): """ Toolkit specific method to set the control's bounds. """ self.control.SetRange(*bounds) + + def _get_control_wrap(self): + """ Toolkit specific method to get whether the control wraps. """ + return bool(self.control.GetWindowStyle() & wx.SP_WRAP) + + def _set_control_wrap(self, wrap): + """ Toolkit specific method to set whether the control wraps. """ + if wrap != self._get_control_wrap(): + self.control.ToggleWindowStyle(wx.SP_WRAP) + self.control.Refresh() diff --git a/pyface/ui/wx/fields/text_field.py b/pyface/ui/wx/fields/text_field.py index 0f745ccbf..efdf4c6f8 100644 --- a/pyface/ui/wx/fields/text_field.py +++ b/pyface/ui/wx/fields/text_field.py @@ -16,11 +16,11 @@ from traits.api import provides from pyface.fields.i_text_field import ITextField, MTextField -from .field import Field +from .editable_field import EditableField @provides(ITextField) -class TextField(MTextField, Field): +class TextField(MTextField, EditableField): """ The Wx-specific implementation of the text field class """ # ------------------------------------------------------------------------ diff --git a/pyface/ui/wx/fields/time_field.py b/pyface/ui/wx/fields/time_field.py index e310c58d0..ce146856f 100644 --- a/pyface/ui/wx/fields/time_field.py +++ b/pyface/ui/wx/fields/time_field.py @@ -17,11 +17,11 @@ from traits.api import provides from pyface.fields.i_time_field import ITimeField, MTimeField -from .field import Field +from .editable_field import EditableField @provides(ITimeField) -class TimeField(MTimeField, Field): +class TimeField(MTimeField, EditableField): """ The Wx-specific implementation of the time field class """ # ------------------------------------------------------------------------ diff --git a/pyface/ui/wx/fields/toggle_field.py b/pyface/ui/wx/fields/toggle_field.py index 69209b005..bb025cf64 100644 --- a/pyface/ui/wx/fields/toggle_field.py +++ b/pyface/ui/wx/fields/toggle_field.py @@ -15,11 +15,11 @@ from traits.api import provides from pyface.fields.i_toggle_field import IToggleField, MToggleField -from .field import Field +from .editable_field import EditableField @provides(IToggleField) -class ToggleField(MToggleField, Field): +class ToggleField(MToggleField, EditableField): """ The Wx-specific implementation of the toggle field class """ # ------------------------------------------------------------------------ diff --git a/pyface/ui/wx/util/alignment.py b/pyface/ui/wx/util/alignment.py new file mode 100644 index 000000000..589cfe103 --- /dev/null +++ b/pyface/ui/wx/util/alignment.py @@ -0,0 +1,41 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# +# Author: Enthought, Inc. +# Description: + +""" Utilities for handling alignment """ + +import wx + + +ALIGNMENT_TO_WX_ALIGNMENT = { + 'default': wx.TE_LEFT, + 'left': wx.TE_LEFT, + 'center': wx.TE_CENTRE, + 'right': wx.TE_RIGHT, +} +WX_ALIGNMENT_TO_ALIGNMENT = { + 0: 'default', + wx.TE_LEFT: 'left', + wx.TE_CENTRE: 'center', + wx.TE_RIGHT: 'right', +} +ALIGNMENT_MASK = wx.TE_LEFT | wx.TE_CENTRE | wx.TE_RIGHT + + +def get_alignment_style(style_flags): + alignment_flag = style_flags & ALIGNMENT_MASK + return WX_ALIGNMENT_TO_ALIGNMENT[alignment_flag] + + +def set_alignment_style(alignment, style_flags): + other_flags = style_flags & ~ALIGNMENT_MASK + return other_flags | ALIGNMENT_TO_WX_ALIGNMENT[alignment] diff --git a/pyface/wx/image_control.py b/pyface/wx/image_control.py new file mode 100644 index 000000000..251294178 --- /dev/null +++ b/pyface/wx/image_control.py @@ -0,0 +1,59 @@ +# (C) Copyright 2004-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Defines a wxPython ImageControl widget that is used by various Widgets. +""" + + +import wx + + +class ImageControl(wx.Window): + """A wxPython control that displays an image. + """ + + def __init__(self, parent, bitmap=None, padding=10): + """Initializes the object.""" + if bitmap is not None: + size = wx.Size( + bitmap.GetWidth() + padding, bitmap.GetHeight() + padding + ) + else: + size = wx.Size(32 + padding, 32 + padding) + + wx.Window.__init__(self, parent, -1, size=size,) + self._bitmap = bitmap + + # Set up the 'paint' event handler: + self.Bind(wx.EVT_PAINT, self._on_paint) + + def SetBitmap(self, bitmap=None): + """Sets the bitmap image.""" + if bitmap is not None: + if bitmap != self._bitmap: + self._bitmap = bitmap + self.Refresh() + else: + self._bitmap = None + + def GetBitmap(self): + """Gets the bitmap image.""" + return self._bitmap + + def _on_paint(self, event=None): + """Handles the control being re-painted.""" + wdc = wx.PaintDC(self) + wdx, wdy = self.GetClientSize() + bitmap = self._bitmap + if bitmap is None: + return + bdx = bitmap.GetWidth() + bdy = bitmap.GetHeight() + wdc.DrawBitmap(bitmap, (wdx - bdx) // 2, (wdy - bdy) // 2, True) From 46f700999284c8104fb2a5468f549677dfadf063 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 6 Apr 2023 13:42:42 +0100 Subject: [PATCH 6/6] Update Documentation for Pyface 8.0 release (#1238) This PR includes general small improvements to the documentation, docstings and examples to improve the generated documentation for the website. --- .github/workflows/test-docs-with-edm.yml | 56 +++++++ .github/workflows/test-with-edm.yml | 2 + .gitignore | 1 + CHANGES.txt | 148 ++++++++++++++++++ README.rst | 92 +++++++---- docs/source/conf.py | 7 +- docs/source/index.rst | 17 +- docs/source/traits.rst | 103 +++++++++++- .../hello_world/hello_application.py | 4 +- pyface/action/action.py | 2 +- pyface/action/action_controller.py | 14 +- pyface/action/action_item.py | 8 +- pyface/action/action_manager.py | 18 +-- pyface/action/action_manager_item.py | 6 +- pyface/action/field_action.py | 2 +- pyface/action/i_action_manager.py | 14 +- pyface/action/i_menu_bar_manager.py | 2 +- pyface/action/i_menu_manager.py | 2 +- pyface/action/i_tool_bar_manager.py | 2 +- pyface/base_toolkit.py | 6 +- pyface/data_view/abstract_data_exporter.py | 8 +- pyface/data_view/abstract_data_model.py | 4 +- pyface/data_view/abstract_value_type.py | 6 +- pyface/data_view/data_formats.py | 8 +- .../data_view/data_models/array_data_model.py | 2 +- .../data_view/data_models/data_accessors.py | 36 ++--- .../data_models/row_table_data_model.py | 4 +- pyface/data_view/exporters/item_exporter.py | 8 +- pyface/data_view/exporters/row_exporter.py | 4 +- pyface/data_view/i_data_wrapper.py | 8 +- pyface/data_view/value_types/color_value.py | 4 +- .../data_view/value_types/constant_value.py | 2 +- .../data_view/value_types/editable_value.py | 4 +- pyface/data_view/value_types/enum_value.py | 4 +- pyface/data_view/value_types/numeric_value.py | 2 +- pyface/dock/dock_window_feature.py | 4 +- pyface/font.py | 16 +- pyface/gui_application.py | 2 +- pyface/i_application_window.py | 4 +- pyface/i_gui.py | 14 +- pyface/i_image_resource.py | 4 +- pyface/i_python_shell.py | 6 +- pyface/i_widget.py | 2 +- pyface/single_choice_dialog.py | 2 +- pyface/splash_screen_log_handler.py | 4 +- pyface/tasks/tasks_application.py | 4 +- pyface/timer/do_later.py | 4 +- pyface/ui/__init__.py | 2 +- pyface/ui/qt/application_window.py | 4 +- pyface/ui/qt/console/console_widget.py | 2 +- pyface/ui/qt/font.py | 2 +- pyface/ui/qt/tasks/split_editor_area_pane.py | 4 +- pyface/ui/qt/util/event_loop_helper.py | 2 +- pyface/ui/qt/util/gui_test_assistant.py | 12 +- pyface/ui/qt/util/modal_dialog_tester.py | 6 +- pyface/ui/qt/util/testing.py | 2 +- pyface/ui/wx/layout_widget.py | 4 +- pyface/ui_traits.py | 62 +++++--- pyface/util/testing.py | 8 +- pyface/workbench/action/action_controller.py | 4 +- pyproject.toml | 2 +- shell_window.png | Bin 0 -> 89300 bytes 62 files changed, 583 insertions(+), 209 deletions(-) create mode 100644 .github/workflows/test-docs-with-edm.yml create mode 100644 shell_window.png diff --git a/.github/workflows/test-docs-with-edm.yml b/.github/workflows/test-docs-with-edm.yml new file mode 100644 index 000000000..f9733e920 --- /dev/null +++ b/.github/workflows/test-docs-with-edm.yml @@ -0,0 +1,56 @@ +# This workflow targets stable released dependencies from EDM. +# Note that some packages may not actually be installed from EDM but from +# PyPI, see etstool.py implementations. + +name: Build docs with EDM + +on: [pull_request, workflow_dispatch] + +env: + INSTALL_EDM_VERSION: 3.5.0 + QT_MAC_WANTS_LAYER: 1 + +jobs: + + # Build docs using EDM packages + docs-with-edm: + strategy: + matrix: + os: [ubuntu-latest] + toolkit: ['pyside6'] + fail-fast: false + timeout-minutes: 20 # should be plenty, it's usually < 5 + runs-on: ${{ matrix.os }} + env: + # Set root directory, mainly for Windows, so that the EDM Python + # environment lives in the same drive as the cloned source. Otherwise + # 'pip install' raises an error while trying to compute + # relative path between the site-packages and the source directory. + EDM_ROOT_DIRECTORY: ${{ github.workspace }}/.edm + steps: + - uses: actions/checkout@v3 + - name: Install Qt dependencies + uses: ./.github/actions/install-qt-support + if: matrix.toolkit != 'wx' + - name: Cache EDM packages + uses: actions/cache@v3 + with: + path: ~/.cache + key: ${{ runner.os }}-${{ matrix.toolkit }}-${{ hashFiles('etstool.py') }} + - name: Setup EDM + uses: enthought/setup-edm-action@v2 + with: + edm-version: ${{ env.INSTALL_EDM_VERSION }} + - name: Install click to the default EDM environment + run: edm install -y wheel click coverage + - name: Install test environment + run: edm run -- python etstool.py install --toolkit=${{ matrix.toolkit }} + - name: Build docs + run: xvfb-run -a edm run -- python etstool.py docs --toolkit=${{ matrix.toolkit }} + - name: Archive HTML docs + uses: actions/upload-artifact@v3 + with: + name: html-doc-bundle + path: docs/build/html + # don't need these kept for long + retention-days: 7 diff --git a/.github/workflows/test-with-edm.yml b/.github/workflows/test-with-edm.yml index 8f72f1a08..917c71e3d 100644 --- a/.github/workflows/test-with-edm.yml +++ b/.github/workflows/test-with-edm.yml @@ -16,6 +16,8 @@ jobs: test-with-edm: strategy: matrix: + # Note: MacOS support is contingent on not hitting any issues with + # unsupported CPU features such as AVX2 and so may be unstable. os: [ubuntu-latest, macos-latest, windows-latest] toolkit: ['pyside6'] fail-fast: false diff --git a/.gitignore b/.gitignore index 853bfd569..0a46cb31c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ dist/ docs/build/ +docs/source/api # Auto-generated by setup.py pyface/_version.py diff --git a/CHANGES.txt b/CHANGES.txt index b49dbfa1d..3e6c80236 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -2,6 +2,154 @@ Pyface Changelog ================ +Release 8.0.0 +============= + +Highlights of this release +-------------------------- + +This is a major release which removes a number of things which have been +deprecated for a number of years. The most significant change in this release +is that the Qt toolkit backend has been moved from the ``pyface.ui.qt4`` +package to the ``pyface.ui.qt`` package. The "qt4" namespace had been an +ongoing source of confusion as Qt5 and Qt6 became the most popular versions of +Qt in use. Because this change has the potential to cause significant backwards +compatibility issues, this release includes import hooks to continue to support +imports from ``pyface.ui.qt4...`` which can be enabled by: + +- using "qt4" for the `ETS_TOOLKIT` or `ETSConfig.toolkit` values. +- setting the `ETS_QT4_IMPORT` environment variable to a non-empty value +- explicitly adding the import hooks to `sys.meta_path` + +In particular the environment variables allow users of ETS-based applications +such as Mayavi to continue to use those applications with newer versions of +Pyface until there is time to update their code to the new import locations. + +Additionally this release uses the module-level ``__getattr__`` system +introduced in Python 3.7 to delay imports from the ``api`` modules which have +side-effects, particularly toolkit selection. This means that you can, for +example, import ``pyface.api`` and toolkit selection will be deferred until +you actually request a class or object which is toolkit-dependent. Part of +this included adding formal interfaces for ``ActionManager`` and its subclasses + +In addition this release: + +- adds support for Python 3.11 and drops support for Python 3.6 +- adds support for PySide 6.4+ and the new enum system +- removes code supporting PyQt4, and supports more modern imports from + `pyface.qt`, such as `pyface.qt.QtWidgets`. +- removes many things flagged as deprecated in Pyface 7 +- consistently add interface classes to ``api`` modules. +- adds new Field subclasses, including an ImageField and LabelField +- moves to a pyproject.toml-based packaging solution, removing setup.py + +Detailed changes +---------------- + +Thanks to: + +* Mark Dickinson +* Dominik Gresch +* JaRoSchm +* Rahul Poruri +* Corran Webster + +Features + +* Refactor IField and add IImageField and ILabelField Field classes (#1234) +* Move ``pyface.ui.qt4`` to ``pyface.ui.qt`` (#1223, #1228) +* Delayed imports with side-effects in ``api`` modules; new interfaces for + ``ActionManager`` and its subclasses (#1229) + +Enhancements + +* Better handling of sizes for multiple (or no) screens (#1237) +* More arguments for the ``confirm`` function (#1221) +* Expose interfaces in ``api`` modules and other improvemnts to ``api`` modules + (#1220, #1222, #1229) +* remove deprecated modules and code (#1209, #1215) +* PySide 6.5 support (#1238) +* Python 3.11 and PySide 6.4 support (#1210) + +Fixes + +* Always use a no-delay timer for ``GUI`` ``invoke_later`` and + ``set_trait_later`` on Qt (#1226) +* Work arounds for some end-of-process segfaults on PySide < 6.4.3 (#1214) +* Use ``exec`` instead of ``exec_`` where possible (#1208) +* Emit a warning rather than asserting in SplitTabWidget (#1192) +* Remove required parent argument in Wx StatusBarManager (#1192) +* Use integer division in DockSplitter.draw (#1190) + +Documentation + +* general updates and enhancements (#1238) +* update copyright dates (#1191) + +CI + +* remove ``setup.py`` and use ``pyproject.toml`` (#1203, #1214) + +Release 7.4.4 +============= + +Highlights of this release +-------------------------- + +This is a quick bugfix release that resolves some issues with the 7.4.3 release +on CI for downstream projects. The issues were on testing code, and so +shouldn't impact application code or behaviour. + +Detailed changes +---------------- + +Thanks to: + +* Corran Webster + +Fixes + +* Don't raise ConditionTimeoutError if test doesn't time out (#1182) + +CI + +* get CI working again with ubuntu-current on GitHub (#1186) + +Release 7.4.3 +============= + +Highlights of this release +-------------------------- + +This is a bugfix release that collects a number of additional issues discovered +and fixed since the 7.4.2 release. Among the fixes, this pins PySide6 to less than +6.4.0, as 6.4 has breaking changes in it. + +Detailed changes +---------------- + +Thanks to: + +* Alex Chabot-Leclerc +* Mark Dickinson +* Eric Larson +* Steven Kern +* Corran Webster + +Fixes + +* Fix code editor gutter widget on recent Python versions (#1176) +* fix issues with FileDialog and DirectoryDialog close method on Linux (#1175) +* update setup.py metadata (#1173) +* restrict to PySide versions before 6.4.0 (#1169) +* don't do unneccessary evaluations of conditions in EventLoopHelper (#1168) +* fix a deleted object error in PyQt5 (#1161) +* better reporting of toolkit errors (#1157) + +Documentation + +* fix some Python 2 style print statements in documentation (#1157) + Release 7.4.2 ============= diff --git a/README.rst b/README.rst index 718e977bf..58a70a812 100644 --- a/README.rst +++ b/README.rst @@ -3,10 +3,12 @@ Pyface: Traits-capable Windowing Framework ========================================== The Pyface project contains a toolkit-independent GUI abstraction layer, -which is used to support the "visualization" features of the Traits package. -Thus, you can write code in terms of the Traits API (views, items, editors, -etc.), and let Pyface and your selected toolkit and back-end take care of -the details of displaying them. +which is used to support the "visualization" features of the Enthought Tool +Suite libraries. Pyface contains Traits-aware wrappers of standard GUI +elements such as Windows, Dialogs and Fields, together with the "Tasks" +application framework which provides a rich GUI experience with dock panes, +tabbed editors, and so forth. This permits you to write cross-platform +interactive GUI code without needing to use the underlying GUI backend. The following GUI backends are supported: @@ -14,33 +16,79 @@ The following GUI backends are supported: - PyQt5 (stable) and PyQt6 (in development) - wxPython 4 (experimental) +Example +------- + +The following code creates a window with a simple Python shell: + +.. code-block:: python + + from pyface.api import ApplicationWindow, GUI, IPythonShell + + class MainWindow(ApplicationWindow): + """ The main application window. """ + + #: The PythonShell that forms the contents of the window + shell = Instance(IPythonShell, allow_none=False) + + def _create_contents(self, parent): + """ Create the editor. """ + self.shell.create(parent) + return self.shell.control + + def destroy(self): + self.shell.destroy() + super().destroy() + + def _shell_default(self): + from pyface.api import PythonShell + return PythonShell() + + # Application entry point. + if __name__ == "__main__": + # Create the GUI. + gui = GUI() + + # Create and open the main window. + window = MainWindow(title="Python Shell", size=(640, 480)) + window.open() + + # Start the GUI event loop! + gui.start_event_loop() + +.. image:: https://raw.github.com/enthought/pyface/main/shell_window.png + :alt: A Pyface GUI window containing a Python shell. + Installation ------------ -GUI backends are marked as optional dependencies of Pyface. Some features -or infrastructures may also require additional dependencies. +Pyface is a pure Python package. In most cases Pyface will be installable +using a simple ``pip install`` command. -To install with PySide2 dependencies:: +To install with a backend, choose one of the following, as appropriate: - $ pip install pyface[pyside2] +.. code-block:: console -To install with PySide6 dependencies (experimental):: + $ pip install pyface[pyside2] $ pip install pyface[pyside6] -To install with PyQt5 dependencies:: - $ pip install pyface[pyqt5] -To install with wxPython4 dependencies (experimental):: - $ pip install pyface[wx] -``pillow`` is an optional dependency for the PILImage class:: +Some optional functionality uses ``pillow`` and ``numpy`` and these can be +installed using optional dependencies: + +.. code-block:: console $ pip install pyface[pillow] -To install with additional test dependencies:: + $ pip install pyface[numpy] + +For running tests a few more packages are required: + +.. code-block:: console $ pip install pyface[test] @@ -51,20 +99,6 @@ Documentation * `API Documentation `_. -Prerequisites -------------- - -Pyface depends on: - -* `Traits `_ - -* a GUI toolkit as described above - -* Pygments for syntax highlighting in the Qt code editor widget. - -* some widgets may have additional optional dependencies such as NumPy or - Pillow. - .. end_of_long_description Developing Pyface diff --git a/docs/source/conf.py b/docs/source/conf.py index a16a56067..3cddc233b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,7 +11,10 @@ # All configuration values have a default value; values that are commented out # serve to show the default value. -import importlib.metadata +try: + from importlib.metadata import version as metadata_version +except: + from importlib_metadata import version as metadata_version # General configuration # --------------------- @@ -43,7 +46,7 @@ # The default replacements for |version| and |release|, also used in various # other places throughout the built documents. -version = release = importlib.metadata.version("pyface") +version = release = metadata_version("pyface") # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/source/index.rst b/docs/source/index.rst index 21281763e..d2c16fd55 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,26 +7,25 @@ the TraitsUI package. Thus, you can write code in the abstraction of the Pyface API and the selected toolkit and backend take care of the details of displaying them. -Pyface -====== - Pyface enables programmers to interact with generic GUI objects, such as an -"MDI Application Window", rather than with raw GUI widgets. (Pyface is named by -analogy to JFace in Java.) TraitsUI uses Pyface to implement views and editors -for displaying and editing Traits-based objects. +"Application Window", rather than with raw GUI widgets. TraitsUI uses Pyface +to implement views and editors for displaying and editing Traits-based objects. +In addition to wrappers around fields and dialogs, Pyface includes an +application framework called "Tasks" for building full-featured +applications featuring dock panes, tabbed editor areas and so forth. Toolkit Backends ================ -TraitsUI and Pyface define APIs that are independent of any GUI toolkit. -However, in order to actually produce user interfaces with them, you must +Pyface defines APIs that are independent of any GUI toolkit. However, in +order to actually produce user interfaces with them, you must install a supported Python-based GUI toolkit and the appropriate toolkit-specific backend project. Conversely, a "null" backend is automatically used in the absence of a real backend. Currently, the GUI toolkits are -* PySide2 (stable) and PySide6 (experimental) +* PySide2 and PySide6 (stable) * PyQt5 (stable) and PyQt6 (in development) * wxPython 4 (experimental) diff --git a/docs/source/traits.rst b/docs/source/traits.rst index 2be140880..b3391c580 100644 --- a/docs/source/traits.rst +++ b/docs/source/traits.rst @@ -32,13 +32,20 @@ color spaces, such as HSV or HLS:: which allow specification of colors via CSS-style color strings, such as:: Color.from_str("aquamarine") - Color.from_str("#662244") + Color.from_str("#662244cc") -All standard web colors are understood, as well as hexadecimal RGB(A) with -1, 2 or 4 hex digits per channel. +All `standard web colors `_ +are understood, as well as hexadecimal RGB(A) with 1, 2 or 4 hex digits per +channel. |Color| instances are mutable, as their intended use is as values stored -in |PyfaceColor| trait classes which can be modified and listened to. The +in |PyfaceColor| trait classes which can be modified and listened to. This +means that they are comparatively heavyweight objects and should be shared +where possible and aren't particularly suited for situations where large +numbers of distinct and independent colors are needed: NumPy arrays are likely +better suited for this sort of application. + +The |PyfaceColor| validator understands string descriptions of colors, and will accept them as values when initializing or modifying the trait:: @@ -64,8 +71,96 @@ building applications on top of Pyface. It is intended that this trait will eventually replace the ``Color`` trait from TraitsUI. +Fonts +===== + +Just as with colors, it is common to want to be able to specify the font to use +for text in the UI. Each toolkit usually has its own way of representing +fonts, and so the ability to specify a font in a toolkit-independent way that +can be converted to a toolkit-specific representation is important. This is +particularly so when you want to allow the user to specify a font. + +Pyface provides a |Font| class and a corresponding |PyfaceFont| trait-type +that allows this sort of representation. Internally, the |Font| class +stores font attributes such as |Font.weight| or |Font.size| as traits:: + + font = Font(family=["Comic Sans"], size=24, weight="bold") + font.weight + +Some of these attributes are mapped traits, or otherwise convert string +values to numeric values. For example |Font.size| will accept strings like +"12pt", while |Font.weight| holds a numeric weight value in a mapped +attribute:: + +.. code-block:: pycon + + >>> font.weight + "bold" + >>> font.weight_ + 700 + +|Font| instances are mutable, as their intended use is as values stored +in |PyfaceFont| trait classes which can be modified and listened to. + +As a convenience, the |PyfaceFont| validator understands string descriptions +of fonts, and will accept them as values when initializing or modifying the +trait:: + + class Style(HasStrictTraits): + + font = PyfaceFont("24 pt Bold Comic Sans") + + @observe('font.*') + def font_changed(self, event): + print('The font has changed to {}'.format(self.font)) + + style = Style(font='12 italc Helvetica') + style.font.weight = 'light' + style.font = Font( + family=["Helvetica", "Arial", "sans-serif"], + variants={"small-caps"}, + ) + +The parsing of strings to fonts is currently handled by a |simple_parser| that +is modelled on the ``Font`` trait from TraitsUI, but it can be substituted +for a more sophisticated one, if needed. + +For interactions with the toolkit, the |Font.from_toolkit| and +|Font.to_toolkit| methods allow conversion to and from the appropriate +toolkit font objects, such as Qt's :py:class:`QFont` or +:py:class:`wx.Font`. These are most likely to be needed by internal +Pyface functionality, and should not be needed by developers who are +building applications on top of Pyface. + +It is intended that this trait will eventually replace the ``Font`` +trait from TraitsUI. It is also likely that the simple parser will be replaced +with a parser that understands CSS-like font strings. + +Layout Traits +============= + +Pyface also provides a number of classes and traits to assist with +layout-related functionality. These include the convenience Enums |Alignment|, +|Orientation| and |Position| as well as the classes |Margin| and |Border| and +their corresponding traits |HasMargin| and |HasBorder|. + + +.. |Alignment| replace:: :py:attr:`~pyface.ui_traits.Alignment` +.. |Border| replace:: :py:class:`~pyface.ui_traits.Border` .. |Color| replace:: :py:class:`~pyface.color.Color` .. |Color.from_str| replace:: :py:meth:`~pyface.color.Color.from_str` .. |Color.from_toolkit| replace:: :py:meth:`~pyface.color.Color.from_toolkit` .. |Color.to_toolkit| replace:: :py:meth:`~pyface.color.Color.to_toolkit` +.. |Font| replace:: :py:class:`~pyface.font.Font` +.. |Font.size| replace:: :py:attr:`~pyface.font.Font.size` +.. |Font.from_toolkit| replace:: :py:meth:`~pyface.font.Font.from_toolkit` +.. |Font.to_toolkit| replace:: :py:meth:`~pyface.font.Font.to_toolkit` +.. |Font.weight| replace:: :py:attr:`~pyface.font.Font.weight` +.. |HasMargin| replace:: :py:class:`~pyface.ui_traits.HasMargin` +.. |HasBorder| replace:: :py:class:`~pyface.ui_traits.HasBorder` +.. |Margin| replace:: :py:class:`~pyface.ui_traits.Margin` +.. |Orientation| replace:: :py:attr:`~pyface.ui_traits.Orientation` +.. |Position| replace:: :py:attr:`~pyface.ui_traits.Position` .. |PyfaceColor| replace:: :py:class:`~pyface.ui_traits.PyfaceColor` +.. |PyfaceFont| replace:: :py:class:`~pyface.ui_traits.PyfaceFont` +.. |simple_parser| replace:: :py:func:`~pyface.util.font_parser.simple_parser` diff --git a/examples/application/hello_world/hello_application.py b/examples/application/hello_world/hello_application.py index ebe5d36a1..e1cab73c6 100644 --- a/examples/application/hello_world/hello_application.py +++ b/examples/application/hello_world/hello_application.py @@ -17,9 +17,9 @@ computation. """ - import argparse -from pyface.application import Application + +from pyface.api import Application from traits.api import Str diff --git a/pyface/action/action.py b/pyface/action/action.py index 2542ac982..526c07f32 100644 --- a/pyface/action/action.py +++ b/pyface/action/action.py @@ -124,7 +124,7 @@ def perform(self, event): Parameters ---------- - event : ActionEvent instance + event : ActionEvent The event which triggered the action. """ if self.on_perform is not None: diff --git a/pyface/action/action_controller.py b/pyface/action/action_controller.py index d8ddf9379..b2c926df9 100644 --- a/pyface/action/action_controller.py +++ b/pyface/action/action_controller.py @@ -25,14 +25,14 @@ def perform(self, action, event): Parameters ---------- - action : Action instance + action : Action The action to perform. - event : ActionEvent instance + event : ActionEvent The event that triggered the action. Returns ------- - result : any + result : Any The result of the action's perform method (usually None). """ return action.perform(event) @@ -42,7 +42,7 @@ def can_add_to_menu(self, action): Parameters ---------- - action : Action instance + action : Action The action to consider. Returns @@ -57,7 +57,7 @@ def add_to_menu(self, action): Parameters ---------- - action : Action instance + action : Action The action added to the menu. """ pass @@ -67,7 +67,7 @@ def can_add_to_toolbar(self, action): Parameters ---------- - action : Action instance + action : Action The action to consider. Returns @@ -82,7 +82,7 @@ def add_to_toolbar(self, action): Parameters ---------- - action : Action instance + action : Action The action added to the toolbar. """ pass diff --git a/pyface/action/action_item.py b/pyface/action/action_item.py index 987ab1b02..d9744383c 100644 --- a/pyface/action/action_item.py +++ b/pyface/action/action_item.py @@ -93,7 +93,7 @@ def add_to_menu(self, parent, menu, controller): The parent of the new menu item control. menu : toolkit menu The menu to add the action item to. - controller : ActionController instance or None + controller : pyface.action.action_controller.ActionController or None The controller to use. """ if (controller is None) or controller.can_add_to_menu(self.action): @@ -117,9 +117,9 @@ def add_to_toolbar( The parent of the new menu item control. tool_bar : toolkit toolbar The toolbar to add the action item to. - image_cache : ImageCache instance + image_cache : ImageCache The image cache for resized images. - controller : ActionController instance or None + controller : pyface.action.action_controller.ActionController or None The controller to use. show_labels : bool Should the toolbar item show a label. @@ -145,7 +145,7 @@ def add_to_palette(self, tool_palette, image_cache, show_labels=True): The parent of the new menu item control. tool_palette : toolkit tool palette The tool palette to add the action item to. - image_cache : ImageCache instance + image_cache : ImageCache The image cache for resized images. show_labels : bool Should the toolbar item show a label. diff --git a/pyface/action/action_manager.py b/pyface/action/action_manager.py index d5d0de280..4f2ef2703 100644 --- a/pyface/action/action_manager.py +++ b/pyface/action/action_manager.py @@ -72,7 +72,7 @@ def __init__(self, *args, **traits): Parameters ---------- - args : collection of strings, Group instances, or ActionManagerItem instances + args : collection of strings, Group instances, or ActionManagerItem s Positional arguments are interpreted as Items or Groups managed by the action manager. @@ -142,7 +142,7 @@ def append(self, item): Parameters ---------- - item : string, Group instance or ActionManagerItem instance + item : string, Group instance or ActionManagerItem The item to append. Notes @@ -180,7 +180,7 @@ def insert(self, index, item): ---------- index : int The position at which to insert the object - item : string, Group instance or ActionManagerItem instance + item : string, Group instance or ActionManagerItem The item to insert. Notes @@ -213,7 +213,7 @@ def find_group(self, id): Returns ------- - group : Group instance + group : Group The group which matches the id, or None if no such group exists. """ for group in self._groups: @@ -256,7 +256,7 @@ def walk(self, fn): Parameters ---------- - fn : callable + fn : Callable A callable to apply to the tree of groups and items, starting with the manager. """ @@ -274,7 +274,7 @@ def walk_group(self, group, fn): ---------- group : Group The group to walk. - fn : callable + fn : Callable A callable to apply to the tree of groups and items. """ fn(group) @@ -295,7 +295,7 @@ def walk_item(self, item, fn): item : item The item to walk. This may be a submenu or similar in addition to simple Action items. - fn : callable + fn : Callable A callable to apply to the tree of items and subgroups. """ if hasattr(item, "groups"): @@ -314,7 +314,7 @@ def _get_default_group(self): Returns ------- - group : Group instance + group : Group The manager's default group. """ group = self.find_group(self.DEFAULT_GROUP) @@ -329,7 +329,7 @@ def _prepare_item(self, item): Parameters ---------- - item : string, Group instance or ActionManagerItem instance + item : string, Group instance or ActionManagerItem The item to be added to this ActionManager Returns diff --git a/pyface/action/action_manager_item.py b/pyface/action/action_manager_item.py index a407b99e5..a6297ed17 100644 --- a/pyface/action/action_manager_item.py +++ b/pyface/action/action_manager_item.py @@ -53,7 +53,7 @@ def add_to_menu(self, parent, menu, controller): The parent of the new menu item control. menu : toolkit menu The menu to add the action item to. - controller : ActionController instance or None + controller : pyface.action.action_controller.ActionController or None The controller to use. """ raise NotImplementedError() @@ -67,9 +67,9 @@ def add_to_toolbar(self, parent, tool_bar, image_cache, controller): The parent of the new menu item control. tool_bar : toolkit toolbar The toolbar to add the action item to. - image_cache : ImageCache instance + image_cache : ImageCache The image cache for resized images. - controller : ActionController instance or None + controller : pyface.action.action_controller.ActionController or None The controller to use. show_labels : bool Should the toolbar item show a label. diff --git a/pyface/action/field_action.py b/pyface/action/field_action.py index 830223f72..f9848ac1e 100644 --- a/pyface/action/field_action.py +++ b/pyface/action/field_action.py @@ -70,7 +70,7 @@ def perform(self, event): Parameters ---------- - event : ActionEvent instance + event : ActionEvent The event which triggered the action. """ if self.on_perform is not None: diff --git a/pyface/action/i_action_manager.py b/pyface/action/i_action_manager.py index f813cd051..3a4f98de7 100644 --- a/pyface/action/i_action_manager.py +++ b/pyface/action/i_action_manager.py @@ -65,7 +65,7 @@ def __init__(self, *items, **traits): Parameters ---------- - items : collection of strings, Group, or ActionManagerItem instances + items : collection of strings, Group, or ActionManagerItem s Positional arguments are interpreted as Items or Groups managed by the action manager. traits : additional traits @@ -90,7 +90,7 @@ def append(self, item): Parameters ---------- - item : string, Group instance or ActionManagerItem instance + item : string, Group instance or ActionManagerItem The item to append. Notes @@ -117,7 +117,7 @@ def insert(self, index, item): ---------- index : int The position at which to insert the object - item : string, Group instance or ActionManagerItem instance + item : string, Group instance or ActionManagerItem The item to insert. Notes @@ -140,7 +140,7 @@ def find_group(self, id): Returns ------- - group : Group instance + group : Group The group which matches the id, or None if no such group exists. """ @@ -166,7 +166,7 @@ def walk(self, fn): Parameters ---------- - fn : callable + fn : Callable A callable to apply to the tree of groups and items, starting with the manager. """ @@ -180,7 +180,7 @@ def walk_group(self, group, fn): ---------- group : Group The group to walk. - fn : callable + fn : Callable A callable to apply to the tree of groups and items. """ @@ -194,6 +194,6 @@ def walk_item(self, item, fn): item : item The item to walk. This may be a submenu or similar in addition to simple Action items. - fn : callable + fn : Callable A callable to apply to the tree of items and subgroups. """ diff --git a/pyface/action/i_menu_bar_manager.py b/pyface/action/i_menu_bar_manager.py index 33b08ee11..7a23a7fb8 100644 --- a/pyface/action/i_menu_bar_manager.py +++ b/pyface/action/i_menu_bar_manager.py @@ -28,6 +28,6 @@ def create_menu_bar(self, parent, controller=None): ---------- parent : toolkit control The toolkit control that owns the menubar. - controller : ActionController + controller : pyface.action.action_controller.ActionController An optional ActionController for all items in the menubar. """ diff --git a/pyface/action/i_menu_manager.py b/pyface/action/i_menu_manager.py index 9469fa7d9..cd1132562 100644 --- a/pyface/action/i_menu_manager.py +++ b/pyface/action/i_menu_manager.py @@ -40,6 +40,6 @@ def create_menu(self, parent, controller=None): ---------- parent : toolkit control The toolkit control that owns the menu. - controller : ActionController + controller : pyface.action.action_controller.ActionController An optional ActionController for all items in the menu. """ diff --git a/pyface/action/i_tool_bar_manager.py b/pyface/action/i_tool_bar_manager.py index f98863c13..08e536036 100644 --- a/pyface/action/i_tool_bar_manager.py +++ b/pyface/action/i_tool_bar_manager.py @@ -45,6 +45,6 @@ def create_tool_bar(self, parent, controller=None): ---------- parent : toolkit control The toolkit control that owns the toolbar. - controller : ActionController + controller : pyface.action.action_controller.ActionController An optional ActionController for all items in the toolbar. """ diff --git a/pyface/base_toolkit.py b/pyface/base_toolkit.py index 657e1e984..0bc1fff9b 100644 --- a/pyface/base_toolkit.py +++ b/pyface/base_toolkit.py @@ -179,7 +179,7 @@ def import_toolkit(toolkit_name, entry_point="pyface.toolkits"): Returns ------- - toolkit_object : callable + toolkit_object : Callable A callable object that implements the Toolkit interface. Raises @@ -251,12 +251,12 @@ def find_toolkit( toolkits : collection of strings Only consider toolkits which match the given strings, ignore other ones. - priorities : callable + priorities : Callable A callable function that returns an priority for each plugin. Returns ------- - toolkit : Toolkit instance + toolkit : Toolkit A callable object that implements the Toolkit interface. Raises diff --git a/pyface/data_view/abstract_data_exporter.py b/pyface/data_view/abstract_data_exporter.py index 8602cfad6..6d1e31d44 100644 --- a/pyface/data_view/abstract_data_exporter.py +++ b/pyface/data_view/abstract_data_exporter.py @@ -36,9 +36,9 @@ def add_data(self, data_wrapper, model, indices): Parameters ---------- - data_wrapper : DataWrapper instance + data_wrapper : DataWrapper The data wrapper that will be used to export data. - model : AbstractDataModel instance + model : AbstractDataModel The data model holding the data. indices : list of (row, column) index pairs The indices where the data is to be stored. @@ -55,14 +55,14 @@ def get_data(self, model, indices): Parameters ---------- - model : AbstractDataModel instance + model : AbstractDataModel The data model holding the data. indices : list of (row, column) index pairs The indices where the data is to be stored. Returns ------- - data : any + data : Any The data, of a type that can be serialized by the format. """ raise NotImplementedError() diff --git a/pyface/data_view/abstract_data_model.py b/pyface/data_view/abstract_data_model.py index 6a6d10aac..2690ad861 100644 --- a/pyface/data_view/abstract_data_model.py +++ b/pyface/data_view/abstract_data_model.py @@ -150,7 +150,7 @@ def get_value(self, row, column): Returns ------- - value : any + value : Any The value represented by the given row and column. Raises @@ -202,7 +202,7 @@ def set_value(self, row, column, value): The indices of the row as a sequence from root to leaf. column : sequence of int The indices of the column as a sequence of length 0 or 1. - value : any + value : Any The new value for the given row and column. Raises diff --git a/pyface/data_view/abstract_value_type.py b/pyface/data_view/abstract_value_type.py index 47cd23640..697007f8f 100644 --- a/pyface/data_view/abstract_value_type.py +++ b/pyface/data_view/abstract_value_type.py @@ -94,7 +94,7 @@ def get_editor_value(self, model, row, column): Returns ------- - value : any + value : Any The value to edit. """ return model.get_value(row, column) @@ -113,7 +113,7 @@ def set_editor_value(self, model, row, column, value): The row in the data model being queried. column : sequence of int The column in the data model being queried. - value : any + value : Any The value to set. Raises @@ -228,7 +228,7 @@ def get_color(self, model, row, column): Returns ------- - color : Color instance + color : Color The color associated with the cell. """ return Color(rgba=(1.0, 1.0, 1.0, 1.0)) diff --git a/pyface/data_view/data_formats.py b/pyface/data_view/data_formats.py index e2f1f0d69..a5f315404 100644 --- a/pyface/data_view/data_formats.py +++ b/pyface/data_view/data_formats.py @@ -23,9 +23,9 @@ def to_json(data, default=None): Parameters ---------- - data : any + data : Any The data to be serialized. - default : callable or None + default : Callable or None Callable that takes a Python object and returns a JSON-serializable data structure. @@ -45,13 +45,13 @@ def from_json(raw_data, object_hook=None): ---------- raw_data : bytes The serialized JSON data as a byte string. - object_hook : callable + object_hook : Callable Callable that takes a dictionary and returns an corresponding Python object. Returns ------- - data : any + data : Any The data extracted. """ return json.loads(raw_data.decode('utf-8'), object_hook=object_hook) diff --git a/pyface/data_view/data_models/array_data_model.py b/pyface/data_view/data_models/array_data_model.py index dce74f47a..3b48afd72 100644 --- a/pyface/data_view/data_models/array_data_model.py +++ b/pyface/data_view/data_models/array_data_model.py @@ -205,7 +205,7 @@ def set_value(self, row, column, value): Returns ------- - value : any + value : Any The value represented by the given row and column. """ if self.can_set_value(row, column): diff --git a/pyface/data_view/data_models/data_accessors.py b/pyface/data_view/data_models/data_accessors.py index cb23ee0da..4b375c40e 100644 --- a/pyface/data_view/data_models/data_accessors.py +++ b/pyface/data_view/data_models/data_accessors.py @@ -52,12 +52,12 @@ def get_value(self, obj): Parameters ---------- - obj : any + obj : Any The object that contains the data. Returns ------- - value : any + value : Any The data value contained in the object. """ raise NotImplementedError() @@ -67,7 +67,7 @@ def can_set_value(self, obj): Parameters ---------- - obj : any + obj : Any The object that contains the data. Returns @@ -82,9 +82,9 @@ def set_value(self, obj, value): Parameters ---------- - obj : any + obj : Any The object that contains the data. - value : any + value : Any The data value to set. Raises @@ -119,12 +119,12 @@ def get_value(self, obj): Parameters ---------- - obj : any + obj : Any An object. Returns ------- - value : any + value : Any The data value contained in this class' value trait. """ return self.value @@ -149,12 +149,12 @@ def get_value(self, obj): Parameters ---------- - obj : any + obj : Any The object that contains the data. Returns ------- - value : any + value : Any The data value contained in the object's attribute. """ return xgetattr(obj, self.attr) @@ -164,7 +164,7 @@ def can_set_value(self, obj): Parameters ---------- - obj : any + obj : Any The object that contains the data. Returns @@ -211,7 +211,7 @@ def get_value(self, obj): Returns ------- - value : any + value : Any The data value contained in the object at the index. """ return obj[self.index] @@ -221,7 +221,7 @@ def can_set_value(self, obj): Parameters ---------- - obj : any + obj : Any The object that contains the data. Returns @@ -236,9 +236,9 @@ def set_value(self, obj, value): Parameters ---------- - obj : any + obj : Any The object that contains the data. - value : any + value : Any The data value to set. Raises @@ -280,7 +280,7 @@ def get_value(self, obj): Returns ------- - value : any + value : Any The data value contained in the given key of the object. """ return obj[self.key] @@ -292,7 +292,7 @@ def can_set_value(self, obj): ---------- obj : mapping The object that contains the data. - value : any + value : Any The data value to set. Raises @@ -307,9 +307,9 @@ def set_value(self, obj, value): Parameters ---------- - obj : any + obj : Any The object that contains the data. - value : any + value : Any The data value to set. Raises diff --git a/pyface/data_view/data_models/row_table_data_model.py b/pyface/data_view/data_models/row_table_data_model.py index e9abf39eb..f9899055e 100644 --- a/pyface/data_view/data_models/row_table_data_model.py +++ b/pyface/data_view/data_models/row_table_data_model.py @@ -111,7 +111,7 @@ def get_value(self, row, column): Returns ------- - value : any + value : Any The value represented by the given row and column. """ if len(column) == 0: @@ -162,7 +162,7 @@ def set_value(self, row, column, value): The indices of the row as a sequence from root to leaf. column : sequence of int The indices of the column as a sequence of length 0 or 1. - value : any + value : Any The new value for the given row and column. Raises diff --git a/pyface/data_view/exporters/item_exporter.py b/pyface/data_view/exporters/item_exporter.py index 12b5bf2ec..d648afdd7 100644 --- a/pyface/data_view/exporters/item_exporter.py +++ b/pyface/data_view/exporters/item_exporter.py @@ -26,9 +26,9 @@ def add_data(self, data_wrapper, model, indices): Parameters ---------- - data_wrapper : DataWrapper instance + data_wrapper : DataWrapper The data wrapper that will be used to export data. - model : AbstractDataModel instance + model : AbstractDataModel The data model holding the data. indices : list of (row, column) index pairs The indices where the data is to be stored. @@ -42,14 +42,14 @@ def get_data(self, model, indices): Parameters ---------- - model : AbstractDataModel instance + model : AbstractDataModel The data model holding the data. indices : list of (row, column) index pairs The indices where the data is to be stored. Returns ------- - data : any + data : Any The data, of a type that can be serialized by the format. """ if len(indices) != 1: diff --git a/pyface/data_view/exporters/row_exporter.py b/pyface/data_view/exporters/row_exporter.py index f5cd9accb..e1bcd8e95 100644 --- a/pyface/data_view/exporters/row_exporter.py +++ b/pyface/data_view/exporters/row_exporter.py @@ -44,14 +44,14 @@ def get_data(self, model, indices): Parameters ---------- - model : AbstractDataModel instance + model : AbstractDataModel The data model holding the data. indices : list of (row, column) index pairs The indices where the data is to be stored. Returns ------- - data : any + data : Any The data, of a type that can be serialized by the format. """ rows = sorted({row for row, column in indices}) diff --git a/pyface/data_view/i_data_wrapper.py b/pyface/data_view/i_data_wrapper.py index 5152a95ed..ed5bba885 100644 --- a/pyface/data_view/i_data_wrapper.py +++ b/pyface/data_view/i_data_wrapper.py @@ -93,7 +93,7 @@ def get_format(self, format): Returns ------- - data : any + data : Any The data decoded for the given format. """ raise NotImplementedError() @@ -105,7 +105,7 @@ def set_format(self, format, data): ---------- format : DataFormat A data format object. - data : any + data : Any The data to be encoded and stored. """ raise NotImplementedError() @@ -171,7 +171,7 @@ def get_format(self, format): Returns ------- - data : any + data : Any The data decoded for the given format. """ return format.deserialize(self.get_mimedata(format.mimetype)) @@ -183,7 +183,7 @@ def set_format(self, format, data): ---------- format : DataFormat A data format object. - data : any + data : Any The data to be encoded and stored. """ self.set_mimedata(format.mimetype, format.serialize(data)) diff --git a/pyface/data_view/value_types/color_value.py b/pyface/data_view/value_types/color_value.py index e8f4dae12..900500c44 100644 --- a/pyface/data_view/value_types/color_value.py +++ b/pyface/data_view/value_types/color_value.py @@ -34,7 +34,7 @@ def is_valid(self, model, row, column, value): The row in the data model being queried. column : sequence of int The column in the data model being queried. - value : any + value : Any The value to test. Returns @@ -168,7 +168,7 @@ def get_color(self, model, row, column): Returns ------- - color : Color instance + color : Color The color associated with the cell. """ return model.get_value(row, column) diff --git a/pyface/data_view/value_types/constant_value.py b/pyface/data_view/value_types/constant_value.py index 855a24e95..1d6bc4133 100644 --- a/pyface/data_view/value_types/constant_value.py +++ b/pyface/data_view/value_types/constant_value.py @@ -79,7 +79,7 @@ def get_color(self, model, row, column): Returns ------- - color : Color instance + color : Color The color associated with the cell. """ return self.color diff --git a/pyface/data_view/value_types/editable_value.py b/pyface/data_view/value_types/editable_value.py index 918cdf961..f03b29425 100644 --- a/pyface/data_view/value_types/editable_value.py +++ b/pyface/data_view/value_types/editable_value.py @@ -40,7 +40,7 @@ def is_valid(self, model, row, column, value): The row in the data model being queried. column : sequence of int The column in the data model being queried. - value : any + value : Any The value to validate. Returns @@ -85,7 +85,7 @@ def set_editor_value(self, model, row, column, value): The row in the data model being set. column : sequence of int The column in the data model being set. - value : any + value : Any The value being set. Raises diff --git a/pyface/data_view/value_types/enum_value.py b/pyface/data_view/value_types/enum_value.py index 45a634347..3ff260ab2 100644 --- a/pyface/data_view/value_types/enum_value.py +++ b/pyface/data_view/value_types/enum_value.py @@ -43,7 +43,7 @@ def is_valid(self, model, row, column, value): The row in the data model being queried. column : sequence of int The column in the data model being queried. - value : any + value : Any The value to validate. Returns @@ -127,7 +127,7 @@ def get_color(self, model, row, column): Returns ------- - color : Color instance + color : Color The color associated with the cell. """ return self.colors(model.get_value(row, column)) diff --git a/pyface/data_view/value_types/numeric_value.py b/pyface/data_view/value_types/numeric_value.py index 447f0e989..b13d46317 100644 --- a/pyface/data_view/value_types/numeric_value.py +++ b/pyface/data_view/value_types/numeric_value.py @@ -51,7 +51,7 @@ def is_valid(self, model, row, column, value): The row in the data model being queried. column : sequence of int The column in the data model being queried. - value : any + value : Any The value to validate. Returns diff --git a/pyface/dock/dock_window_feature.py b/pyface/dock/dock_window_feature.py index 82f5a6528..34fff4500 100644 --- a/pyface/dock/dock_window_feature.py +++ b/pyface/dock/dock_window_feature.py @@ -302,7 +302,7 @@ def drop(self, object): Parameters ---------- - object : any object + object : Any object The object being dropped onto the feature image Returns @@ -329,7 +329,7 @@ def can_drop(self, object): Parameters ---------- - object : any object + object : Any object The object being dragged onto the feature image Returns diff --git a/pyface/font.py b/pyface/font.py index 5d4434930..7d287f00e 100644 --- a/pyface/font.py +++ b/pyface/font.py @@ -247,6 +247,11 @@ def __init__(self, default_value=NoDefaultSpecified, **metadata): super().__init__(default_value, **metadata) def validate(self, object, name, value): + """Validate the trait + + This is a CFloat trait that also accepts percentage strings. Values + must be in the range 50 to 200 inclusive + """ if isinstance(value, str) and value.endswith('%'): value = value[:-1] value = STRETCHES.get(value, value) @@ -256,6 +261,7 @@ def validate(self, object, name, value): return value def info(self): + """Describe the trait""" info = ( "a float from 50 to 200, " "a value that can convert to a float from 50 to 200, " @@ -284,6 +290,11 @@ def __init__(self, default_value=NoDefaultSpecified, **metadata): super().__init__(default_value, **metadata) def validate(self, object, name, value): + """Validate the trait + + This is a CFloat trait that also accepts strings with 'pt' or 'px' + suffixes. Values must be positive. + """ if ( isinstance(value, str) and (value.endswith('pt') or value.endswith('px')) @@ -296,6 +307,7 @@ def validate(self, object, name, value): return value def info(self): + """Describe the trait""" info = ( "a positive float, a value that can convert to a positive float, " ) @@ -343,7 +355,7 @@ def from_toolkit(cls, toolkit_font): Parameters ---------- - toolkit_font : any + toolkit_font : Any A toolkit font to be converted to a corresponding class instance, within the limitations of the options supported by the class. """ @@ -358,7 +370,7 @@ def to_toolkit(self): Returns ------- - toolkit_font : any + toolkit_font : Any A toolkit font which matches the property of the font as closely as possible given the constraints of the toolkit. """ diff --git a/pyface/gui_application.py b/pyface/gui_application.py index 1f285c948..ee4e9a46c 100644 --- a/pyface/gui_application.py +++ b/pyface/gui_application.py @@ -116,7 +116,7 @@ def create_window(self, **kwargs): Returns ------- - window : IWindow instance or None + window : IWindow or None The new IWindow instance. """ window = self.window_factory(application=self, **kwargs) diff --git a/pyface/i_application_window.py b/pyface/i_application_window.py index ee8061853..5f32ddae1 100644 --- a/pyface/i_application_window.py +++ b/pyface/i_application_window.py @@ -26,10 +26,10 @@ class IApplicationWindow(IWindow): The application window has support for a menu bar, tool bar and a status bar (all of which are optional). - Usage + Notes ----- - Create a sub-class of this class and override the + To use, create a sub-class of this class and override the :py:meth:`._create_contents` method. """ diff --git a/pyface/i_gui.py b/pyface/i_gui.py index b950b617f..9b5fa6771 100644 --- a/pyface/i_gui.py +++ b/pyface/i_gui.py @@ -49,7 +49,7 @@ def __init__(self, splash_screen=None): Parameters ---------- - splash_screen : ISplashScreen instance or None + splash_screen : ISplashScreen or None An optional splash screen that will be displayed until the event loop is started. """ @@ -78,7 +78,7 @@ def invoke_after(cls, millisecs, callable, *args, **kw): ---------- millisecs : float Delay in milliseconds - callable : callable + callable : Callable Callable to be called after the delay args, kwargs : Arguments and keyword arguments to be used when calling. @@ -90,7 +90,7 @@ def invoke_later(cls, callable, *args, **kw): Parameters ---------- - callable : callable + callable : Callable Callable to be called after the delay args, kwargs : Arguments and keyword arguments to be used when calling. @@ -104,11 +104,11 @@ def set_trait_after(cls, millisecs, obj, trait_name, new): ---------- millisecs : float Delay in milliseconds - obj : HasTraits instance + obj : traits.has_traits.HasTraits Object on which the trait is to be set trait_name : str The name of the trait to set - new : any + new : Any The value to set. """ @@ -118,11 +118,11 @@ def set_trait_later(cls, obj, trait_name, new): Parameters ---------- - obj : HasTraits instance + obj : traits.has_traits.HasTraits Object on which the trait is to be set trait_name : str The name of the trait to set - new : any + new : Any The value to set. """ diff --git a/pyface/i_image_resource.py b/pyface/i_image_resource.py index 5df954c05..b1874198b 100644 --- a/pyface/i_image_resource.py +++ b/pyface/i_image_resource.py @@ -124,7 +124,7 @@ def _get_ref(self, size=None): Returns ------- - ref : ImageReference instance + ref : ImageReference The reference to the requested image. """ @@ -162,7 +162,7 @@ def _get_image_not_found(cls): Returns ------- - not_found : ImageResource instance + not_found : ImageResource An image resource for the the 'not found' image. """ diff --git a/pyface/i_python_shell.py b/pyface/i_python_shell.py index e98582f9c..039f72f64 100644 --- a/pyface/i_python_shell.py +++ b/pyface/i_python_shell.py @@ -38,7 +38,7 @@ def interpreter(self): Returns ------- - interpreter : InteractiveInterpreter instance + interpreter : InteractiveInterpreter Returns the InteractiveInterpreter instance. """ @@ -49,7 +49,7 @@ def bind(self, name, value): ---------- name : str The python idetifier to bind the value to. - value : any + value : Any The python object to be bound into the interpreter's namespace. """ @@ -118,7 +118,7 @@ def bind(self, name, value): ---------- name : str The python idetifier to bind the value to. - value : any + value : Any The python object to be bound into the interpreter's namespace. """ self.interpreter().locals[name] = value diff --git a/pyface/i_widget.py b/pyface/i_widget.py index 0cd6ad7ad..dd71465cd 100644 --- a/pyface/i_widget.py +++ b/pyface/i_widget.py @@ -77,7 +77,7 @@ def create(self, parent=None): """ Creates the toolkit specific control. This method should create the control and assign it to the - :py:attr:``control`` trait. + :py:attr:`control` trait. """ def destroy(self): diff --git a/pyface/single_choice_dialog.py b/pyface/single_choice_dialog.py index c7f8d278c..e187e6475 100644 --- a/pyface/single_choice_dialog.py +++ b/pyface/single_choice_dialog.py @@ -38,7 +38,7 @@ def choose_one(parent, message, choices, title="Choose", cancel=True): Returns ------- - choice : any + choice : Any The selected object, or None if cancelled. """ dialog = SingleChoiceDialog( diff --git a/pyface/splash_screen_log_handler.py b/pyface/splash_screen_log_handler.py index 4ccf4dc78..50ec28c0f 100644 --- a/pyface/splash_screen_log_handler.py +++ b/pyface/splash_screen_log_handler.py @@ -22,7 +22,7 @@ def __init__(self, splash_screen): Parameters ---------- - splash_screen : ISplashScreen instance + splash_screen : ISplashScreen The splash screen being used to display the log messages """ # Base class constructor. @@ -36,7 +36,7 @@ def emit(self, record): Parameters ---------- - record : logging record instance + record : logging record The log record to be displayed. """ self._splash_screen.text = str(record.getMessage()) + "..." diff --git a/pyface/tasks/tasks_application.py b/pyface/tasks/tasks_application.py index 9f14443d9..510445098 100644 --- a/pyface/tasks/tasks_application.py +++ b/pyface/tasks/tasks_application.py @@ -121,7 +121,7 @@ def create_window(self, layout=None, **kwargs): Parameters ---------- - layout : TaskLayout instance or None + layout : TaskLayout or None The pane layout for the window. **kwargs : dict Additional keyword arguments to pass to the window factory. @@ -129,7 +129,7 @@ def create_window(self, layout=None, **kwargs): Returns ------- - window : ITaskWindow instance or None + window : ITaskWindow or None The new TaskWindow. """ from pyface.tasks.task_window_layout import TaskWindowLayout diff --git a/pyface/timer/do_later.py b/pyface/timer/do_later.py index 92e996e88..07f5cc69a 100644 --- a/pyface/timer/do_later.py +++ b/pyface/timer/do_later.py @@ -34,7 +34,7 @@ def do_later(callable, *args, **kwargs): Parameters ---------- - callable : callable + callable : Callable The callable to call in 50ms time. args, kwargs : tuple, dict Arguments to be passed through to the callable. @@ -51,7 +51,7 @@ def do_after(interval, callable, *args, **kwargs): ---------- interval : float The time interval in milliseconds to wait before calling. - callable : callable + callable : Callable The callable to call. args Positional arguments to be passed through to the callable. diff --git a/pyface/ui/__init__.py b/pyface/ui/__init__.py index 5cc66c880..002e3d564 100644 --- a/pyface/ui/__init__.py +++ b/pyface/ui/__init__.py @@ -37,7 +37,7 @@ class ShadowedModuleLoader(Loader): new_name : str The full name of the corresponding "real" module. Eg. "pyface.ui.qt.foo" - new_spec : ModuleSpec instance + new_spec : ModuleSpec The spec object for the corresponding "real" module. """ diff --git a/pyface/ui/qt/application_window.py b/pyface/ui/qt/application_window.py index 2efb0e8a6..bd2d64961 100644 --- a/pyface/ui/qt/application_window.py +++ b/pyface/ui/qt/application_window.py @@ -141,6 +141,7 @@ def _menu_bar_manager_updated(self, event): def _status_bar_manager_updated(self, event): if self.control is not None: if event.old is not None: + self.control.setStatusBar(None) event.old.destroy() self._create_status_bar(self.control) @@ -154,7 +155,8 @@ def _update_tool_bar_managers(self, event): child.deleteLater() # Add the new toolbars. - self._create_tool_bar(self.control) + if event.new is not None: + self._create_status_bar(self.control) @observe("icon") def _icon_updated(self, event): diff --git a/pyface/ui/qt/console/console_widget.py b/pyface/ui/qt/console/console_widget.py index 746aca8fc..9508d24fc 100644 --- a/pyface/ui/qt/console/console_widget.py +++ b/pyface/ui/qt/console/console_widget.py @@ -1874,7 +1874,7 @@ def _readline(self, prompt="", callback=None): prompt : str, optional The prompt to print before reading the line. - callback : callable, optional + callback : Callable, optional A callback to execute with the read line. If not specified, input is read *synchronously* and this method does not return until it has been read. diff --git a/pyface/ui/qt/font.py b/pyface/ui/qt/font.py index 1c0b2a20f..beb21e789 100644 --- a/pyface/ui/qt/font.py +++ b/pyface/ui/qt/font.py @@ -187,7 +187,7 @@ def map_to_nearest(target, mapping): Returns ------- - value : any + value : Any The value corresponding to the nearest key. In the case of a tie, the first value is returned. """ diff --git a/pyface/ui/qt/tasks/split_editor_area_pane.py b/pyface/ui/qt/tasks/split_editor_area_pane.py index 9baf03687..889857fc8 100644 --- a/pyface/ui/qt/tasks/split_editor_area_pane.py +++ b/pyface/ui/qt/tasks/split_editor_area_pane.py @@ -472,7 +472,7 @@ class EditorAreaWidget(QtGui.QSplitter): def __init__(self, editor_area, parent=None, tabwidget=None): """ Creates an EditorAreaWidget object. - editor_area : global SplitEditorAreaPane instance + editor_area : global SplitEditorAreaPane parent : parent splitter tabwidget : tabwidget object contained by this splitter @@ -759,7 +759,7 @@ class DraggableTabWidget(QtGui.QTabWidget): def __init__(self, editor_area, parent): """ - editor_area : global SplitEditorAreaPane instance + editor_area : global SplitEditorAreaPane parent : parent of the tabwidget """ super().__init__(parent) diff --git a/pyface/ui/qt/util/event_loop_helper.py b/pyface/ui/qt/util/event_loop_helper.py index a863bf7ac..18676ce08 100644 --- a/pyface/ui/qt/util/event_loop_helper.py +++ b/pyface/ui/qt/util/event_loop_helper.py @@ -110,7 +110,7 @@ def event_loop_until_condition(self, condition, timeout=10.0): Parameters ---------- - condition : callable + condition : Callable A callable to determine if the stop criteria have been met. This should accept no arguments. diff --git a/pyface/ui/qt/util/gui_test_assistant.py b/pyface/ui/qt/util/gui_test_assistant.py index 12e3bdcec..620cc05b9 100644 --- a/pyface/ui/qt/util/gui_test_assistant.py +++ b/pyface/ui/qt/util/gui_test_assistant.py @@ -159,7 +159,7 @@ def event_loop_until_condition(self, condition, timeout=10.0): Parameters ---------- - condition : callable + condition : Callable A callable to determine if the stop criteria have been met. This should accept no arguments. timeout : float @@ -197,7 +197,7 @@ def assertEventuallyTrueInGui(self, condition, timeout=10.0): Parameters ---------- - condition : callable() -> bool + condition : Callable() -> bool Callable accepting no arguments and returning a bool. timeout : float Maximum length of time to wait for the condition to become @@ -224,11 +224,11 @@ def assertTraitChangesInEventLoop( Parameters ---------- - obj : HasTraits + obj : traits.has_traits.HasTraits The HasTraits instance whose trait will change. trait : str The extended trait name of trait changes to listen to. - condition : callable + condition : Callable A callable to determine if the stop criteria have been met. This takes obj as the only argument. count : int @@ -269,7 +269,7 @@ def event_loop_until_traits_change(self, traits_object, *traits, **kw): Paramaters ---------- - traits_object : HasTraits instance + traits_object : traits.has_traits.HasTraits The object on which to listen for a trait events traits : one or more str The names of the traits to listen to for events @@ -345,7 +345,7 @@ def find_qt_widget(self, start, type_, test=None): type_ : type A subclass of QWidget to use for an initial type filter while walking the tree - test : callable + test : Callable A filter function that takes one argument (the current widget being evaluated) and returns either True or False to determine if the widget matches the required criteria. diff --git a/pyface/ui/qt/util/modal_dialog_tester.py b/pyface/ui/qt/util/modal_dialog_tester.py index 00782fde3..b4fbf0cdb 100644 --- a/pyface/ui/qt/util/modal_dialog_tester.py +++ b/pyface/ui/qt/util/modal_dialog_tester.py @@ -28,7 +28,7 @@ class ModalDialogTester(object): """ Test helper for code that open a traits ui or QDialog window. - Usage + Notes ----- :: @@ -86,7 +86,7 @@ def open_and_run(self, when_opened, *args, **kwargs): Parameters ---------- - when_opened : callable + when_opened : Callable A callable to be called when the dialog has been created and opened. The callable with be called with the tester instance as argument. @@ -142,7 +142,7 @@ def open_and_wait(self, when_opened, *args, **kwargs): Parameters ---------- - when_opened : callable + when_opened : Callable A callable to be called when the dialog has been created and opened. The callable with be called with the tester instance as argument. diff --git a/pyface/ui/qt/util/testing.py b/pyface/ui/qt/util/testing.py index cfdf36368..c35276d97 100644 --- a/pyface/ui/qt/util/testing.py +++ b/pyface/ui/qt/util/testing.py @@ -128,7 +128,7 @@ def find_qt_widget(start, type_, test=None): type_ : type A subclass of QWidget to use for an initial type filter while walking the tree - test : callable + test : Callable A filter function that takes one argument (the current widget being evaluated) and returns either True or False to determine if the widget matches the required criteria. diff --git a/pyface/ui/wx/layout_widget.py b/pyface/ui/wx/layout_widget.py index 665fa44e0..dce282f31 100644 --- a/pyface/ui/wx/layout_widget.py +++ b/pyface/ui/wx/layout_widget.py @@ -94,7 +94,7 @@ def _size_to_wx_size(size): Returns ------- - wx_size : wx.Size instance + wx_size : wx.Size A corresponding wx Size instance. """ return wx.Size(*( @@ -108,7 +108,7 @@ def _wx_size_to_size(wx_size): Parameters ---------- - wx_size : wx.Size instance + wx_size : wx.Size A wx Size instance. Returns diff --git a/pyface/ui_traits.py b/pyface/ui_traits.py index e78274123..a968e8ad8 100644 --- a/pyface/ui_traits.py +++ b/pyface/ui_traits.py @@ -149,7 +149,7 @@ class PyfaceColor(TraitType): """ A Trait which casts strings and tuples to a Pyface Color value. """ - #: The default value should be a tuple (factory, args, kwargs) + #: The default value should be a tuple (factory, args, kwargs). default_value_type = DefaultValue.callable_and_args def __init__(self, value=None, **metadata): @@ -161,6 +161,11 @@ def __init__(self, value=None, **metadata): super().__init__(default_value, **metadata) def validate(self, object, name, value): + """Validate the trait + + This accepts, Color values, parseable strings and RGB(A) sequences + (including numpy arrays). + """ if isinstance(value, Color): return value if isinstance(value, str): @@ -182,6 +187,7 @@ def validate(self, object, name, value): self.error(object, name, value) def info(self): + """Describe the trait""" return ( "a Pyface Color, a #-hexadecimal rgb or rgba string, a standard " "color name, or a sequence of RGBA or RGB values between 0 and 1" @@ -225,6 +231,10 @@ def __init__(self, value=None, *, parser=simple_parser, **metadata): super().__init__(default_value, **metadata) def validate(self, object, name, value): + """Validate the trait + + This accepts, Font values and parseable strings. + """ if isinstance(value, Font): return value if isinstance(value, str): @@ -236,6 +246,7 @@ def validate(self, object, name, value): self.error(object, name, value) def info(self): + """Describe the trait""" return ( "a Pyface Font, or a string describing a Pyface Font" ) @@ -247,15 +258,18 @@ def info(self): class BaseMB(ABCHasStrictTraits): - def __init__(self, *args, **traits): - """ Map posiitonal arguments to traits. + """ Base class for Margins and Borders - If one value is provided it is taken as the value for all sides. - If two values are provided, then the first argument is used for - left and right, while the second is used for top and bottom. - If 4 values are provided, then the arguments are mapped to - left, right, top, and bottom, respectively. - """ + The constructor of this class maps posiitonal arguments to traits. + + - If one value is provided it is taken as the value for all sides. + - If two values are provided, then the first argument is used for + left and right, while the second is used for top and bottom. + - If 4 values are provided, then the arguments are mapped to + left, right, top, and bottom, respectively. + """ + + def __init__(self, *args, **traits): n = len(args) if n > 0: if n == 1: @@ -277,32 +291,34 @@ def __init__(self, *args, **traits): class Margin(BaseMB): + """A HasTraits class that holds margin sizes.""" - # The amount of padding/margin at the top: + #: The amount of padding/margin at the top. top = Range(-32, 32, 0) - # The amount of padding/margin at the bottom: + #: The amount of padding/margin at the bottom. bottom = Range(-32, 32, 0) - # The amount of padding/margin on the left: + #: The amount of padding/margin on the left. left = Range(-32, 32, 0) - # The amount of padding/margin on the right: + #: The amount of padding/margin on the right. right = Range(-32, 32, 0) class Border(BaseMB): + """A HasTraits class that holds border thicknesses.""" - # The amount of border at the top: + #: The amount of border at the top. top = Range(0, 32, 0) - # The amount of border at the bottom: + #: The amount of border at the bottom. bottom = Range(0, 32, 0) - # The amount of border on the left: + #: The amount of border on the left. left = Range(0, 32, 0) - # The amount of border on the right: + #: The amount of border on the right. right = Range(0, 32, 0) @@ -311,13 +327,13 @@ class HasMargin(TraitType): tuple value that can be converted to one. """ - # The desired value class: + #: The desired value class. klass = Margin - # Define the default value for the trait: + #: Define the default value for the trait. default_value = Margin(0) - # A description of the type of value this trait accepts: + #: A description of the type of value this trait accepts. info_text = ( "a Margin instance, or an integer in the range from -32 to 32 " "or a tuple with 1, 2 or 4 integers in that range that can be " @@ -371,13 +387,13 @@ class HasBorder(HasMargin): or tuple value that can be converted to one. """ - # The desired value class: + #: The desired value class. klass = Border - # Define the default value for the trait: + #: Define the default value for the trait. default_value = Border(0) - # A description of the type of value this trait accepts: + #: A description of the type of value this trait accepts. info_text = ( "a Border instance, or an integer in the range from 0 to 32 " "or a tuple with 1, 2 or 4 integers in that range that can be " diff --git a/pyface/util/testing.py b/pyface/util/testing.py index ce154809f..d55069e8f 100644 --- a/pyface/util/testing.py +++ b/pyface/util/testing.py @@ -11,9 +11,15 @@ import re from unittest import TestSuite +try: + from importlib.metadata import version +except: + from importlib_metadata import version + from packaging.version import Version -from traits import __version__ as TRAITS_VERSION + +TRAITS_VERSION = version("traits") def filter_tests(test_suite, exclusion_pattern): diff --git a/pyface/workbench/action/action_controller.py b/pyface/workbench/action/action_controller.py index 0d03c8e07..28620553d 100644 --- a/pyface/workbench/action/action_controller.py +++ b/pyface/workbench/action/action_controller.py @@ -10,12 +10,12 @@ """ The action controller for workbench menu and tool bars. """ -from pyface.action.api import ActionController +from pyface.action.api import ActionController as PyfaceActionController from pyface.workbench.api import WorkbenchWindow from traits.api import Instance -class ActionController(ActionController): +class ActionController(PyfaceActionController): """ The action controller for workbench menu and tool bars. The controller is used to 'hook' the invocation of every action on the menu diff --git a/pyproject.toml b/pyproject.toml index b0687a01f..b91cc3c31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ 'importlib-resources>=1.1.0; python_version<"3.9"', 'traits>=6.2', ] -version = '8.0.0.dev0' +version = '8.0.0' license = {file = "LICENSE.txt"} [project.entry-points.'pyface.toolkits'] diff --git a/shell_window.png b/shell_window.png new file mode 100644 index 0000000000000000000000000000000000000000..12b18a0634beca10f4e1435e829759f84d39bcab GIT binary patch literal 89300 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 literal 0 HcmV?d00001