diff --git a/.github/actions/install-qt-support/README.md b/.github/actions/install-qt-support/README.md new file mode 100644 index 000000000..47c86e009 --- /dev/null +++ b/.github/actions/install-qt-support/README.md @@ -0,0 +1,27 @@ +# Install Qt dependencies + +This action calls `apt-get` to install packages required for running Qt on Ubuntu. + +## Inputs + +There are no inputs. + +## Outputs + +There are no outputs. + +## Example usage + +```yml + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Install Qt dependencies + uses: ./.github/actions/install-qt-support +``` diff --git a/.github/actions/install-qt-support/action.yml b/.github/actions/install-qt-support/action.yml new file mode 100644 index 000000000..66b98ab7e --- /dev/null +++ b/.github/actions/install-qt-support/action.yml @@ -0,0 +1,30 @@ +name: install-qt-support +description: 'Install supporting OS packages for Qt-using code' +runs: + using: composite + steps: + - name: Install Linux packages for Qt + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install qtbase5-dev + sudo apt-get install qtchooser + sudo apt-get install qt5-qmake + sudo apt-get install qtbase5-dev-tools + sudo apt-get install libegl1 + sudo apt-get install libxkbcommon-x11-0 + sudo apt-get install libxcb-icccm4 + sudo apt-get install libxcb-image0 + sudo apt-get install libxcb-keysyms1 + sudo apt-get install libxcb-randr0 + 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 + sudo apt-get install libopengl0 + # Needed for Qt6 video playback + sudo apt-get install libgstreamer-gl1.0-0 + shell: bash diff --git a/.github/workflows/test-with-pip.yml b/.github/workflows/test-with-pip.yml index 38ceb6996..6c463b05c 100644 --- a/.github/workflows/test-with-pip.yml +++ b/.github/workflows/test-with-pip.yml @@ -22,9 +22,72 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies and local packages run: | - python -m pip install .[h5,preferences] + python -m pip install .[test,h5,preferences] + mkdir testdir - name: Run tests run: | + python -m unittest discover -v apptools + working-directory: testdir + + tests-qt: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.8', '3.11'] + qt-api: ['pyside6'] + env: + ETS_TOOLKIT: qt + runs-on: ${{ matrix.os }} + timeout-minutes: 20 # should be plenty, it's usually < 5 + + steps: + - name: Check out the target commit + uses: actions/checkout@v3 + - name: Install Qt dependencies + uses: ./.github/actions/install-qt-support + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies and local packages + run: | + python -m pip install "traitsui[${{ matrix.qt-api }}]" + python -m pip install ".[test,h5,preferences]" mkdir testdir - cd testdir + - name: Run tests (Linux) + run: | + xvfb-run -a python -m unittest discover -v apptools + if: matrix.os == 'ubuntu-latest' + working-directory: testdir + - name: Run tests (Windows and MacOS) + run: | + python -m unittest discover -v apptools + if: matrix.os != 'ubuntu-latest' + working-directory: testdir + + tests-wx: + strategy: + matrix: + os: [windows-latest] + python-version: ['3.8', '3.10'] + env: + ETS_TOOLKIT: wx + runs-on: ${{ matrix.os }} + timeout-minutes: 20 # should be plenty, it's usually < 5 + + steps: + - name: Check out the target commit + uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies and local packages + run: | + python -m pip install "traitsui[wx]" + python -m pip install ".[test,h5,preferences]" + mkdir testdir + - name: Run tests + run: | python -m unittest discover -v apptools + working-directory: testdir diff --git a/apptools/logger/plugin/logger_service.py b/apptools/logger/plugin/logger_service.py index bfeb86cc1..a38043cb4 100644 --- a/apptools/logger/plugin/logger_service.py +++ b/apptools/logger/plugin/logger_service.py @@ -14,7 +14,7 @@ import zipfile # Enthought library imports -from pyface.workbench.api import View as WorkbenchView +from apptools.workbench.api import View as WorkbenchView from traits.api import ( Any, Callable, diff --git a/apptools/logger/plugin/view/logger_view.py b/apptools/logger/plugin/view/logger_view.py index 12c4a8410..2ca3494ec 100644 --- a/apptools/logger/plugin/view/logger_view.py +++ b/apptools/logger/plugin/view/logger_view.py @@ -14,7 +14,7 @@ # Enthought library imports. from pyface.api import ImageResource, clipboard -from pyface.workbench.api import TraitsUIView +from apptools.workbench.api import TraitsUIView from traits.api import ( Button, Instance, diff --git a/apptools/workbench/__init__.py b/apptools/workbench/__init__.py new file mode 100644 index 000000000..aa2218ef6 --- /dev/null +++ b/apptools/workbench/__init__.py @@ -0,0 +1,9 @@ +# (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! diff --git a/apptools/workbench/action/__init__.py b/apptools/workbench/action/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/apptools/workbench/action/action_controller.py b/apptools/workbench/action/action_controller.py new file mode 100644 index 000000000..10e637f40 --- /dev/null +++ b/apptools/workbench/action/action_controller.py @@ -0,0 +1,44 @@ +# (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 action controller for workbench menu and tool bars. """ + + +from pyface.action.api import ActionController +from traits.api import Instance + +from apptools.workbench.workbench_window import WorkbenchWindow + + +class ActionController(ActionController): + """The action controller for workbench menu and tool bars. + + The controller is used to 'hook' the invocation of every action on the menu + and tool bars. This is done so that additional (and workbench specific) + information can be added to action events. Currently, we attach a reference + to the workbench window. + + """ + + # 'ActionController' interface ----------------------------------------- + + # The workbench window that this is the controller for. + window = Instance(WorkbenchWindow) + + # ------------------------------------------------------------------------ + # 'ActionController' interface. + # ------------------------------------------------------------------------ + + def perform(self, action, event): + """Control an action invocation.""" + + # Add a reference to the window and the application to the event. + event.window = self.window + + return action.perform(event) diff --git a/apptools/workbench/action/api.py b/apptools/workbench/action/api.py new file mode 100644 index 000000000..e092df5d5 --- /dev/null +++ b/apptools/workbench/action/api.py @@ -0,0 +1,14 @@ +# (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 .menu_bar_manager import MenuBarManager +from .tool_bar_manager import ToolBarManager +from .view_menu_manager import ViewMenuManager diff --git a/apptools/workbench/action/delete_user_perspective_action.py b/apptools/workbench/action/delete_user_perspective_action.py new file mode 100644 index 000000000..47d25c684 --- /dev/null +++ b/apptools/workbench/action/delete_user_perspective_action.py @@ -0,0 +1,78 @@ +# (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! +""" An action that deletes a user perspective. """ + + +from pyface.api import YES + +from .user_perspective_action import UserPerspectiveAction + + +class DeleteUserPerspectiveAction(UserPerspectiveAction): + """An action that deletes a user perspective.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = "apptools.workbench.action.delete_user_perspective_action" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Delete Perspective" + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + window = event.window + manager = window.workbench.user_perspective_manager + + # The perspective to delete. + perspective = window.active_perspective + + # Make sure that the user isn't having second thoughts! + message = ( + 'Are you sure you want to delete the "%s" perspective?' + % perspective.name + ) + + answer = window.confirm(message, title="Confirm Delete") + if answer == YES: + # Set the active perspective to be the first remaining perspective. + # + # There is always a default NON-user perspective (even if no + # perspectives are explicitly defined) so we should never(!) not + # be able to find one! + window.active_perspective = self._get_next_perspective(window) + + # Remove the perspective from the window. + window.perspectives.remove(perspective) + + # Remove it from the user perspective manager. + manager.remove(perspective.id) + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _get_next_perspective(self, window): + """Return the first perspective that is not the active one!""" + + if window.active_perspective is window.perspectives[0]: + index = 1 + + else: + index = 0 + + return window.perspectives[index] diff --git a/apptools/workbench/action/menu_bar_manager.py b/apptools/workbench/action/menu_bar_manager.py new file mode 100644 index 000000000..6fe6d1aa5 --- /dev/null +++ b/apptools/workbench/action/menu_bar_manager.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! +""" The menu bar manager for Envisage workbench windows. """ + + +from pyface.action.api import MenuBarManager as BaseMenuBarManager +from traits.api import Instance + +from .action_controller import ActionController + + +class MenuBarManager(BaseMenuBarManager): + """The menu bar manager for Envisage workbench windows.""" + + # 'MenuBarManager' interface ------------------------------------------- + + # The workbench window that we are the menu bar manager for. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # ------------------------------------------------------------------------ + # 'MenuBarManager' interface. + # ------------------------------------------------------------------------ + + def create_menu_bar(self, parent): + """Creates a menu bar representation of the manager.""" + + # The controller handles the invocation of every action. + controller = ActionController(window=self.window) + + menu_bar = super().create_menu_bar(parent, controller=controller) + + return menu_bar diff --git a/apptools/workbench/action/new_user_perspective_action.py b/apptools/workbench/action/new_user_perspective_action.py new file mode 100644 index 000000000..a439651dc --- /dev/null +++ b/apptools/workbench/action/new_user_perspective_action.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! +""" An action that creates a new (and empty) user perspective. """ + + +from .user_perspective_name import UserPerspectiveName +from .workbench_action import WorkbenchAction + + +class NewUserPerspectiveAction(WorkbenchAction): + """An action that creates a new (and empty) user perspective.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier. + id = "apptools.workbench.action.new_user_perspective_action" + + # The action's name. + name = "New Perspective..." + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Peform the action.""" + + window = event.window + manager = window.workbench.user_perspective_manager + + # Get the details of the new perspective. + upn = UserPerspectiveName(name="User Perspective %d" % manager.next_id) + if upn.edit_traits(view="new_view").result: + # Create a new (and empty) user perspective. + perspective = manager.create_perspective( + upn.name.strip(), upn.show_editor_area + ) + + # Add it to the window... + window.perspectives.append(perspective) + + # ... and make it the active perspective. + window.active_perspective = perspective + + return diff --git a/apptools/workbench/action/perspective_menu_manager.py b/apptools/workbench/action/perspective_menu_manager.py new file mode 100644 index 000000000..41d276a14 --- /dev/null +++ b/apptools/workbench/action/perspective_menu_manager.py @@ -0,0 +1,136 @@ +# (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 default perspective menu for a workbench window. """ + + +from pyface.action.api import Group, MenuManager +from traits.api import Instance, List, observe + +from .delete_user_perspective_action import DeleteUserPerspectiveAction +from .new_user_perspective_action import NewUserPerspectiveAction +from .rename_user_perspective_action import RenameUserPerspectiveAction +from .reset_active_perspective_action import ResetActivePerspectiveAction +from .reset_all_perspectives_action import ResetAllPerspectivesAction +from .save_as_user_perspective_action import SaveAsUserPerspectiveAction +from .set_active_perspective_action import SetActivePerspectiveAction + + +class PerspectiveMenuManager(MenuManager): + """The default perspective menu for a workbench window.""" + + # 'ActionManager' interface -------------------------------------------- + + # All of the groups in the manager. + groups = List(Group) + + # The manager's unique identifier. + id = "PerspectivesMenu" + + # 'MenuManager' interface ---------------------------------------------# + + # The menu manager's name. + name = "Perspectives" + + # 'PerspectiveMenuManager' interface ----------------------------------- + + # The workbench window that the manager is part of. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # ------------------------------------------------------------------------ + # 'ActionManager' interface. + # ------------------------------------------------------------------------ + + def _groups_default(self): + """Trait initializer.""" + + groups = [ + # Create a group containing the actions that switch to specific + # perspectives. + self._create_perspective_group(self.window), + # Create a group containing the user perspective create/save/rename + # /delete actions. + self._create_user_perspective_group(self.window), + # Create a group containing the reset actions. + self._create_reset_perspective_group(self.window), + ] + + return groups + + # ------------------------------------------------------------------------ + # 'PerspectiveMenuManager' interface. + # ------------------------------------------------------------------------ + + @observe("window.perspectives.items") + def rebuild(self, event): + """Rebuild the menu. + + This is called when user perspectives have been added or removed. + + """ + + # Clear out the old menu. This gives any actions that have trait + # listeners (i.e. the rename and delete actions!) a chance to unhook + # them. + self.destroy() + + # Resetting the trait allows the initializer to run again (which will + # happen just as soon as we fire the 'changed' event). + self.reset_traits(["groups"]) + + # Let the associated menu know that we have changed. + self.changed = True + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _create_perspective_group(self, window): + """Create the actions that switch to specific perspectives.""" + + # fixme: Not sure if alphabetic sorting is appropriate in all cases, + # but it will do for now! + perspectives = window.perspectives[:] + perspectives.sort(key=lambda x: x.name) + + # For each perspective, create an action that sets the active + # perspective to it. + group = Group() + for perspective in perspectives: + group.append( + SetActivePerspectiveAction( + perspective=perspective, window=window + ) + ) + + return group + + def _create_user_perspective_group(self, window): + """Create the user perspective create/save/rename/delete actions.""" + + group = Group( + NewUserPerspectiveAction(window=window), + SaveAsUserPerspectiveAction(window=window), + RenameUserPerspectiveAction(window=window), + DeleteUserPerspectiveAction(window=window), + ) + + return group + + def _create_reset_perspective_group(self, window): + """Create the reset perspective actions.""" + + group = Group( + ResetActivePerspectiveAction(window=window), + ResetAllPerspectivesAction(window=window), + ) + + return group diff --git a/apptools/workbench/action/rename_user_perspective_action.py b/apptools/workbench/action/rename_user_perspective_action.py new file mode 100644 index 000000000..0d840c925 --- /dev/null +++ b/apptools/workbench/action/rename_user_perspective_action.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! +""" An action that renames a user perspective. """ + + +from .user_perspective_action import UserPerspectiveAction +from .user_perspective_name import UserPerspectiveName + + +class RenameUserPerspectiveAction(UserPerspectiveAction): + """An action that renames a user perspective.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = "apptools.workbench.action.rename_user_perspective_action" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Rename Perspective..." + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + window = event.window + manager = window.workbench.user_perspective_manager + + # Get the new name. + upn = UserPerspectiveName(name=window.active_perspective.name) + if upn.edit_traits(view="rename_view").result: + manager.rename(window.active_perspective, upn.name.strip()) + + return diff --git a/apptools/workbench/action/reset_active_perspective_action.py b/apptools/workbench/action/reset_active_perspective_action.py new file mode 100644 index 000000000..260bbfde3 --- /dev/null +++ b/apptools/workbench/action/reset_active_perspective_action.py @@ -0,0 +1,44 @@ +# (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! +""" An action that resets the active perspective. """ + + +from pyface.api import YES + +from .workbench_action import WorkbenchAction + +# The message used when confirming the action. +MESSAGE = 'Do you want to reset the current "%s" perspective to its defaults?' + + +class ResetActivePerspectiveAction(WorkbenchAction): + """An action that resets the active perspective.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = "apptools.workbench.action.reset_active_perspective" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Reset Perspective" + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + window = self.window + + if window.confirm(MESSAGE % window.active_perspective.name) == YES: + window.reset_active_perspective() + + return diff --git a/apptools/workbench/action/reset_all_perspectives_action.py b/apptools/workbench/action/reset_all_perspectives_action.py new file mode 100644 index 000000000..8ef3b1d41 --- /dev/null +++ b/apptools/workbench/action/reset_all_perspectives_action.py @@ -0,0 +1,44 @@ +# (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! +""" An action that resets *all* perspectives. """ + + +from pyface.api import YES + +from .workbench_action import WorkbenchAction + +# The message used when confirming the action. +MESSAGE = "Do you want to reset ALL perspectives to their defaults?" + + +class ResetAllPerspectivesAction(WorkbenchAction): + """An action that resets *all* perspectives.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = "apptools.workbench.action.reset_all_perspectives" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Reset All Perspectives" + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + window = self.window + + if window.confirm(MESSAGE) == YES: + window.reset_all_perspectives() + + return diff --git a/apptools/workbench/action/save_as_user_perspective_action.py b/apptools/workbench/action/save_as_user_perspective_action.py new file mode 100644 index 000000000..c44f6910e --- /dev/null +++ b/apptools/workbench/action/save_as_user_perspective_action.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! +""" An action that saves the active perspective as a user perspective. """ + + +from .user_perspective_name import UserPerspectiveName +from .workbench_action import WorkbenchAction + + +class SaveAsUserPerspectiveAction(WorkbenchAction): + """An action that saves the active perspective as a user perspective.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier. + id = "apptools.workbench.action.save_as_user_perspective_action" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Save Perspective As..." + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + window = event.window + manager = window.workbench.user_perspective_manager + + # Get the name of the new perspective. + upn = UserPerspectiveName(name=window.active_perspective.name) + if upn.edit_traits(view="save_as_view").result: + # Make a clone of the active perspective, but give it the new name. + perspective = manager.clone_perspective( + window, window.active_perspective, name=upn.name.strip() + ) + + # Add it to the window... + window.perspectives.append(perspective) + + # ... and make it the active perspective. + window.active_perspective = perspective + + return diff --git a/apptools/workbench/action/set_active_perspective_action.py b/apptools/workbench/action/set_active_perspective_action.py new file mode 100644 index 000000000..25d5270fb --- /dev/null +++ b/apptools/workbench/action/set_active_perspective_action.py @@ -0,0 +1,73 @@ +# (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! +""" An action that sets the active perspective. """ + + +from traits.api import Delegate, Instance, observe + +from apptools.workbench.i_perspective import IPerspective + +from .workbench_action import WorkbenchAction + + +class SetActivePerspectiveAction(WorkbenchAction): + """An action that sets the active perspective.""" + + # 'Action' interface --------------------------------------------------- + + # Is the action enabled? + enabled = Delegate("perspective") + + # The action's unique identifier (may be None). + id = Delegate("perspective") + + # The action's name (displayed on menus/tool bar tools etc). + name = Delegate("perspective") + + # The action's style. + style = "radio" + + # 'SetActivePerspectiveAction' interface ------------------------------- + + # The perspective that we set the active perspective to. + perspective = Instance(IPerspective) + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def destroy(self): + """Destroy the action.""" + + self.window = None + + def perform(self, event): + """Perform the action.""" + + self.window.active_perspective = self.perspective + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + @observe("perspective,window.active_perspective") + def _refresh_checked(self, event): + """Refresh the checked state of the action.""" + + self.checked = ( + self.perspective is not None + and self.window is not None + and self.window.active_perspective is not None + and self.perspective.id is self.window.active_perspective.id + ) + + return diff --git a/apptools/workbench/action/setattr_action.py b/apptools/workbench/action/setattr_action.py new file mode 100644 index 000000000..1b876d33e --- /dev/null +++ b/apptools/workbench/action/setattr_action.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! +""" An action that sets an attribute. """ + + +from traits.api import Any, Str + +from .workbench_action import WorkbenchAction + + +class SetattrAction(WorkbenchAction): + """An action that sets an attribute.""" + + # 'SetattrAction' interface -------------------------------------------- + + # The object that we set the attribute on. + obj = Any() + + # The name of the attribute that we set. + attribute_name = Str() + + # The value that we set the attribute to. + value = Any() + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Performs the action.""" + + setattr(self.obj, self.attribute_name, self.value) + + return diff --git a/apptools/workbench/action/show_view_action.py b/apptools/workbench/action/show_view_action.py new file mode 100644 index 000000000..60ad2f570 --- /dev/null +++ b/apptools/workbench/action/show_view_action.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! +""" An action that shows a dialog to allow the user to choose a view. """ + + +from .view_chooser import ViewChooser +from .workbench_action import WorkbenchAction + + +class ShowViewAction(WorkbenchAction): + """An action that shows a dialog to allow the user to choose a view.""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = "apptools.workbench.action.show_view" + + # The action's name (displayed on menus/tool bar tools etc). + name = "Show View" + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def perform(self, event): + """Perform the action.""" + + chooser = ViewChooser(window=self.window) + + ui = chooser.edit_traits(parent=self.window.control, kind="livemodal") + + # If the user closes the dialog by using the window manager's close button + # (e.g. the little [x] in the top corner), ui.result is True, but chooser.view + # might be None, so we need an explicit check for that. + if ui.result and chooser.view is not None: + # This shows the view... + chooser.view.show() + + # ... and this makes it active (brings it to the front, gives it + # focus etc). + chooser.view.activate() + + return diff --git a/apptools/workbench/action/toggle_view_visibility_action.py b/apptools/workbench/action/toggle_view_visibility_action.py new file mode 100644 index 000000000..1f2e10148 --- /dev/null +++ b/apptools/workbench/action/toggle_view_visibility_action.py @@ -0,0 +1,107 @@ +# (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! +""" An action that toggles a view's visibility (ie. hides/shows it). """ + + +from traits.api import Delegate, Instance + +from apptools.workbench.i_view import IView + +from .workbench_action import WorkbenchAction + + +class ToggleViewVisibilityAction(WorkbenchAction): + """An action that toggles a view's visibility (ie. hides/shows it).""" + + # 'Action' interface --------------------------------------------------- + + # The action's unique identifier (may be None). + id = Delegate("view", modify=True) + + # The action's name (displayed on menus/tool bar tools etc). + name = Delegate("view", modify=True) + + # The action's style. + style = "toggle" + + # 'ViewAction' interface ----------------------------------------------- + + # The view that we toggle the visibility for. + view = Instance(IView) + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def destroy(self): + """Called when the action is no longer required.""" + + if self.view is not None: + self._remove_view_listeners(self.view) + + def perform(self, event): + """Perform the action.""" + + self._toggle_view_visibility(self.view) + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + # Trait change handlers ------------------------------------------------ + + def _view_changed(self, old, new): + """Static trait change handler.""" + + if old is not None: + self._remove_view_listeners(old) + + if new is not None: + self._add_view_listeners(new) + + self._refresh_checked() + + return + + # Methods -------------------------------------------------------------# + + def _add_view_listeners(self, view): + """Add listeners for trait events on a view.""" + + view.observe(self._refresh_checked, "visible") + view.observe(self._refresh_checked, "window") + + def _remove_view_listeners(self, view): + """Add listeners for trait events on a view.""" + + view.observe(self._refresh_checked, "visible", remove=True) + view.observe(self._refresh_checked, "window", remove=True) + + def _refresh_checked(self, event=None): + """Refresh the checked state of the action.""" + + self.checked = ( + self.view is not None + and self.view.window is not None + and self.view.visible + ) + + def _toggle_view_visibility(self, view): + """Toggle the visibility of a view.""" + + if view.visible: + view.hide() + + else: + view.show() + + return diff --git a/apptools/workbench/action/tool_bar_manager.py b/apptools/workbench/action/tool_bar_manager.py new file mode 100644 index 000000000..a89f8e394 --- /dev/null +++ b/apptools/workbench/action/tool_bar_manager.py @@ -0,0 +1,42 @@ +# (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 tool bar manager for the Envisage workbench window. """ + + +import pyface.action.api as pyface +from traits.api import Instance + +from .action_controller import ActionController + + +class ToolBarManager(pyface.ToolBarManager): + """The tool bar manager for the Envisage workbench window.""" + + # 'ToolBarManager' interface ------------------------------------------- + + # The workbench window that we are the tool bar manager for. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # ------------------------------------------------------------------------ + # 'ToolBarManager' interface. + # ------------------------------------------------------------------------ + + def create_tool_bar(self, parent, controller=None, **kwargs): + """Creates a tool bar representation of the manager.""" + + # The controller handles the invocation of every action. + if controller is None: + controller = ActionController(window=self.window) + + tool_bar = super().create_tool_bar( + parent, controller=controller, **kwargs + ) + + return tool_bar diff --git a/apptools/workbench/action/user_perspective_action.py b/apptools/workbench/action/user_perspective_action.py new file mode 100644 index 000000000..96e519d8c --- /dev/null +++ b/apptools/workbench/action/user_perspective_action.py @@ -0,0 +1,60 @@ +# (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 base class for user perspective actions. """ + + +from traits.api import observe + +from .workbench_action import WorkbenchAction + + +class UserPerspectiveAction(WorkbenchAction): + """The base class for user perspective actions. + + Instances of this class (or its subclasses ;^) are enabled only when the + active perspective is a user perspective. + + """ + + # ------------------------------------------------------------------------ + # 'Action' interface. + # ------------------------------------------------------------------------ + + def destroy(self): + """Destroy the action.""" + + # This removes the active perspective listener. + self.window = None + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _is_user_perspective(self, perspective): + """Is the specified perspective a user perspective?""" + + # fixme: This seems a bit of a smelly way to make the determinaction! + id = perspective.id + + return (id[:19] == "__user_perspective_") and (id[-2:] == "__") + + @observe("window.active_perspective") + def _refresh_enabled(self, event): + """Refresh the enabled state of the action.""" + + self.enabled = ( + self.window is not None + and self.window.active_perspective is not None + and self._is_user_perspective(self.window.active_perspective) + ) + + return diff --git a/apptools/workbench/action/user_perspective_name.py b/apptools/workbench/action/user_perspective_name.py new file mode 100644 index 000000000..e539df36a --- /dev/null +++ b/apptools/workbench/action/user_perspective_name.py @@ -0,0 +1,78 @@ +# (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! +""" Object with views for naming or renaming a user perspective. """ + + +from traits.api import Bool, Constant, HasTraits, String +from traitsui.api import Item, VGroup, View + +# Trait definitions -------------------------------------------------------- + +# Define a trait which can not be the empty string: +NotEmptyString = String(minlen=1) + + +class UserPerspectiveName(HasTraits): + """Object with views for naming or renaming a user perspective.""" + + # ------------------------------------------------------------------------ + # 'UserPerspectiveName' interface. + # ------------------------------------------------------------------------ + + # The name of the new user perspective. + name = NotEmptyString + + # Should the editor area be shown in this perpsective? + show_editor_area = Bool(True) + + # Help notes when creating a new view. + new_help = Constant( + """Note: + - The new perspective will initially be empty. + - Add new views to the perspective by selecting + them from the 'View' menu. + - Drag the notebook tabs and splitter bars to + arrange the views within the perspective.""" + ) + + # Traits views --------------------------------------------------------- + + new_view = View( + VGroup( + VGroup("name", "show_editor_area"), + VGroup("_", Item("new_help", style="readonly"), show_labels=False), + ), + title="New User Perspective", + id="envisage.workbench.action." + "new_user_perspective_action.UserPerspectiveName", + buttons=["OK", "Cancel"], + kind="livemodal", + width=300, + ) + + save_as_view = View( + "name", + title="Save User Perspective As", + id="envisage.workbench.action." + "save_as_user_perspective_action.UserPerspectiveName", + buttons=["OK", "Cancel"], + kind="livemodal", + width=300, + ) + + rename_view = View( + "name", + title="Rename User Perspective", + id="envisage.workbench.action." + "rename_user_perspective_action.UserPerspectiveName", + buttons=["OK", "Cancel"], + kind="livemodal", + width=300, + ) diff --git a/apptools/workbench/action/view_chooser.py b/apptools/workbench/action/view_chooser.py new file mode 100644 index 000000000..238977a06 --- /dev/null +++ b/apptools/workbench/action/view_chooser.py @@ -0,0 +1,204 @@ +# (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! +""" A UI that allows the user to choose a view. """ + + +from traits.api import ( + Any, + HasTraits, + Instance, + List, + Str, + TraitError, + Undefined, +) +from traitsui.api import Item, TreeEditor, TreeNode, View +from traitsui.menu import Action # fixme: Non-api import! + +from apptools.workbench.i_view import IView +from apptools.workbench.workbench_window import WorkbenchWindow + + +class Category(HasTraits): + """A view category.""" + + # The name of the category. + name = Str() + + # The views in the category. + views = List() + + +class WorkbenchWindowTreeNode(TreeNode): + """A tree node for workbench windows that displays the window's views. + + The views are grouped by their category. + + """ + + # 'TreeNode' interface ------------------------------------------------- + + # List of object classes that the node applies to. + node_for = [WorkbenchWindow] + + # ------------------------------------------------------------------------ + # 'TreeNode' interface. + # ------------------------------------------------------------------------ + + def get_children(self, object): + """Get the object's children.""" + + # Collate the window's views into categories. + categories_by_name = self._get_categories_by_name(object) + + categories = list(categories_by_name.values()) + categories.sort(key=lambda category: category.name) + + return categories + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _get_categories_by_name(self, window): + """Return a dictionary containing all categories keyed by name.""" + + categories_by_name = {} + for view in window.views: + category = categories_by_name.get(view.category) + if category is None: + category = Category(name=view.category) + categories_by_name[view.category] = category + + category.views.append(view) + + return categories_by_name + + +class IViewTreeNode(TreeNode): + """A tree node for objects that implement the 'IView' interface. + + This node does *not* recognise objects that can be *adapted* to the 'IView' + interface, only those that actually implement it. If we wanted to allow + for adaptation we would have to work out a way for the rest of the + 'TreeNode' code to access the adapter, not the original object. We could, + of course override every method, but that seems a little, errr, tedious. + We could probably do with something like in the Pyface tree where there + is a method that returns the actual object that we want to manipulate. + + """ + + def is_node_for(self, obj): + """Returns whether this is the node that handles a specified object.""" + + # By checking for 'is obj' here, we are *not* allowing adaptation (if + # we were allowing adaptation it would be 'is not None'). See the class + # doc string for details. + return IView(obj, Undefined) is obj + + def get_icon(self, obj, is_expanded): + """Returns the icon for a specified object.""" + + if obj.image is not None: + icon = obj.image + + else: + # fixme: A bit of magic here! Is there a better way to say 'use + # the default leaf icon'? + icon = "" + + return icon + + +class ViewChooser(HasTraits): + """Allow the user to choose a view. + + This implementation shows views in a tree grouped by category. + + """ + + # The window that contains the views to choose from. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # The currently selected tree item (at any point in time this might be + # either None, a view category, or a view). + selected = Any() + + # The selected view (None if the selected item is not a view). + view = Instance(IView) + + # Traits UI views -----------------------------------------------------# + + traits_ui_view = View( + Item( + name="window", + editor=TreeEditor( + nodes=[ + WorkbenchWindowTreeNode( + auto_open=True, + label="=Views", + rename=False, + copy=False, + delete=False, + insert=False, + menu=None, + ), + TreeNode( + node_for=[Category], + auto_open=True, + children="views", + label="name", + rename=False, + copy=False, + delete=False, + insert=False, + menu=None, + ), + IViewTreeNode( + auto_open=False, + label="name", + rename=False, + copy=False, + delete=False, + insert=False, + menu=None, + ), + ], + editable=False, + hide_root=True, + selected="selected", + show_icons=True, + ), + show_label=False, + ), + buttons=[Action(name="OK", enabled_when="view is not None"), "Cancel"], + resizable=True, + style="custom", + title="Show View", + width=0.2, + height=0.4, + ) + + # ------------------------------------------------------------------------ + # 'ViewChooser' interface. + # ------------------------------------------------------------------------ + + def _selected_changed(self, old, new): + """Static trait change handler.""" + + # If the assignment fails then the selected object does *not* implement + # the 'IView' interface. + try: + self.view = new + + except TraitError: + self.view = None + + return diff --git a/apptools/workbench/action/view_menu_manager.py b/apptools/workbench/action/view_menu_manager.py new file mode 100644 index 000000000..991359f9c --- /dev/null +++ b/apptools/workbench/action/view_menu_manager.py @@ -0,0 +1,142 @@ +# (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 'View' menu """ + + +import logging + +from pyface.action.api import Group, MenuManager +from traits.api import Any, Bool, Instance, List, observe, Str + +from .perspective_menu_manager import PerspectiveMenuManager +from .show_view_action import ShowViewAction +from .toggle_view_visibility_action import ToggleViewVisibilityAction + +# Logging. +logger = logging.getLogger(__name__) + + +class ViewMenuManager(MenuManager): + """The 'View' menu. + + By default, this menu is displayed on the main menu bar. + + """ + + # 'ActionManager' interface -------------------------------------------- + + # All of the groups in the manager. + groups = List(Group) + + # The manager's unique identifier (if it has one). + id = Str("View") + + # 'MenuManager' interface ---------------------------------------------# + + # The menu manager's name (if the manager is a sub-menu, this is what its + # label will be). + name = Str("&View") + + # 'ViewMenuManager' interface -----------------------------------------# + + # Should the perspective menu be shown? + show_perspective_menu = Bool(True) + + # The workbench window that the menu is part of. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # 'Private' interface -------------------------------------------------# + + # The group containing the view hide/show actions. + _view_group = Any() + + # ------------------------------------------------------------------------ + # 'ActionManager' interface. + # ------------------------------------------------------------------------ + + def _groups_default(self): + """Trait initializer.""" + + groups = [] + + # Add a group containing the perspective menu (if requested). + if self.show_perspective_menu and len(self.window.perspectives) > 0: + groups.append(Group(PerspectiveMenuManager(window=self.window))) + + # Add a group containing a 'toggler' for all visible views. + self._view_group = self._create_view_group(self.window) + groups.append(self._view_group) + + # Add a group containing an 'Other...' item that will launch a dialog + # to allow the user to choose a view to show. + groups.append(self._create_other_group(self.window)) + + return groups + + # ------------------------------------------------------------------------ + # 'ViewMenuManager' interface. + # ------------------------------------------------------------------------ + + @observe("window.active_perspective,window.active_part,window.views.items") + def refresh(self, event): + """Refreshes the checked state of the actions in the menu.""" + + logger.debug("refreshing view menu") + + if self._view_group is not None: + self._clear_group(self._view_group) + self._initialize_view_group(self.window, self._view_group) + self.changed = True + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _clear_group(self, group): + """Remove all items in a group.""" + + # fixme: Fix this API in Pyface so there is only one call! + group.destroy() + group.clear() + + def _create_other_group(self, window): + """Creates a group containing the 'Other...' action.""" + + group = Group() + group.append(ShowViewAction(name="Other...", window=window)) + + return group + + def _create_view_group(self, window): + """Creates a group containing the view 'togglers'.""" + + group = Group() + self._initialize_view_group(window, group) + + return group + + def _initialize_view_group(self, window, group): + """Initializes a group containing the view 'togglers'.""" + + views = window.views[:] + views.sort(key=lambda view: view.name) + + for view in views: + # fixme: It seems a little smelly to be reaching in to the window + # layout here. Should the 'contains_view' method be part of the + # window interface? + if window.layout.contains_view(view): + group.append( + ToggleViewVisibilityAction(view=view, window=window) + ) + + return diff --git a/apptools/workbench/action/workbench_action.py b/apptools/workbench/action/workbench_action.py new file mode 100644 index 000000000..b8afefa17 --- /dev/null +++ b/apptools/workbench/action/workbench_action.py @@ -0,0 +1,27 @@ +# (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! +""" Abstract base class for all workbench actions. """ + + +from pyface.action.api import Action +from traits.api import Instance + +from apptools.workbench.workbench_window import WorkbenchWindow + + +class WorkbenchAction(Action): + """Abstract base class for all workbench actions.""" + + # 'WorkbenchAction' interface -----------------------------------------# + + # The workbench window that the action is in. + # + # This is set by the framework. + window = Instance(WorkbenchWindow) diff --git a/apptools/workbench/api.py b/apptools/workbench/api.py new file mode 100755 index 000000000..5afe6ad3f --- /dev/null +++ b/apptools/workbench/api.py @@ -0,0 +1,26 @@ +# (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 .editor import Editor +from .editor_manager import EditorManager +from .i_editor import IEditor +from .i_editor_manager import IEditorManager +from .i_perspective import IPerspective +from .i_view import IView +from .i_workbench import IWorkbench +from .perspective import Perspective +from .perspective_item import PerspectiveItem +from .toolkit import toolkit, toolkit_object +from .traits_ui_editor import TraitsUIEditor +from .traits_ui_view import TraitsUIView +from .view import View +from .workbench import Workbench +from .workbench_window import WorkbenchWindow diff --git a/apptools/workbench/debug/__init__.py b/apptools/workbench/debug/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/workbench/debug/api.py b/apptools/workbench/debug/api.py new file mode 100644 index 000000000..4eb8ef2be --- /dev/null +++ b/apptools/workbench/debug/api.py @@ -0,0 +1,12 @@ +# (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 .debug_view import DebugView diff --git a/apptools/workbench/debug/debug_view.py b/apptools/workbench/debug/debug_view.py new file mode 100644 index 000000000..e2fc91df3 --- /dev/null +++ b/apptools/workbench/debug/debug_view.py @@ -0,0 +1,98 @@ +# (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! +""" A view containing a main walter canvas. """ + + +from traits.api import HasTraits, Instance, observe, Str +from traitsui.api import View as TraitsView + +from apptools.workbench.view import View +from apptools.workbench.workbench_window import WorkbenchWindow + + +class DebugViewModel(HasTraits): + """The model for the debug view!""" + + # 'Model' interface ---------------------------------------------------- + + active_editor = Str() + active_part = Str() + active_view = Str() + + window = Instance(WorkbenchWindow) + + # ------------------------------------------------------------------------ + # 'Model' interface. + # ------------------------------------------------------------------------ + + @observe("window.active_editor,window.active_part,window.active_view") + def refresh(self, event): + """Refresh the model.""" + + self.active_editor = self._get_id(self.window.active_editor) + self.active_part = self._get_id(self.window.active_part) + self.active_view = self._get_id(self.window.active_view) + + def _window_changed(self): + """Window changed!""" + + self.refresh() + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _get_id(self, obj): + """Return the Id of an object.""" + + if obj is None: + id = "None" + + else: + id = obj.id + + return id + + +class DebugView(View): + """A view containing a main walter canvas.""" + + # 'IWorkbenchPart' interface ------------------------------------------- + + # The part's name (displayed to the user). + name = "Debug" + + # 'DebugView' interface ------------------------------------------------ + + # The model for the debug view! + model = Instance(DebugViewModel) + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def create_control(self, parent): + """Creates the toolkit-specific control that represents the view. + + 'parent' is the toolkit-specific control that is the view's parent. + + """ + + self.model = DebugViewModel(window=self.window) + + ui = self.model.edit_traits( + parent=parent, + kind="subpanel", + view=TraitsView("active_part", "active_editor", "active_view"), + ) + + return ui.control diff --git a/apptools/workbench/editor.py b/apptools/workbench/editor.py new file mode 100755 index 000000000..bf9034df3 --- /dev/null +++ b/apptools/workbench/editor.py @@ -0,0 +1,17 @@ +# (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 implementation of a workbench editor. """ + + +# Import the toolkit specific version. +from .toolkit import toolkit_object + +Editor = toolkit_object("editor:Editor") diff --git a/apptools/workbench/editor_manager.py b/apptools/workbench/editor_manager.py new file mode 100755 index 000000000..6a3a804fa --- /dev/null +++ b/apptools/workbench/editor_manager.py @@ -0,0 +1,101 @@ +# (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 default editor manager. """ + + +import weakref + +from traits.api import HasTraits, Instance, provides + +from .i_editor_manager import IEditorManager +from .traits_ui_editor import TraitsUIEditor + + +@provides(IEditorManager) +class EditorManager(HasTraits): + """The default editor manager.""" + + # 'IEditorManager' interface ------------------------------------------- + + # The workbench window that the editor manager manages editors for ;^) + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __init__(self, **traits): + """Constructor.""" + + super().__init__(**traits) + + # A mapping from editor to editor kind (the factory that created them). + self._editor_to_kind_map = weakref.WeakKeyDictionary() + + return + + # ------------------------------------------------------------------------ + # 'IEditorManager' interface. + # ------------------------------------------------------------------------ + + def add_editor(self, editor, kind): + """Registers an existing editor.""" + + self._editor_to_kind_map[editor] = kind + + def create_editor(self, window, obj, kind): + """Create an editor for an object.""" + + editor = TraitsUIEditor(window=window, obj=obj) + + self.add_editor(editor, kind) + + return editor + + def get_editor(self, window, obj, kind): + """Get the editor that is currently editing an object.""" + + for editor in window.editors: + if self._is_editing(editor, obj, kind): + break + else: + editor = None + + return editor + + def get_editor_kind(self, editor): + """Return the 'kind' associated with 'editor'.""" + + return self._editor_to_kind_map[editor] + + def get_editor_memento(self, editor): + """Return the state of an editor suitable for pickling etc. + + By default we don't save the state of editors. + """ + + return None + + def set_editor_memento(self, memento): + """Restore the state of an editor from a memento. + + By default we don't try to restore the state of editors. + """ + + return None + + # ------------------------------------------------------------------------ + # 'Protected' 'EditorManager' interface. + # ------------------------------------------------------------------------ + + def _is_editing(self, editor, obj, kind): + """Return True if the editor is editing the object.""" + + return editor.obj == obj diff --git a/apptools/workbench/i_editor.py b/apptools/workbench/i_editor.py new file mode 100755 index 000000000..7ceb30c51 --- /dev/null +++ b/apptools/workbench/i_editor.py @@ -0,0 +1,143 @@ +# (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 of a workbench editor. """ + +import uuid + +from traits.api import ( + Any, + Bool, + Event, + Instance, + provides, + Vetoable, + VetoableEvent, +) + +from .i_workbench_part import IWorkbenchPart, MWorkbenchPart + + +class IEditor(IWorkbenchPart): + """The interface of a workbench editor.""" + + # The optional command stack. + command_stack = Instance("pyface.undo.api.ICommandStack") + + # Is the object that the editor is editing 'dirty' i.e., has it been + # modified but not saved? + dirty = Bool(False) + + # The object that the editor is editing. + # + # The framework sets this when the editor is created. + obj = Any() + + # Editor Lifecycle Events ---------------------------------------------# + + # Fired when the editor is closing. + closing = VetoableEvent() + + # Fired when the editor is closed. + closed = Event() + + # Methods -------------------------------------------------------------# + + def close(self): + """Close the editor. + + This method is not currently called by the framework itself as the user + is normally in control of the editor lifecycle. Call this if you want + to control the editor lifecycle programmatically. + + """ + + +@provides(IEditor) +class MEditor(MWorkbenchPart): + """Mixin containing common code for toolkit-specific implementations.""" + + # 'IEditor' interface -------------------------------------------------# + + # The optional command stack. + command_stack = Instance("pyface.undo.api.ICommandStack") + + # Is the object that the editor is editing 'dirty' i.e., has it been + # modified but not saved? + dirty = Bool(False) + + # The object that the editor is editing. + # + # The framework sets this when the editor is created. + obj = Any() + + # Editor Lifecycle Events ---------------------------------------------# + + # Fired when the editor is opening. + opening = VetoableEvent() + + # Fired when the editor has been opened. + open = Event() + + # Fired when the editor is closing. + closing = Event(VetoableEvent) + + # Fired when the editor is closed. + closed = Event() + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __str__(self): + """Return an informal string representation of the object.""" + + return "Editor(%s)" % self.id + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def _id_default(self): + """Trait initializer.""" + + # If no Id is specified then use a random uuid + # this gaurantees (barring *really* unusual cases) that there are no + # collisions between the ids of editors. + return uuid.uuid4().hex + + # ------------------------------------------------------------------------ + # 'IEditor' interface. + # ------------------------------------------------------------------------ + + def close(self): + """Close the editor.""" + + if self.control is not None: + self.closing = event = Vetoable() + if not event.veto: + self.window.close_editor(self) + + self.closed = True + + return + + # Initializers --------------------------------------------------------- + + def _command_stack_default(self): + """Trait initializer.""" + + # We make sure the undo package is entirely optional. + try: + from pyface.undo.api import CommandStack + except ImportError: + return None + + return CommandStack(undo_manager=self.window.workbench.undo_manager) diff --git a/apptools/workbench/i_editor_manager.py b/apptools/workbench/i_editor_manager.py new file mode 100755 index 000000000..954e33e5d --- /dev/null +++ b/apptools/workbench/i_editor_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! +""" The editor manager interface. """ + + +from traits.api import Instance, Interface + + +class IEditorManager(Interface): + """The editor manager interface.""" + + # The workbench window that the editor manager manages editors for ;^) + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + def add_editor(self, editor, kind): + """Registers an existing editor.""" + + def create_editor(self, window, obj, kind): + """Create an editor for an object. + + 'kind' optionally contains any data required by the specific editor + manager implementation to decide what type of editor to create. + + Returns None if no editor can be created for the resource. + """ + + def get_editor(self, window, obj, kind): + """Get the editor that is currently editing an object. + + 'kind' optionally contains any data required by the specific editor + manager implementation to decide what type of editor to create. + + Returns None if no such editor exists. + """ + + def get_editor_kind(self, editor): + """Return the 'kind' associated with 'editor'.""" + + def get_editor_memento(self, editor): + """Return the state of an editor suitable for pickling etc.""" + + def set_editor_memento(self, memento): + """Restore an editor from a memento and return it.""" diff --git a/apptools/workbench/i_perspective.py b/apptools/workbench/i_perspective.py new file mode 100755 index 000000000..068987f78 --- /dev/null +++ b/apptools/workbench/i_perspective.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 perspective interface. """ + + +from traits.api import Bool, Interface, List, Str, Tuple + +from .perspective_item import PerspectiveItem + + +class IPerspective(Interface): + """The perspective interface.""" + + # The perspective's unique identifier (unique within a workbench window). + id = Str() + + # The perspective's name. + name = Str() + + # The contents of the perspective. + contents = List(PerspectiveItem) + + # The size of the editor area in this perspective. A value of (-1, -1) + # indicates that the workbench window should choose an appropriate size + # based on the sizes of the views in the perspective. + editor_area_size = Tuple() + + # Is the perspective enabled? + enabled = Bool() + + # Should the editor area be shown in this perspective? + show_editor_area = Bool() + + # Methods -------------------------------------------------------------# + + def create(self, window): + """Create the perspective in a workbench window.""" + + def show(self, window): + """Called when the perspective is shown in a workbench window.""" diff --git a/apptools/workbench/i_perspective_item.py b/apptools/workbench/i_perspective_item.py new file mode 100644 index 000000000..933c54a36 --- /dev/null +++ b/apptools/workbench/i_perspective_item.py @@ -0,0 +1,58 @@ +# (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 perspective items. """ + + +from traits.api import Enum, Float, Interface, Str + + +class IPerspectiveItem(Interface): + """The interface for perspective items.""" + + # The Id of the view to display in the perspective. + id = Str() + + # The position of the view relative to the item specified in the + # 'relative_to' trait. + # + # 'top' puts the view above the 'relative_to' item. + # 'bottom' puts the view below the 'relative_to' item. + # 'left' puts the view to the left of the 'relative_to' item. + # 'right' puts the view to the right of the 'relative_to' item. + # 'with' puts the view in the same region as the 'relative_to' item. + # + # If the position is specified as 'with' you must specify a 'relative_to' + # item other than the editor area (i.e., you cannot position a view 'with' + # the editor area). + position = Enum("left", "top", "bottom", "right", "with") + + # The Id of the view to position relative to. If this is not specified + # (or if no view exists with this Id) then the view will be placed relative + # to the editor area. + relative_to = Str() + + # The width of the item (as a fraction of the window width). + # + # e.g. 0.5 == half the window width. + # + # Note that this is treated as a suggestion, and it may not be possible + # for the workbench to allocate the space requested. + width = Float(-1) + + # The height of the item (as a fraction of the window height). + # + # e.g. 0.5 == half the window height. + # + # Note that this is treated as a suggestion, and it may not be possible + # for the workbench to allocate the space requested. + height = Float(-1) + + # The style of the dock window. + style_hint = Enum("tab", "horizontal", "vertical", "fixed") diff --git a/apptools/workbench/i_view.py b/apptools/workbench/i_view.py new file mode 100755 index 000000000..8a120e922 --- /dev/null +++ b/apptools/workbench/i_view.py @@ -0,0 +1,121 @@ +# (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 workbench views. """ + + +import logging + +from pyface.api import Image +from traits.api import Bool, provides, Str +from traits.util.camel_case import camel_case_to_words + +from .i_perspective_item import IPerspectiveItem +from .i_workbench_part import IWorkbenchPart, MWorkbenchPart +from .perspective_item import PerspectiveItem + +# Logging. +logger = logging.getLogger(__name__) + + +class IView(IWorkbenchPart, IPerspectiveItem): + """The interface for workbench views.""" + + # Is the view busy? (i.e., should the busy cursor (often an hourglass) be + # displayed?). + busy = Bool(False) + + # The category that the view belongs to (this can used to group views when + # they are displayed to the user). + category = Str("General") + + # An image used to represent the view to the user (shown in the view tab + # and in the view chooser etc). + image = Image() + + # Whether the view is visible or not. + visible = Bool(False) + + # ------------------------------------------------------------------------ + # 'IView' interface. + # ------------------------------------------------------------------------ + + def activate(self): + """Activate the view.""" + + def hide(self): + """Hide the view.""" + + def show(self): + """Show the view.""" + + +@provides(IView) +class MView(MWorkbenchPart, PerspectiveItem): + """Mixin containing common code for toolkit-specific implementations.""" + + # 'IView' interface ---------------------------------------------------- + + # Is the view busy? (i.e., should the busy cursor (often an hourglass) be + # displayed?). + busy = Bool(False) + + # The category that the view belongs to (this can be used to group views + # when they are displayed to the user). + category = Str("General") + + # An image used to represent the view to the user (shown in the view tab + # and in the view chooser etc). + image = Image() + + # Whether the view is visible or not. + visible = Bool(False) + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def _id_default(self): + """Trait initializer.""" + + id = "%s.%s" % (type(self).__module__, type(self).__name__) + logger.warning("view %s has no Id - using <%s>" % (self, id)) + + # If no Id is specified then use the name. + return id + + def _name_default(self): + """Trait initializer.""" + + name = camel_case_to_words(type(self).__name__) + logger.warning("view %s has no name - using <%s>" % (self, name)) + + return name + + # ------------------------------------------------------------------------ + # 'IView' interface. + # ------------------------------------------------------------------------ + + def activate(self): + """Activate the view.""" + + self.window.activate_view(self) + + def hide(self): + """Hide the view.""" + + self.window.hide_view(self) + + def show(self): + """Show the view.""" + + self.window.show_view(self) + + return diff --git a/apptools/workbench/i_workbench.py b/apptools/workbench/i_workbench.py new file mode 100644 index 000000000..e956d7c56 --- /dev/null +++ b/apptools/workbench/i_workbench.py @@ -0,0 +1,108 @@ +# (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 workbench interface. """ + + +from traits.api import Event, Instance, Interface, List, Str, VetoableEvent + +from .user_perspective_manager import UserPerspectiveManager +from .window_event import VetoableWindowEvent, WindowEvent + + +class IWorkbench(Interface): + """The workbench interface.""" + + # 'IWorkbench' interface ----------------------------------------------- + + # The active workbench window (the last one to get focus). + active_window = Instance("apptools.workbench_window.WorkbenchWindow") + + # The optional application scripting manager. + script_manager = Instance("apptools.appscripting.api.IScriptManager") + + # A directory on the local file system that we can read and write to at + # will. This is used to persist window layout information, etc. + state_location = Str() + + # The optional undo manager. + undo_manager = Instance("pyface.undo.api.IUndoManager") + + # The user defined perspectives manager. + user_perspective_manager = Instance(UserPerspectiveManager) + + # All of the workbench windows created by the workbench. + windows = List(Instance("apptools.workbench_window.WorkbenchWindow")) + + # Workbench lifecycle events ---- + + # Fired when the workbench is about to exit. + # + # This can be caused by either:- + # + # a) The 'exit' method being called. + # b) The last open window being closed. + exiting = VetoableEvent() + + # Fired when the workbench has exited. + # + # This is fired after the last open window has been closed. + exited = Event() + + # Window lifecycle events ---- + + # Fired when a workbench window has been created. + window_created = Event(WindowEvent) + + # Fired when a workbench window is opening. + window_opening = Event(VetoableWindowEvent) + + # Fired when a workbench window has been opened. + window_opened = Event(WindowEvent) + + # Fired when a workbench window is closing. + window_closing = Event(VetoableWindowEvent) + + # Fired when a workbench window has been closed. + window_closed = Event(WindowEvent) + + # ------------------------------------------------------------------------ + # 'IWorkbench' interface. + # ------------------------------------------------------------------------ + + def create_window(self, **kw): + """Factory method that creates a new workbench window.""" + + def edit(self, obj, kind=None, use_existing=True): + """Edit an object in the active workbench window.""" + + def exit(self): + """Exit the workbench. + + This closes all open workbench windows. + + This method is not called when the user clicks the close icon. Nor when + they do an Alt+F4 in Windows. It is only called when the application + menu File->Exit item is selected. + + """ + + def get_editor(self, obj, kind=None): + """Return the editor that is editing an object. + + Returns None if no such editor exists. + + """ + + def get_editor_by_id(self, id): + """Return the editor with the specified Id. + + Returns None if no such editor exists. + + """ diff --git a/apptools/workbench/i_workbench_part.py b/apptools/workbench/i_workbench_part.py new file mode 100644 index 000000000..4db353a52 --- /dev/null +++ b/apptools/workbench/i_workbench_part.py @@ -0,0 +1,126 @@ +# (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 workbench parts. """ + + +from traits.api import ( + Any, + Bool, + HasTraits, + Instance, + Interface, + List, + provides, + Str, +) + + +class IWorkbenchPart(Interface): + """The interface for workbench parts. + + A workbench part is a visual section within the workbench. There are two + sub-types, 'View' and 'Editor'. + + """ + + # The toolkit-specific control that represents the part. + # + # The framework sets this to the value returned by 'create_control'. + control = Any() + + # Does the part currently have the focus? + has_focus = Bool(False) + + # The part's globally unique identifier. + id = Str() + + # The part's name (displayed to the user). + name = Str() + + # The current selection within the part. + selection = List() + + # The workbench window that the part is in. + # + # The framework sets this when the part is created. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # Methods -------------------------------------------------------------# + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part. + + The parameter *parent* is the toolkit-specific control that is the + parts's parent. + + Return the toolkit-specific control. + + """ + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part. + + Return None. + + """ + + def set_focus(self): + """Set the focus to the appropriate control in the part. + + Return None. + + """ + + +@provides(IWorkbenchPart) +class MWorkbenchPart(HasTraits): + """Mixin containing common code for toolkit-specific implementations.""" + + # 'IWorkbenchPart' interface ------------------------------------------- + + # The toolkit-specific control that represents the part. + # + # The framework sets this to the value returned by 'create_control'. + control = Any() + + # Does the part currently have the focus? + has_focus = Bool(False) + + # The part's globally unique identifier. + id = Str() + + # The part's name (displayed to the user). + name = Str() + + # The current selection within the part. + selection = List() + + # The workbench window that the part is in. + # + # The framework sets this when the part is created. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # Methods -------------------------------------------------------------# + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part.""" + + raise NotImplementedError() + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part.""" + + raise NotImplementedError() + + def set_focus(self): + """Set the focus to the appropriate control in the part.""" + + raise NotImplementedError() diff --git a/apptools/workbench/i_workbench_window_layout.py b/apptools/workbench/i_workbench_window_layout.py new file mode 100755 index 000000000..edb13e78e --- /dev/null +++ b/apptools/workbench/i_workbench_window_layout.py @@ -0,0 +1,360 @@ +# (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 workbench window layout interface. """ + + +from traits.api import Event, HasTraits, Instance, Interface, provides, Str + +from .i_editor import IEditor +from .i_view import IView + + +class IWorkbenchWindowLayout(Interface): + """The workbench window layout interface. + + Window layouts are responsible for creating and managing the internal + structure of a workbench window (it knows how to add and remove views and + editors etc). + + """ + + # The Id of the editor area. + # FIXME v3: This is toolkit specific. + editor_area_id = Str() + + # The workbench window that this is the layout for. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # Events ---- + + # Fired when an editor is about to be opened (or restored). + editor_opening = Event(IEditor) + + # Fired when an editor has been opened (or restored). + editor_opened = Event(IEditor) + + # Fired when an editor is about to be closed. + editor_closing = Event(IEditor) + + # Fired when an editor has been closed. + editor_closed = Event(IEditor) + + # Fired when a view is about to be opened (or restored). + view_opening = Event(IView) + + # Fired when a view has been opened (or restored). + view_opened = Event(IView) + + # Fired when a view is about to be closed (*not* hidden!). + view_closing = Event(IView) + + # Fired when a view has been closed (*not* hidden!). + view_closed = Event(IView) + + # FIXME v3: The "just for convenience" returns are a really bad idea. + # + # Why? They allow the call to be used on the LHS of an expression... + # Because they have nothing to do with what the call is supposed to be + # doing, they are unlikely to be used (because they are so unexpected and + # inconsistently implemented), and only serve to replace two shorter lines + # of code with one long one, arguably making code more difficult to read. + def activate_editor(self, editor): + """Activate an editor. + + Returns the editor (just for convenience). + + """ + + def activate_view(self, view): + """Activate a view. + + Returns the view (just for convenience). + + """ + + def add_editor(self, editor, title): + """Add an editor. + + Returns the editor (just for convenience). + + """ + + def add_view(self, view, position=None, relative_to=None, size=(-1, -1)): + """Add a view. + + Returns the view (just for convenience). + + """ + + def close_editor(self, editor): + """Close an editor. + + Returns the editor (just for convenience). + + """ + + def close_view(self, view): + """Close a view. + + FIXME v3: Currently views are never 'closed' in the same sense as an + editor is closed. When we close an editor, we destroy its control. + When we close a view, we merely hide its control. I'm not sure if this + is a good idea or not. It came about after discussion with Dave P. and + he mentioned that some views might find it hard to persist enough state + that they can be re-created exactly as they were when they are shown + again. + + Returns the view (just for convenience). + + """ + + def close(self): + """Close the entire window layout. + + FIXME v3: Should this be called 'destroy'? + + """ + + def create_initial_layout(self, parent): + """Create the initial window layout. + + Returns the layout. + + """ + + def contains_view(self, view): + """Return True if the view exists in the window layout. + + Note that this returns True even if the view is hidden. + + """ + + def hide_editor_area(self): + """Hide the editor area.""" + + def hide_view(self, view): + """Hide a view. + + Returns the view (just for convenience). + + """ + + def refresh(self): + """Refresh the window layout to reflect any changes.""" + + def reset_editors(self): + """Activate the first editor in every group.""" + + def reset_views(self): + """Activate the first view in every region.""" + + def show_editor_area(self): + """Show the editor area.""" + + def show_view(self, view): + """Show a view.""" + + # Methods for saving and restoring the layout -------------------------# + + def get_view_memento(self): + """Returns the state of the views.""" + + def set_view_memento(self, memento): + """Restores the state of the views.""" + + def get_editor_memento(self): + """Returns the state of the editors.""" + + def set_editor_memento(self, memento): + """Restores the state of the editors.""" + + def get_toolkit_memento(self): + """Return any toolkit-specific data that should be part of the memento.""" + + def set_toolkit_memento(self, memento): + """Restores any toolkit-specific data.""" + + +@provides(IWorkbenchWindowLayout) +class MWorkbenchWindowLayout(HasTraits): + """Mixin containing common code for toolkit-specific implementations.""" + + # 'IWorkbenchWindowLayout' interface ----------------------------------- + + # The Id of the editor area. + # FIXME v3: This is toolkit specific. + editor_area_id = Str() + + # The workbench window that this is the layout for. + window = Instance("apptools.workbench.workbench_window.WorkbenchWindow") + + # Events ---- + + # Fired when an editor is about to be opened (or restored). + editor_opening = Event(IEditor) + + # Fired when an editor has been opened (or restored). + editor_opened = Event(IEditor) + + # Fired when an editor is about to be closed. + editor_closing = Event(IEditor) + + # Fired when an editor has been closed. + editor_closed = Event(IEditor) + + # Fired when a view is about to be opened (or restored). + view_opening = Event(IView) + + # Fired when a view has been opened (or restored). + view_opened = Event(IView) + + # Fired when a view is about to be closed (*not* hidden!). + view_closing = Event(IView) + + # Fired when a view has been closed (*not* hidden!). + view_closed = Event(IView) + + # ------------------------------------------------------------------------ + # 'IWorkbenchWindowLayout' interface. + # ------------------------------------------------------------------------ + + def activate_editor(self, editor): + """Activate an editor.""" + + raise NotImplementedError() + + def activate_view(self, view): + """Activate a view.""" + + raise NotImplementedError() + + def add_editor(self, editor, title): + """Add an editor.""" + + raise NotImplementedError() + + def add_view(self, view, position=None, relative_to=None, size=(-1, -1)): + """Add a view.""" + + raise NotImplementedError() + + def close_editor(self, editor): + """Close an editor.""" + + raise NotImplementedError() + + def close_view(self, view): + """Close a view.""" + + raise NotImplementedError() + + def close(self): + """Close the entire window layout.""" + + raise NotImplementedError() + + def create_initial_layout(self, parent): + """Create the initial window layout.""" + + raise NotImplementedError() + + def contains_view(self, view): + """Return True if the view exists in the window layout.""" + + raise NotImplementedError() + + def hide_editor_area(self): + """Hide the editor area.""" + + raise NotImplementedError() + + def hide_view(self, view): + """Hide a view.""" + + raise NotImplementedError() + + def refresh(self): + """Refresh the window layout to reflect any changes.""" + + raise NotImplementedError() + + def reset_editors(self): + """Activate the first editor in every group.""" + + raise NotImplementedError() + + def reset_views(self): + """Activate the first view in every region.""" + + raise NotImplementedError() + + def show_editor_area(self): + """Show the editor area.""" + + raise NotImplementedError() + + def show_view(self, view): + """Show a view.""" + + raise NotImplementedError() + + # Methods for saving and restoring the layout -------------------------# + + def get_view_memento(self): + """Returns the state of the views.""" + + raise NotImplementedError() + + def set_view_memento(self, memento): + """Restores the state of the views.""" + + raise NotImplementedError() + + def get_editor_memento(self): + """Returns the state of the editors.""" + + raise NotImplementedError() + + def set_editor_memento(self, memento): + """Restores the state of the editors.""" + + raise NotImplementedError() + + def get_toolkit_memento(self): + """Return any toolkit-specific data that should be part of the memento.""" + return None + + def set_toolkit_memento(self, memento): + """Restores any toolkit-specific data.""" + return + + # ------------------------------------------------------------------------ + # Protected 'MWorkbenchWindowLayout' interface. + # ------------------------------------------------------------------------ + + def _get_editor_references(self): + """Returns a reference to every editor.""" + + editor_manager = self.window.editor_manager + + editor_references = {} + for editor in self.window.editors: + # Create the editor reference. + # + # If the editor manager returns 'None' instead of a resource + # reference then this editor will not appear the next time the + # workbench starts up. This is useful for things like text files + # that have an editor but have NEVER been saved. + editor_reference = editor_manager.get_editor_memento(editor) + if editor_reference is not None: + editor_references[editor.id] = editor_reference + + return editor_references diff --git a/apptools/workbench/perspective.py b/apptools/workbench/perspective.py new file mode 100755 index 000000000..53b3ae0a6 --- /dev/null +++ b/apptools/workbench/perspective.py @@ -0,0 +1,189 @@ +# (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 default perspective. """ + + +import logging + +from traits.api import Bool, HasTraits, List, provides, Str, Tuple + +from .i_perspective import IPerspective +from .perspective_item import PerspectiveItem + +# Logging. +logger = logging.getLogger(__name__) + + +@provides(IPerspective) +class Perspective(HasTraits): + """The default perspective.""" + + # The ID of the default perspective. + DEFAULT_ID = "apptools.workbench.default" + + # The name of the default perspective. + DEFAULT_NAME = "Default" + + # 'IPerspective' interface --------------------------------------------- + + # The perspective's unique identifier (unique within a workbench window). + id = Str(DEFAULT_ID) + + # The perspective's name. + name = Str(DEFAULT_NAME) + + # The contents of the perspective. + contents = List(PerspectiveItem) + + # The size of the editor area in this perspective. A value of (-1, -1) + # indicates that the workbench window should choose an appropriate size + # based on the sizes of the views in the perspective. + editor_area_size = Tuple((-1, -1)) + + # Is the perspective enabled? + enabled = Bool(True) + + # Should the editor area be shown in this perspective? + show_editor_area = Bool(True) + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __str__(self): + """Return an informal string representation of the object.""" + + return "Perspective(%s)" % self.id + + # ------------------------------------------------------------------------ + # 'Perspective' interface. + # ------------------------------------------------------------------------ + + # Initializers --------------------------------------------------------- + + def _id_default(self): + """Trait initializer.""" + + # If no Id is specified then use the name. + return self.name + + # Methods -------------------------------------------------------------# + + def create(self, window): + """Create the perspective in a workbench window. + + For most cases you should just be able to set the 'contents' trait to + lay out views as required. However, you can override this method if + you want to have complete control over how the perspective is created. + + """ + + # Set the size of the editor area. + if self.editor_area_size != (-1, -1): + window.editor_area_size = self.editor_area_size + + # If the perspective has specific contents then add just those. + if len(self.contents) > 0: + self._add_contents(window, self.contents) + + # Otherwise, add all of the views defined in the window at their + # default positions realtive to the editor area. + else: + self._add_all(window) + + # Activate the first view in every region. + window.reset_views() + + def show(self, window): + """Called when the perspective is shown in a workbench window. + + The default implementation does nothing, but you can override this + method if you want to do something whenever the perspective is + activated. + + """ + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _add_contents(self, window, contents): + """Adds the specified contents.""" + + # If we are adding specific contents then we ignore any default view + # visibility. + # + # fixme: This is a bit ugly! Why don't we pass the visibility in to + # 'window.add_view'? + for view in window.views: + view.visible = False + + for item in contents: + self._add_perspective_item(window, item) + + def _add_perspective_item(self, window, item): + """Adds a perspective item to a window.""" + + # If no 'relative_to' is specified then the view is positioned + # relative to the editor area. + if len(item.relative_to) > 0: + relative_to = window.get_view_by_id(item.relative_to) + + else: + relative_to = None + + # fixme: This seems a bit ugly, having to reach back up to the + # window to get the view. Maybe its not that bad? + view = window.get_view_by_id(item.id) + if view is not None: + # fixme: This is probably not the ideal way to sync view traits + # and perspective_item traits. + view.style_hint = item.style_hint + # Add the view to the window. + window.add_view( + view, item.position, relative_to, (item.width, item.height) + ) + + else: + # The reason that we don't just barf here is that a perspective + # might use views from multiple plugins, and we probably want to + # continue even if one or two of them aren't present. + # + # fixme: This is worth keeping an eye on though. If we end up with + # a strict mode that throws exceptions early and often for + # developers, then this might be a good place to throw one ;^) + logger.error("missing view for perspective item <%s>" % item.id) + + def _add_all(self, window): + """Adds *all* of the window's views defined in the window.""" + + for view in window.views: + if view.visible: + self._add_view(window, view) + + def _add_view(self, window, view): + """Adds a view to a window.""" + + # If no 'relative_to' is specified then the view is positioned + # relative to the editor area. + if len(view.relative_to) > 0: + relative_to = window.get_view_by_id(view.relative_to) + + else: + relative_to = None + + # Add the view to the window. + window.add_view( + view, view.position, relative_to, (view.width, view.height) + ) + + return diff --git a/apptools/workbench/perspective_item.py b/apptools/workbench/perspective_item.py new file mode 100755 index 000000000..363fa1fbd --- /dev/null +++ b/apptools/workbench/perspective_item.py @@ -0,0 +1,61 @@ +# (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! +""" An item in a Perspective contents list. """ + + +from traits.api import Enum, Float, HasTraits, provides, Str + +from .i_perspective_item import IPerspectiveItem + + +@provides(IPerspectiveItem) +class PerspectiveItem(HasTraits): + """An item in a Perspective contents list.""" + + # The Id of the view to display in the perspective. + id = Str() + + # The position of the view relative to the item specified in the + # 'relative_to' trait. + # + # 'top' puts the view above the 'relative_to' item. + # 'bottom' puts the view below the 'relative_to' item. + # 'left' puts the view to the left of the 'relative_to' item. + # 'right' puts the view to the right of the 'relative_to' item. + # 'with' puts the view in the same region as the 'relative_to' item. + # + # If the position is specified as 'with' you must specify a 'relative_to' + # item other than the editor area (i.e., you cannot position a view 'with' + # the editor area). + position = Enum("left", "top", "bottom", "right", "with") + + # The Id of the view to position relative to. If this is not specified + # (or if no view exists with this Id) then the view will be placed relative + # to the editor area. + relative_to = Str() + + # The width of the item (as a fraction of the window width). + # + # e.g. 0.5 == half the window width. + # + # Note that this is treated as a suggestion, and it may not be possible + # for the workbench to allocate the space requested. + width = Float(-1) + + # The height of the item (as a fraction of the window height). + # + # e.g. 0.5 == half the window height. + # + # Note that this is treated as a suggestion, and it may not be possible + # for the workbench to allocate the space requested. + height = Float(-1) + + # The style of the dock control created. + style_hint = Enum("tab", "vertical", "horizontal", "fixed") diff --git a/apptools/workbench/tests/__init__.py b/apptools/workbench/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/workbench/tests/test_workbench_window.py b/apptools/workbench/tests/test_workbench_window.py new file mode 100644 index 000000000..89d783031 --- /dev/null +++ b/apptools/workbench/tests/test_workbench_window.py @@ -0,0 +1,190 @@ +# (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 shutil +import tempfile +import unittest +from unittest import mock + +from traits.testing.api import UnittestTools + +from apptools.workbench.perspective import Perspective +from apptools.workbench.toolkit import toolkit +from apptools.workbench.workbench import Workbench +from apptools.workbench.workbench_window import WorkbenchWindow +from apptools.workbench.workbench_window_layout import WorkbenchWindowLayout +from apptools.workbench.workbench_window_memento import WorkbenchWindowMemento + + +@unittest.skipIf( + toolkit.toolkit == "null", + "Null toolkit doesn't support Workbench", +) +class TestWorkbenchWindowUserPerspective(unittest.TestCase, UnittestTools): + def setUp(self): + # A perspective with show_editor_area switched on + self.with_editor = Perspective( + show_editor_area=True, id="test_id", name="test_name" + ) + + # A perspective with show_editor_area switched off + self.without_editor = Perspective( + show_editor_area=False, id="test_id2", name="test_name2" + ) + + # Where the state file should be saved + self.state_location = tempfile.mkdtemp(dir="./") + + # Make sure the temporary directory is removed + self.addCleanup(self.rm_tempdir) + + def rm_tempdir(self): + shutil.rmtree(self.state_location) + + def get_workbench_with_window(self): + workbench = Workbench() + workbench_window = WorkbenchWindow() + workbench.windows = [workbench_window] + + # Saved perspectives should go to the temporary directory + workbench.state_location = self.state_location + + # Mock the layout for the workbench window + workbench_window.layout = mock.MagicMock(spec=WorkbenchWindowLayout) + workbench_window.layout.window = workbench_window + + return workbench, workbench_window + + def show_perspective(self, workbench_window, perspective): + workbench_window.active_perspective = perspective + workbench_window.layout.is_editor_area_visible = mock.MagicMock( + return_value=perspective.show_editor_area + ) + + def test_editor_area_with_perspectives(self): + """Test show_editor_area is respected while switching perspective""" + + # The workbench and workbench window with layout mocked + workbench, workbench_window = self.get_workbench_with_window() + workbench.active_window = workbench_window + + # Add perspectives + workbench.user_perspective_manager.add(self.with_editor) + workbench.user_perspective_manager.add(self.without_editor) + + # There are the methods we want to test if they are called + workbench_window.show_editor_area = mock.MagicMock() + workbench_window.hide_editor_area = mock.MagicMock() + + # Mock more things for initialing the Workbench Window + workbench_window._memento = WorkbenchWindowMemento() + workbench_window._initial_layout = workbench_window._memento + + # Show a perspective with an editor area + self.show_perspective(workbench_window, self.with_editor) + + # show_editor_area should be called + self.assertTrue(workbench_window.show_editor_area.called) + + # Show a perspective withOUT an editor area + workbench_window.hide_editor_area.reset_mock() + self.show_perspective(workbench_window, self.without_editor) + + # hide_editor_area should be called + self.assertTrue(workbench_window.hide_editor_area.called) + + # The with_editor has been seen so this will be restored from the memento + workbench_window.show_editor_area.reset_mock() + self.show_perspective(workbench_window, self.with_editor) + + # show_editor_area should be called + self.assertTrue(workbench_window.show_editor_area.called) + + def test_editor_area_restore_from_saved_state(self): + """Test if show_editor_area is restored properly from saved state""" + + # The workbench and workbench window with layout mocked + workbench, workbench_window = self.get_workbench_with_window() + workbench.active_window = workbench_window + + # Add perspectives + workbench.user_perspective_manager.add(self.with_editor) + workbench.user_perspective_manager.add(self.without_editor) + + # Mock for initialising the workbench window + workbench_window._memento = WorkbenchWindowMemento() + workbench_window._initial_layout = workbench_window._memento + + # Mock layout functions for pickling + # We only care about show_editor_area and not the layout in this test + layout_functions = { + "get_view_memento.return_value": (0, (None, None)), + "get_editor_memento.return_value": (0, (None, None)), + "get_toolkit_memento.return_value": (0, dict(geometry="")), + } + + workbench_window.layout.configure_mock(**layout_functions) + + # The following records perspective mementos to workbench_window._memento + self.show_perspective(workbench_window, self.without_editor) + self.show_perspective(workbench_window, self.with_editor) + + # Save the window layout to a state file + workbench._save_window_layout(workbench_window) + + # We only needed the state file for this test + del workbench_window + del workbench + + # We create another workbench which uses the state location + # and we test if we can retore the saved perspective correctly + workbench, workbench_window = self.get_workbench_with_window() + + # Mock window factory since we already created a workbench window + workbench.window_factory = mock.MagicMock( + return_value=workbench_window + ) + + # There are the methods we want to test if they are called + workbench_window.show_editor_area = mock.MagicMock() + workbench_window.hide_editor_area = mock.MagicMock() + + # This restores the perspectives and mementos + workbench.create_window() + + # Create contents + workbench_window._create_contents(mock.Mock()) + + # Perspective mementos should be restored + self.assertIn( + self.with_editor.id, workbench_window._memento.perspective_mementos + ) + self.assertIn( + self.without_editor.id, + workbench_window._memento.perspective_mementos, + ) + + # Since the with_editor perspective is used last, + # it should be used as initial perspective + self.assertTrue(workbench_window.show_editor_area.called) + + # Try restoring the perspective without editor + # The restored perspectives are not the same instance as before + # We need to get them using their id + perspective_without_editor = workbench_window.get_perspective_by_id( + self.without_editor.id + ) + + # Show the perspective with editor area + workbench_window.hide_editor_area.reset_mock() + self.show_perspective(workbench_window, perspective_without_editor) + + # make sure hide_editor_area is called + self.assertTrue(workbench_window.hide_editor_area.called) diff --git a/apptools/workbench/toolkit.py b/apptools/workbench/toolkit.py new file mode 100644 index 000000000..97b19217c --- /dev/null +++ b/apptools/workbench/toolkit.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 pyface.base_toolkit import Toolkit +from pyface.toolkit import toolkit as pyface_toolkit + +# Pyface will have selected the backend correctly by this point. +toolkit_name = pyface_toolkit.toolkit +# backwards compatibility +if toolkit_name == "qt4": + toolkit_name = "qt" + +toolkit = toolkit_object = Toolkit( + "workbench", + toolkit_name, + f"apptools.workbench.ui.{toolkit_name}", +) diff --git a/apptools/workbench/traits_ui_editor.py b/apptools/workbench/traits_ui_editor.py new file mode 100644 index 000000000..9af3278f3 --- /dev/null +++ b/apptools/workbench/traits_ui_editor.py @@ -0,0 +1,94 @@ +# (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! +""" An editor whose content is provided by a traits UI. """ + + +import logging + +from traits.api import Instance, Str + +from .editor import Editor + +# Logging. +logger = logging.getLogger(__name__) + + +class TraitsUIEditor(Editor): + """An editor whose content is provided by a traits UI.""" + + # 'TraitsUIEditor' interface ------------------------------------------- + + # The traits UI that represents the editor. + # + # The framework sets this to the value returned by 'create_ui'. + ui = Instance("traitsui.ui.UI") + + # The name of the traits UI view used to create the UI (if not specified, + # the default traits UI view is used). + view = Str() + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _name_default(self): + """Trait initializer.""" + + return str(self.obj) + + # Methods -------------------------------------------------------------# + + def create_control(self, parent): + """Creates the toolkit-specific control that represents the editor. + + 'parent' is the toolkit-specific control that is the editor's parent. + + Overridden to call 'create_ui' to get the traits UI. + + """ + + self.ui = self.create_ui(parent) + + return self.ui.control + + def destroy_control(self): + """Destroys the toolkit-specific control that represents the editor. + + Overridden to call 'dispose' on the traits UI. + + """ + + # Give the traits UI a chance to clean itself up. + if self.ui is not None: + logger.debug("disposing traits UI for editor [%s]", self) + self.ui.dispose() + self.ui = None + + return + + # ------------------------------------------------------------------------ + # 'TraitsUIEditor' interface. + # ------------------------------------------------------------------------ + + def create_ui(self, parent): + """Creates the traits UI that represents the editor. + + By default it calls 'edit_traits' on the editor's 'obj'. If you + want more control over the creation of the traits UI then override! + + """ + + ui = self.obj.edit_traits( + parent=parent, view=self.view, kind="subpanel" + ) + + return ui diff --git a/apptools/workbench/traits_ui_view.py b/apptools/workbench/traits_ui_view.py new file mode 100644 index 000000000..31069264a --- /dev/null +++ b/apptools/workbench/traits_ui_view.py @@ -0,0 +1,110 @@ +# (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! +""" A view whose content is provided by a traits UI. """ + + +import logging + +from traits.api import Any, Instance, Str + +from .view import View + +# Logging. +logger = logging.getLogger(__name__) + + +class TraitsUIView(View): + """A view whose content is provided by a traits UI.""" + + # 'TraitsUIView' interface --------------------------------------------- + + # The object that we povide a traits UI of (this defaults to the view + # iteself ie. 'self'). + obj = Any() + + # The traits UI that represents the view. + # + # The framework sets this to the value returned by 'create_ui'. + ui = Instance("traitsui.ui.UI") + + # The name of the traits UI view used to create the UI (if not specified, + # the default traits UI view is used). + view = Str() + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _name_default(self): + """Trait initializer.""" + + return str(self.obj) + + # Methods -------------------------------------------------------------# + + def create_control(self, parent): + """Creates the toolkit-specific control that represents the editor. + + 'parent' is the toolkit-specific control that is the editor's parent. + + Overridden to call 'create_ui' to get the traits UI. + + """ + + self.ui = self.create_ui(parent) + + return self.ui.control + + def destroy_control(self): + """Destroys the toolkit-specific control that represents the editor. + + Overridden to call 'dispose' on the traits UI. + + """ + + # Give the traits UI a chance to clean itself up. + if self.ui is not None: + logger.debug("disposing traits UI for view [%s]", self) + self.ui.dispose() + self.ui = None + # Break reference to the control, so the view is created afresh + # next time. + self.control = None + + return + + # ------------------------------------------------------------------------ + # 'TraitsUIView' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _obj_default(self): + """Trait initializer.""" + + return self + + # Methods -------------------------------------------------------------# + + def create_ui(self, parent): + """Creates the traits UI that represents the editor. + + By default it calls 'edit_traits' on the view's 'model'. If you + want more control over the creation of the traits UI then override! + + """ + + ui = self.obj.edit_traits( + parent=parent, view=self.view, kind="subpanel" + ) + + return ui diff --git a/apptools/workbench/ui/null/__init__.py b/apptools/workbench/ui/null/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/workbench/ui/qt/__init__.py b/apptools/workbench/ui/qt/__init__.py new file mode 100644 index 000000000..aa2218ef6 --- /dev/null +++ b/apptools/workbench/ui/qt/__init__.py @@ -0,0 +1,9 @@ +# (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! diff --git a/apptools/workbench/ui/qt/editor.py b/apptools/workbench/ui/qt/editor.py new file mode 100644 index 000000000..dad443fdc --- /dev/null +++ b/apptools/workbench/ui/qt/editor.py @@ -0,0 +1,78 @@ +# (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! +# (C) Copyright 2007 Riverbank Computing Limited +# 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 traits.api import Bool, Event + +from apptools.workbench.i_editor import MEditor + + +class Editor(MEditor): + """The toolkit specific implementation of an Editor. + + See the IEditor interface for the API documentation. + + """ + + # Traits for showing spinner + _loading = Event(Bool) + _loading_on_open = Bool(False) + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part.""" + + from pyface.qt import QtCore, QtGui + + # By default we create a yellow panel! + control = QtGui.QWidget(parent) + + pal = control.palette() + pal.setColour( + QtGui.QPalette.ColorRole.Window, QtCore.Qt.GlobalColor.yellow + ) + control.setPalette(pal) + + control.setAutoFillBackground(True) + control.resize(100, 200) + + return control + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part.""" + + if self.control is not None: + # The `close` method emits a closeEvent event which is listened + # by the workbench window layout, which responds by calling + # destroy_control again. + + # We copy the control locally and set it to None immediately + # to make sure this block of code is executed exactly once. + + _control = self.control + self.control = None + + _control.hide() + _control.close() + _control.deleteLater() + + def set_focus(self): + """Set the focus to the appropriate control in the part.""" + + if self.control is not None: + self.control.setFocus() + + return diff --git a/apptools/workbench/ui/qt/images/spinner.gif b/apptools/workbench/ui/qt/images/spinner.gif new file mode 100644 index 000000000..1560b646c Binary files /dev/null and b/apptools/workbench/ui/qt/images/spinner.gif differ diff --git a/apptools/workbench/ui/qt/split_tab_widget.py b/apptools/workbench/ui/qt/split_tab_widget.py new file mode 100644 index 000000000..8aeb803b0 --- /dev/null +++ b/apptools/workbench/ui/qt/split_tab_widget.py @@ -0,0 +1,1120 @@ +# (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! +# (C) Copyright 2008 Riverbank Computing Limited +# All rights reserved. +# +# 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 + +# ------------------------------------------------------------------------------ + + +import sys +import warnings + +from pyface.image_resource import ImageResource +from pyface.qt import qt_api, QtCore, QtGui + + +class SplitTabWidget(QtGui.QSplitter): + """The SplitTabWidget class is a hierarchy of QSplitters the leaves of + which are QTabWidgets. Any tab may be moved around with the hierarchy + automatically extended and reduced as required. + """ + + # Signals for WorkbenchWindowLayout to handle + new_window_request = QtCore.Signal(QtCore.QPoint, QtGui.QWidget) + tab_close_request = QtCore.Signal(QtGui.QWidget) + tab_window_changed = QtCore.Signal(QtGui.QWidget) + editor_has_focus = QtCore.Signal(QtGui.QWidget) + focus_changed = QtCore.Signal(QtGui.QWidget, QtGui.QWidget) + + # The different hotspots of a QTabWidget. An non-negative value is a tab + # index and the hotspot is to the left of it. + + tabTextChanged = QtCore.Signal(QtGui.QWidget, str) + _HS_NONE = -1 + _HS_AFTER_LAST_TAB = -2 + _HS_NORTH = -3 + _HS_SOUTH = -4 + _HS_EAST = -5 + _HS_WEST = -6 + _HS_OUTSIDE = -7 + + def __init__(self, *args): + """Initialise the instance.""" + + QtGui.QSplitter.__init__(self, *args) + + self.clear() + + QtGui.QApplication.instance().focusChanged.connect(self._focus_changed) + + def clear(self): + """Restore the widget to its pristine state.""" + + w = None + for i in range(self.count()): + w = self.widget(i) + w.hide() + w.deleteLater() + del w + + self._repeat_focus_changes = True + self._rband = None + self._selected_tab_widget = None + self._selected_hotspot = self._HS_NONE + + self._current_tab_w = None + self._current_tab_idx = -1 + + def saveState(self): + """Returns a Python object containing the saved state of the widget. + Widgets are saved only by their object name. + """ + + return self._save_qsplitter(self) + + def _save_qsplitter(self, qsplitter): + # A splitter state is a tuple of the QSplitter state (as a string) and + # the list of child states. + sp_ch_states = [] + + # Save the children. + for i in range(qsplitter.count()): + ch = qsplitter.widget(i) + + if isinstance(ch, _TabWidget): + # A tab widget state is a tuple of the current tab index and + # the list of individual tab states. + tab_states = [] + + for t in range(ch.count()): + # A tab state is a tuple of the widget's object name and + # the title. + name = str(ch.widget(t).objectName()) + title = str(ch.tabText(t)) + + tab_states.append((name, title)) + + ch_state = (ch.currentIndex(), tab_states) + else: + # Recurse down the tree of splitters. + ch_state = self._save_qsplitter(ch) + + sp_ch_states.append(ch_state) + + return (QtGui.QSplitter.saveState(qsplitter).data(), sp_ch_states) + + def restoreState(self, state, factory): + """Restore the contents from the given state (returned by a previous + call to saveState()). factory is a callable that is passed the object + name of the widget that is in the state and needs to be restored. The + callable returns the restored widget. + """ + + # Warn if we are restoring to a non-empty widget. + warnings.warn("Attempting to restore to a non-empty widget.") + + self._restore_qsplitter(state, factory, self) + + def _restore_qsplitter(self, state, factory, qsplitter): + sp_qstate, sp_ch_states = state + + # Go through each child state which will consist of a tuple of two + # objects. We use the type of the first to determine if the child is a + # tab widget or another splitter. + for ch_state in sp_ch_states: + if isinstance(ch_state[0], int): + current_idx, tabs = ch_state + + new_tab = _TabWidget(self) + + # Go through each tab and use the factory to restore the page. + for name, title in tabs: + page = factory(name) + + if page is not None: + new_tab.addTab(page, title) + + # Only add the new tab widget if it is used. + if new_tab.count() > 0: + qsplitter.addWidget(new_tab) + + # Set the correct tab as the current one. + new_tab.setCurrentIndex(current_idx) + else: + del new_tab + else: + new_qsp = QtGui.QSplitter() + + # Recurse down the tree of splitters. + self._restore_qsplitter(ch_state, factory, new_qsp) + + # Only add the new splitter if it is used. + if new_qsp.count() > 0: + qsplitter.addWidget(new_qsp) + else: + del new_qsp + + # Restore the QSplitter state (being careful to get the right + # implementation). + QtGui.QSplitter.restoreState(qsplitter, sp_qstate) + + def addTab(self, w, text): + """Add a new tab to the main tab widget.""" + + # Find the first tab widget going down the left of the hierarchy. This + # will be the one in the top left corner. + if self.count() > 0: + ch = self.widget(0) + + while not isinstance(ch, _TabWidget): + assert isinstance(ch, QtGui.QSplitter) + ch = ch.widget(0) + else: + # There is no tab widget so create one. + ch = _TabWidget(self) + self.addWidget(ch) + + idx = ch.insertTab(self._current_tab_idx + 1, w, text) + + # If the tab has been added to the current tab widget then make it the + # current tab. + if ch is not self._current_tab_w: + self._set_current_tab(ch, idx) + ch.tabBar().setFocus() + + def _close_tab_request(self, w): + """A close button was clicked in one of out _TabWidgets""" + + self.tab_close_request.emit(w) + + def setCurrentWidget(self, w): + """Make the given widget current.""" + + tw, tidx = self._tab_widget(w) + + if tw is not None: + self._set_current_tab(tw, tidx) + + def setActiveIcon(self, w, icon=None): + """Set the active icon on a widget.""" + + tw, tidx = self._tab_widget(w) + + if tw is not None: + if icon is None: + icon = tw.active_icon() + + tw.setTabIcon(tidx, icon) + + def setTabTextColor(self, w, color=None): + """Set the tab text color on a particular widget w""" + tw, tidx = self._tab_widget(w) + + if tw is not None: + if color is None: + # null color reverts to foreground role color + color = QtGui.QColor() + tw.tabBar().setTabTextColor(tidx, color) + + def setWidgetTitle(self, w, title): + """Set the title for the given widget.""" + + tw, idx = self._tab_widget(w) + + if tw is not None: + tw.setTabText(idx, title) + + def _tab_widget(self, w): + """Return the tab widget and index containing the given widget.""" + + for tw in self.findChildren(_TabWidget, None): + idx = tw.indexOf(w) + + if idx >= 0: + return (tw, idx) + + return (None, None) + + def _set_current_tab(self, tw, tidx): + """Set the new current tab.""" + + # Handle the trivial case. + if self._current_tab_w is tw and self._current_tab_idx == tidx: + return + + if tw is not None: + tw.setCurrentIndex(tidx) + + # Save the new current widget. + self._current_tab_w = tw + self._current_tab_idx = tidx + + def _set_focus(self): + """Set the focus to an appropriate widget in the current tab.""" + + # Only try and change the focus if the current focus isn't already a + # child of the widget. + w = self._current_tab_w.widget(self._current_tab_idx) + fw = self.window().focusWidget() + + if fw is not None and not w.isAncestorOf(fw): + # Find a widget to focus using the same method as + # QStackedLayout::setCurrentIndex(). First try the last widget + # with the focus. + nfw = w.focusWidget() + + if nfw is None: + # Next, try the first child widget in the focus chain. + nfw = fw.nextInFocusChain() + + while nfw is not fw: + if ( + nfw.focusPolicy() & QtCore.Qt.FocusPolicy.TabFocus + and nfw.focusProxy() is None + and nfw.isVisibleTo(w) + and nfw.isEnabled() + and w.isAncestorOf(nfw) + ): + break + + nfw = nfw.nextInFocusChain() + else: + # Fallback to the tab page widget. + nfw = w + + nfw.setFocus() + + 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) + + if new is None: + return + elif isinstance(new, _DragableTabBar): + ntw = new.parent() + ntidx = ntw.currentIndex() + else: + ntw, ntidx = self._tab_widget_of(new) + + if ntw is not None: + self._set_current_tab(ntw, ntidx) + + # See if the widget that has lost the focus is ours. + otw, _ = self._tab_widget_of(old) + + if otw is not None or ntw is not None: + if ntw is None: + nw = None + else: + nw = ntw.widget(ntidx) + + self.editor_has_focus.emit(nw) + + def _tab_widget_of(self, target): + """Return the tab widget and index of the widget that contains the + given widget. + """ + + for tw in self.findChildren(_TabWidget, None): + for tidx in range(tw.count()): + w = tw.widget(tidx) + + if w is not None and w.isAncestorOf(target): + return (tw, tidx) + + return (None, None) + + def _move_left(self, tw, tidx): + """Move the current tab to the one logically to the left.""" + + tidx -= 1 + + if tidx < 0: + # Find the tab widget logically to the left. + twlist = self.findChildren(_TabWidget, None) + i = twlist.index(tw) + i -= 1 + + if i < 0: + i = len(twlist) - 1 + + tw = twlist[i] + + # Move the to right most tab. + tidx = tw.count() - 1 + + self._set_current_tab(tw, tidx) + tw.setFocus() + + def _move_right(self, tw, tidx): + """Move the current tab to the one logically to the right.""" + + tidx += 1 + + if tidx >= tw.count(): + # Find the tab widget logically to the right. + twlist = self.findChildren(_TabWidget, None) + i = twlist.index(tw) + i += 1 + + if i >= len(twlist): + i = 0 + + tw = twlist[i] + + # Move the to left most tab. + tidx = 0 + + self._set_current_tab(tw, tidx) + tw.setFocus() + + def _select(self, pos): + tw, hs, hs_geom = self._hotspot(pos) + + # See if the hotspot has changed. + if self._selected_tab_widget is not tw or self._selected_hotspot != hs: + if self._selected_tab_widget is not None: + self._rband.hide() + + if tw is not None and hs != self._HS_NONE: + if self._rband: + self._rband.deleteLater() + position = QtCore.QPoint(*hs_geom[0:2]) + window = tw.window() + self._rband = QtGui.QRubberBand( + QtGui.QRubberBand.Shape.Rectangle, window + ) + self._rband.move(window.mapFromGlobal(position)) + self._rband.resize(*hs_geom[2:4]) + self._rband.show() + + self._selected_tab_widget = tw + self._selected_hotspot = hs + + def _drop(self, pos, stab_w, stab): + self._rband.hide() + + # Get the destination locations. + dtab_w = self._selected_tab_widget + dhs = self._selected_hotspot + if dhs == self._HS_NONE: + return + elif dhs != self._HS_OUTSIDE: + dsplit_w = dtab_w.parent() + while not isinstance(dsplit_w, SplitTabWidget): + dsplit_w = dsplit_w.parent() + + self._selected_tab_widget = None + self._selected_hotspot = self._HS_NONE + + # See if the tab is being moved to a new window. + if dhs == self._HS_OUTSIDE: + # Disable tab tear-out for now. It works, but this is something that + # should be turned on manually. We need an interface for this. + # ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab(stab_w, stab) + # self.new_window_request.emit(pos, twidg) + return + + # See if the tab is being moved to an existing tab widget. + if dhs >= 0 or dhs == self._HS_AFTER_LAST_TAB: + # Make sure it really is being moved. + if stab_w is dtab_w: + if stab == dhs: + return + + if ( + dhs == self._HS_AFTER_LAST_TAB + and stab == stab_w.count() - 1 + ): + return + + QtGui.QApplication.instance().blockSignals(True) + + ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab( + stab_w, stab + ) + + if dhs == self._HS_AFTER_LAST_TAB: + idx = dtab_w.addTab(twidg, ticon, ttext) + dtab_w.tabBar().setTabTextColor(idx, ttextcolor) + elif dtab_w is stab_w: + # Adjust the index if necessary in case the removal of the tab + # from its old position has skewed things. + dst = dhs + + if dhs > stab: + dst -= 1 + + idx = dtab_w.insertTab(dst, twidg, ticon, ttext) + dtab_w.tabBar().setTabTextColor(idx, ttextcolor) + else: + idx = dtab_w.insertTab(dhs, twidg, ticon, ttext) + dtab_w.tabBar().setTabTextColor(idx, ttextcolor) + + if tbuttn: + dtab_w.show_button(idx) + dsplit_w._set_current_tab(dtab_w, idx) + + else: + # Ignore drops to the same tab widget when it only has one tab. + if stab_w is dtab_w and stab_w.count() == 1: + return + + QtGui.QApplication.instance().blockSignals(True) + + # Remove the tab from its current tab widget and create a new one + # for it. + ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab( + stab_w, stab + ) + new_tw = _TabWidget(dsplit_w) + idx = new_tw.addTab(twidg, ticon, ttext) + new_tw.tabBar().setTabTextColor(0, ttextcolor) + if tbuttn: + new_tw.show_button(idx) + + # Get the splitter containing the destination tab widget. + dspl = dtab_w.parent() + dspl_idx = dspl.indexOf(dtab_w) + + if dhs in (self._HS_NORTH, self._HS_SOUTH): + dspl, dspl_idx = dsplit_w._horizontal_split( + dspl, dspl_idx, dhs + ) + else: + dspl, dspl_idx = dsplit_w._vertical_split(dspl, dspl_idx, dhs) + + # Add the new tab widget in the right place. + dspl.insertWidget(dspl_idx, new_tw) + + dsplit_w._set_current_tab(new_tw, 0) + + dsplit_w._set_focus() + + # Signal that the tab's SplitTabWidget has changed, if necessary. + if dsplit_w != self: + self.tab_window_changed.emit(twidg) + + QtGui.QApplication.instance().blockSignals(False) + + def _horizontal_split(self, spl, idx, hs): + """Returns a tuple of the splitter and index where the new tab widget + should be put. + """ + + if spl.orientation() == QtCore.Qt.Orientation.Vertical: + if hs == self._HS_SOUTH: + idx += 1 + elif spl is self and spl.count() == 1: + # The splitter is the root and only has one child so we can just + # change its orientation. + spl.setOrientation(QtCore.Qt.Orientation.Vertical) + + if hs == self._HS_SOUTH: + idx = -1 + else: + new_spl = QtGui.QSplitter(QtCore.Qt.Orientation.Vertical) + new_spl.addWidget(spl.widget(idx)) + spl.insertWidget(idx, new_spl) + + if hs == self._HS_SOUTH: + idx = -1 + else: + idx = 0 + + spl = new_spl + + return (spl, idx) + + def _vertical_split(self, spl, idx, hs): + """Returns a tuple of the splitter and index where the new tab widget + should be put. + """ + + if spl.orientation() == QtCore.Qt.Orientation.Horizontal: + if hs == self._HS_EAST: + idx += 1 + elif spl is self and spl.count() == 1: + # The splitter is the root and only has one child so we can just + # change its orientation. + spl.setOrientation(QtCore.Qt.Orientation.Horizontal) + + if hs == self._HS_EAST: + idx = -1 + else: + new_spl = QtGui.QSplitter(QtCore.Qt.Orientation.Horizontal) + new_spl.addWidget(spl.widget(idx)) + spl.insertWidget(idx, new_spl) + + if hs == self._HS_EAST: + idx = -1 + else: + idx = 0 + + spl = new_spl + + return (spl, idx) + + def _remove_tab(self, tab_w, tab): + """Remove a tab from a tab widget and return a tuple of the icon, + label text and the widget so that it can be recreated. + """ + + icon = tab_w.tabIcon(tab) + text = tab_w.tabText(tab) + text_color = tab_w.tabBar().tabTextColor(tab) + button = tab_w.tabBar().tabButton( + tab, QtGui.QTabBar.ButtonPosition.LeftSide + ) + w = tab_w.widget(tab) + tab_w.removeTab(tab) + + return (icon, text, text_color, button, w) + + def _hotspot(self, pos): + """Return a tuple of the tab widget, hotspot and hostspot geometry (as + a tuple) at the given position. + """ + global_pos = self.mapToGlobal(pos) + miss = (None, self._HS_NONE, None) + + # Get the bounding rect of the cloned QTbarBar. + top_widget = QtGui.QApplication.instance().topLevelAt(global_pos) + if isinstance(top_widget, QtGui.QTabBar): + cloned_rect = top_widget.frameGeometry() + else: + cloned_rect = None + + # Determine which visible SplitTabWidget, if any, is under the cursor + # (compensating for the cloned QTabBar that may be rendered over it). + split_widget = None + for top_widget in QtGui.QApplication.instance().topLevelWidgets(): + for split_widget in top_widget.findChildren(SplitTabWidget, None): + visible_region = split_widget.visibleRegion() + widget_pos = split_widget.mapFromGlobal(global_pos) + if cloned_rect and split_widget.geometry().contains( + widget_pos + ): + visible_rect = visible_region.boundingRect() + widget_rect = QtCore.QRect( + split_widget.mapFromGlobal(cloned_rect.topLeft()), + split_widget.mapFromGlobal(cloned_rect.bottomRight()), + ) + if not visible_rect.intersected(widget_rect).isEmpty(): + break + elif visible_region.contains(widget_pos): + break + else: + split_widget = None + if split_widget: + break + + # Handle a drag outside of any split tab widget. + if not split_widget: + if self.window().frameGeometry().contains(global_pos): + return miss + else: + return (None, self._HS_OUTSIDE, None) + + # Go through each tab widget. + pos = split_widget.mapFromGlobal(global_pos) + for tw in split_widget.findChildren(_TabWidget, None): + if tw.geometry().contains(tw.parent().mapFrom(split_widget, pos)): + break + else: + return miss + + # See if the hotspot is in the widget area. + widg = tw.currentWidget() + if widg is not None: + # Get the widget's position relative to its parent. + wpos = widg.parent().mapFrom(split_widget, pos) + + if widg.geometry().contains(wpos): + # Get the position of the widget relative to itself (ie. the + # top left corner is (0, 0)). + p = widg.mapFromParent(wpos) + x = p.x() + y = p.y() + h = widg.height() + w = widg.width() + + # Get the global position of the widget. + gpos = widg.mapToGlobal(widg.pos()) + gx = gpos.x() + gy = gpos.y() + + # The corners of the widget belong to the north and south + # sides. + if y < h / 4: + return (tw, self._HS_NORTH, (gx, gy, w, h / 4)) + + if y >= (3 * h) / 4: + return ( + tw, + self._HS_SOUTH, + (gx, gy + (3 * h) / 4, w, h / 4), + ) + + if x < w / 4: + return (tw, self._HS_WEST, (gx, gy, w / 4, h)) + + if x >= (3 * w) / 4: + return ( + tw, + self._HS_EAST, + (gx + (3 * w) / 4, gy, w / 4, h), + ) + + return miss + + # See if the hotspot is in the tab area. + tpos = tw.mapFrom(split_widget, pos) + tab_bar = tw.tabBar() + top_bottom = tw.tabPosition() in ( + QtGui.QTabWidget.TabPosition.North, + QtGui.QTabWidget.TabPosition.South, + ) + for i in range(tw.count()): + rect = tab_bar.tabRect(i) + + if rect.contains(tpos): + w = rect.width() + h = rect.height() + + # Get the global position. + gpos = tab_bar.mapToGlobal(rect.topLeft()) + gx = gpos.x() + gy = gpos.y() + + if top_bottom: + off = pos.x() - rect.x() + ext = w + gx -= w / 2 + else: + off = pos.y() - rect.y() + ext = h + gy -= h / 2 + + # See if it is in the left (or top) half or the right (or + # bottom) half. + if off < ext / 2: + return (tw, i, (gx, gy, w, h)) + + if top_bottom: + gx += w + else: + gy += h + + if i + 1 == tw.count(): + return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h)) + + return (tw, i + 1, (gx, gy, w, h)) + else: + rect = tab_bar.rect() + if rect.contains(tpos): + gpos = tab_bar.mapToGlobal(rect.topLeft()) + gx = gpos.x() + gy = gpos.y() + w = rect.width() + h = rect.height() + if top_bottom: + tab_widths = sum( + tab_bar.tabRect(i).width() + for i in range(tab_bar.count()) + ) + w -= tab_widths + gx += tab_widths + else: + tab_heights = sum( + tab_bar.tabRect(i).height() + for i in range(tab_bar.count()) + ) + h -= tab_heights + gy -= tab_heights + return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h)) + + return miss + + +active_style = """QTabWidget::pane { /* The tab widget frame */ + border: 2px solid #00FF00; + } +""" +inactive_style = """QTabWidget::pane { /* The tab widget frame */ + border: 2px solid #C2C7CB; + margin: 0px; + } +""" + + +class _TabWidget(QtGui.QTabWidget): + """The _TabWidget class is a QTabWidget with a dragable tab bar.""" + + # The active icon. It is created when it is first needed. + _active_icon = None + + _spinner_data = None + + def __init__(self, root, *args): + """Initialise the instance.""" + + QtGui.QTabWidget.__init__(self, *args) + + # XXX this requires Qt > 4.5 + if sys.platform == "darwin": + self.setDocumentMode(True) + # self.setStyleSheet(inactive_style) + + self._root = root + + # We explicitly pass the parent to the tab bar ctor to work round a bug + # in PyQt v4.2 and earlier. + self.setTabBar(_DragableTabBar(self._root, self)) + + self.setTabsClosable(True) + self.tabCloseRequested.connect(self._close_tab) + + if not (_TabWidget._spinner_data): + _TabWidget._spinner_data = ImageResource("spinner.gif") + + def show_button(self, index): + lbl = QtGui.QLabel(self) + movie = QtGui.QMovie( + _TabWidget._spinner_data.absolute_path, parent=lbl + ) + movie.setCacheMode(QtGui.QMovie.CacheMode.CacheAll) + movie.setScaledSize(QtCore.QSize(16, 16)) + lbl.setMovie(movie) + movie.start() + self.tabBar().setTabButton( + index, QtGui.QTabBar.ButtonPosition.LeftSide, lbl + ) + + def hide_button(self, index): + curr = self.tabBar().tabButton( + index, QtGui.QTabBar.ButtonPosition.LeftSide + ) + if curr: + curr.close() + self.tabBar().setTabButton( + index, QtGui.QTabBar.ButtonPosition.LeftSide, None + ) + + def active_icon(self): + """Return the QIcon to be used to indicate an active tab page.""" + + if _TabWidget._active_icon is None: + # The gradient start and stop colours. + start = QtGui.QColor(0, 255, 0) + stop = QtGui.QColor(0, 63, 0) + + size = self.iconSize() + width = size.width() + height = size.height() + + pm = QtGui.QPixmap(size) + + p = QtGui.QPainter() + p.begin(pm) + + # Fill the image background from the tab background. + p.initFrom(self.tabBar()) + p.fillRect(0, 0, width, height, p.background()) + + # Create the colour gradient. + rg = QtGui.QRadialGradient(width / 2, height / 2, width) + rg.setColorAt(0.0, start) + rg.setColorAt(1.0, stop) + + # Draw the circle. + p.setBrush(rg) + p.setPen(QtCore.Qt.PenStyle.NoPen) + p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + p.drawEllipse(0, 0, width, height) + + p.end() + + _TabWidget._active_icon = QtGui.QIcon(pm) + + return _TabWidget._active_icon + + def _still_needed(self): + """Delete the tab widget (and any relevant parent splitters) if it is + no longer needed. + """ + + if self.count() == 0: + prune = self + parent = prune.parent() + + # Go up the QSplitter hierarchy until we find one with at least one + # sibling. + while parent is not self._root and parent.count() == 1: + prune = parent + parent = prune.parent() + + prune.hide() + prune.deleteLater() + + def tabRemoved(self, idx): + """Reimplemented to update the record of the current tab if it is + removed. + """ + + self._still_needed() + + if ( + self._root._current_tab_w is self + and self._root._current_tab_idx == idx + ): + self._root._current_tab_w = None + + def _close_tab(self, index): + """Close the current tab.""" + + self._root._close_tab_request(self.widget(index)) + + +class _IndependentLineEdit(QtGui.QLineEdit): + def keyPressEvent(self, e): + QtGui.QLineEdit.keyPressEvent(self, e) + if e.key() == QtCore.Qt.Key.Key_Escape: + self.hide() + + +class _DragableTabBar(QtGui.QTabBar): + """The _DragableTabBar class is a QTabBar that can be dragged around.""" + + def __init__(self, root, parent): + """Initialise the instance.""" + + QtGui.QTabBar.__init__(self, parent) + + # XXX this requires Qt > 4.5 + if sys.platform == "darwin": + self.setDocumentMode(True) + + self._root = root + self._drag_state = None + # LineEdit to change tab bar title + te = _IndependentLineEdit("", self) + te.hide() + te.editingFinished.connect(te.hide) + te.returnPressed.connect(self._setCurrentTabText) + self._title_edit = te + + def resizeEvent(self, e): + # resize edit tab + if self._title_edit.isVisible(): + self._resize_title_edit_to_current_tab() + QtGui.QTabBar.resizeEvent(self, e) + + def keyPressEvent(self, e): + """Reimplemented to handle traversal across different tab widgets.""" + + if e.key() == QtCore.Qt.Key.Key_Left: + self._root._move_left(self.parent(), self.currentIndex()) + elif e.key() == QtCore.Qt.Key.Key_Right: + self._root._move_right(self.parent(), self.currentIndex()) + else: + e.ignore() + + def mouseDoubleClickEvent(self, e): + self._resize_title_edit_to_current_tab() + te = self._title_edit + te.setText(self.tabText(self.currentIndex())[1:]) + te.setFocus() + te.selectAll() + te.show() + + def mousePressEvent(self, e): + """Reimplemented to handle mouse press events.""" + + # There is something odd in the focus handling where focus temporarily + # moves elsewhere (actually to a View) when switching to a different + # tab page. We suppress the notification so that the workbench doesn't + # temporarily make the View active. + self._root._repeat_focus_changes = False + QtGui.QTabBar.mousePressEvent(self, e) + self._root._repeat_focus_changes = True + + # Update the current tab. + self._root._set_current_tab(self.parent(), self.currentIndex()) + self._root._set_focus() + + if e.button() != QtCore.Qt.MouseButton.LeftButton: + return + + if self._drag_state is not None: + return + + # Potentially start dragging if the tab under the mouse is the current + # one (which will eliminate disabled tabs). + tab = self._tab_at(e.pos()) + + if tab < 0 or tab != self.currentIndex(): + return + + self._drag_state = _DragState(self._root, self, tab, e.pos()) + + def mouseMoveEvent(self, e): + """Reimplemented to handle mouse move events.""" + + QtGui.QTabBar.mouseMoveEvent(self, e) + + if self._drag_state is None: + return + + if self._drag_state.dragging: + self._drag_state.drag(e.pos()) + else: + self._drag_state.start_dragging(e.pos()) + + # If the mouse has moved far enough that dragging has started then + # tell the user. + if self._drag_state.dragging: + QtGui.QApplication.setOverrideCursor( + QtCore.Qt.CursorShape.OpenHandCursor + ) + + def mouseReleaseEvent(self, e): + """Reimplemented to handle mouse release events.""" + + QtGui.QTabBar.mouseReleaseEvent(self, e) + + if e.button() != QtCore.Qt.MouseButton.LeftButton: + if e.button() == QtCore.Qt.MouseButton.MiddleButton: + self.tabCloseRequested.emit(self.tabAt(e.pos())) + return + + if self._drag_state is not None and self._drag_state.dragging: + QtGui.QApplication.restoreOverrideCursor() + self._drag_state.drop(e.pos()) + + self._drag_state = None + + def _tab_at(self, pos): + """Return the index of the tab at the given point.""" + + for i in range(self.count()): + if self.tabRect(i).contains(pos): + return i + + return -1 + + def _setCurrentTabText(self): + idx = self.currentIndex() + text = self._title_edit.text() + self.setTabText(idx, "\u25b6" + text) + self._root.tabTextChanged.emit(self.parent().widget(idx), text) + + def _resize_title_edit_to_current_tab(self): + idx = self.currentIndex() + tab = QtGui.QStyleOptionTabV3() + self.initStyleOption(tab, idx) + rect = self.style().subElementRect( + QtGui.QStyle.SubElement.SE_TabBarTabText, tab + ) + self._title_edit.setGeometry(rect.adjusted(0, 8, 0, -8)) + + +class _DragState(object): + """The _DragState class handles most of the work when dragging a tab.""" + + def __init__(self, root, tab_bar, tab, start_pos): + """Initialise the instance.""" + + self.dragging = False + + self._root = root + self._tab_bar = tab_bar + self._tab = tab + self._start_pos = QtCore.QPoint(start_pos) + self._clone = None + + def start_dragging(self, pos): + """Start dragging a tab.""" + + if ( + pos - self._start_pos + ).manhattanLength() <= QtGui.QApplication.startDragDistance(): + return + + self.dragging = True + + # Create a clone of the tab being moved (except for its icon). + otb = self._tab_bar + tab = self._tab + + ctb = self._clone = QtGui.QTabBar() + if sys.platform == "darwin" and QtCore.QT_VERSION >= 0x40500: + ctb.setDocumentMode(True) + + ctb.setAttribute( + QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + ctb.setWindowFlags( + QtCore.Qt.WindowType.FramelessWindowHint + | QtCore.Qt.WindowType.Tool + | QtCore.Qt.WindowType.X11BypassWindowManagerHint + ) + ctb.setWindowOpacity(0.5) + ctb.setElideMode(otb.elideMode()) + ctb.setShape(otb.shape()) + + ctb.addTab(otb.tabText(tab)) + ctb.setTabTextColor(0, otb.tabTextColor(tab)) + + # The clone offset is the position of the clone relative to the mouse. + trect = otb.tabRect(tab) + self._clone_offset = trect.topLeft() - pos + + # The centre offset is the position of the center of the clone relative + # to the mouse. The center of the clone determines the hotspot, not + # the position of the mouse. + self._centre_offset = trect.center() - pos + + self.drag(pos) + + ctb.show() + + def drag(self, pos): + """Handle the movement of the cloned tab during dragging.""" + + self._clone.move(self._tab_bar.mapToGlobal(pos) + self._clone_offset) + self._root._select( + self._tab_bar.mapTo(self._root, pos + self._centre_offset) + ) + + def drop(self, pos): + """Handle the drop of the cloned tab.""" + + self.drag(pos) + self._clone = None + + global_pos = self._tab_bar.mapToGlobal(pos) + self._root._drop(global_pos, self._tab_bar.parent(), self._tab) + + self.dragging = False diff --git a/apptools/workbench/ui/qt/tests/__init__.py b/apptools/workbench/ui/qt/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/workbench/ui/qt/tests/test_split_tab_widget.py b/apptools/workbench/ui/qt/tests/test_split_tab_widget.py new file mode 100644 index 000000000..5da8dd56a --- /dev/null +++ b/apptools/workbench/ui/qt/tests/test_split_tab_widget.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! + + +import unittest + +from pyface.qt import QtCore, QtGui +from pyface.ui.qt.workbench.split_tab_widget import _DragableTabBar + + +class TestSplitTabWidget(unittest.TestCase): + def test_mouseReleaseEvent(self): + widget = _DragableTabBar(None, None) + event = QtGui.QMouseEvent( + QtCore.QEvent.Type.MouseButtonRelease, + QtCore.QPointF(0.0, 0.0), + QtCore.QPointF(0.0, 0.0), + QtCore.Qt.MouseButton.RightButton, + QtCore.Qt.RightButton, + QtCore.Qt.NoModifier, + ) + + # smoke test: should do nothing + widget.mouseReleaseEvent(event) + + widget.destroy() diff --git a/apptools/workbench/ui/qt/tests/test_workbench_window_layout.py b/apptools/workbench/ui/qt/tests/test_workbench_window_layout.py new file mode 100644 index 000000000..5eb0d8cef --- /dev/null +++ b/apptools/workbench/ui/qt/tests/test_workbench_window_layout.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 unittest import mock + +from pyface.ui.qt.workbench.split_tab_widget import SplitTabWidget +from pyface.ui.qt.workbench.workbench_window_layout import ( + WorkbenchWindowLayout, +) + + +class TestWorkbenchWindowLayout(unittest.TestCase): + def test_change_of_active_qt_editor(self): + # Test error condition for enthought/mayavi#321 + mock_split_tab_widget = mock.Mock(spec=SplitTabWidget) + + layout = WorkbenchWindowLayout(_qt4_editor_area=mock_split_tab_widget) + + class DummyEvent: + def __init__(self, new): + self.new = new + + # This should not throw + layout._qt4_active_editor_changed(DummyEvent(new=None)) + self.assertEqual(mock_split_tab_widget.setTabTextColor.called, False) + + mock_active_editor = mock.Mock() + layout._qt4_active_editor_changed(mock_active_editor) + + self.assertEqual(mock_split_tab_widget.setTabTextColor.called, True) diff --git a/apptools/workbench/ui/qt/view.py b/apptools/workbench/ui/qt/view.py new file mode 100644 index 000000000..d355cec48 --- /dev/null +++ b/apptools/workbench/ui/qt/view.py @@ -0,0 +1,57 @@ +# (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! +# (C) Copyright 2007 Riverbank Computing Limited +# 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 apptools.workbench.i_view import MView + + +class View(MView): + """The toolkit specific implementation of a View. + + See the IView interface for the API documentation. + + """ + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part.""" + + from pyface.qt import QtGui + + control = QtGui.QWidget(parent) + + palette = control.palette() + palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("red")) + control.setPalette(palette) + control.setAutoFillBackground(True) + + return control + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part.""" + + if self.control is not None: + self.control.hide() + self.control.deleteLater() + self.control = None + + def set_focus(self): + """Set the focus to the appropriate control in the part.""" + + if self.control is not None: + self.control.setFocus() + + return diff --git a/apptools/workbench/ui/qt/workbench_window_layout.py b/apptools/workbench/ui/qt/workbench_window_layout.py new file mode 100755 index 000000000..75b5b5ce3 --- /dev/null +++ b/apptools/workbench/ui/qt/workbench_window_layout.py @@ -0,0 +1,660 @@ +# (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! +# (C) Copyright 2008 Riverbank Computing Limited +# All rights reserved. +# +# 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 + + +import logging + +from pyface.message_dialog import error +from pyface.qt import QtCore, QtGui +from traits.api import Instance, observe + +from apptools.workbench.i_workbench_window_layout import MWorkbenchWindowLayout + +from .split_tab_widget import SplitTabWidget + +# Logging. +logger = logging.getLogger(__name__) + + +# For mapping positions relative to the editor area. +_EDIT_AREA_MAP = { + "left": QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, + "right": QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + "top": QtCore.Qt.DockWidgetArea.TopDockWidgetArea, + "bottom": QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, +} + +# For mapping positions relative to another view. +_VIEW_AREA_MAP = { + "left": (QtCore.Qt.Orientation.Horizontal, True), + "right": (QtCore.Qt.Orientation.Horizontal, False), + "top": (QtCore.Qt.Orientation.Vertical, True), + "bottom": (QtCore.Qt.Orientation.Vertical, False), +} + + +class WorkbenchWindowLayout(MWorkbenchWindowLayout): + """The Qt4 implementation of the workbench window layout interface. + + See the 'IWorkbenchWindowLayout' interface for the API documentation. + + """ + + # Private interface ---------------------------------------------------- + + # The widget that provides the editor area. We keep (and use) this + # separate reference because we can't always assume that it has been set to + # be the main window's central widget. + _qt4_editor_area = Instance(SplitTabWidget) + + # ------------------------------------------------------------------------ + # 'IWorkbenchWindowLayout' interface. + # ------------------------------------------------------------------------ + + def activate_editor(self, editor): + if editor.control is not None: + editor.control.show() + self._qt4_editor_area.setCurrentWidget(editor.control) + editor.set_focus() + + return editor + + def activate_view(self, view): + # FIXME v3: This probably doesn't work as expected. + view.control.raise_() + view.set_focus() + + return view + + def add_editor(self, editor, title): + if editor is None: + return None + + try: + self._qt4_editor_area.addTab( + self._qt4_get_editor_control(editor), title + ) + + if editor._loading_on_open: + self._qt4_editor_tab_spinner(editor, "", True) + except Exception: + logger.exception("error creating editor control [%s]", editor.id) + + return editor + + def add_view(self, view, position=None, relative_to=None, size=(-1, -1)): + if view is None: + return None + + try: + self._qt4_add_view(view, position, relative_to, size) + view.visible = True + except Exception: + logger.exception("error creating view control [%s]", view.id) + + # Even though we caught the exception, it sometimes happens that + # the view's control has been created as a child of the application + # window (or maybe even the dock control). We should destroy the + # control to avoid bad UI effects. + view.destroy_control() + + # Additionally, display an error message to the user. + error( + self.window.control, + "Unable to add view [%s]" % view.id, + "Workbench Plugin Error", + ) + + return view + + def close_editor(self, editor): + if editor.control is not None: + editor.control.close() + + return editor + + def close_view(self, view): + self.hide_view(view) + + return view + + def close(self): + # Don't fire signals for editors that have destroyed their controls. + self._qt4_editor_area.editor_has_focus.disconnect( + self._qt4_editor_focus + ) + + self._qt4_editor_area.clear() + + # Delete all dock widgets. + for v in self.window.views: + if self.contains_view(v): + self._qt4_delete_view_dock_widget(v) + + def create_initial_layout(self, parent): + self._qt4_editor_area = editor_area = SplitTabWidget(parent) + + editor_area.editor_has_focus.connect(self._qt4_editor_focus) + + # We are interested in focus changes but we get them from the editor + # area rather than qApp to allow the editor area to restrict them when + # needed. + editor_area.focus_changed.connect(self._qt4_view_focus_changed) + + editor_area.tabTextChanged.connect(self._qt4_editor_title_changed) + editor_area.new_window_request.connect(self._qt4_new_window_request) + editor_area.tab_close_request.connect(self._qt4_tab_close_request) + editor_area.tab_window_changed.connect(self._qt4_tab_window_changed) + + return editor_area + + def contains_view(self, view): + return hasattr(view, "_qt4_dock") + + def hide_editor_area(self): + self._qt4_editor_area.hide() + + def hide_view(self, view): + view._qt4_dock.hide() + view.visible = False + + return view + + def refresh(self): + # Nothing to do. + pass + + def reset_editors(self): + self._qt4_editor_area.setCurrentIndex(0) + + def reset_views(self): + # Qt doesn't provide information about the order of dock widgets in a + # dock area. + pass + + def show_editor_area(self): + self._qt4_editor_area.show() + + def show_view(self, view): + view._qt4_dock.show() + view.visible = True + + # Methods for saving and restoring the layout -------------------------# + + def get_view_memento(self): + # Get the IDs of the views in the main window. This information is + # also in the QMainWindow state, but that is opaque. + view_ids = [v.id for v in self.window.views if self.contains_view(v)] + + # Everything else is provided by QMainWindow. + state = self.window.control.saveState().data() + return (0, (view_ids, state)) + + def set_view_memento(self, memento): + version, mdata = memento + + # There has only ever been version 0 so far so check with an assert. + assert version == 0 + + # Now we know the structure of the memento we can "parse" it. + view_ids, state = mdata + + # Get a list of all views that have dock widgets and mark them. + dock_views = [v for v in self.window.views if self.contains_view(v)] + + for v in dock_views: + v._qt4_gone = True + + # Create a dock window for all views that had one last time. + for v in self.window.views: + # Make sure this is in a known state. + v.visible = False + + for vid in view_ids: + if vid == v.id: + # Create the dock widget if needed and make sure that it is + # invisible so that it matches the state of the visible + # trait. Things will all come right when the main window + # state is restored below. + self._qt4_create_view_dock_widget(v).setVisible(False) + + if v in dock_views: + delattr(v, "_qt4_gone") + + break + + # Remove any remain unused dock widgets. + for v in dock_views: + try: + delattr(v, "_qt4_gone") + except AttributeError: + pass + else: + self._qt4_delete_view_dock_widget(v) + + # Restore the state. This will update the view's visible trait through + # the dock window's toggle action. + self.window.control.restoreState(state) + + def get_editor_memento(self): + # Get the layout of the editors. + editor_layout = self._qt4_editor_area.saveState() + + # Get a memento for each editor that describes its contents. + editor_references = self._get_editor_references() + + return (0, (editor_layout, editor_references)) + + def set_editor_memento(self, memento): + version, mdata = memento + + # There has only ever been version 0 so far so check with an assert. + assert version == 0 + + # Now we know the structure of the memento we can "parse" it. + editor_layout, editor_references = mdata + + def resolve_id(id): + # Get the memento for the editor contents (if any). + editor_memento = editor_references.get(id) + + if editor_memento is None: + return None + + # Create the restored editor. + editor = self.window.editor_manager.set_editor_memento( + editor_memento + ) + if editor is None: + return None + + # Save the editor. + self.window.editors.append(editor) + + # Create the control if needed and return it. + return self._qt4_get_editor_control(editor) + + self._qt4_editor_area.restoreState(editor_layout, resolve_id) + + def get_toolkit_memento(self): + return (0, {"geometry": self.window.control.saveGeometry()}) + + def set_toolkit_memento(self, memento): + if hasattr(memento, "toolkit_data"): + data = memento.toolkit_data + if isinstance(data, tuple) and len(data) == 2: + version, datadict = data + if version == 0: + geometry = datadict.pop("geometry", None) + if geometry is not None: + self.window.control.restoreGeometry(geometry) + + def is_editor_area_visible(self): + return self._qt4_editor_area.isVisible() + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _qt4_editor_focus(self, new): + """Handle an editor getting the focus.""" + + for editor in self.window.editors: + control = editor.control + editor.has_focus = control is new or ( + control is not None and new in control.children() + ) + + def _qt4_editor_title_changed(self, control, title): + """Handle the title being changed""" + for editor in self.window.editors: + if editor.control == control: + editor.name = str(title) + + def _qt4_editor_tab_spinner(self, event): + editor = event.object + + # Do we need to do this verification? + tw, tidx = self._qt4_editor_area._tab_widget(editor.control) + + if event.new: + tw.show_button(tidx) + else: + tw.hide_button(tidx) + + if not event.new and not editor == self.window.active_editor: + self._qt4_editor_area.setTabTextColor( + editor.control, QtCore.Qt.GlobalColor.red + ) + + @observe("window:active_editor") + def _qt4_active_editor_changed(self, event): + """Handle change of active editor""" + # Reset tab title to foreground color + editor = event.new + if editor is not None: + self._qt4_editor_area.setTabTextColor(editor.control) + + def _qt4_view_focus_changed(self, old, new): + """Handle the change of focus for a view.""" + + focus_part = None + + if new is not None: + # Handle focus changes to views. + for view in self.window.views: + if view.control is not None and view.control.isAncestorOf(new): + view.has_focus = True + focus_part = view + break + + if old is not None: + # Handle focus changes from views. + for view in self.window.views: + if ( + view is not focus_part + and view.control is not None + and view.control.isAncestorOf(old) + ): + view.has_focus = False + break + + def _qt4_new_window_request(self, pos, control): + """Handle a tab tear-out request from the splitter widget.""" + + editor = self._qt4_remove_editor_with_control(control) + kind = self.window.editor_manager.get_editor_kind(editor) + + window = self.window.workbench.create_window() + window.open() + window.add_editor(editor) + window.editor_manager.add_editor(editor, kind) + window.position = (pos.x(), pos.y()) + window.size = self.window.size + window.activate_editor(editor) + editor.window = window + + def _qt4_tab_close_request(self, control): + """Handle a tabCloseRequest from the splitter widget.""" + + for editor in self.window.editors: + if editor.control == control: + editor.close() + break + + def _qt4_tab_window_changed(self, control): + """Handle a tab drag to a different WorkbenchWindow.""" + + editor = self._qt4_remove_editor_with_control(control) + kind = self.window.editor_manager.get_editor_kind(editor) + + while not control.isWindow(): + control = control.parent() + for window in self.window.workbench.windows: + if window.control == control: + window.editors.append(editor) + window.editor_manager.add_editor(editor, kind) + window.layout._qt4_get_editor_control(editor) + window.activate_editor(editor) + editor.window = window + break + + def _qt4_remove_editor_with_control(self, control): + """Finds the editor associated with 'control' and removes it. Returns + the editor, or None if no editor was found. + """ + for editor in self.window.editors: + if editor.control == control: + self.editor_closing = editor + control.removeEventFilter(self._qt4_mon) + self.editor_closed = editor + + # Make sure that focus events get fired if this editor is + # subsequently added to another window. + editor.has_focus = False + + return editor + + def _qt4_get_editor_control(self, editor): + """Create the editor control if it hasn't already been done.""" + + if editor.control is None: + self.editor_opening = editor + + # We must provide a parent (because TraitsUI checks for it when + # deciding what sort of panel to create) but it can't be the editor + # area (because it will be automatically added to the base + # QSplitter). + editor.control = editor.create_control(self.window.control) + editor.control.setObjectName(editor.id) + + editor.observe(self._qt4_editor_tab_spinner, "_loading") + + self.editor_opened = editor + + def on_name_changed(event): + editor = event.object + self._qt4_editor_area.setWidgetTitle(editor.control, editor.name) + + editor.observe(on_name_changed, "name") + + self._qt4_monitor(editor.control) + + return editor.control + + def _qt4_add_view(self, view, position, relative_to, size): + """Add a view.""" + + # If no specific position is specified then use the view's default + # position. + if position is None: + position = view.position + + dw = self._qt4_create_view_dock_widget(view, size) + mw = self.window.control + + try: + rel_dw = relative_to._qt4_dock + except AttributeError: + rel_dw = None + + if rel_dw is None: + # If we are trying to add a view with a non-existent item, then + # just default to the left of the editor area. + if position == "with": + position = "left" + + # Position the view relative to the editor area. + try: + dwa = _EDIT_AREA_MAP[position] + except KeyError: + raise ValueError("unknown view position: %s" % position) + + mw.addDockWidget(dwa, dw) + elif position == "with": + # FIXME v3: The Qt documentation says that the second should be + # placed above the first, but it always seems to be underneath (ie. + # hidden) which is not what the user is expecting. + mw.tabifyDockWidget(rel_dw, dw) + else: + try: + orient, swap = _VIEW_AREA_MAP[position] + except KeyError: + raise ValueError("unknown view position: %s" % position) + + mw.splitDockWidget(rel_dw, dw, orient) + + # The Qt documentation implies that the layout direction can be + # used to position the new dock widget relative to the existing one + # but I could only get the button positions to change. Instead we + # move things around afterwards if required. + if swap: + mw.removeDockWidget(rel_dw) + mw.splitDockWidget(dw, rel_dw, orient) + rel_dw.show() + + def _qt4_create_view_dock_widget(self, view, size=(-1, -1)): + """Create a dock widget that wraps a view.""" + + # See if it has already been created. + try: + dw = view._qt4_dock + except AttributeError: + dw = QtGui.QDockWidget(view.name, self.window.control) + dw.setWidget(_ViewContainer(size, self.window.control)) + dw.setObjectName(view.id) + dw.toggleViewAction().toggled.connect( + self._qt4_handle_dock_visibility + ) + dw.visibilityChanged.connect(self._qt4_handle_dock_visibility) + + # Save the dock window. + view._qt4_dock = dw + + def on_name_changed(event): + view._qt4_dock.setWindowTitle(view.name) + + view.observe(on_name_changed, "name") + + # Make sure the view control exists. + if view.control is None: + # Make sure that the view knows which window it is in. + view.window = self.window + + try: + view.control = view.create_control(dw.widget()) + except Exception: + # Tidy up if the view couldn't be created. + delattr(view, "_qt4_dock") + self.window.control.removeDockWidget(dw) + dw.deleteLater() + del dw + raise + + dw.widget().setCentralWidget(view.control) + + return dw + + def _qt4_delete_view_dock_widget(self, view): + """Delete a view's dock widget.""" + + dw = view._qt4_dock + + # Disassociate the view from the dock. + if view.control is not None: + view.control.setParent(None) + + delattr(view, "_qt4_dock") + + # Delete the dock (and the view container). + self.window.control.removeDockWidget(dw) + dw.deleteLater() + + def _qt4_handle_dock_visibility(self, checked): + """Handle the visibility of a dock window changing.""" + + # Find the dock window by its toggle action. + for v in self.window.views: + try: + dw = v._qt4_dock + except AttributeError: + continue + + sender = dw.sender() + if sender is dw.toggleViewAction() or sender in dw.children(): + # Toggling the action or pressing the close button on + # the view + v.visible = checked + + def _qt4_monitor(self, control): + """Install an event filter for a view or editor control to keep an eye + on certain events. + """ + + # Create the monitoring object if needed. + try: + mon = self._qt4_mon + except AttributeError: + mon = self._qt4_mon = _Monitor(self) + + control.installEventFilter(mon) + + +class _Monitor(QtCore.QObject): + """This class monitors a view or editor control.""" + + def __init__(self, layout): + QtCore.QObject.__init__(self, layout.window.control) + + self._layout = layout + + def eventFilter(self, obj, e): + if isinstance(e, QtGui.QCloseEvent): + for editor in self._layout.window.editors: + if editor.control is obj: + self._layout.editor_closing = editor + editor.destroy_control() + self._layout.editor_closed = editor + + break + + return False + + +class _ViewContainer(QtGui.QMainWindow): + """This class is a container for a view that allows an initial size + (specified as a tuple) to be set. + """ + + def __init__(self, size, main_window): + """Initialise the object.""" + + QtGui.QMainWindow.__init__(self) + + # Save the size and main window. + self._width, self._height = size + self._main_window = main_window + + def sizeHint(self): + """Reimplemented to return the initial size or the view's current + size. + """ + + sh = self.centralWidget().sizeHint() + + if self._width > 0: + if self._width > 1: + w = self._width + else: + w = self._main_window.width() * self._width + + sh.setWidth(int(w)) + + if self._height > 0: + if self._height > 1: + h = self._height + else: + h = self._main_window.height() * self._height + + sh.setHeight(int(h)) + + return sh + + def showEvent(self, e): + """Reimplemented to use the view's current size once shown.""" + + self._width = self._height = -1 + + QtGui.QMainWindow.showEvent(self, e) diff --git a/apptools/workbench/ui/wx/__init__.py b/apptools/workbench/ui/wx/__init__.py new file mode 100644 index 000000000..aa2218ef6 --- /dev/null +++ b/apptools/workbench/ui/wx/__init__.py @@ -0,0 +1,9 @@ +# (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! diff --git a/apptools/workbench/ui/wx/editor.py b/apptools/workbench/ui/wx/editor.py new file mode 100644 index 000000000..e7046047e --- /dev/null +++ b/apptools/workbench/ui/wx/editor.py @@ -0,0 +1,55 @@ +# (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! + + +""" Enthought pyface package component +""" + + +from apptools.workbench.i_editor import MEditor + + +class Editor(MEditor): + """The toolkit specific implementation of an Editor. + + See the IEditor interface for the API documentation. + + """ + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part.""" + + import wx + + # By default we create a yellow panel! + control = wx.Panel(parent, -1) + control.SetBackgroundColour("yellow") + control.SetSize((100, 200)) + + return control + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part.""" + + if self.control is not None: + self.control.Destroy() + self.control = None + + def set_focus(self): + """Set the focus to the appropriate control in the part.""" + + if self.control is not None: + self.control.SetFocus() + + return diff --git a/apptools/workbench/ui/wx/editor_set_structure_handler.py b/apptools/workbench/ui/wx/editor_set_structure_handler.py new file mode 100755 index 000000000..c224284fe --- /dev/null +++ b/apptools/workbench/ui/wx/editor_set_structure_handler.py @@ -0,0 +1,91 @@ +# (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 handler used to restore editors. +""" + + +import logging + +from pyface.dock.api import SetStructureHandler + +logger = logging.getLogger(__name__) + + +class EditorSetStructureHandler(SetStructureHandler): + """The handler used to restore editors. + + This is part of the 'dock window' API. It is used to resolve dock control + Ids when setting the structure of a dock window. + + """ + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __init__(self, window_layout, editor_mementos): + """Creates a new handler.""" + + self.window_layout = window_layout + self.editor_mementos = editor_mementos + + return + + # ------------------------------------------------------------------------ + # 'SetStructureHandler' interface. + # ------------------------------------------------------------------------ + + def resolve_id(self, id): + """Resolves an unresolved dock control id.""" + + window_layout = self.window_layout + window = window_layout.window + + try: + # Get the memento for the editor with this Id. + memento = self._get_editor_memento(id) + + # Ask the editor manager to create an editor from the memento. + editor = window.editor_manager.set_editor_memento(memento) + + # Get the editor's toolkit-specific control. + # + # fixme: This is using a 'private' method on the window layout. + # This may be ok since this structure handler is really part of the + # layout! + control = window_layout._wx_get_editor_control(editor) + + # fixme: This is ugly manipulating the editors list from in here! + window.editors.append(editor) + + except Exception: + logger.warning("could not restore editor [%s]", id) + control = None + + return control + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _get_editor_memento(self, id): + """Return the editor memento for the editor with the specified Id. + + Raises a 'ValueError' if no such memento exists. + + """ + + editor_memento = self.editor_mementos.get(id) + if editor_memento is None: + raise ValueError("no editor memento with Id %s" % id) + + return editor_memento diff --git a/apptools/workbench/ui/wx/view.py b/apptools/workbench/ui/wx/view.py new file mode 100644 index 000000000..4db2cb8d3 --- /dev/null +++ b/apptools/workbench/ui/wx/view.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! + + +""" Enthought pyface package component +""" + + +from traits.api import Bool + +from apptools.workbench.i_view import MView + + +class View(MView): + """The toolkit specific implementation of a View. + + See the IView interface for the API documentation. + + """ + + # Trait to indicate if the dock window containing the view should be + # closeable. See FIXME comment in the _wx_create_view_dock_control method + # in workbench_window_layout.py. + closeable = Bool(False) + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + def create_control(self, parent): + """Create the toolkit-specific control that represents the part.""" + + import wx + + # By default we create a red panel! + control = wx.Panel(parent, -1) + control.SetBackgroundColour("red") + control.SetSize((100, 200)) + + return control + + def destroy_control(self): + """Destroy the toolkit-specific control that represents the part.""" + + if self.control is not None: + self.control.Destroy() + self.control = None + + def set_focus(self): + """Set the focus to the appropriate control in the part.""" + + if self.control is not None: + self.control.SetFocus() + + return diff --git a/apptools/workbench/ui/wx/view_set_structure_handler.py b/apptools/workbench/ui/wx/view_set_structure_handler.py new file mode 100755 index 000000000..382c3769f --- /dev/null +++ b/apptools/workbench/ui/wx/view_set_structure_handler.py @@ -0,0 +1,64 @@ +# (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 handler used to restore views. +""" + + +import logging + +from pyface.dock.api import SetStructureHandler + +logger = logging.getLogger(__name__) + + +class ViewSetStructureHandler(SetStructureHandler): + """The handler used to restore views. + + This is part of the 'dock window' API. It is used to resolve dock control + IDs when setting the structure of a dock window. + + """ + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __init__(self, window_layout): + """Creates a new handler.""" + + self.window_layout = window_layout + + return + + # ------------------------------------------------------------------------ + # 'SetStructureHandler' interface. + # ------------------------------------------------------------------------ + + def resolve_id(self, id): + """Resolves an unresolved dock control *id*.""" + + window_layout = self.window_layout + window = window_layout.window + + view = window.get_view_by_id(id) + if view is not None: + # Get the view's toolkit-specific control. + # + # fixme: This is using a 'private' method on the window layout. + # This may be ok since this is really part of the layout! + control = window_layout._wx_get_view_control(view) + + else: + logger.warning("could not restore view [%s]", id) + control = None + + return control diff --git a/apptools/workbench/ui/wx/workbench_dock_window.py b/apptools/workbench/ui/wx/workbench_dock_window.py new file mode 100755 index 000000000..d8a9a7876 --- /dev/null +++ b/apptools/workbench/ui/wx/workbench_dock_window.py @@ -0,0 +1,141 @@ +# (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! + + +""" Base class for workbench dock windows. +""" + + +import logging + +from pyface.dock.api import DockGroup, DockRegion, DockWindow + +logger = logging.getLogger(__name__) + + +class WorkbenchDockWindow(DockWindow): + """Base class for workbench dock windows. + + This class just adds a few useful methods to the standard 'DockWindow' + interface. Hopefully at some stage these can be part of that API too! + + """ + + # ------------------------------------------------------------------------ + # Protected 'DockWindow' interface. + # ------------------------------------------------------------------------ + + def _right_up(self, event): + """Handles the right mouse button being released. + + We override this to stop the default dock window context menus from + appearing. + + """ + + pass + + # ------------------------------------------------------------------------ + # 'WorkbenchDockWindow' interface. + # ------------------------------------------------------------------------ + + def activate_control(self, id): + """Activates the dock control with the specified Id. + + Does nothing if no such dock control exists (well, it *does* write + a debug message to the logger). + + """ + + control = self.get_control(id) + if control is not None: + logger.debug("activating control <%s>", id) + control.activate() + + else: + logger.debug("no control <%s> to activate", id) + + def close_control(self, id): + """Closes the dock control with the specified Id. + + Does nothing if no such dock control exists (well, it *does* write + a debug message to the logger). + + """ + + control = self.get_control(id) + if control is not None: + logger.debug("closing control <%s>", id) + control.close() + + else: + logger.debug("no control <%s> to close", id) + + def get_control(self, id, visible_only=True): + """Returns the dock control with the specified Id. + + Returns None if no such dock control exists. + + """ + + for control in self.get_controls(visible_only): + if control.id == id: + break + + else: + control = None + + return control + + def get_controls(self, visible_only=True): + """Returns all of the dock controls in the window.""" + + sizer = self.control.GetSizer() + section = sizer.GetContents() + + return section.get_controls(visible_only=visible_only) + + def get_regions(self, group): + """Returns all dock regions in a dock group (recursively).""" + + regions = [] + for item in group.contents: + if isinstance(item, DockRegion): + regions.append(item) + + if isinstance(item, DockGroup): + regions.extend(self.get_regions(item)) + + return regions + + def get_structure(self): + """Returns the window structure (minus the content).""" + + sizer = self.control.GetSizer() + + return sizer.GetStructure() + + def reset_regions(self): + """Activates the first dock control in every region.""" + + sizer = self.control.GetSizer() + section = sizer.GetContents() + + for region in self.get_regions(section): + if len(region.contents) > 0: + region.contents[0].activate(layout=False) + + def set_structure(self, structure, handler=None): + """Sets the window structure.""" + + sizer = self.control.GetSizer() + sizer.SetStructure(self.control.GetParent(), structure, handler) + + return diff --git a/apptools/workbench/ui/wx/workbench_window_layout.py b/apptools/workbench/ui/wx/workbench_window_layout.py new file mode 100644 index 000000000..6b580f664 --- /dev/null +++ b/apptools/workbench/ui/wx/workbench_window_layout.py @@ -0,0 +1,802 @@ +# (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 implementation of the workbench window layout interface. +""" + + +import logging +import pickle + +import wx + +from pyface.dock.api import ( + DOCK_BOTTOM, + DOCK_LEFT, + DOCK_RIGHT, + DOCK_TOP, + DockControl, + DockRegion, + DockSection, + DockSizer, +) +from traits.api import Delegate + +from apptools.workbench.i_workbench_window_layout import MWorkbenchWindowLayout + +from .editor_set_structure_handler import EditorSetStructureHandler +from .view_set_structure_handler import ViewSetStructureHandler +from .workbench_dock_window import WorkbenchDockWindow + +# Logging. +logger = logging.getLogger(__name__) + +# Mapping from view position to the appropriate dock window constant. +_POSITION_MAP = { + "top": DOCK_TOP, + "bottom": DOCK_BOTTOM, + "left": DOCK_LEFT, + "right": DOCK_RIGHT, +} + + +class WorkbenchWindowLayout(MWorkbenchWindowLayout): + """The wx implementation of the workbench window layout interface. + + See the 'IWorkbenchWindowLayout' interface for the API documentation. + + """ + + # 'IWorkbenchWindowLayout' interface ----------------------------------- + + editor_area_id = Delegate("window") + + # ------------------------------------------------------------------------ + # 'IWorkbenchWindowLayout' interface. + # ------------------------------------------------------------------------ + + def activate_editor(self, editor): + """Activate an editor.""" + + # This brings the dock control tab to the front. + self._wx_editor_dock_window.activate_control(editor.id) + + editor.set_focus() + + return editor + + def activate_view(self, view): + """Activate a view.""" + + # This brings the dock control tab to the front. + self._wx_view_dock_window.activate_control(view.id) + + view.set_focus() + + return view + + def add_editor(self, editor, title): + """Add an editor.""" + + try: + self._wx_add_editor(editor, title) + + except Exception: + logger.exception("error creating editor control <%s>", editor.id) + + return editor + + def add_view(self, view, position=None, relative_to=None, size=(-1, -1)): + """Add a view.""" + + try: + self._wx_add_view(view, position, relative_to, size) + view.visible = True + + except Exception: + logger.exception("error creating view control <%s>", view.id) + + # Even though we caught the exception, it sometimes happens that + # the view's control has been created as a child of the application + # window (or maybe even the dock control). We should destroy the + # control to avoid bad UI effects. + view.destroy_control() + + # Additionally, display an error message to the user. + self.window.error("Unable to add view %s" % view.id) + + return view + + def close_editor(self, editor): + """Close and editor.""" + + self._wx_editor_dock_window.close_control(editor.id) + + return editor + + def close_view(self, view): + """Close a view.""" + + self.hide_view(view) + + return view + + def close(self): + """Close the entire window layout.""" + + self._wx_editor_dock_window.close() + self._wx_view_dock_window.close() + + def create_initial_layout(self, parent): + """Create the initial window layout.""" + + # The view dock window is where all of the views live. It also contains + # a nested dock window where all of the editors live. + self._wx_view_dock_window = WorkbenchDockWindow(parent) + + # The editor dock window (which is nested inside the view dock window) + # is where all of the editors live. + self._wx_editor_dock_window = WorkbenchDockWindow( + self._wx_view_dock_window.control + ) + editor_dock_window_sizer = DockSizer(contents=DockSection()) + self._wx_editor_dock_window.control.SetSizer(editor_dock_window_sizer) + + # Nest the editor dock window in the view dock window. + editor_dock_window_control = DockControl( + id=self.editor_area_id, + name="Editors", + control=self._wx_editor_dock_window.control, + style="fixed", + width=self.window.editor_area_size[0], + height=self.window.editor_area_size[1], + ) + + view_dock_window_sizer = DockSizer( + contents=[editor_dock_window_control] + ) + + self._wx_view_dock_window.control.SetSizer(view_dock_window_sizer) + + return self._wx_view_dock_window.control + + def contains_view(self, view): + """Return True if the view exists in the window layout.""" + + view_control = self._wx_view_dock_window.get_control(view.id, False) + + return view_control is not None + + def hide_editor_area(self): + """Hide the editor area.""" + + dock_control = self._wx_view_dock_window.get_control( + self.editor_area_id, visible_only=False + ) + dock_control.show(False, layout=True) + + def hide_view(self, view): + """Hide a view.""" + + dock_control = self._wx_view_dock_window.get_control( + view.id, visible_only=False + ) + + dock_control.show(False, layout=True) + view.visible = False + + return view + + def refresh(self): + """Refresh the window layout to reflect any changes.""" + + self._wx_view_dock_window.update_layout() + + def reset_editors(self): + """Activate the first editor in every group.""" + + self._wx_editor_dock_window.reset_regions() + + def reset_views(self): + """Activate the first view in every group.""" + + self._wx_view_dock_window.reset_regions() + + def show_editor_area(self): + """Show the editor area.""" + + dock_control = self._wx_view_dock_window.get_control( + self.editor_area_id, visible_only=False + ) + dock_control.show(True, layout=True) + + def show_view(self, view): + """Show a view.""" + + dock_control = self._wx_view_dock_window.get_control( + view.id, visible_only=False + ) + + dock_control.show(True, layout=True) + view.visible = True + + def is_editor_area_visible(self): + dock_control = self._wx_view_dock_window.get_control( + self.editor_area_id, visible_only=False + ) + return dock_control.visible + + # Methods for saving and restoring the layout -------------------------# + + def get_view_memento(self): + structure = self._wx_view_dock_window.get_structure() + + # We always return a clone. + return pickle.loads(pickle.dumps(structure)) + + def set_view_memento(self, memento): + # We always use a clone. + memento = pickle.loads(pickle.dumps(memento)) + + # The handler knows how to resolve view Ids when setting the dock + # window structure. + handler = ViewSetStructureHandler(self) + + # Set the layout of the views. + self._wx_view_dock_window.set_structure(memento, handler) + + # fixme: We should be able to do this in the handler but we don't get a + # reference to the actual dock control in 'resolve_id'. + for view in self.window.views: + control = self._wx_view_dock_window.get_control(view.id) + if control is not None: + self._wx_initialize_view_dock_control(view, control) + view.visible = control.visible + else: + view.visible = False + + def get_editor_memento(self): + # Get the layout of the editors. + structure = self._wx_editor_dock_window.get_structure() + + # Get a memento to every editor. + editor_references = self._get_editor_references() + + return (structure, editor_references) + + def set_editor_memento(self, memento): + # fixme: Mementos might want to be a bit more formal than tuples! + structure, editor_references = memento + + if len(structure.contents) > 0: + # The handler knows how to resolve editor Ids when setting the dock + # window structure. + handler = EditorSetStructureHandler(self, editor_references) + + # Set the layout of the editors. + self._wx_editor_dock_window.set_structure(structure, handler) + + # fixme: We should be able to do this in the handler but we don't + # get a reference to the actual dock control in 'resolve_id'. + for editor in self.window.editors: + control = self._wx_editor_dock_window.get_control(editor.id) + if control is not None: + self._wx_initialize_editor_dock_control(editor, control) + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _wx_add_editor(self, editor, title): + """Adds an editor.""" + + # Create a dock control that contains the editor. + editor_dock_control = self._wx_create_editor_dock_control(editor) + + # If there are no other editors open (i.e., this is the first one!), + # then create a new region to put the editor in. + controls = self._wx_editor_dock_window.get_controls() + if len(controls) == 0: + # Get a reference to the empty editor section. + sizer = self._wx_editor_dock_window.control.GetSizer() + section = sizer.GetContents() + + # Add a region containing the editor dock control. + region = DockRegion(contents=[editor_dock_control]) + section.contents = [region] + + # Otherwise, add the editor to the same region as the first editor + # control. + # + # fixme: We might want a more flexible placement strategy at some + # point! + else: + region = controls[0].parent + region.add(editor_dock_control) + + # fixme: Without this the window does not draw properly (manually + # resizing the window makes it better!). + self._wx_editor_dock_window.update_layout() + + def _wx_add_view(self, view, position, relative_to, size): + """Adds a view.""" + + # If no specific position is specified then use the view's default + # position. + if position is None: + position = view.position + + # Create a dock control that contains the view. + dock_control = self._wx_create_view_dock_control(view) + + if position == "with": + # Does the item we are supposed to be positioned 'with' actual + # exist? + with_item = self._wx_view_dock_window.get_control(relative_to.id) + + # If so then we put the items in the same tab group. + if with_item is not None: + self._wx_add_view_with(dock_control, relative_to) + + # Otherwise, just fall back to the 'left' of the editor area. + else: + self._wx_add_view_relative(dock_control, None, "left", size) + + else: + self._wx_add_view_relative( + dock_control, relative_to, position, size + ) + + return + + # fixme: Make the view dock window a sub class of dock window, and add + # 'add_with' and 'add_relative_to' as methods on that. + # + # fixme: This is a good idea in theory, but the sizing is a bit iffy, as + # it requires the window to be passed in to calculate the relative size + # of the control. We could just calculate that here and pass in absolute + # pixel sizes to the dock window subclass? + def _wx_add_view_relative(self, dock_control, relative_to, position, size): + """Adds a view relative to another item.""" + + # If no 'relative to' Id is specified then we assume that the position + # is relative to the editor area. + if relative_to is None: + relative_to_item = self._wx_view_dock_window.get_control( + self.editor_area_id, visible_only=False + ) + + # Find the item that we are adding the view relative to. + else: + relative_to_item = self._wx_view_dock_window.get_control( + relative_to.id + ) + + # Set the size of the dock control. + self._wx_set_item_size(dock_control, size) + + # The parent of a dock control is a dock region. + region = relative_to_item.parent + section = region.parent + section.add(dock_control, region, _POSITION_MAP[position]) + + def _wx_add_view_with(self, dock_control, with_obj): + """Adds a view in the same region as another item.""" + + # Find the item that we are adding the view 'with'. + with_item = self._wx_view_dock_window.get_control(with_obj.id) + if with_item is None: + raise ValueError("Cannot find item %s" % with_obj) + + # The parent of a dock control is a dock region. + with_item.parent.add(dock_control) + + def _wx_set_item_size(self, dock_control, size): + """Sets the size of a dock control.""" + + window_width, window_height = self.window.control.GetSize().Get() + width, height = size + + if width != -1: + dock_control.width = int(window_width * width) + + if height != -1: + dock_control.height = int(window_height * height) + + def _wx_create_editor_dock_control(self, editor): + """Creates a dock control that contains the specified editor.""" + + self._wx_get_editor_control(editor) + + # Wrap a dock control around it. + editor_dock_control = DockControl( + id=editor.id, + name=editor.name, + closeable=True, + control=editor.control, + style="tab", + # fixme: Create a subclass of dock control and give it a proper + # editor trait! + _editor=editor, + ) + + # Hook up the 'on_close' and trait change handlers etc. + self._wx_initialize_editor_dock_control(editor, editor_dock_control) + + return editor_dock_control + + def _wx_create_view_dock_control(self, view): + """Creates a dock control that contains the specified view.""" + + # Get the view's toolkit-specific control. + control = self._wx_get_view_control(view) + + # Check if the dock control should be 'closeable'. + # FIXME: The 'fixme' comment below suggests some issue with closing a + # view by clicking 'X' rather than just hiding the view. The two actions + # appear to do the same thing however, so I'm not sure if the comment + # below is an out-of-date comment. This needs more investigation. + # For the time being, I am making a view closeable if it has a + # 'closeable' trait set to True. + closeable = view.closeable + + # Wrap a dock control around it. + view_dock_control = DockControl( + id=view.id, + name=view.name, + # fixme: We would like to make views closeable, but closing via the + # tab is different than calling show(False, layout=True) on the + # control! If we use a close handler can we change that?!? + closeable=closeable, + control=control, + style=view.style_hint, + # fixme: Create a subclass of dock control and give it a proper + # view trait! + _view=view, + ) + + # Hook up the 'on_close' and trait change handlers etc. + self._wx_initialize_view_dock_control(view, view_dock_control) + + return view_dock_control + + def _wx_get_editor_control(self, editor): + """Returns the editor's toolkit-specific control. + + If the editor has not yet created its control, we will ask it to create + it here. + + """ + + if editor.control is None: + parent = self._wx_editor_dock_window.control + + # This is the toolkit-specific control that represents the 'guts' + # of the editor. + self.editor_opening = editor + editor.control = editor.create_control(parent) + self.editor_opened = editor + + # Hook up toolkit-specific events that are managed by the framework + # etc. + self._wx_initialize_editor_control(editor) + + return editor.control + + def _wx_initialize_editor_control(self, editor): + """Initializes the toolkit-specific control for an editor. + + This is used to hook events managed by the framework etc. + + """ + + def on_set_focus(event): + """Called when the control gets the focus.""" + + editor.has_focus = True + + # Let the default wx event handling do its thang. + event.Skip() + + def on_kill_focus(event): + """Called when the control gets the focus.""" + + editor.has_focus = False + + # Let the default wx event handling do its thang. + event.Skip() + + return + + self._wx_add_focus_listeners( + editor.control, on_set_focus, on_kill_focus + ) + + def _wx_get_view_control(self, view): + """Returns a view's toolkit-specific control. + + If the view has not yet created its control, we will ask it to create + it here. + + """ + + if view.control is None: + parent = self._wx_view_dock_window.control + + # Make sure that the view knows which window it is in. + view.window = self.window + + # This is the toolkit-specific control that represents the 'guts' + # of the view. + self.view_opening = view + view.control = view.create_control(parent) + self.view_opened = view + + # Hook up toolkit-specific events that are managed by the + # framework etc. + self._wx_initialize_view_control(view) + + return view.control + + def _wx_initialize_view_control(self, view): + """Initializes the toolkit-specific control for a view. + + This is used to hook events managed by the framework. + + """ + + def on_set_focus(event): + """Called when the control gets the focus.""" + + view.has_focus = True + + # Let the default wx event handling do its thang. + event.Skip() + + def on_kill_focus(event): + """Called when the control gets the focus.""" + + view.has_focus = False + + # Let the default wx event handling do its thang. + event.Skip() + + return + + self._wx_add_focus_listeners(view.control, on_set_focus, on_kill_focus) + + def _wx_add_focus_listeners(self, control, on_set_focus, on_kill_focus): + """Recursively adds focus listeners to a control.""" + + # NOTE: If we are passed a wx control that isn't correctly initialized + # (like when the TraitsUIView isn't properly creating it) but it is + # actually a wx control, then we get weird exceptions from trying to + # register event handlers. The exception messages complain that + # the passed control is a str object instead of a wx object. + if on_set_focus is not None: + # control.Bind(wx.EVT_SET_FOCUS, on_set_focus) + control.Bind(wx.EVT_SET_FOCUS, on_set_focus) + + if on_kill_focus is not None: + # control.Bind(wx.EVT_KILL_FOCUS, on_kill_focus) + control.Bind(wx.EVT_KILL_FOCUS, on_kill_focus) + + for child in control.GetChildren(): + self._wx_add_focus_listeners(child, on_set_focus, on_kill_focus) + + def _wx_initialize_editor_dock_control(self, editor, editor_dock_control): + """Initializes an editor dock control. + + fixme: We only need this method because of a problem with the dock + window API in the 'SetStructureHandler' class. Currently we do not get + a reference to the dock control in 'resolve_id' and hence we cannot set + up the 'on_close' and trait change handlers etc. + + """ + + # Some editors append information to their name to indicate status (in + # our case this is often a 'dirty' indicator that shows when the + # contents of an editor have been modified but not saved). When the + # dock window structure is persisted it contains the name of each dock + # control, which obviously includes any appended state information. + # Here we make sure that when the dock control is recreated its name is + # set to the editor name and nothing more! + editor_dock_control.set_name(editor.name) + + # fixme: Should we roll the traits UI stuff into the default editor. + if hasattr(editor, "ui") and editor.ui is not None: + from traitsui.dockable_view_element import DockableViewElement + + # This makes the control draggable outside of the main window. + # editor_dock_control.export = 'apptools.workbench.editor' + editor_dock_control.dockable = DockableViewElement( + should_close=True, ui=editor.ui + ) + + editor_dock_control.on_close = self._wx_on_editor_closed + + def on_id_changed(event): + editor = event.object + editor_dock_control.id = editor.id + return + + editor.observe(on_id_changed, "id") + + def on_name_changed(event): + editor = event.object + editor_dock_control.set_name(editor.name) + return + + editor.observe(on_name_changed, "name") + + def on_activated_changed(event): + editor_dock_control = event.object + if editor_dock_control._editor is not None: + editor_dock_control._editor.set_focus() + return + + editor_dock_control.observe(on_activated_changed, "activated") + + def _wx_initialize_view_dock_control(self, view, view_dock_control): + """Initializes a view dock control. + + fixme: We only need this method because of a problem with the dock + window API in the 'SetStructureHandler' class. Currently we do not get + a reference to the dock control in 'resolve_id' and hence we cannot set + up the 'on_close' and trait change handlers etc. + + """ + + # Some views append information to their name to indicate status (in + # our case this is often a 'dirty' indicator that shows when the + # contents of a view have been modified but not saved). When the + # dock window structure is persisted it contains the name of each dock + # control, which obviously includes any appended state information. + # Here we make sure that when the dock control is recreated its name is + # set to the view name and nothing more! + view_dock_control.set_name(view.name) + + # fixme: Should we roll the traits UI stuff into the default editor. + if hasattr(view, "ui") and view.ui is not None: + from traitsui.dockable_view_element import DockableViewElement + + # This makes the control draggable outside of the main window. + # view_dock_control.export = 'apptools.workbench.view' + # If the ui's 'view' trait has an 'export' field set, pass that on + # to the dock control. This makes the control detachable from the + # main window (if 'export' is not an empty string). + if view.ui.view is not None: + view_dock_control.export = view.ui.view.export + view_dock_control.dockable = DockableViewElement( + should_close=True, ui=view.ui + ) + + view_dock_control.on_close = self._wx_on_view_closed + + def on_id_changed(event): + view = event.object + view_dock_control.id = view.id + return + + view.observe(on_id_changed, "id") + + def on_name_changed(event): + view = event.object + view_dock_control.set_name(view.name) + return + + view.observe(on_name_changed, "name") + + def on_activated_changed(event): + view_dock_control = event.object + if view_dock_control._view is not None: + view_dock_control._view.set_focus() + return + + view_dock_control.observe(on_activated_changed, "activated") + + return + + # Trait change handlers ------------------------------------------------ + + # Static ---- + + def _window_changed(self, old, new): + """Static trait change handler.""" + + if old is not None: + old.observe( + self._wx_on_editor_area_size_changed, + "editor_area_size", + remove=True, + ) + + if new is not None: + new.observe( + self._wx_on_editor_area_size_changed, "editor_area_size" + ) + + # Dynamic ---- + + def _wx_on_editor_area_size_changed(self, event): + """Dynamic trait change handler.""" + + window_width, window_height = self.window.control.GetSize().Get() + + # Get the dock control that contains the editor dock window. + control = self._wx_view_dock_window.get_control(self.editor_area_id) + + # We actually resize the region that the editor area is in. + region = control.parent + region.width = int(event.new[0] * window_width) + region.height = int(event.new[1] * window_height) + return + + # Dock window handlers ------------------------------------------------- + + # fixme: Should these just fire events that the window listens to? + def _wx_on_view_closed(self, dock_control, force): + """Called when a view is closed via the dock window control.""" + + view = self.window.get_view_by_id(dock_control.id) + if view is not None: + logger.debug("workbench destroying view control <%s>", view) + try: + view.visible = False + + self.view_closing = view + view.destroy_control() + self.view_closed = view + + except Exception: + logger.exception("error destroying view control <%s>", view) + + return True + + def _wx_on_editor_closed(self, dock_control, force): + """Called when an editor is closed via the dock window control.""" + + dock_control._editor = None + editor = self.window.get_editor_by_id(dock_control.id) + + ## import weakref + ## editor_ref = weakref.ref(editor) + + if editor is not None: + logger.debug("workbench destroying editor control <%s>", editor) + try: + # fixme: We would like this event to be vetoable, but it isn't + # just yet (we will need to modify the dock window package). + self.editor_closing = editor + editor.destroy_control() + self.editor_closed = editor + + except Exception: + logger.exception( + "error destroying editor control <%s>", editor + ) + + ## import gc + ## gc.collect() + + ## print 'Editor references', len(gc.get_referrers(editor)) + ## for r in gc.get_referrers(editor): + ## print '********************************************' + ## print type(r), id(r), r + + ## del editor + ## gc.collect() + + ## print 'Is editor gone?', editor_ref() is None, 'ref', editor_ref() + + return True diff --git a/apptools/workbench/user_perspective_manager.py b/apptools/workbench/user_perspective_manager.py new file mode 100644 index 000000000..8a82382db --- /dev/null +++ b/apptools/workbench/user_perspective_manager.py @@ -0,0 +1,224 @@ +# (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! +""" Manages a set of user perspectives. """ + + +import logging +import os + +from traits.api import Any, Dict, HasTraits, Int, List, Property, Str + +from apptools.workbench.perspective import Perspective + +# Logging. +logger = logging.getLogger(__name__) + + +class UserPerspectiveManager(HasTraits): + """Manages a set of user perspectives.""" + + # 'UserPerspective' interface -----------------------------------------# + + # A directory on the local file system that we can read and write to at + # will. This is used to persist window layout information, etc. + state_location = Str() + + # Next available user perspective id. + next_id = Property(Int) + + # Dictionary mapping perspective id to user defined perspective definition. + id_to_perspective = Property(Dict) + + # The list of user defined perspective definitions. + perspectives = Property(List) + + # The name of the user defined perspectives definition file. + file_name = Property(Str) + + # Private interface ---------------------------------------------------- + + # Shadow trait for the 'id_to_perspective' property. + _id_to_perspective = Any() + + # ------------------------------------------------------------------------ + # 'UserPerspective' interface. + # ------------------------------------------------------------------------ + + # Properties ----------------------------------------------------------- + + def _get_next_id(self): + """Property getter.""" + + # Get all of the current perspective ids: + ids = list(self.id_to_perspective.keys()) + + # If there are none: + if len(ids) == 0: + # Return the starting id: + return 1 + + # Else return the current highest id + 1 as the next id: + ids.sort() + + return int(ids[-1][19:-2]) + 1 + + def _get_id_to_perspective(self): + """Property getter.""" + + if self._id_to_perspective is None: + self._id_to_perspective = dic = {} + try: + fh = open(self.file_name, "r") + for line in fh: + data = line.split(":", 1) + if len(data) == 2: + id, name = data[0].strip(), data[1].strip() + dic[id] = Perspective( + id=id, name=name, show_editor_area=False + ) + fh.close() + except Exception: + pass + + return self._id_to_perspective + + def _get_perspectives(self): + """Property getter.""" + + return list(self.id_to_perspective.values()) + + def _get_file_name(self): + """Property getter.""" + + return os.path.join(self.state_location, "__user_perspective__") + + # Methods -------------------------------------------------------------# + + def create_perspective(self, name, show_editor_area=True): + """Create a new (and empty) user-defined perspective.""" + + perspective = Perspective( + id="__user_perspective_%09d__" % self.next_id, + name=name, + show_editor_area=show_editor_area, + ) + + # Add the perspective to the map. + self.id_to_perspective[perspective.id] = perspective + + # Update the persistent file information. + self._update_persistent_data() + + return perspective + + def clone_perspective(self, window, perspective, **traits): + """Clone a perspective as a user perspective.""" + + clone = perspective.clone_traits() + + # Give the clone a special user perspective Id! + clone.id = "__user_perspective_%09d__" % self.next_id + + # Set any traits specified as keyword arguments. + clone.trait_set(**traits) + + # Add the perspective to the map. + self.id_to_perspective[clone.id] = clone + + # fixme: This needs to be pushed into the window API!!!!!!! + window._memento.perspective_mementos[clone.id] = ( + window.layout.get_view_memento(), + window.active_view and window.active_view.id or None, + window.layout.is_editor_area_visible(), + ) + + # Update the persistent file information. + self._update_persistent_data() + + return clone + + def save(self): + """Persist the current state of the user perspectives.""" + + self._update_persistent_data() + + def add(self, perspective, name=None): + """Add a perspective with an optional name.""" + + # Define the id for the new perspective: + perspective.id = id = "__user_perspective_%09d__" % self.next_id + + # Save the new name (if specified): + if name is not None: + perspective.name = name + + # Create the perspective: + self.id_to_perspective[id] = perspective + + # Update the persistent file information: + self._update_persistent_data() + + # Return the new perspective created: + return perspective + + def rename(self, perspective, name): + """Rename the user perspective with the specified id.""" + + perspective.name = name + + self.id_to_perspective[perspective.id].name = name + + # Update the persistent file information: + self._update_persistent_data() + + def remove(self, id): + """Remove the user perspective with the specified id. + + This method also updates the persistent data. + + """ + + if id in self.id_to_perspective: + del self.id_to_perspective[id] + + # Update the persistent file information: + self._update_persistent_data() + + # Try to delete the associated perspective layout file: + try: + os.remove(os.path.join(self.state_location, id)) + except Exception: + pass + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _update_persistent_data(self): + """Update the persistent file information.""" + + try: + fh = open(self.file_name, "w") + fh.write( + "\n".join( + ["%s: %s" % (p.id, p.name) for p in self.perspectives] + ) + ) + fh.close() + + except Exception: + logger.error( + "Could not write the user defined perspective " + "definition file: " + self.file_name + ) + + return diff --git a/apptools/workbench/view.py b/apptools/workbench/view.py new file mode 100755 index 000000000..0d0a24aa4 --- /dev/null +++ b/apptools/workbench/view.py @@ -0,0 +1,17 @@ +# (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 implementation of a workbench view. """ + + +# Import the toolkit specific version. +from .toolkit import toolkit_object + +View = toolkit_object("view:View") diff --git a/apptools/workbench/window_event.py b/apptools/workbench/window_event.py new file mode 100644 index 000000000..e7993c932 --- /dev/null +++ b/apptools/workbench/window_event.py @@ -0,0 +1,30 @@ +# (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! +""" Window events. """ + + +from traits.api import HasTraits, Instance, Vetoable + +from .workbench_window import WorkbenchWindow + + +class WindowEvent(HasTraits): + """A window lifecycle event.""" + + # 'WindowEvent' interface ---------------------------------------------# + + # The window that the event occurred on. + window = Instance(WorkbenchWindow) + + +class VetoableWindowEvent(WindowEvent, Vetoable): + """A vetoable window lifecycle event.""" + + pass diff --git a/apptools/workbench/workbench.py b/apptools/workbench/workbench.py new file mode 100755 index 000000000..6c0c9d259 --- /dev/null +++ b/apptools/workbench/workbench.py @@ -0,0 +1,425 @@ +# (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! +""" A workbench. """ + + +import logging +import os +import pickle + +from pyface.api import NO +from traits.api import ( + Bool, + Callable, + Event, + HasTraits, + Instance, + List, + provides, + Str, + Vetoable, + VetoableEvent, +) +from traits.etsconfig.api import ETSConfig + +from .i_editor_manager import IEditorManager +from .i_workbench import IWorkbench +from .user_perspective_manager import UserPerspectiveManager +from .window_event import VetoableWindowEvent, WindowEvent +from .workbench_window import WorkbenchWindow + +# Logging. +logger = logging.getLogger(__name__) + + +@provides(IWorkbench) +class Workbench(HasTraits): + """A workbench. + + There is exactly *one* workbench per application. The workbench can create + any number of workbench windows. + + """ + + # 'IWorkbench' interface ----------------------------------------------- + + # The active workbench window (the last one to get focus). + active_window = Instance(WorkbenchWindow) + + # The editor manager is used to create/restore editors. + editor_manager = Instance(IEditorManager) + + # The optional application scripting manager. + script_manager = Instance("apptools.appscripting.api.IScriptManager") + + # A directory on the local file system that we can read and write to at + # will. This is used to persist window layout information, etc. + state_location = Str() + + # The optional undo manager. + undo_manager = Instance("pyface.undo.api.IUndoManager") + + # The user-defined perspectives manager. + user_perspective_manager = Instance(UserPerspectiveManager) + + # All of the workbench windows created by the workbench. + windows = List(WorkbenchWindow) + + # Workbench lifecycle events ------------------------------------------- + + # Fired when the workbench is about to exit. + # + # This can be caused by either:- + # + # a) The 'exit' method being called. + # b) The last open window being closed. + # + exiting = VetoableEvent() + + # Fired when the workbench has exited. + exited = Event() + + # Window lifecycle events ---------------------------------------------# + + # Fired when a workbench window has been created. + window_created = Event(WindowEvent) + + # Fired when a workbench window is opening. + window_opening = Event(VetoableWindowEvent) + + # Fired when a workbench window has been opened. + window_opened = Event(WindowEvent) + + # Fired when a workbench window is closing. + window_closing = Event(VetoableWindowEvent) + + # Fired when a workbench window has been closed. + window_closed = Event(WindowEvent) + + # 'Workbench' interface ------------------------------------------------ + + # The factory that is used to create workbench windows. This is used in + # the default implementation of 'create_window'. If you override that + # method then you obviously don't need to set this trait! + window_factory = Callable + + # Private interface ---------------------------------------------------- + + # An 'explicit' exit is when the the 'exit' method is called. + # An 'implicit' exit is when the user closes the last open window. + _explicit_exit = Bool(False) + + # ------------------------------------------------------------------------ + # 'IWorkbench' interface. + # ------------------------------------------------------------------------ + + def create_window(self, **kw): + """Factory method that creates a new workbench window.""" + + window = self.window_factory(workbench=self, **kw) + + # Add on any user-defined perspectives. + window.perspectives.extend(self.user_perspective_manager.perspectives) + + # Restore the saved window memento (if there is one). + self._restore_window_layout(window) + + # Listen for the window being activated/opened/closed etc. Activated in + # this context means 'gets the focus'. + # + # NOTE: 'activated' is not fired on a window when the window first + # opens and gets focus. It is only fired when the window comes from + # lower in the stack to be the active window. + window.observe(self._on_window_activated, "activated") + window.observe(self._on_window_opening, "opening") + window.observe(self._on_window_opened, "opened") + window.observe(self._on_window_closing, "closing") + window.observe(self._on_window_closed, "closed") + + # Event notification. + self.window_created = WindowEvent(window=window) + + return window + + def exit(self): + """Exits the workbench. + + This closes all open workbench windows. + + This method is not called when the user clicks the close icon. Nor when + they do an Alt+F4 in Windows. It is only called when the application + menu File->Exit item is selected. + + Returns True if the exit succeeded, False if it was vetoed. + + """ + + logger.debug("**** exiting the workbench ****") + + # Event notification. + self.exiting = event = Vetoable() + if not event.veto: + # This flag is checked in '_on_window_closing' to see what kind of + # exit is being performed. + self._explicit_exit = True + + if len(self.windows) > 0: + exited = self._close_all_windows() + + # The degenerate case where no workbench windows have ever been + # created! + else: + # Trait notification. + self.exited = self + + exited = True + + # Whether the exit succeeded or not, we are no longer in the + # process of exiting! + self._explicit_exit = False + + else: + exited = False + + if not exited: + logger.debug("**** exit of the workbench vetoed ****") + + return exited + + # Convenience methods on the active window ----------------------------- + + def edit(self, obj, kind=None, use_existing=True): + """Edit an object in the active workbench window.""" + + return self.active_window.edit(obj, kind, use_existing) + + def get_editor(self, obj, kind=None): + """Return the editor that is editing an object. + + Returns None if no such editor exists. + + """ + + if self.active_window is None: + return None + + return self.active_window.get_editor(obj, kind) + + def get_editor_by_id(self, id): + """Return the editor with the specified Id. + + Returns None if no such editor exists. + + """ + + return self.active_window.get_editor_by_id(id) + + # Message dialogs ---- + + def confirm(self, message, title=None, cancel=False, default=NO): + """Convenience method to show a confirmation dialog.""" + + return self.active_window.confirm(message, title, cancel, default) + + def information(self, message, title="Information"): + """Convenience method to show an information message dialog.""" + + return self.active_window.information(message, title) + + def warning(self, message, title="Warning"): + """Convenience method to show a warning message dialog.""" + + return self.active_window.warning(message, title) + + def error(self, message, title="Error"): + """Convenience method to show an error message dialog.""" + + return self.active_window.error(message, title) + + # ------------------------------------------------------------------------ + # 'Workbench' interface. + # ------------------------------------------------------------------------ + + # Initializers --------------------------------------------------------- + + def _state_location_default(self): + """Trait initializer.""" + + # It would be preferable to base this on GUI.state_location. + state_location = os.path.join( + ETSConfig.application_home, + "pyface", + "workbench", + ETSConfig.toolkit, + ) + + if not os.path.exists(state_location): + os.makedirs(state_location) + + logger.debug("workbench state location is %s", state_location) + + return state_location + + def _undo_manager_default(self): + """Trait initializer.""" + + # We make sure the undo package is entirely optional. + try: + from pyface.undo.api import UndoManager + except ImportError: + return None + + return UndoManager() + + def _user_perspective_manager_default(self): + """Trait initializer.""" + + return UserPerspectiveManager(state_location=self.state_location) + + # ------------------------------------------------------------------------ + # Protected 'Workbench' interface. + # ------------------------------------------------------------------------ + + def _create_window(self, **kw): + """Factory method that creates a new workbench window.""" + + raise NotImplementedError() + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _close_all_windows(self): + """Closes all open windows. + + Returns True if all windows were closed, False if the user changed + their mind ;^) + + """ + + # We take a copy of the windows list because as windows are closed + # they are removed from it! + windows = self.windows[:] + windows.reverse() + + for window in windows: + # We give the user chance to cancel the exit as each window is + # closed. + if not window.close(): + all_closed = False + break + + else: + all_closed = True + + return all_closed + + def _restore_window_layout(self, window): + """Restore the window layout.""" + + filename = os.path.join(self.state_location, "window_memento") + if os.path.exists(filename): + try: + # If the memento class itself has been modified then there + # is a chance that the unpickle will fail. If so then we just + # carry on as if there was no memento! + f = open(filename, "rb") + memento = pickle.load(f) + f.close() + + # The memento doesn't actually get used until the window is + # opened, so there is nothing to go wrong in this step! + window.set_memento(memento) + + # If *anything* goes wrong then simply log the error and carry on + # with no memento! + except Exception: + logger.exception("restoring window layout from %s", filename) + + def _save_window_layout(self, window): + """Save the window layout.""" + + # Save the window layout. + f = open(os.path.join(self.state_location, "window_memento"), "wb") + pickle.dump(window.get_memento(), f) + f.close() + + return + + # Trait change handlers ------------------------------------------------ + + def _on_window_activated(self, event): + """Dynamic trait change handler.""" + window = event.object + logger.debug("window %s activated", window) + + self.active_window = window + + def _on_window_opening(self, event): + """Dynamic trait change handler.""" + window = event.object + # Event notification. + self.window_opening = window_event = VetoableWindowEvent(window=window) + if window_event.veto: + event.new.veto = True + + def _on_window_opened(self, event): + """Dynamic trait change handler.""" + window = event.object + # We maintain a list of all open windows so that (amongst other things) + # we can detect when the user is attempting to close the last one. + self.windows.append(window) + + # This is necessary because the activated event is not fired when a + # window is first opened and gets focus. It is only fired when the + # window comes from lower in the stack to be the active window. + self.active_window = window + + # Event notification. + self.window_opened = WindowEvent(window=window) + + def _on_window_closing(self, event): + """Dynamic trait change handler.""" + window = event.object + # Event notification. + self.window_closing = window_event = VetoableWindowEvent(window=window) + + if window_event.veto: + event.new.veto = True + + else: + # Is this the last open window? + if len(self.windows) == 1: + # If this is an 'implicit exit' then make sure that we fire the + # appropriate workbench lifecycle events. + if not self._explicit_exit: + # Event notification. + self.exiting = window_event = Vetoable() + if window_event.veto: + event.new.veto = True + + if not event.new.veto: + # Save the window size, position and layout. + self._save_window_layout(window) + + def _on_window_closed(self, event): + """Dynamic trait change handler.""" + window = event.object + self.windows.remove(window) + + # Event notification. + self.window_closed = WindowEvent(window=window) + + # Was this the last window? + if len(self.windows) == 0: + # Event notification. + self.exited = self + + return diff --git a/apptools/workbench/workbench_window.py b/apptools/workbench/workbench_window.py new file mode 100755 index 000000000..10c4f17e9 --- /dev/null +++ b/apptools/workbench/workbench_window.py @@ -0,0 +1,913 @@ +# (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! +""" A workbench window. """ + + +import logging + +from pyface.api import ApplicationWindow, GUI +from traits.api import ( + Constant, + Delegate, + Instance, + List, + observe, + Str, + Tuple, + Undefined, + Vetoable, +) + +from .i_editor import IEditor +from .i_editor_manager import IEditorManager +from .i_perspective import IPerspective +from .i_view import IView +from .i_workbench_part import IWorkbenchPart +from .i_workbench_window_layout import IWorkbenchWindowLayout +from .perspective import Perspective +from .workbench_window_memento import WorkbenchWindowMemento + +# Logging. +logger = logging.getLogger(__name__) + + +class WorkbenchWindow(ApplicationWindow): + """A workbench window.""" + + # 'IWorkbenchWindow' interface ----------------------------------------- + + # The view or editor that currently has the focus. + active_part = Instance(IWorkbenchPart) + + # The editor manager is used to create/restore editors. + editor_manager = Instance(IEditorManager) + + # The current selection within the window. + selection = List() + + # The workbench that the window belongs to. + workbench = Instance("apptools.workbench.i_workbench.IWorkbench") + + # Editors ----------------------- + + # The active editor. + active_editor = Instance(IEditor) + + # The visible (open) editors. + editors = List(IEditor) + + # The Id of the editor area. + editor_area_id = Constant("apptools.workbench.editors") + + # The (initial) size of the editor area (the user is free to resize it of + # course). + editor_area_size = Tuple((100, 100)) + + # Fired when an editor is about to be opened (or restored). + editor_opening = Delegate("layout") # Event(IEditor) + + # Fired when an editor has been opened (or restored). + editor_opened = Delegate("layout") # Event(IEditor) + + # Fired when an editor is about to be closed. + editor_closing = Delegate("layout") # Event(IEditor) + + # Fired when an editor has been closed. + editor_closed = Delegate("layout") # Event(IEditor) + + # Views ------------------------- + + # The active view. + active_view = Instance(IView) + + # The available views (note that this is *all* of the views, not just those + # currently visible). + # + # Views *cannot* be shared between windows as each view has a reference to + # its toolkit-specific control etc. + views = List(IView) + + # Perspectives -----------------# + + # The active perspective. + active_perspective = Instance(IPerspective) + + # The available perspectives. If no perspectives are specified then the + # a single instance of the 'Perspective' class is created. + perspectives = List(IPerspective) + + # The Id of the default perspective. + # + # There are two situations in which this is used: + # + # 1. When the window is being created from scratch (i.e., not restored). + # + # If this is the empty string, then the first perspective in the list of + # perspectives is shown (if there are no perspectives then an instance + # of the default 'Perspective' class is used). If this is *not* the + # empty string then the perspective with this Id is shown. + # + # 2. When the window is being restored. + # + # If this is the empty string, then the last perspective that was + # visible when the window last closed is shown. If this is not the empty + # string then the perspective with this Id is shown. + # + default_perspective_id = Str() + + # 'WorkbenchWindow' interface -----------------------------------------# + + # The window layout is responsible for creating and managing the internal + # structure of the window (i.e., it knows how to add and remove views and + # editors etc). + layout = Instance(IWorkbenchWindowLayout) + + # 'Private' interface -------------------------------------------------# + + # The state of the window suitable for pickling etc. + _memento = Instance(WorkbenchWindowMemento) + + # ------------------------------------------------------------------------ + # 'Window' interface. + # ------------------------------------------------------------------------ + + def open(self): + """Open the window. + + Overridden to make the 'opening' event vetoable. + + Return True if the window opened successfully; False if the open event + was vetoed. + + """ + + logger.debug("window %s opening", self) + + # Trait notification. + self.opening = event = Vetoable() + if not event.veto: + if self.control is None: + self.create() + + self.show(True) + + # Trait notification. + self.opened = self + + logger.debug("window %s opened", self) + + else: + logger.debug("window %s open was vetoed", self) + + # fixme: This is not actually part of the Pyface 'Window' API (but + # maybe it should be). We return this to indicate whether the window + # actually opened. + return self.control is not None + + def close(self): + """Closes the window. + + Overridden to make the 'closing' event vetoable. + + Return True if the window closed successfully (or was not even open!), + False if the close event was vetoed. + + """ + + logger.debug("window %s closing", self) + + if self.control is not None: + # Trait notification. + self.closing = event = Vetoable() + + # fixme: Hack to mimic vetoable events! + if not event.veto: + # Give views and editors a chance to cleanup after themselves. + self.destroy_views(self.views) + self.destroy_editors(self.editors) + + # Cleanup the window layout (event handlers, etc.) + self.layout.close() + + # Cleanup the toolkit-specific control. + self.destroy() + + # Cleanup our reference to the control so that we can (at least + # in theory!) be opened again. + self.control = None + + # Trait notification. + self.closed = self + + logger.debug("window %s closed", self) + + else: + logger.debug("window %s close was vetoed", self) + + else: + logger.debug("window %s is not open", self) + + # FIXME v3: This is not actually part of the Pyface 'Window' API (but + # maybe it should be). We return this to indicate whether the window + # actually closed. + return self.control is None + + # ------------------------------------------------------------------------ + # Protected 'Window' interface. + # ------------------------------------------------------------------------ + + def _create_contents(self, parent): + """Create and return the window contents.""" + + # Create the initial window layout. + contents = self.layout.create_initial_layout(parent) + + # Save the initial window layout so that we can reset it when changing + # to a perspective that has not been seen yet. + self._initial_layout = self.layout.get_view_memento() + + # Are we creating the window from scratch or restoring it from a + # memento? + if self._memento is None: + self._memento = WorkbenchWindowMemento() + + else: + self._restore_contents() + + # Set the initial perspective. + self.active_perspective = self._get_initial_perspective() + + return contents + + # ------------------------------------------------------------------------ + # 'WorkbenchWindow' interface. + # ------------------------------------------------------------------------ + + # Initializers --------------------------------------------------------- + + def _editor_manager_default(self): + """Trait initializer.""" + + from .editor_manager import EditorManager + + return EditorManager(window=self) + + def _layout_default(self): + """Trait initializer.""" + from apptools.workbench.workbench_window_layout import ( + WorkbenchWindowLayout, + ) + + return WorkbenchWindowLayout(window=self) + + # Methods -------------------------------------------------------------# + + def activate_editor(self, editor): + """Activates an editor.""" + + self.layout.activate_editor(editor) + + def activate_view(self, view): + """Activates a view.""" + + self.layout.activate_view(view) + + def add_editor(self, editor, title=None): + """Adds an editor. + + If no title is specified, the editor's name is used. + + """ + + if title is None: + title = editor.name + + self.layout.add_editor(editor, title) + self.editors.append(editor) + + def add_view(self, view, position=None, relative_to=None, size=(-1, -1)): + """Adds a view.""" + + self.layout.add_view(view, position, relative_to, size) + + # This case allows for views that are created and added dynamically + # (i.e. they were not even known about when the window was created). + if view not in self.views: + self.views.append(view) + + def close_editor(self, editor): + """Closes an editor.""" + + self.layout.close_editor(editor) + + def close_view(self, view): + """Closes a view. + + fixme: Currently views are never 'closed' in the same sense as an + editor is closed. Views are merely hidden. + + """ + + self.hide_view(view) + + def create_editor(self, obj, kind=None): + """Create an editor for an object. + + Return None if no editor can be created for the object. + + """ + + return self.editor_manager.create_editor(self, obj, kind) + + def destroy_editors(self, editors): + """Destroy a list of editors.""" + + for editor in editors: + if editor.control is not None: + editor.destroy_control() + + def destroy_views(self, views): + """Destroy a list of views.""" + + for view in views: + if view.control is not None: + view.destroy_control() + + def edit(self, obj, kind=None, use_existing=True): + """Edit an object. + + 'kind' is simply passed through to the window's editor manager to + allow it to create a particular kind of editor depending on context + etc. + + If 'use_existing' is True and the object is already being edited in + the window then the existing editor will be activated (i.e., given + focus, brought to the front, etc.). + + If 'use_existing' is False, then a new editor will be created even if + one already exists. + + """ + + if use_existing: + # Is the object already being edited in the window? + editor = self.get_editor(obj, kind) + + if editor is not None: + # If so, activate the existing editor (i.e., bring it to the + # front, give it the focus etc). + self.activate_editor(editor) + return editor + + # Otherwise, create an editor for it. + editor = self.create_editor(obj, kind) + + if editor is None: + logger.warning("no editor for object %s", obj) + + self.add_editor(editor) + self.activate_editor(editor) + + return editor + + def get_editor(self, obj, kind=None): + """Return the editor that is editing an object. + + Return None if no such editor exists. + + """ + + return self.editor_manager.get_editor(self, obj, kind) + + def get_editor_by_id(self, id): + """Return the editor with the specified Id. + + Return None if no such editor exists. + + """ + + for editor in self.editors: + if editor.id == id: + break + + else: + editor = None + + return editor + + def get_part_by_id(self, id): + """Return the workbench part with the specified Id. + + Return None if no such part exists. + + """ + + return self.get_view_by_id(id) or self.get_editor_by_id(id) + + def get_perspective_by_id(self, id): + """Return the perspective with the specified Id. + + Return None if no such perspective exists. + + """ + + for perspective in self.perspectives: + if perspective.id == id: + break + + else: + if id == Perspective.DEFAULT_ID: + perspective = Perspective() + + else: + perspective = None + + return perspective + + def get_perspective_by_name(self, name): + """Return the perspective with the specified name. + + Return None if no such perspective exists. + + """ + + for perspective in self.perspectives: + if perspective.name == name: + break + + else: + perspective = None + + return perspective + + def get_view_by_id(self, id): + """Return the view with the specified Id. + + Return None if no such view exists. + + """ + + for view in self.views: + if view.id == id: + break + + else: + view = None + + return view + + def hide_editor_area(self): + """Hide the editor area.""" + + self.layout.hide_editor_area() + + def hide_view(self, view): + """Hide a view.""" + + self.layout.hide_view(view) + + def refresh(self): + """Refresh the window to reflect any changes.""" + + self.layout.refresh() + + def reset_active_perspective(self): + """Reset the active perspective back to its original contents.""" + + perspective = self.active_perspective + + # If the perspective has been seen before then delete its memento. + if perspective.id in self._memento.perspective_mementos: + # Remove the perspective's memento. + del self._memento.perspective_mementos[perspective.id] + + # Re-display the perspective (because a memento no longer exists for + # the perspective, its 'create_contents' method will be called again). + self._show_perspective(perspective, perspective) + + def reset_all_perspectives(self): + """Reset all perspectives back to their original contents.""" + + # Remove all perspective mementos (except user perspectives). + for id in self._memento.perspective_mementos.keys(): + if not id.startswith("__user_perspective"): + del self._memento.perspective_mementos[id] + + # Re-display the active perspective. + self._show_perspective( + self.active_perspective, self.active_perspective + ) + + def reset_editors(self): + """Activate the first editor in every tab.""" + + self.layout.reset_editors() + + def reset_views(self): + """Activate the first view in every tab.""" + + self.layout.reset_views() + + def show_editor_area(self): + """Show the editor area.""" + + self.layout.show_editor_area() + + def show_view(self, view): + """Show a view.""" + + # If the view is already in the window layout, but hidden, then just + # show it. + # + # fixme: This is a little gorpy, reaching into the window layout here, + # but currently this is the only thing that knows whether or not the + # view exists but is hidden. + if self.layout.contains_view(view): + self.layout.show_view(view) + + # Otherwise, we have to add the view to the layout. + else: + self._add_view_in_default_position(view) + self.refresh() + + return + + # Methods for saving and restoring the layout -------------------------# + + def get_memento(self): + """Return the state of the window suitable for pickling etc.""" + + # The size and position of the window. + self._memento.size = self.size + self._memento.position = self.position + + # The Id of the active perspective. + self._memento.active_perspective_id = self.active_perspective.id + + # The layout of the active perspective. + self._memento.perspective_mementos[self.active_perspective.id] = ( + self.layout.get_view_memento(), + self.active_view and self.active_view.id or None, + self.layout.is_editor_area_visible(), + ) + + # The layout of the editor area. + self._memento.editor_area_memento = self.layout.get_editor_memento() + + # Any extra toolkit-specific data. + self._memento.toolkit_data = self.layout.get_toolkit_memento() + + return self._memento + + def set_memento(self, memento): + """Restore the state of the window from a memento.""" + + # All we do here is save a reference to the memento - we don't actually + # do anything with it until the window is opened. + # + # This obviously means that you can't set the memento of a window + # that is already open, but I can't see a use case for that anyway! + self._memento = memento + + return + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _add_view_in_default_position(self, view): + """Adds a view in its 'default' position.""" + + # Is the view in the current perspectives contents list? If it is then + # we use the positioning information in the perspective item. Otherwise + # we will use the default positioning specified in the view itself. + item = self._get_perspective_item(self.active_perspective, view) + if item is None: + item = view + + # fixme: This only works because 'PerspectiveItem' and 'View' have the + # identical 'position', 'relative_to', 'width' and 'height' traits! We + # need to unify these somehow! + relative_to = self.get_view_by_id(item.relative_to) + size = (item.width, item.height) + + self.add_view(view, item.position, relative_to, size) + + def _get_initial_perspective(self, *methods): + """Return the initial perspective.""" + + methods = [ + # If a default perspective was specified then we prefer that over + # any other perspective. + self._get_default_perspective, + # If there was no default perspective then try the perspective that + # was active the last time the application was run. + self._get_previous_perspective, + # If there was no previous perspective, then try the first one that + # we know about. + self._get_first_perspective, + ] + + for method in methods: + perspective = method() + if perspective is not None: + break + + # If we have no known perspectives, make a new blank one up. + else: + logger.warning("no known perspectives - creating a new one") + perspective = Perspective() + + return perspective + + def _get_default_perspective(self): + """Return the default perspective. + + Return None if no default perspective was specified or it no longer + exists. + + """ + + id = self.default_perspective_id + + if len(id) > 0: + perspective = self.get_perspective_by_id(id) + if perspective is None: + logger.warning( + "default perspective %s no longer available", id + ) + + else: + perspective = None + + return perspective + + def _get_previous_perspective(self): + """Return the previous perspective. + + Return None if there has been no previous perspective or it no longer + exists. + + """ + + id = self._memento.active_perspective_id + + if len(id) > 0: + perspective = self.get_perspective_by_id(id) + if perspective is None: + logger.warning( + "previous perspective %s no longer available", id + ) + + else: + perspective = None + + return perspective + + def _get_first_perspective(self): + """Return the first perspective in our list of perspectives. + + Return None if no perspectives have been defined. + + """ + + if len(self.perspectives) > 0: + perspective = self.perspectives[0] + + else: + perspective = None + + return perspective + + def _get_perspective_item(self, perspective, view): + """Return the perspective item for a view. + + Return None if the view is not mentioned in the perspectives contents. + + """ + + # fixme: Errrr, shouldn't this be a method on the window?!? + for item in perspective.contents: + if item.id == view.id: + break + + else: + item = None + + return item + + def _hide_perspective(self, perspective): + """Hide a perspective.""" + + # fixme: This is a bit ugly but... when we restore the layout we ignore + # the default view visibility. + for view in self.views: + view.visible = False + + # Save the current layout of the perspective. + self._memento.perspective_mementos[perspective.id] = ( + self.layout.get_view_memento(), + self.active_view and self.active_view.id or None, + self.layout.is_editor_area_visible(), + ) + + def _show_perspective(self, old, new): + """Show a perspective.""" + + # If the perspective has been seen before then restore it. + memento = self._memento.perspective_mementos.get(new.id) + + if memento is not None: + # Show the editor area? + # We need to set the editor area before setting the views + if len(memento) == 2: + logger.warning("Restoring perspective from an older version.") + editor_area_visible = True + else: + editor_area_visible = memento[2] + + # Show the editor area if it is set to be visible + if editor_area_visible: + self.show_editor_area() + else: + self.hide_editor_area() + self.active_editor = None + + # Now set the views + view_memento, active_view_id = memento[:2] + self.layout.set_view_memento(view_memento) + + # Make sure the active part, view and editor reflect the new + # perspective. + view = self.get_view_by_id(active_view_id) + if view is not None: + self.active_view = view + + # Otherwise, this is the first time the perspective has been seen + # so create it. + else: + if old is not None: + # Reset the window layout to its initial state. + self.layout.set_view_memento(self._initial_layout) + + # Create the perspective in the window. + new.create(self) + + # Make sure the active part, view and editor reflect the new + # perspective. + self.active_view = None + + # Show the editor area? + if new.show_editor_area: + self.show_editor_area() + else: + self.hide_editor_area() + self.active_editor = None + + # Inform the perspective that it has been shown. + new.show(self) + + # This forces the dock window to update its layout. + if old is not None: + self.refresh() + + def _restore_contents(self): + """Restore the contents of the window.""" + + self.layout.set_editor_memento(self._memento.editor_area_memento) + + self.size = self._memento.size + self.position = self._memento.position + + # Set the toolkit-specific data last because it may override the generic + # implementation. + # FIXME: The primary use case is to let Qt restore the window's geometry + # wholesale, including maximization state. If we ever go Qt-only, this + # is a good area to refactor. + self.layout.set_toolkit_memento(self._memento) + + return + + # Trait change handlers ------------------------------------------------ + + # Static ---- + + def _active_perspective_changed(self, old, new): + """Static trait change handler.""" + + logger.debug("active perspective changed from <%s> to <%s>", old, new) + + # Hide the old perspective... + if old is not None: + self._hide_perspective(old) + + # ... and show the new one. + if new is not None: + self._show_perspective(old, new) + + def _active_editor_changed(self, old, new): + """Static trait change handler.""" + + logger.debug("active editor changed from <%s> to <%s>", old, new) + self.active_part = new + + def _active_part_changed(self, old, new): + """Static trait change handler.""" + + if new is None: + self.selection = [] + + else: + self.selection = new.selection + + logger.debug("active part changed from <%s> to <%s>", old, new) + + def _active_view_changed(self, old, new): + """Static trait change handler.""" + + logger.debug("active view changed from <%s> to <%s>", old, new) + self.active_part = new + + def _views_changed(self, old, new): + """Static trait change handler.""" + + # Cleanup any old views. + for view in old: + view.window = None + + # Initialize any new views. + for view in new: + view.window = self + + def _views_items_changed(self, event): + """Static trait change handler.""" + + # Cleanup any old views. + for view in event.removed: + view.window = None + + # Initialize any new views. + for view in event.added: + view.window = self + + return + + # Dynamic ---- + + @observe("layout:editor_closed") + def _on_editor_closed(self, event): + """Dynamic trait change handler.""" + + if event.new is None or event.new is Undefined: + return + index = self.editors.index(event.new) + del self.editors[index] + if event.new is self.active_editor: + if len(self.editors) > 0: + index = min(index, len(self.editors) - 1) + # If the user closed the editor manually then this method is + # being called from a toolkit-specific event handler. Because + # of that we have to make sure that we don't change the focus + # from within this method directly hence we activate the editor + # later in the GUI thread. + GUI.invoke_later(self.activate_editor, self.editors[index]) + + else: + self.active_editor = None + + return + + @observe("editors:items:has_focus") + def _on_editor_has_focus_changed(self, event): + """Dynamic trait change handler.""" + + if event.new: + self.active_editor = event.object + + return + + @observe("views:items:has_focus") + def _has_focus_changed_for_view(self, event): + """Dynamic trait change handler.""" + + if event.new: + self.active_view = event.object + + return + + @observe("views:items:visible") + def _visible_changed_for_view(self, event): + """Dynamic trait change handler.""" + + if not event.new: + if event.object is self.active_view: + self.active_view = None + + return diff --git a/apptools/workbench/workbench_window_layout.py b/apptools/workbench/workbench_window_layout.py new file mode 100755 index 000000000..a408e62f0 --- /dev/null +++ b/apptools/workbench/workbench_window_layout.py @@ -0,0 +1,19 @@ +# (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 implementation of a workbench window layout. """ + + +# Import the toolkit specific version. +from .toolkit import toolkit_object + +WorkbenchWindowLayout = toolkit_object( + "workbench_window_layout:WorkbenchWindowLayout" +) diff --git a/apptools/workbench/workbench_window_memento.py b/apptools/workbench/workbench_window_memento.py new file mode 100644 index 000000000..e7a48c3f0 --- /dev/null +++ b/apptools/workbench/workbench_window_memento.py @@ -0,0 +1,38 @@ +# (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! +""" A memento for a workbench window. """ + + +from traits.api import Any, Dict, HasTraits, Str, Tuple + + +class WorkbenchWindowMemento(HasTraits): + """A memento for a workbench window.""" + + # The Id of the active perspective. + active_perspective_id = Str() + + # The memento for the editor area. + editor_area_memento = Any() + + # Mementos for each perspective that has been seen. + # + # The keys are the perspective Ids, the values are the toolkit-specific + # mementos. + perspective_mementos = Dict(Str, Any) + + # The position of the window. + position = Tuple() + + # The size of the window. + size = Tuple() + + # Any extra data the toolkit implementation may want to keep. + toolkit_data = Any() diff --git a/docs/releases/upcoming/331.feature.rst b/docs/releases/upcoming/331.feature.rst new file mode 100644 index 000000000..bb2e2c819 --- /dev/null +++ b/docs/releases/upcoming/331.feature.rst @@ -0,0 +1 @@ +Move Workbench to Apptools, as advertised in Pyface (#331) \ No newline at end of file diff --git a/examples/undo/example.py b/examples/undo/example.py index 821b6c40b..37bee3b81 100644 --- a/examples/undo/example.py +++ b/examples/undo/example.py @@ -18,7 +18,7 @@ # Enthought library imports. from pyface.api import GUI, YES -from pyface.workbench.api import Workbench +from apptools.workbench.api import Workbench # Local imports. from example_undo_window import ExampleUndoWindow diff --git a/examples/undo/example_editor_manager.py b/examples/undo/example_editor_manager.py index 078d996b6..037017c9b 100644 --- a/examples/undo/example_editor_manager.py +++ b/examples/undo/example_editor_manager.py @@ -15,7 +15,7 @@ # Enthought library imports. from traits.etsconfig.api import ETSConfig -from pyface.workbench.api import Editor, EditorManager +from apptools.workbench.api import Editor, EditorManager class _wxLabelEditor(Editor): diff --git a/examples/undo/example_undo_window.py b/examples/undo/example_undo_window.py index 979de5508..2da7aa77a 100644 --- a/examples/undo/example_undo_window.py +++ b/examples/undo/example_undo_window.py @@ -15,8 +15,8 @@ # Enthought library imports. from pyface.action.api import Action, Group, MenuManager -from pyface.workbench.api import WorkbenchWindow -from pyface.workbench.action.api import MenuBarManager, ToolBarManager +from apptools.workbench.api import WorkbenchWindow +from apptools.workbench.action.api import MenuBarManager, ToolBarManager from traits.api import Instance from apptools.undo.action.api import CommandAction, RedoAction, UndoAction diff --git a/examples/workbench/black_view.py b/examples/workbench/black_view.py new file mode 100644 index 000000000..cedd85c71 --- /dev/null +++ b/examples/workbench/black_view.py @@ -0,0 +1,26 @@ +# (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! +""" A view containing a black panel! """ + + +from color_view import ColorView + + +class BlackView(ColorView): + """A view containing a black panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The view's name. + name = "Black" + + # The default position of the view relative to the item specified in the + # 'relative_to' trait. + position = "top" diff --git a/examples/workbench/blue_view.py b/examples/workbench/blue_view.py new file mode 100644 index 000000000..ec26c9d58 --- /dev/null +++ b/examples/workbench/blue_view.py @@ -0,0 +1,26 @@ +# (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! +""" A view containing a blue panel! """ + + +from color_view import ColorView + + +class BlueView(ColorView): + """A view containing a blue panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The view's name. + name = "Blue" + + # The default position of the view relative to the item specified in the + # 'relative_to' trait. + position = "bottom" diff --git a/examples/workbench/color_view.py b/examples/workbench/color_view.py new file mode 100644 index 000000000..5f19af3a6 --- /dev/null +++ b/examples/workbench/color_view.py @@ -0,0 +1,81 @@ +# (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! + +""" A view containing a colored panel! """ + +from apptools.workbench.api import View, toolkit + + +class ColorView(View): + """A view containing a colored panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The category that the view belongs to. + category = "Color" + + # ------------------------------------------------------------------------ + # 'IWorkbenchPart' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _id_default(self): + """Trait initializer.""" + + # By making the Id the same as the name, we make it easy to specify + # the views in the example perspectives. Note for larger applications + # the Id should be globally unique, and by default we use the module + # name and class name. + return self.name + + # Methods -------------------------------------------------------------- + + def create_control(self, parent): + """Creates the toolkit-specific control that represents the view. + + 'parent' is the toolkit-specific control that is the view's parent. + + """ + method = getattr(self, "_%s_create_control" % toolkit.toolkit, None) + if method is None: + raise SystemError("Unknown toolkit %s", toolkit.toolkit) + + color = self.name.lower() + + return method(parent, color) + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _wx_create_control(self, parent, color): + """Create a wx version of the control.""" + + import wx + + panel = wx.Panel(parent, -1) + panel.SetBackgroundColour(color) + + return panel + + def _qt_create_control(self, parent, color): + """Create a Qt version of the control.""" + + from pyface.qt import QtGui + + widget = QtGui.QWidget(parent) + + palette = widget.palette() + palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(color)) + widget.setPalette(palette) + widget.setAutoFillBackground(True) + + return widget diff --git a/examples/workbench/example_workbench.py b/examples/workbench/example_workbench.py new file mode 100644 index 000000000..53a8597b9 --- /dev/null +++ b/examples/workbench/example_workbench.py @@ -0,0 +1,38 @@ +# (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! +""" A simple example of using the workbench. """ + + +from example_workbench_window import ExampleWorkbenchWindow +from pyface.api import YES + +from apptools.workbench.api import Workbench + + +class ExampleWorkbench(Workbench): + """A simple example of using the workbench.""" + + # 'Workbench' interface ------------------------------------------------ + + # The factory (in this case simply a class!) that is used to create + # workbench windows. + window_factory = ExampleWorkbenchWindow + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def _exiting_changed(self, event): + """Called when the workbench is exiting.""" + + if self.confirm("Ok to exit?") != YES: + event.veto = True + + return diff --git a/examples/workbench/example_workbench_window.py b/examples/workbench/example_workbench_window.py new file mode 100644 index 000000000..30713a438 --- /dev/null +++ b/examples/workbench/example_workbench_window.py @@ -0,0 +1,185 @@ +# (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! +""" A simple example of using the workbench window. """ + + +from black_view import BlackView +from blue_view import BlueView +from green_view import GreenView +from person import Person +from pyface.action.api import Action, MenuManager +from red_view import RedView +from traits.api import Callable, Instance, List +from yellow_view import YellowView + +from apptools.workbench.action.api import ( + MenuBarManager, + ToolBarManager, + ViewMenuManager, +) +from apptools.workbench.api import ( + EditorManager, + Perspective, + PerspectiveItem, + WorkbenchWindow, +) + + +class ExampleEditorManager(EditorManager): + """An editor manager that supports the editor memento protocol.""" + + # --------------------------------------------------------------------- + # 'IEditorManager' interface. + # --------------------------------------------------------------------- + + def get_editor_memento(self, editor): + """Return the state of the editor contents.""" + + # Return the data attributes as a tuple. + return (editor.obj.name, editor.obj.age) + + def set_editor_memento(self, memento): + """Restore an editor from a memento and return it.""" + + # Create a new data object. + name, age = memento + person = Person(name=name, age=age) + + # Create an editor for the data. + return self.create_editor(self.window, person, None) + + +class ExampleWorkbenchWindow(WorkbenchWindow): + """A simple example of using the workbench window.""" + + # 'WorkbenchWindow' interface ------------------------------------------ + + # The available perspectives. + perspectives = [ + Perspective( + name="Foo", + contents=[ + PerspectiveItem(id="Black", position="bottom", height=0.1), + PerspectiveItem(id="Debug", position="left", width=0.25), + ], + ), + Perspective( + name="Bar", + contents=[ + PerspectiveItem(id="Black", position="top"), + PerspectiveItem(id="Blue", position="bottom"), + PerspectiveItem(id="Green", position="left"), + PerspectiveItem(id="Red", position="right"), + PerspectiveItem(id="Debug", position="left"), + ], + ), + ] + + # 'ExampleWorkbenchWindow' interface ----------------------------------- + + # The view factories. + # + # fixme: This should be part of the standadr 'WorkbenchWindow'! + view_factories = List(Callable) + + # Private interface ---------------------------------------------------- + + # The Exit action. + _exit_action = Instance(Action) + + # The New Person action. + _new_person_action = Instance(Action) + + # ------------------------------------------------------------------------ + # 'ApplicationWindow' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _editor_manager_default(self): + """Trait initializer. + + Here we return the replacement editor manager. + """ + + return ExampleEditorManager() + + def _menu_bar_manager_default(self): + """Trait initializer.""" + + file_menu = MenuManager( + self._new_person_action, + self._exit_action, + name="&File", + id="FileMenu", + ) + view_menu = ViewMenuManager(name="&View", id="ViewMenu", window=self) + + return MenuBarManager(file_menu, view_menu, window=self) + + def _tool_bar_managers_default(self): + """Trait initializer.""" + + # Add multiple (albeit identical!) tool bars just to show that it is + # allowed! + tool_bar_managers = [ + ToolBarManager( + self._exit_action, + show_tool_names=False, + name=str(i), + ) + for i in range(5) + ] + + return tool_bar_managers + + # ------------------------------------------------------------------------ + # 'WorkbenchWindow' interface. + # ------------------------------------------------------------------------ + + # Trait initializers --------------------------------------------------- + + def _view_factories_default(self): + """Trait initializer.""" + + from apptools.workbench.debug.api import DebugView + + return [DebugView, BlackView, BlueView, GreenView, RedView, YellowView] + + def _views_default(self): + """Trait initializer.""" + + # Using an initializer makes sure that every window instance gets its + # own view instances (which is necessary since each view has a + # reference to its toolkit-specific control etc.). + return [factory(window=self) for factory in self.view_factories] + + # ------------------------------------------------------------------------ + # Private interface. + # ------------------------------------------------------------------------ + + def __exit_action_default(self): + """Trait initializer.""" + + return Action(name="E&xit", on_perform=self.workbench.exit) + + def __new_person_action_default(self): + """Trait initializer.""" + + return Action(name="New Person", on_perform=self._new_person) + + def _new_person(self): + """Create a new person.""" + + from person import Person + + self.workbench.edit(Person(name="New", age=100)) + + return diff --git a/examples/workbench/green_view.py b/examples/workbench/green_view.py new file mode 100644 index 000000000..0e08d04e5 --- /dev/null +++ b/examples/workbench/green_view.py @@ -0,0 +1,26 @@ +# (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! +""" A view containing a green panel! """ + + +from color_view import ColorView + + +class GreenView(ColorView): + """A view containing a green panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The view's name. + name = "Green" + + # The default position of the view relative to the item specified in the + # 'relative_to' trait. + position = "left" diff --git a/examples/workbench/images/example.ico b/examples/workbench/images/example.ico new file mode 100644 index 000000000..7af90ee96 Binary files /dev/null and b/examples/workbench/images/example.ico differ diff --git a/examples/workbench/person.py b/examples/workbench/person.py new file mode 100644 index 000000000..e34f324b6 --- /dev/null +++ b/examples/workbench/person.py @@ -0,0 +1,32 @@ +# (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! +""" A simple example of some object model. """ + + +from traits.api import HasTraits, Int, Str + + +class Person(HasTraits): + """A simple example of some object model!""" + + # Age in years. + age = Int() + + # Name. + name = Str() + + # ------------------------------------------------------------------------ + # 'object' interface. + # ------------------------------------------------------------------------ + + def __str__(self): + """Return an informal string representation of the object.""" + + return self.name diff --git a/examples/workbench/red_view.py b/examples/workbench/red_view.py new file mode 100644 index 000000000..a72fd8dc7 --- /dev/null +++ b/examples/workbench/red_view.py @@ -0,0 +1,26 @@ +# (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! +""" A view containing a red panel! """ + + +from color_view import ColorView + + +class RedView(ColorView): + """A view containing a red panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The view's name. + name = "Red" + + # The default position of the view relative to the item specified in the + # 'relative_to' trait. + position = "right" diff --git a/examples/workbench/run.py b/examples/workbench/run.py new file mode 100644 index 000000000..d1421393d --- /dev/null +++ b/examples/workbench/run.py @@ -0,0 +1,71 @@ +# (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! +""" Run the workbench example. """ + + +import logging + +from example_workbench import ExampleWorkbench +from person import Person +from pyface.api import GUI + +# Log to stderr. +logger = logging.getLogger() +logger.addHandler(logging.StreamHandler(open("example_workbench.log", "w"))) +logger.setLevel(logging.DEBUG) + + +def main(argv): + """A simple example of using the workbench.""" + + # Create the GUI (this does NOT start the GUI event loop). + gui = GUI() + + # Create some objects to edit. + fred = Person(name="fred", age=42) + wilma = Person(name="wilma", age=35) + + # Create the workbench. + # + # fixme: I wouldn't really want to specify the state location here. + # Ideally this would be part of the GUI's as DOMs idea, and the state + # location would be an attribute picked up from the DOM hierarchy. This + # would also be the mechanism for doing 'confirm' etc... Let the request + # bubble up the DOM until somebody handles it. + workbench = ExampleWorkbench(state_location=gui.state_location) + + # Create some workbench windows. + x = 300 + y = 300 + for i in range(2): + window = workbench.create_window(position=(x, y), size=(800, 600)) + window.open() + + # Edit the objects if they weren't restored from a previous session. + if window.get_editor_by_id("fred") is None: + window.edit(fred) + + if window.get_editor_by_id("wilma") is None: + window.edit(wilma) + + # Cascade the windows. + x += 100 + y += 100 + + # Start the GUI event loop. + gui.start_event_loop() + + return + + +if __name__ == "__main__": + import sys + + main(sys.argv) diff --git a/examples/workbench/yellow_view.py b/examples/workbench/yellow_view.py new file mode 100644 index 000000000..e25e8288c --- /dev/null +++ b/examples/workbench/yellow_view.py @@ -0,0 +1,31 @@ +# (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! +""" A view containing a yellow panel! """ + + +from color_view import ColorView + + +class YellowView(ColorView): + """A view containing a yellow panel!""" + + # 'IView' interface ---------------------------------------------------- + + # The view's name. + name = "Yellow" + + # The default position of the view relative to the item specified in the + # 'relative_to' trait. + position = "with" + + # The Id of the view to position this view relative to. If this is not + # specified (or if no view exists with this Id) then the view will be + # placed relative to the editor area. + relative_to = "Green" diff --git a/pyproject.toml b/pyproject.toml index 7e01ab27f..e528f9f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,11 @@ [build-system] requires = ['setuptools', 'wheel'] build-backend = 'setuptools.build_meta' + +[tool.isort] +profile = 'black' +sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'ENTHOUGHT', 'FIRSTPARTY', 'LOCALFOLDER'] +known_third_party = ['wx', 'PyQt5', 'PyQt6', 'PySide2', 'PySide6', 'PIL', 'pygments', 'numpy'] +known_enthought = ['pyface', 'traits', 'traitsui'] +line_length = 79 +order_by_type = false diff --git a/setup.cfg b/setup.cfg index c0076014c..ddd8f5c98 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,12 @@ [flake8] ignore = E266, W503 -per-file-ignores = +per-file-ignores = */api.py:F401, */__init__.py:F401, + apptools/workbench/api.py:F401 + apptools/workbench/*/api.py:F401 # ignore undo related errors because it has been copied to pyface and will # soon be deprecated. See enthought/apptools#243 */undo/*:F401,H101 + # blackend code likes longer lines + apptools/workbench/*:E501