diff --git a/enaml/workbench/__init__.py b/enaml/workbench/__init__.py new file mode 100644 index 000000000..0bc6ead48 --- /dev/null +++ b/enaml/workbench/__init__.py @@ -0,0 +1,7 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ diff --git a/enaml/workbench/api.py b/enaml/workbench/api.py new file mode 100644 index 000000000..a981af214 --- /dev/null +++ b/enaml/workbench/api.py @@ -0,0 +1,12 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from .extension import Extension +from .extension_point import ExtensionPoint +from .plugin import Plugin +from .plugin_manifest import PluginManifest +from .workbench import Workbench diff --git a/enaml/workbench/core/__init__.py b/enaml/workbench/core/__init__.py new file mode 100644 index 000000000..0bc6ead48 --- /dev/null +++ b/enaml/workbench/core/__init__.py @@ -0,0 +1,7 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ diff --git a/enaml/workbench/core/api.py b/enaml/workbench/core/api.py new file mode 100644 index 000000000..12a5bad35 --- /dev/null +++ b/enaml/workbench/core/api.py @@ -0,0 +1,8 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from .command import Command diff --git a/enaml/workbench/core/command.py b/enaml/workbench/core/command.py new file mode 100644 index 000000000..7afc1a10c --- /dev/null +++ b/enaml/workbench/core/command.py @@ -0,0 +1,25 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Callable, Unicode + +from enaml.core.declarative import Declarative, d_ + + +class Command(Declarative): + """ A declarative class for defining a workbench command. + + """ + #: The globally unique identifier for the command. + id = d_(Unicode()) + + #: An optional description of the command. + description = d_(Unicode()) + + #: A required callable which handles the command. It must accept a + #: single argument, which is an instance of ExecutionEvent. + handler = d_(Callable()) diff --git a/enaml/workbench/core/core_manifest.enaml b/enaml/workbench/core/core_manifest.enaml new file mode 100644 index 000000000..21b1af9fb --- /dev/null +++ b/enaml/workbench/core/core_manifest.enaml @@ -0,0 +1,36 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.workbench.extension_point import ExtensionPoint +from enaml.workbench.plugin_manifest import PluginManifest + + +def core_plugin_factory(): + """ A factory function which creates a CorePlugin instance. + + """ + from .core_plugin import CorePlugin + return CorePlugin() + + +COMMANDS_DESCRIPTION = \ +""" Extensions to this point may contribute `Command` objects which can +be invoked via the `invoke_command` method of the CorePlugin instance. +Commands can be provided by declaring them as children of the Extension +and/or by declaring a factory function which takes the workbench as an +argument and returns a list of Command instances. """ + + +enamldef CoreManifest(PluginManifest): + """ The manifest for the Enaml workbench core plugin. + + """ + id = 'enaml.workbench.core' + factory = core_plugin_factory + ExtensionPoint: + id = 'commands' + description = COMMANDS_DESCRIPTION diff --git a/enaml/workbench/core/core_plugin.py b/enaml/workbench/core/core_plugin.py new file mode 100644 index 000000000..c3944c811 --- /dev/null +++ b/enaml/workbench/core/core_plugin.py @@ -0,0 +1,164 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from collections import defaultdict + +from atom.api import Typed + +from enaml.workbench.plugin import Plugin + +from .command import Command +from .execution_event import ExecutionEvent + + +COMMANDS_POINT = u'enaml.workbench.core.commands' + + +class CorePlugin(Plugin): + """ The core plugin for the Enaml workbench. + + """ + def start(self): + """ Start the plugin life-cycle. + + This method is called by the framework at the appropriate time. + It should never be called by user code. + + """ + self._refresh_commands() + self._bind_observers() + + def stop(self): + """ Stop the plugin life-cycle. + + This method is called by the framework at the appropriate time. + It should never be called by user code. + + """ + self._unbind_observers() + self._commands.clear() + self._command_extensions.clear() + + def invoke_command(self, command_id, parameters={}, trigger=None): + """ Invoke the command handler for the given command id. + + Parameters + ---------- + command_id : unicode + The unique identifier of the command to invoke. + + parameters : dict, optional + The parameters to pass to the command handler. + + trigger : object, optional + The object which triggered the command. + + """ + if command_id not in self._commands: + msg = "'%s' is not a registered command id" + raise ValueError(msg % command_id) + + command = self._commands[command_id] + + event = ExecutionEvent() + event.command = command + event.workbench = self.workbench + event.parameters = parameters + event.trigger = trigger + + command.handler(event) + + #-------------------------------------------------------------------------- + # Private API + #-------------------------------------------------------------------------- + #: The mapping of command id to Command object. + _commands = Typed(dict, ()) + + #: The mapping of extension object to list of Command objects. + _command_extensions = Typed(defaultdict, (list,)) + + def _refresh_commands(self): + """ Refresh the command objects for the plugin. + + """ + workbench = self.workbench + point = workbench.get_extension_point(COMMANDS_POINT) + extensions = point.extensions + if not extensions: + self._commands.clear() + self._command_extensions.clear() + return + + new_extensions = defaultdict(list) + old_extensions = self._command_extensions + for extension in extensions: + if extension in old_extensions: + commands = old_extensions[extension] + else: + commands = self._load_commands(extension) + new_extensions[extension].extend(commands) + + commands = {} + for extension in extensions: + for command in new_extensions[extension]: + if command.id in commands: + msg = "command '%s' is already registered" + raise ValueError(msg % command.id) + if command.handler is None: + msg = "command '%s' does not declare a handler" + raise ValueError(msg % command.id) + commands[command.id] = command + + self._commands = commands + self._command_extensions = new_extensions + + def _load_commands(self, extension): + """ Load the command objects for the given extension. + + Parameters + ---------- + extension : Extension + The extension object of interest. + + Returns + ------- + result : list + The list of Command objects declared by the extension. + + """ + workbench = self.workbench + commands = extension.get_children(Command) + if extension.factory is not None: + for item in extension.factory(workbench): + if not isinstance(item, Command): + msg = "extension '%s' created non-Command of type '%s'" + args = (extension.qualified_id, type(item).__name__) + raise TypeError(msg % args) + commands.append(item) + return commands + + def _on_commands_updated(self, change): + """ The observer for the commands extension point. + + """ + self._refresh_commands() + + def _bind_observers(self): + """ Setup the observers for the plugin. + + """ + workbench = self.workbench + point = workbench.get_extension_point(COMMANDS_POINT) + point.observe('extensions', self._on_commands_updated) + + def _unbind_observers(self): + """ Remove the observers for the plugin. + + """ + workbench = self.workbench + point = workbench.get_extension_point(COMMANDS_POINT) + point.unobserve('extensions', self._on_commands_updated) diff --git a/enaml/workbench/core/execution_event.py b/enaml/workbench/core/execution_event.py new file mode 100644 index 000000000..f9b0598dc --- /dev/null +++ b/enaml/workbench/core/execution_event.py @@ -0,0 +1,29 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Atom, Dict, Typed, Value + +from enaml.workbench.workbench import Workbench + +from .command import Command + + +class ExecutionEvent(Atom): + """ The object passed to a command handler when it is invoked. + + """ + #: The command which is being invoked. + command = Typed(Command) + + #: The workbench instance which owns the command. + workbench = Typed(Workbench) + + #: The user-supplied parameters for the command. + parameters = Dict() + + #: The user-object object which triggered the command. + trigger = Value() diff --git a/enaml/workbench/extension.py b/enaml/workbench/extension.py new file mode 100644 index 000000000..e97567831 --- /dev/null +++ b/enaml/workbench/extension.py @@ -0,0 +1,90 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Callable, Int, Unicode + +from enaml.core.declarative import Declarative, d_ + + +class Extension(Declarative): + """ A declarative class which represents a plugin extension. + + An Extension must be declared as a child of a PluginManifest. + + """ + #: The globally unique identifier for the extension. + id = d_(Unicode()) + + #: The fully qualified id of the target extension point. + point = d_(Unicode()) + + #: An optional rank to use for order the extension among others. + rank = d_(Int()) + + #: A callable which will create the implementation object for the + #: extension point. The call signature and return type are defined + #: by the extension point plugin which invokes the factory. + factory = d_(Callable()) + + #: An optional description of the extension. + description = d_(Unicode()) + + @property + def plugin_id(self): + """ Get the plugin id from the parent plugin manifest. + + """ + return self.parent.id + + @property + def qualified_id(self): + """ Get the fully qualified extension identifer. + + """ + this_id = self.id + if u'.' in this_id: + return this_id + return u'%s.%s' % (self.plugin_id, this_id) + + def get_child(self, kind, reverse=False): + """ Find a child by the given type. + + Parameters + ---------- + kind : type + The declartive type of the child of interest. + + reverse : bool, optional + Whether to search in reversed order. The default is False. + + Returns + ------- + result : child or None + The first child found of the requested type. + + """ + it = reversed if reverse else iter + for child in it(self.children): + if isinstance(child, kind): + return child + return None + + def get_children(self, kind): + """ Get all the children of the given type. + + Parameters + ---------- + kind : type + The declartive type of the children of interest. + + Returns + ------- + result : list + The list of children of the request type. + + """ + return [c for c in self.children if isinstance(c, kind)] diff --git a/enaml/workbench/extension_point.py b/enaml/workbench/extension_point.py new file mode 100644 index 000000000..d0da39435 --- /dev/null +++ b/enaml/workbench/extension_point.py @@ -0,0 +1,46 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Tuple, Unicode + +from enaml.core.declarative import Declarative, d_ + + +class ExtensionPoint(Declarative): + """ A declarative class which represents a pulgin extension point. + + An ExtensionPoint must be declared as a child of a PluginManifest. + + """ + #: The globally unique identifier for the extension point. + id = d_(Unicode()) + + #: The tuple of extensions contributed to this extension point. The + #: tuple is updated by the framework as needed. It is kept in sorted + #: order from lowest to highest extension rank. This should never be + #: modified directly by user code. + extensions = Tuple() + + #: An optional description of the extension point. + description = d_(Unicode()) + + @property + def plugin_id(self): + """ Get the plugin id from the parent plugin manifest. + + """ + return self.parent.id + + @property + def qualified_id(self): + """ Get the fully qualified extension point identifer. + + """ + this_id = self.id + if u'.' in this_id: + return this_id + return u'%s.%s' % (self.plugin_id, this_id) diff --git a/enaml/workbench/plugin.py b/enaml/workbench/plugin.py new file mode 100644 index 000000000..846e108f6 --- /dev/null +++ b/enaml/workbench/plugin.py @@ -0,0 +1,51 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Atom, Typed + +from .plugin_manifest import PluginManifest + + +class Plugin(Atom): + """ A base class for defining workbench plugins. + + """ + #: A reference to the plugin manifest instance which declared the + #: plugin. This is assigned by the framework and should never be + #: manipulated by user code. + manifest = Typed(PluginManifest) + + @property + def workbench(self): + """ Get the workbench which is handling the plugin. + + """ + return self.manifest.workbench + + def start(self): + """ Start the life-cycle of the plugin. + + This method will be called by the workbench after it creates + the plugin. The default implementation does nothing and can be + ignored by subclasses which do not need life-cycle behavior. + + This method should never be called by user code. + + """ + pass + + def stop(self): + """ Stop the life-cycle of the plugin. + + This method will be called by the workbench when the plugin is + removed. The default implementation does nothing and can be + ignored by subclasses which do not need life-cycle behavior. + + This method should never be called by user code. + + """ + pass diff --git a/enaml/workbench/plugin_manifest.py b/enaml/workbench/plugin_manifest.py new file mode 100644 index 000000000..2d848714b --- /dev/null +++ b/enaml/workbench/plugin_manifest.py @@ -0,0 +1,66 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Callable, ForwardTyped, Unicode + +from enaml.core.declarative import Declarative, d_ + +from .extension import Extension +from .extension_point import ExtensionPoint + + +def Workbench(): + """ A lazy forward import function for the Workbench type. + + """ + from .workbench import Workbench + return Workbench + + +def plugin_factory(): + """ A factory function which returns a plain Plugin instance. + + """ + from .plugin import Plugin + return Plugin() + + +class PluginManifest(Declarative): + """ A declarative class which represents a plugin manifest. + + """ + #: The globally unique identifier for the plugin. The suggested + #: format is dot-separated, e.g. 'foo.bar.baz'. + id = d_(Unicode()) + + #: The factory which will create the Plugin instance. It should + #: take no arguments and return an instance of Plugin. Well behaved + #: applications will make this a function which lazily imports the + #: plugin class so that startup times remain small. + factory = d_(Callable(plugin_factory)) + + #: The workbench instance with which this manifest is registered. + #: This is assigned by the framework and should not be manipulated + #: by user code. + workbench = ForwardTyped(Workbench) + + #: An optional description of the plugin. + description = d_(Unicode()) + + @property + def extensions(self): + """ Get the list of extensions defined by the manifest. + + """ + return [c for c in self.children if isinstance(c, Extension)] + + @property + def extension_points(self): + """ Get the list of extensions points defined by the manifest. + + """ + return [c for c in self.children if isinstance(c, ExtensionPoint)] diff --git a/enaml/workbench/ui/__init__.py b/enaml/workbench/ui/__init__.py new file mode 100644 index 000000000..0bc6ead48 --- /dev/null +++ b/enaml/workbench/ui/__init__.py @@ -0,0 +1,7 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ diff --git a/enaml/workbench/ui/action_item.py b/enaml/workbench/ui/action_item.py new file mode 100644 index 000000000..a4b1120f6 --- /dev/null +++ b/enaml/workbench/ui/action_item.py @@ -0,0 +1,61 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Bool, Dict, Typed, Unicode + +from enaml.core.declarative import Declarative, d_ +from enaml.icon import Icon + + +class ActionItem(Declarative): + """ A declarative class for defining a workbench action item. + + """ + #: The "/" separated path to this item in the menu bar. + path = d_(Unicode()) + + #: The parent menu group to which this action item belongs. + group = d_(Unicode()) + + #: The action item will appear before this item in its group. + before = d_(Unicode()) + + #: The action item will appear after this item in its group. + after = d_(Unicode()) + + #: The id of the Command invoked by the action. + command = d_(Unicode()) + + #: The user parameters to pass to the command handler. + parameters = d_(Dict()) + + #: The display label for the action. + label = d_(Unicode()) + + #: The shortcut keybinding for the action. e.g. Ctrl+C + shortcut = d_(Unicode()) + + #: Whether or not the action is visible. + visible = d_(Bool(True)) + + #: Whether or not the action is enabled. + enabled = d_(Bool(True)) + + #: Whether or not the action is checkable. + checkable = d_(Bool(False)) + + #: Whether or not the checkable action is checked. + checked = d_(Bool(False)) + + #: The default display icon for the action. + icon = d_(Typed(Icon)) + + #: The tooltip for the action. + tool_tip = d_(Unicode()) + + #: The statustip for the action. + status_tip = d_(Unicode()) diff --git a/enaml/workbench/ui/api.py b/enaml/workbench/ui/api.py new file mode 100644 index 000000000..809526f8f --- /dev/null +++ b/enaml/workbench/ui/api.py @@ -0,0 +1,14 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from .action_item import ActionItem +from .autostart import Autostart +from .branding import Branding +from .item_group import ItemGroup +from .menu_item import MenuItem +from .ui_workbench import UIWorkbench +from .workspace import Workspace diff --git a/enaml/workbench/ui/autostart.py b/enaml/workbench/ui/autostart.py new file mode 100644 index 000000000..1aec9174e --- /dev/null +++ b/enaml/workbench/ui/autostart.py @@ -0,0 +1,18 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Unicode + +from enaml.core.declarative import Declarative, d_ + + +class Autostart(Declarative): + """ A declarative object for use with auto start extensions. + + """ + #: The id of the plugin which should be preemptively started. + plugin_id = d_(Unicode()) diff --git a/enaml/workbench/ui/branding.py b/enaml/workbench/ui/branding.py new file mode 100644 index 000000000..2af988072 --- /dev/null +++ b/enaml/workbench/ui/branding.py @@ -0,0 +1,22 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Typed, Unicode + +from enaml.core.declarative import Declarative, d_ +from enaml.icon import Icon + + +class Branding(Declarative): + """ A declarative class for defining window branding. + + """ + #: The primary title of the workbench window. + title = d_(Unicode()) + + #: The icon for the workbench window. + icon = d_(Typed(Icon)) diff --git a/enaml/workbench/ui/item_group.py b/enaml/workbench/ui/item_group.py new file mode 100644 index 000000000..005dd6722 --- /dev/null +++ b/enaml/workbench/ui/item_group.py @@ -0,0 +1,27 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Bool, Unicode + +from enaml.core.declarative import Declarative, d_ + + +class ItemGroup(Declarative): + """ A declarative class for defining an item group in a menu. + + """ + #: The identifier of group within the menu. + id = d_(Unicode()) + + #: Whether or not the group is visible. + visible = d_(Bool(True)) + + #: Whether or not the group is enabled. + enabled = d_(Bool(True)) + + #: Whether or not checkable ations in the group are exclusive. + exclusive = d_(Bool(False)) diff --git a/enaml/workbench/ui/menu_helper.py b/enaml/workbench/ui/menu_helper.py new file mode 100644 index 000000000..96ff92e91 --- /dev/null +++ b/enaml/workbench/ui/menu_helper.py @@ -0,0 +1,345 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from collections import defaultdict + +from atom.api import Atom, Instance, List, Typed + +import kiwisolver as kiwi + +from enaml.widgets.action import Action +from enaml.workbench.workbench import Workbench + +from .action_item import ActionItem +from .item_group import ItemGroup +from .menu_item import MenuItem + +import enaml +with enaml.imports(): + from .workbench_menus import ( + WorkbenchAction, WorkbenchActionGroup, WorkbenchMenu + ) + + +def solve_ordering(nodes): + """ Solve for the desired order of the list of nodes. + + This function is an implementation detail and should not be + consumed by code outside of this module. + + Parameters + ---------- + nodes : list + The list of PathNode objects which should be ordered. It + is assumed that all nodes reside in the same group. + + Returns + ------- + result : list + The PathNode objects ordered according to the constraints + specified by the 'before' and 'after' items attributes. + + """ + variables = {} + for idx, node in enumerate(nodes): + variables[node.id] = kiwi.Variable(str(node.id)) + + prev_var = None + constraints = [] + for node in nodes: + this_var = variables[node.id] + constraints.append(this_var >= 0) + if prev_var is not None: # weakly preserve relative order + constraints.append((prev_var + 0.1 <= this_var) | 'weak') + before = node.item.before + if before: + if before not in variables: + msg = "item '%s' has invalid `before` reference '%s'" + raise ValueError(msg % (node.path, before)) + target_var = variables[before] + constraints.append((this_var + 0.1 <= target_var) | 'strong') + after = node.item.after + if after: + if after not in variables: + msg = "item '%s' has invalid `after` reference '%s'" + raise ValueError(msg % (node.path, after)) + target_var = variables[after] + constraints.append((target_var + 0.1 <= this_var) | 'strong') + prev_var = this_var + + solver = kiwi.Solver() + for cn in constraints: + solver.addConstraint(cn) + solver.updateVariables() + + flat = [] + for node in nodes: + node_var = variables[node.id] + flat.append((node_var.value(), node)) + flat.sort() + + return [pair[1] for pair in flat] + + +class PathNode(Atom): + """ The base class for the menu building nodes. + + This class is an implementation detail and should not be consumed + by code outside of this module. + + """ + #: The declarative item for the node. + item = Instance((MenuItem, ActionItem)) + + @property + def path(self): + """ Get the sanitized path for the node. + + """ + path = self.item.path.rstrip(u'/') + if not path: + return u'/' + if path[0] != u'/': + return u'/' + path + return path + + @property + def parent_path(self): + """ Get the sanitized path of the parent node. + + """ + path = self.path.rsplit(u'/', 1)[0] + return path or u'/' + + @property + def id(self): + """ Get the id portion of the path. + + """ + return self.path.rsplit(u'/', 1)[1] + + def assemble(self): + """ Assemble the menu or action object for the node. + + """ + raise NotImplementedError + + +class ActionNode(PathNode): + """ A path node representing an action item. + + This class is an implementation detail and should not be consumed + by code outside of this module. + + """ + #: The workbench instance to associate with action. + workbench = Typed(Workbench) + + def assemble(self): + """ Assemble and return a WorkbenchAction for the node. + + """ + return WorkbenchAction(workbench=self.workbench, item=self.item) + + +class MenuNode(PathNode): + """ A path node representing a menu item. + + This class is an implementation detail and should not be consumed + by code outside of this module. + + """ + #: The child objects defined for this menu node. + children = List(PathNode) + + def group_data(self): + """ The group map and list of group items for the node. + + Returns + ------- + result : tuple + A tuple of (dict, list) which holds the mapping of group + id to ItemGroup object, and the flat list of ordered groups. + + """ + group_map = {} + item_groups = self.item.item_groups + + for group in item_groups: + if group.id in group_map: + msg = "menu item '%s' has duplicate group '%s'" + raise ValueError(msg % (self.path, group.id)) + group_map[group.id] = group + + if u'' not in group_map: + group = ItemGroup() + group_map[u''] = group + item_groups.append(group) + + return group_map, item_groups + + def collect_child_groups(self): + """ Yield the ordered and grouped children. + + """ + group_map, item_groups = self.group_data() + + grouped = defaultdict(list) + for child in self.children: + target_group = child.item.group + if target_group not in group_map: + msg = "item '%s' has invalid group '%s'" + raise ValueError(msg % (child.path, target_group)) + grouped[target_group].append(child) + + for group in item_groups: + if group.id in grouped: + nodes = grouped.pop(group.id) + yield group, solve_ordering(nodes) + + def create_children(self, group, nodes): + """ Create the child widgets for the given group of nodes. + + This will assemble the nodes and setup the action groups. + + """ + result = [] + actions = [] + children = [node.assemble() for node in nodes] + + def process_actions(): + if actions: + wag = WorkbenchActionGroup(group=group) + wag.insert_children(None, actions) + result.append(wag) + del actions[:] + + for child in children: + if isinstance(child, WorkbenchAction): + actions.append(child) + else: + process_actions() + child.group = group + result.append(child) + + process_actions() + + return result + + def assemble_children(self): + """ Assemble the list of child objects for the menu. + + """ + children = [] + for group, nodes in self.collect_child_groups(): + children.extend(self.create_children(group, nodes)) + children.append(Action(separator=True)) + if children: + children.pop() + return children + + def assemble(self): + """ Assemble and return a WorkbenchMenu for the node. + + """ + menu = WorkbenchMenu(item=self.item) + menu.insert_children(None, self.assemble_children()) + return menu + + +class RootMenuNode(MenuNode): + """ A path node representing a root menu item. + + This class is an implementation detail and should not be consumed + by code outside of this module. + + """ + def group_data(self): + """ Get the group data for the root menu node. + + """ + group = ItemGroup() + return {u'': group}, [group] + + def assemble(self): + """ Assemble and return the list of root menu bar menus. + + """ + return self.assemble_children() + + +def create_menus(workbench, menu_items, action_items): + """ Create the WorkbenchMenu objects for the menu bar. + + This is the only external public API of this module. + + Parameters + ---------- + workbench : Workbench + The workbench object which is creating the menus. + + menu_items : list + The list of all MenuItem objects to include in the menus. The + order of the items in this list is irrelevant. + + action_items : list + The list of all ActionItem objects to include in the menus. + The order of the items in this list is irrelevant. + + Returns + ------- + result : list + An ordered list of Menu objects which can be directly included + into the main window's MenuBar. + + """ + # create the nodes for the menu items + menu_nodes = [] + for item in menu_items: + node = MenuNode(item=item) + menu_nodes.append(node) + + # assemble the menu nodes into a tree structure in two passes + # in order to maintain the relative item definition order + root = RootMenuNode() + node_map = {u'/': root} + for node in menu_nodes: + path = node.path + if path in node_map: + msg = "a menu item already exist for path '%s'" + raise ValueError(msg % path) + node_map[path] = node + for node in menu_nodes: + parent_path = node.parent_path + if parent_path not in node_map: + msg = "the path '%s' does not point to a menu item" + raise ValueError(msg % parent_path) + parent = node_map[parent_path] + parent.children.append(node) + + # create the nodes for the action items + action_nodes = [] + for item in action_items: + node = ActionNode(item=item, workbench=workbench) + action_nodes.append(node) + + # add the action nodes to the tree structure + for node in action_nodes: + parent_path = node.parent_path + if parent_path not in node_map: + msg = "the path '%s' does not point to a menu item" + raise ValueError(msg % parent_path) + path = node.path + if path in node_map: + msg = "an item already exist for path '%s'" + raise ValueError(msg % path) + parent = node_map[parent_path] + parent.children.append(node) + node_map[path] = node + + # generate the menus for the root nodes + return root.assemble() diff --git a/enaml/workbench/ui/menu_item.py b/enaml/workbench/ui/menu_item.py new file mode 100644 index 000000000..ae6c36c2c --- /dev/null +++ b/enaml/workbench/ui/menu_item.py @@ -0,0 +1,45 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Bool, Unicode + +from enaml.core.declarative import Declarative, d_ + +from .item_group import ItemGroup + + +class MenuItem(Declarative): + """ A declarative class for defining a menu in the workbench. + + """ + #: The "/" separated path to this item in the menu bar. + path = d_(Unicode()) + + #: The parent menu group to which this menu item belongs. + group = d_(Unicode()) + + #: The menu item will appear before this item in its group. + before = d_(Unicode()) + + #: The menu item will appear after this item in its group. + after = d_(Unicode()) + + #: The display label for the menu. + label = d_(Unicode()) + + #: Whether or not the menu is visible. + visible = d_(Bool(True)) + + #: Whether or not the menu is enabled. + enabled = d_(Bool(True)) + + @property + def item_groups(self): + """ Get the item groups defined on this menu item. + + """ + return [c for c in self.children if isinstance(c, ItemGroup)] diff --git a/enaml/workbench/ui/ui_manifest.enaml b/enaml/workbench/ui/ui_manifest.enaml new file mode 100644 index 000000000..778afeca4 --- /dev/null +++ b/enaml/workbench/ui/ui_manifest.enaml @@ -0,0 +1,171 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.workbench.core.command import Command +from enaml.workbench.extension import Extension +from enaml.workbench.extension_point import ExtensionPoint +from enaml.workbench.plugin_manifest import PluginManifest + + +def ui_plugin_factory(): + """ A factory function which creates a UIPlugin instance. + + """ + from .ui_plugin import UIPlugin + return UIPlugin() + + +def application_factory(): + """ A factory function which creates the default Application. + + """ + from enaml.qt.qt_application import QtApplication + return QtApplication() + + +def window_factory(workbench): + """ A factory function which creates the default WorkbenchWindow. + + """ + import enaml + with enaml.imports(): + from .workbench_window import WorkbenchWindow + return WorkbenchWindow() + + +def close_window(event): + """ The command handler for closing the workbench window. + + """ + ui = event.workbench.get_plugin('enaml.workbench.ui') + ui.close_window() + + +def close_workspace(event): + """ The command handler for closing the workspace. + + """ + ui = event.workbench.get_plugin('enaml.workbench.ui') + ui.close_workspace() + + +def select_workspace(event): + """ The command handler for selecting a workspace. + + """ + ui = event.workbench.get_plugin('enaml.workbench.ui') + ui.select_workspace(event.parameters['workspace']) + + +APPLICATION_FACTORY_DESCRIPTION = \ +""" An Extension to this point can be used to provide a custom +application object for the workbench. The extension factory should +accept no arguments and return an Application instance. The highest +ranking extension will be chosen to create the application.""" + + +WINDOW_FACTORY_DESCRIPTION = \ +""" An Extension to this point can be used to provide a custom main +window for the workbench. The extension factory should accept the +workbench as an argument and return a WorkbenchWindow instance. The +highest ranking extension will be chosen to create the window.""" + + +BRANDING_DESCRIPTION = \ +""" An Extension to this point can be used to provide a custom window +title and icon to the primary workbench window. A Branding object can +be declared as the child of the extension, or created by the extension +factory function which accepts the workbench as an argument. The highest +ranking extension will be chosen to provide the branding.""" + + +ACTIONS_DESCRIPTION = \ +""" Extensions to this point can be used to provide menu items and +action items to be added to the primary workbench window menu bar. The +extension can declare child MenuItem and ActionItem instances as well +as provide a factory function which returns a list of the same. """ + + +WORKSPACES_DESCRIPTION = \ +""" Extensions to this point can be used to provide workspaces which +can be readily swapped to provide the main content for the workbench +window. The extension factory function should accept the workbench as +an argument and return an instance of Workspace. """ + + +AUTOSTART_DESCRIPTION = \ +""" Extensions to this point can be used to provide the id of a plugin +which should be started preemptively on application startup. The extension +should declare children of type Autostart. The plugins will be started in +order of extension rank. Warning - abusing this facility can cause drastic +slowdowns in application startup time. Only use it if you are *absolutely* +sure your plugin must be loaded on startup. """ + + +CLOSE_WINDOW_DESCRIPTION = \ +""" Close the primary workbench window. """ + + +CLOSE_WORKSPACE_DESCRIPTION = \ +""" Close the currently active workspace. """ + + +SELECT_WORKSPACE_DESCRIPTION = \ +""" Select and activate a new workspace. The parameters dict must +contain a 'workspace' key which is the fully qualified identifier +of the extension which will create the workspace object. """ + + +enamldef UIManifest(PluginManifest): + """ The manifest for the Enaml workbench ui plugin. + + """ + id = 'enaml.workbench.ui' + factory = ui_plugin_factory + ExtensionPoint: + id = 'application_factory' + description = APPLICATION_FACTORY_DESCRIPTION + ExtensionPoint: + id = 'window_factory' + description = WINDOW_FACTORY_DESCRIPTION + ExtensionPoint: + id = 'branding' + description = BRANDING_DESCRIPTION + ExtensionPoint: + id = 'actions' + description = ACTIONS_DESCRIPTION + ExtensionPoint: + id = 'workspaces' + description = WORKSPACES_DESCRIPTION + ExtensionPoint: + id = 'autostart' + description = AUTOSTART_DESCRIPTION + Extension: + id = 'default_application_factory' + point = 'enaml.workbench.ui.application_factory' + factory = application_factory + rank = -1000 + Extension: + id = 'default_window_factory' + point = 'enaml.workbench.ui.window_factory' + factory = window_factory + rank = -1000 + Extension: + id = 'default_commands' + point = 'enaml.workbench.core.commands' + Command: + id = 'enaml.workbench.ui.close_window' + description = CLOSE_WINDOW_DESCRIPTION + handler = close_window + Command: + id = 'enaml.workbench.ui.close_workspace' + description = CLOSE_WORKSPACE_DESCRIPTION + handler = close_workspace + Command: + id = 'enaml.workbench.ui.select_workspace' + description = SELECT_WORKSPACE_DESCRIPTION + handler = select_workspace diff --git a/enaml/workbench/ui/ui_plugin.py b/enaml/workbench/ui/ui_plugin.py new file mode 100644 index 000000000..ba78566dd --- /dev/null +++ b/enaml/workbench/ui/ui_plugin.py @@ -0,0 +1,440 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Typed + +from enaml.application import Application +from enaml.workbench.extension import Extension +from enaml.workbench.plugin import Plugin + +from .action_item import ActionItem +from .autostart import Autostart +from .branding import Branding +from .menu_helper import create_menus +from .menu_item import MenuItem +from .window_model import WindowModel +from .workspace import Workspace + +import enaml +with enaml.imports(): + from .workbench_window import WorkbenchWindow + + +ACTIONS_POINT = u'enaml.workbench.ui.actions' + +APPLICATION_FACTORY_POINT = u'enaml.workbench.ui.application_factory' + +BRANDING_POINT = u'enaml.workbench.ui.branding' + +WINDOW_FACTORY_POINT = u'enaml.workbench.ui.window_factory' + +WORKSPACES_POINT = u'enaml.workbench.ui.workspaces' + +AUTOSTART_POINT = u'enaml.workbench.ui.autostart' + + +class UIPlugin(Plugin): + """ The main UI plugin class for the Enaml studio. + + The ui plugin manages the extension points for user contributions + to the main window. + + """ + def start(self): + """ Start the plugin life-cycle. + + This method is called by the framework at the appropriate time. + It should never be called by user code. + + """ + self._create_application() + self._create_model() + self._create_window() + self._bind_observers() + self._start_autostarts() + + def stop(self): + """ Stop the plugin life-cycle. + + This method is called by the framework at the appropriate time. + It should never be called by user code. + + """ + self._stop_autostarts() + self._unbind_observers() + self._destroy_window() + self._release_model() + self._release_application() + + @property + def window(self): + """ Get a reference to the primary window. + + """ + return self._window + + @property + def workspace(self): + """ Get a reference to the currently active workspace. + + """ + return self._model.workspace + + def show_window(self): + """ Ensure the underlying window object is shown. + + """ + self._window.show() + + def hide_window(self): + """ Ensure the underlying window object is hidden. + + """ + self._window.hide() + + def start_application(self): + """ Start the application event loop. + + """ + self._application.start() + + def stop_application(self): + """ Stop the application event loop. + + """ + self._application.stop() + + def close_window(self): + """ Close the underlying workbench window. + + """ + self._window.close() + + def close_workspace(self): + """ Close and dispose of the currently active workspace. + + """ + self._workspace_extension = None + self._model.workspace.stop() + self._model.workspace.workbench = None + self._model.workspace = Workspace() + + def select_workspace(self, extension_id): + """ Select and start the workspace for the given extension id. + + The current workspace will be stopped and released. + + """ + target = None + workbench = self.workbench + point = workbench.get_extension_point(WORKSPACES_POINT) + for extension in point.extensions: + if extension.qualified_id == extension_id: + target = extension + break + + if target is None: + msg = "'%s' is not a registered workspace extension" + raise ValueError(msg % extension_id) + + if target is self._workspace_extension: + return + + old_workspace = self._model.workspace + old_workspace.stop() + old_workspace.workbench = None + + self._workspace_extension = target + new_workspace = self._create_workspace(target) + + new_workspace.workbench = workbench + new_workspace.start() + self._model.workspace = new_workspace + + #-------------------------------------------------------------------------- + # Private API + #-------------------------------------------------------------------------- + #: The application provided by an ApplicationFactory extension. + _application = Typed(Application) + + #: The window object provided by a WindowFactory extension. + _window = Typed(WorkbenchWindow) + + #: The view model object used to drive the window. + _model = Typed(WindowModel) + + #: The currently activate branding extension object. + _branding_extension = Typed(Extension) + + #: The currently activate workspace extension object. + _workspace_extension = Typed(Extension) + + #: The currently active action extension objects. + _action_extensions = Typed(dict, ()) + + def _create_application(self): + """ Create the Application object for the ui. + + This will load the highest ranking extension to the application + factory extension point, and use it to create the instance. + + If an application object already exists, that application will + be used instead of any defined by a factory, since there can be + only one application per-process. + + """ + if Application.instance() is not None: + self._application = Application.instance() + return + + workbench = self.workbench + point = workbench.get_extension_point(APPLICATION_FACTORY_POINT) + extensions = point.extensions + if not extensions: + msg = "no contributions to the '%s' extension point" + raise RuntimeError(msg % APPLICATION_FACTORY_POINT) + + extension = extensions[-1] + if extension.factory is None: + msg = "extension '%s' does not declare an application factory" + raise ValueError(msg % extension.qualified_id) + + application = extension.factory() + if not isinstance(application, Application): + msg = "extension '%s' created non-Application type '%s'" + args = (extension.qualified_id, type(application).__name__) + raise TypeError(msg % args) + + self._application = application + + def _create_model(self): + """ Create and initialize the model which drives the window. + + """ + self._model = WindowModel() + self._refresh_branding() + self._refresh_actions() + + def _create_window(self): + """ Create the WorkbenchWindow object for the workbench. + + This will load the highest ranking extension to the window + factory extension point, and use it to create the instance. + + """ + workbench = self.workbench + point = workbench.get_extension_point(WINDOW_FACTORY_POINT) + extensions = point.extensions + if not extensions: + msg = "no contributions to the '%s' extension point" + raise RuntimeError(msg % WINDOW_FACTORY_POINT) + + extension = extensions[-1] + if extension.factory is None: + msg = "extension '%s' does not declare a window factory" + raise ValueError(msg % extension.qualified_id) + + window = extension.factory(workbench) + if not isinstance(window, WorkbenchWindow): + msg = "extension '%s' created non-WorkbenchWindow type '%s'" + args = (extension.qualified_id, type(window).__name__) + raise TypeError(msg % args) + + window.workbench = workbench + window.window_model = self._model + self._window = window + + def _create_workspace(self, extension): + """ Create the Workspace object for the given extension. + + Parameters + ---------- + extension : Extension + The extension object of interest. + + Returns + ------- + result : Workspace + The workspace object for the given extension. + + """ + if extension.factory is None: + msg = "extension '%s' does not declare a workspace factory" + raise ValueError(msg % extension.qualified_id) + + workspace = extension.factory(self.workbench) + if not isinstance(workspace, Workspace): + msg = "extension '%s' created non-Workspace type '%s'" + args = (extension.qualified_id, type(workspace).__name__) + raise TypeError(msg % args) + + return workspace + + def _create_action_items(self, extension): + """ Create the action items for the extension. + + """ + workbench = self.workbench + menu_items = extension.get_children(MenuItem) + action_items = extension.get_children(ActionItem) + if extension.factory: + for item in extension.factory(workbench): + if isinstance(item, MenuItem): + menu_items.append(item) + elif isinstance(item, ActionItem): + action_items.append(item) + else: + msg = "action extension created invalid action type '%s'" + raise TypeError(msg % type(item).__name__) + return menu_items, action_items + + def _destroy_window(self): + """ Destroy and release the underlying window object. + + """ + self._window.hide() + self._window.destroy() + self._window = None + + def _release_model(self): + """ Release the underlying window model object. + + """ + self._model.workspace.stop() + self._model = None + + def _release_application(self): + """ Stop and release the underlyling application object. + + """ + self._application.stop() + self._application = None + + def _refresh_branding(self): + """ Refresh the branding object for the window model. + + """ + workbench = self.workbench + point = workbench.get_extension_point(BRANDING_POINT) + extensions = point.extensions + if not extensions: + self._branding_extension = None + self._model.branding = Branding() + return + + extension = extensions[-1] + if extension is self._branding_extension: + return + + if extension.factory: + branding = extension.factory(workbench) + if not isinstance(branding, Branding): + msg = "extension '%s' created non-Branding type '%s'" + args = (extension.qualified_id, type(branding).__name__) + raise TypeError(msg % args) + else: + branding = extension.get_child(Branding, reverse=True) + branding = branding or Branding() + + self._branding_extension = extension + self._model.branding = branding + + def _refresh_actions(self): + """ Refresh the actions for the workbench window. + + """ + workbench = self.workbench + point = workbench.get_extension_point(ACTIONS_POINT) + extensions = point.extensions + if not extensions: + self._action_extensions.clear() + self._model.menus = [] + return + + menu_items = [] + action_items = [] + new_extensions = {} + old_extensions = self._action_extensions + for extension in extensions: + if extension in old_extensions: + m_items, a_items = old_extensions[extension] + else: + m_items, a_items = self._create_action_items(extension) + new_extensions[extension] = (m_items, a_items) + menu_items.extend(m_items) + action_items.extend(a_items) + + menus = create_menus(workbench, menu_items, action_items) + self._action_extensions = new_extensions + self._model.menus = menus + + def _get_autostarts(self): + """ Get the autostart extension objects. + + """ + workbench = self.workbench + point = workbench.get_extension_point(AUTOSTART_POINT) + extensions = sorted(point.extensions, key=lambda ext: ext.rank) + + autostarts = [] + for extension in extensions: + autostarts.extend(extension.get_children(Autostart)) + + return autostarts + + def _start_autostarts(self): + """ Start the plugins for the autostart extension point. + + """ + workbench = self.workbench + for autostart in self._get_autostarts(): + workbench.get_plugin(autostart.plugin_id) + + def _stop_autostarts(self): + """ Stop the plugins for the autostart extension point. + + """ + workbench = self.workbench + for autostart in reversed(self._get_autostarts()): + plugin = workbench.get_plugin(autostart.plugin_id) + plugin.stop() + + def _on_branding_updated(self, change): + """ The observer for the branding extension point. + + """ + self._refresh_branding() + + def _on_actions_updated(self, change): + """ The observer for the actions extension point. + + """ + self._refresh_actions() + + def _bind_observers(self): + """ Setup the observers for the plugin. + + """ + workbench = self.workbench + + point = workbench.get_extension_point(BRANDING_POINT) + point.observe('extensions', self._on_branding_updated) + + point = workbench.get_extension_point(ACTIONS_POINT) + point.observe('extensions', self._on_actions_updated) + + def _unbind_observers(self): + """ Remove the observers for the plugin. + + """ + workbench = self.workbench + + point = workbench.get_extension_point(BRANDING_POINT) + point.unobserve('extensions', self._on_branding_updated) + + point = workbench.get_extension_point(ACTIONS_POINT) + point.unobserve('extensions', self._on_actions_updated) diff --git a/enaml/workbench/ui/ui_workbench.py b/enaml/workbench/ui/ui_workbench.py new file mode 100644 index 000000000..ee82bd059 --- /dev/null +++ b/enaml/workbench/ui/ui_workbench.py @@ -0,0 +1,44 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.workbench.workbench import Workbench + + +UI_PLUGIN = u'enaml.workbench.ui' + + +class UIWorkbench(Workbench): + """ A class for creating workbench UI applications. + + The UIWorkbench class is a subclass of Workbench which loads the + builtin ui plugin and provides an entry point to start the main + application event loop. + + """ + def run(self): + """ Run the UI workbench application. + + This method will load the core and ui plugins and start the + main application event loop. This is a blocking call which + will return when the application event loop exits. + + """ + import enaml + with enaml.imports(): + from enaml.workbench.core.core_manifest import CoreManifest + from enaml.workbench.ui.ui_manifest import UIManifest + + self.register(CoreManifest()) + self.register(UIManifest()) + + ui = self.get_plugin(UI_PLUGIN) + ui.show_window() + ui.start_application() + + # TODO stop all plugins on app exit? + + self.unregister(UI_PLUGIN) diff --git a/enaml/workbench/ui/window_model.py b/enaml/workbench/ui/window_model.py new file mode 100644 index 000000000..593499155 --- /dev/null +++ b/enaml/workbench/ui/window_model.py @@ -0,0 +1,27 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Atom, List, Typed + +from enaml.widgets.menu import Menu + +from .branding import Branding +from .workspace import Workspace + + +class WindowModel(Atom): + """ A model which is used to drive the WorkbenchWindow instance. + + """ + #: The branding which contributes the window title and icon. + branding = Typed(Branding, ()) + + #: The menu objects for the menu bar. + menus = List(Menu) + + #: The currently active workspace for the window. + workspace = Typed(Workspace, ()) diff --git a/enaml/workbench/ui/workbench_menus.enaml b/enaml/workbench/ui/workbench_menus.enaml new file mode 100644 index 000000000..020b04183 --- /dev/null +++ b/enaml/workbench/ui/workbench_menus.enaml @@ -0,0 +1,69 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.application import deferred_call +from enaml.widgets.action import Action +from enaml.widgets.action_group import ActionGroup +from enaml.widgets.menu import Menu +from enaml.workbench.workbench import Workbench + +from .action_item import ActionItem +from .item_group import ItemGroup +from .menu_item import MenuItem + + +def invoke_action(workbench, item): + """ Invoke the command indicated by the given action item. + + """ + core = workbench.get_plugin('enaml.workbench.core') + core.invoke_command(item.command, item.parameters, item) + + +def action_text(text, shortcut): + """ Concatenate action text and a keyboard shortcut. + + """ + return u'\t'.join(filter(None, (text, shortcut))) + + +enamldef WorkbenchAction(Action): + """ A custom Action def for use in the Enaml workbench. + + """ + attr workbench: Workbench + attr item: ActionItem + text << action_text(item.label, item.shortcut) + visible << item.visible + enabled << item.enabled + checkable << item.checkable + checked := item.checked + icon << item.icon + tool_tip << item.tool_tip + status_tip << item.status_tip + triggered :: deferred_call(invoke_action, workbench, item) + + +enamldef WorkbenchActionGroup(ActionGroup): + """ A custom action group def for use in the Enaml workbench. + + """ + attr group: ItemGroup + visible << group.visible + enabled << group.enabled + exclusive << group.exclusive + + +enamldef WorkbenchMenu(Menu): + """ A custom menu def for use in the Enaml workbench. + + """ + attr group: ItemGroup + attr item: MenuItem + title << item.label + visible << item.visible and group.visible + enabled << item.enabled and group.enabled diff --git a/enaml/workbench/ui/workbench_window.enaml b/enaml/workbench/ui/workbench_window.enaml new file mode 100644 index 000000000..48241250f --- /dev/null +++ b/enaml/workbench/ui/workbench_window.enaml @@ -0,0 +1,35 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.core.include import Include +from enaml.widgets.main_window import MainWindow +from enaml.widgets.menu_bar import MenuBar +from enaml.workbench.workbench import Workbench + +from .window_model import WindowModel + + +def make_title(primary, workspace): + return u' - '.join(filter(None, (primary, workspace))) + + +enamldef WorkbenchWindow(MainWindow): + """ The custom MainWindow enamldef used by the Enaml studio. + + """ + attr workbench: Workbench + attr window_model: WindowModel + title << make_title( + window_model.branding.title, + window_model.workspace.window_title, + ) + icon << window_model.branding.icon + MenuBar: + Include: + objects << window_model.menus + Include: + objects << filter(None, [window_model.workspace.content]) diff --git a/enaml/workbench/ui/workspace.py b/enaml/workbench/ui/workspace.py new file mode 100644 index 000000000..f6888a903 --- /dev/null +++ b/enaml/workbench/ui/workspace.py @@ -0,0 +1,50 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Typed, Unicode + +from enaml.core.declarative import Declarative, d_ +from enaml.widgets.container import Container +from enaml.workbench.workbench import Workbench + + +class Workspace(Declarative): + """ A declarative class for defining a workspace object. + + """ + #: Extra information to display in the window title bar. + window_title = d_(Unicode()) + + #: The primary window content for the workspace. This will be + #: destroyed automatically when the workspace is disposed. + content = d_(Typed(Container)) + + #: The workbench object which owns the workspace. This will be + #: assigned when the ui plugin creates the workspace. It will + #: be available by the time the 'start' method is called. + workbench = Typed(Workbench) + + def start(self): + """ Start the workspace. + + This method is called when the UI plugin starts the workspace. + This can be used to load content or any other resource which + should exist for the life of the workspace. + + """ + pass + + def stop(self): + """ Stop the workspace. + + This method is called when the UI plugin closes the workspace. + This should be used to release any resources acquired during + the lifetime of the workspace. The content Container will be + destroyed automatically after this method returns. + + """ + pass diff --git a/enaml/workbench/workbench.py b/enaml/workbench/workbench.py new file mode 100644 index 000000000..c624fb3ac --- /dev/null +++ b/enaml/workbench/workbench.py @@ -0,0 +1,329 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from collections import defaultdict + +from atom.api import Atom, Event, Typed + +from .plugin import Plugin + + +class Workbench(Atom): + """ A base class for creating plugin-style applications. + + This class is used for managing the lifecycle of plugins. It does + not provide any plugins of its own. The UIWorkbench subclass adds + the 'core' and 'ui' workbench plugins by default. + + """ + #: An event fired when a plugin is added to the workbench. The + #: payload will be the plugin id. + plugin_added = Event(unicode) + + #: An event fired when a plugin is removed from the workbench. The + #: payload will be the plugin id. + plugin_removed = Event(unicode) + + #: An event fired when an extension point is added to the + #: workbench. The payload will be the fully qualified id of the + #: extension point. + extension_point_added = Event(unicode) + + #: An event fired when an extension point is removed from the + #: workbench. The payload will be the fully qualified id of the + #: extension point. + extension_point_removed = Event(unicode) + + def register(self, manifest): + """ Register a plugin with the workbench. + + Parameters + ---------- + manifest : PluginManifest + The plugin manifest to register with the workbench. + + """ + plugin_id = manifest.id + if plugin_id in self._manifests: + msg = "plugin '%s' is already registered" + raise ValueError(msg % plugin_id) + + self._manifests[plugin_id] = manifest + manifest.workbench = self + + self._add_extensions(manifest.extensions) + self._add_extension_points(manifest.extension_points) + + self.plugin_added(plugin_id) + + def unregister(self, plugin_id): + """ Remove a plugin from the workbench. + + This will remove the extension points and extensions from the + workbench, and stop the plugin if it was activated. + + Parameters + ---------- + plugin_id : unicode + The identifier of the plugin of interest. + + """ + manifest = self._manifests.get(plugin_id) + if manifest is None: + msg = "plugin '%s' is not registered" + raise ValueError(msg % plugin_id) + + plugin = self._plugins.pop(plugin_id, None) + if plugin is not None: + plugin.stop() + plugin.manifest = None + + self._remove_extensions(manifest.extensions) + self._remove_extension_points(manifest.extension_points) + + del self._manifests[plugin_id] + manifest.workbench = None + + self.plugin_removed(plugin_id) + + def get_manifest(self, plugin_id): + """ Get the plugin manifest for a given plugin id. + + Parameters + ---------- + plugin_id : unicode + The identifier of the plugin of interest. + + Returns + ------- + result : PluginManifest or None + The manifest for the plugin of interest, or None if it + does not exist. + + """ + return self._manifests.get(plugin_id) + + def get_plugin(self, plugin_id, force_create=True): + """ Get the plugin object for a given plugin id. + + Parameters + ---------- + plugin_id : unicode + The identifier of the plugin of interest. + + force_create : bool, optional + Whether to automatically import and start the plugin object + if it is not already active. The default is True. + + Returns + ------- + result : Plugin or None + The plugin of interest, or None if it does not exist and/or + could not be created. + + """ + if plugin_id in self._plugins: + return self._plugins[plugin_id] + + manifest = self._manifests.get(plugin_id) + if manifest is None: + msg = "plugin '%s' is not registered" + raise ValueError(msg % plugin_id) + + if not force_create: + return None + + plugin = manifest.factory() + if not isinstance(plugin, Plugin): + msg = "plugin '%s' factory created non-Plugin type '%s'" + raise TypeError(msg % (plugin_id, type(plugin).__name__)) + + self._plugins[plugin_id] = plugin + plugin.manifest = manifest + plugin.start() + return plugin + + def get_extension_point(self, extension_point_id): + """ Get the extension point associated with an id. + + Parameters + ---------- + extension_point_id : unicode + The fully qualified id of the extension point of interest. + + Returns + ------- + result : ExtensionPoint or None + The desired ExtensionPoint or None if it does not exist. + + """ + return self._extension_points.get(extension_point_id) + + def get_extension_points(self): + """ Get all of the extension points in the workbench. + + Returns + ------- + result : list + A list of all of the extension points in the workbench. + + """ + return self._extension_points.values() + + #-------------------------------------------------------------------------- + # Private API + #-------------------------------------------------------------------------- + #: A mapping of plugin id to PluginManifest. + _manifests = Typed(dict, ()) + + #: A mapping of plugin id to Plugin instance. + _plugins = Typed(dict, ()) + + #: A mapping of extension point id to ExtensionPoint. + _extension_points = Typed(dict, ()) + + #: A mapping of extension id to Extension. + _extensions = Typed(dict, ()) + + #: A mapping of extension point id to set of Extensions. + _contributions = Typed(defaultdict, (set,)) + + def _add_extension_points(self, extension_points): + """ Add extension points to the workbench. + + Parameters + ---------- + extension_points : list + The list of ExtensionPoints to add to the workbench. + + """ + for point in extension_points: + self._add_extension_point(point) + + def _add_extension_point(self, point): + """ Add an extension point to the workbench. + + Parameters + ---------- + point : ExtensionPoint + The ExtensionPoint to add to the workbench. + + """ + point_id = point.qualified_id + if point_id in self._extension_points: + msg = "extension point '%s' is already registered" + raise ValueError(msg % point_id) + + self._extension_points[point_id] = point + if point_id in self._contributions: + to_add = self._contributions[point_id] + self._update_extension_point(point, [], to_add) + + self.extension_point_added(point_id) + + def _remove_extension_points(self, extension_points): + """ Remove extension points from the workbench. + + Parameters + ---------- + extension_points : list + The list of ExtensionPoints to remove from the workbench. + + """ + for point in extension_points: + self._remove_extension_point(point) + + def _remove_extension_point(self, point): + """ Remove an extension point from the workbench. + + Parameters + ---------- + point : ExtensionPoint + The ExtensionPoint to remove from the workbench. + + """ + point_id = point.qualified_id + if point_id not in self._extension_points: + msg = "extension point '%s' is not registered" + raise ValueError(msg % point_id) + + del self._extension_points[point_id] + if point_id in self._contributions: + to_remove = self._contributions.pop(point_id) + self._update_extension_point(point, to_remove, []) + + self.extension_point_removed(point_id) + + def _add_extensions(self, extensions): + """ Add extensions to the workbench. + + Parameters + ---------- + extensions : list + The list of Extensions to add to the workbench. + + """ + grouped = defaultdict(set) + for extension in extensions: + ext_id = extension.qualified_id + if ext_id in self._extensions: + msg = "extension '%s' is already registered" + raise ValueError(msg % ext_id) + self._extensions[ext_id] = extension + grouped[extension.point].add(extension) + + for point_id, exts in grouped.iteritems(): + self._contributions[point_id].update(exts) + if point_id in self._extension_points: + point = self._extension_points[point_id] + self._update_extension_point(point, (), exts) + + def _remove_extensions(self, extensions): + """ Remove extensions from a workbench. + + Parameters + ---------- + extensions : list + The list of Extensions to remove from the workbench. + + """ + grouped = defaultdict(set) + for extension in extensions: + ext_id = extension.qualified_id + if ext_id not in self._extensions: + msg = "extension '%s' is not registered" + raise ValueError(msg % ext_id) + del self._extensions[ext_id] + grouped[extension.point].add(extension) + + for point_id, exts in grouped.iteritems(): + self._contributions[point_id].difference_update(exts) + if point_id in self._extension_points: + point = self._extension_points[point_id] + self._update_extension_point(point, exts, ()) + + def _update_extension_point(self, point, to_remove, to_add): + """ Update an extension point with delta extension objects. + + Parameters + ---------- + point : ExtensionPoint + The extension point of interest. + + to_remove : iterable + The Extension objects to remove from the point. + + to_add : iterable + The Extension objects to add to the point. + + """ + if to_remove or to_add: + extensions = set(point.extensions) + extensions.difference_update(to_remove) + extensions.update(to_add) + key = lambda ext: ext.rank + point.extensions = tuple(sorted(extensions, key=key)) diff --git a/examples/workbench/crash_course.rst b/examples/workbench/crash_course.rst new file mode 100644 index 000000000..4bff76737 --- /dev/null +++ b/examples/workbench/crash_course.rst @@ -0,0 +1,671 @@ +Enaml Workbench Developer Crash Course +====================================== +This document is a short introduction to the Enaml Workbench plugin framework. +It is intended for developers of plugin applications that need to get up and +running with the framework in a short amount of time. The Workbench framework +is not large, and a good developer can be comfortable with it in an afternoon. + +This document covers the concepts, terminology, workflow, and the core plugins +and classes of the framework. The accompanying example demonstrates the various +parts of the framework with a simple plugin application which allows the user +to toggle between a handful of sample views. + +Concepts +-------- +Writing large applications is hard. Writing large UI applications is harder. +Writing large UI applications which can be *safetly extended at runtime* by +other developers is a recipe for hair loss. There are several difficult issues +which must be addressed when developing such applications, some of the most +notable are: + +Registration + How does user code get dynamically registered and unregistered at runtime? + +Life Cyle + When and how should user code be loaded and run? How and when and how + should it be unloaded and stopped? + +Dependencies + How does the application get started without requiring all user code to + be available at startup? How does the application avoid loading external + dependencies until they are actually required to do work? + +Notifications + How can various parts of the application be notified when user code is + registered and unregistered? + +User Interfaces + How can the application be flexible enough to allow user code to add + user interface elements to the window at runtime, without clobbering + or interfering with the existing user interface elements? + +Flexibility + How can an application be designed in a way where it may be extended + to support future use cases which have not yet been conceived? + +Ease of Use + How can all of these difficult problems be solved in such a way that + a good developer can be comfortable developing with the application + in an afternoon? + +The Enaml Workbench framework attempts to solve these problems by providing +a set of low-level components which can be used to develop high-level plugin +applications. Think of it as a mini-Eclipse framework for Enaml. + +Unlike Eclipse however, the Enaml Workbench framework strives to be compact +and efficient. Following the "less is more" mantra, it seeks to provide only +the core low-level features required for generic plugin applications. It is +intended that the core development team for a large application will build +domain specific abstractions on top of the core workbench pieces which will +then used to assemble the final application. + +Terminology +----------- +Before continuuing with the crash course, the following terminology is +introduced and used throughout the rest of the document. + +Workbench + The core framework object which manages the registration of plugin + manifests and the creation of plugin objects. It acts as the central + registry and primary communication hub for the various parts of a + plugin application. + +Plugin Manifest + An object which declares a plugin and its public behavior. It does + not provide an implementation of that behavior. + +Plugin + An object which can be dynamically loaded and unloaded from a workbench. + It is the implementation of the behavior defined by its plugin manifest. + This term is often overload to also indicate the collection of manifest, + plugin, extension points, and extensions. That is, 'plugin' can refer to + the actual plugin instance, or the entire package of related objects + written by the developer. + +Extension Point + A declaration in a plugin manifest which advertises that other plugins + may contribute functionality to this plugin through extensions. It + defines the interface to which an extension must conform in order to + be useful to the plugin which declares the extension point. + +Extension + A contribution to the extension point of a plugin. An extension adds + functionality and behavior to an existing application by implementing + the interface required by a given extension point. + +Workflow +-------- +Using the workbench framework is relatively straightforward and has only +a few conceptual steps. + +0. Define the classes which implement your application business logic. +1. If your application will create a plugin which contribute extensions + to an extension point, define the extension classes and ensure that + they implement the interface required by the extension point. The + extension classes should interact with the business logic classes to + expose their functionality to the rest of the application. +2. If your application will create a plugin which defines new extension + points, define a Plugin subclasses which will implement the extension + point behavior by interacting with the extensions contributed to the + extension point by other plugins. +3. Create a PluginManifest for each plugin defined by your application. + The manifest will declare the extension points provided by the plugin + as well as the extensions it contributes to other extension points. If + needed, it will supply a factory to create the custom Plugin object. +4. Create an instance of Workbench or one of its subclasses. +5. Register the plugin manifests required by your application with the + workbench. Only the plugins required for startup need to be registered. + Additional manifest can be added and removed dynamically at runtime. +6. Start the application. How this is done is application dependent. + +Points 0 - 3 require the most mental effort. The framework rovides a few pre- +defined plugins and Workbench subclasses (described later) which make the last +few steps of the process more-or-less trivial. + +The important takeaway here is that the application business logic should be +defined first, and then be bundled up as extensions and extension points to +expose that logic to various other parts of the application. This design +pattern forces a strong separation between logical components. And while it +requires a bit more up-front work, it results in better code reuse and a more +maintainable and extensible code base. + +Core Classes +------------ +This section covers the core classes of the workbench framework. + +Workbench +~~~~~~~~~ +The Workbench class acts as the fundamental registry and manager object for +all the other parts of the plugin framework. As a central hub, it's usually +possible to access any object of interest in the application by starting with +a reference to the workbench object. + +The core `Workbench` class can be imported from `enaml.workbench.api`. + +The core `Workbench` class may be used directly, though application developers +will typically create a subclass to register default plugins on startup. A +perfect example of this is the `UIWorkbench` subclass which registers the +'enaml.workbench.core' and 'enaml.workbench.ui' plugins when started. + +The following methods on a Workbench are of particular interest: + +register + This method is used to register a `PluginManifest` instance with the + workbench. This is the one-and-only way to contribute plugins to an + application, whether during initialization or later at runtime. + +unregister + This method is used to unregister a plugin manifest which was previously + added to the workbench with a call to `register`. This is the one-and- + only way to remove plugins from the workbench application. + +get_plugin + This method is used to query for, and lazily create, the plugin object + for a given manifest. The plugin object will be created the *first* time + this method is called. Future calls will return the cached plugin object. + +get_extension_point + This method will return the extension point declared by a plugin. The + extension point can be queried for contributed extensions at runtime. + +PluginManifest +~~~~~~~~~~~~~~ +The PluginManifest class is used to describe a plugin in terms of its +extension points and extensions. It also defines a globally unique +identifier for the plugin along with an optional factory function which +can be used to create the underlying plugin instance when needed. + +The `PluginManifest` class can be imported from `enaml.workbench.api`. + +The PluginManifest class is a declarative class and defines the following +attributes of interest: + +id + This is a globally unique identifier which identifies both the manifest + and the plugin which will be created for it. It should be a string in + dot-separated form, typically 'org.pkg.module.name'. It also servers as + the enclosing namespace for the identifiers of its extension points and + extensions. The global uniqueness of this identifier is enfored. + +factory + A callable which takes no arguments and returns an instance of Plugin. + For most use-cases, this factory can be ignored. The default factory + will create an instance of the default Plugin class which is suitable + for the frequent case of a plugin providing nothing but extensions to + the extension points of other plugins. + +Since this class is declarative, children may be defined on it. In particular, +a plugin's extension points and extensions are defined by declaring children +of type `ExtensionPoint` and `Extension` on the plugin manifest. + +Plugin +~~~~~~ +The Plugin class is what does the actual work for implementing the behaviors +defined by extension points. It acts as a sort of manager, ensuring that the +extensions which were contributed to a given extension point are invoked +properly and in accordance with interface defined by the extension point. + +Well-behaved plugins also react appropriately when extensions are added or +removed from one of their extension points at runtime. + +The `Plugin` class can be imported from `enaml.workbench.api`. + +It will be uncommon for most end-user developers to ever need to create a +custom plugin class. That job is reserved for core application developers +which actually define how the application can be extened. That said, there +are two methods on a plugin which will be of interest to developers: + +start + This method will be called by the workbench after it creates the + plugin. The default implementation does nothing and can be ignored + by subclasses which do not need life-cycle behavior. + +stop + This method will be called by the workbench when the plugin is + removed. The default implementation does nothing and can be + ignored by subclasses which do not need life-cycle behavior. + +ExtensionPoint +~~~~~~~~~~~~~~ +The ExtensionPoint class is used to publicly declare a point to which +extensions can be contributed to the plugin. Is is declared as the +child of a PluginManifest. + +The `ExtensionPoint` class can be imported from `enaml.workbench.api`. + +The ExtensionPoint class is a declarative class and defines the following +attributes of interest: + +id + The unique identifier for the extension point. It should be simple + string with no dots. The fully qualified id of the extension point + will be formed by dot-joining the id of the parent plugin manifest + with this id. + +Declarative children of an extension point do not have any meaning as +far as the workbench framework is concerned. + +Extension +~~~~~~~~~ +The Extension class is used to pubclicly declare the contribution a plugin +provides to the extension point of another plugin. It is declared as the +child of a PluginManifest. + +The `Extension` class can be imported from `enaml.workbench.api`. + +The Extension class is a declarative class and defines the following +attributes of interest: + +id + The unique identifier for the extension. It should be simple string + with no dots. The fully qualified id of the extension will be formed + by dot-joining the id of the parent plugin manifest with this id. + +point + The fully qualified id of the extension point to which the extension + is contributing. + +rank + An optional integer to rank the extension among other extensions + contributed to the same extension point. The semantics of how the + rank value is used is specified by a given extension point. + +factory + An optional callable which is used to create the implementation + object for an extension. The semantics of the call signature and + return value are specified by a given extension point. + +Declarative children of an Extension are allowed, and their semantic meaning +are defined by a given extension point. For example, the extension point +'enaml.workbench.core.commands' allows extension commands to be defined as +declarative children of the extension. + +Core Plugin +----------- +The section covers the workbench core plugin. + +The core plugin is a pre-defined plugin supplied by the workbench framework. +It provides non-ui related functionality that is useful across a wide variety +of applications. It must be explicitly registered with a workbench in order +to be used. + +The `CoreManifest` class can be imported from `enaml.workbench.core.api`. It +is a declarative enamldef and so must be imported from within an Enaml imports +context. + +The id for the core plugin is 'enaml.workbench.core' and it declares the +following extension points: + +'commands' + Extensions to this point may contribute `Command` objects which can + be invoked via the `invoke_command` method of the CorePlugin instance. + Commands can be provided by declaring them as children of the Extension + and/or by declaring a factory function which takes the workbench as an + argument and returns a list of Command instances. + +Command +~~~~~~~ +A Command object is used to declare that a plugin can take some action when +invoked by a user. It is declared as the child of an Extension which +contributes to the 'enaml.workbench.core.commands' extension point. + +The `Command` class can be imported from `enaml.workbench.core.api`. + +The Command class is a declarative class and defines the following +attributes of interest: + +id + The globally unique identifier for the command. This should be a + dot-separated string. The global uniqueness is enforced. + +handler + A callable object which implements the command behavior. It must + accept a single argument which is an instance of `ExecutionEvent`. + +ExecutionEvent +~~~~~~~~~~~~~~ +An ExecutionEvent is an object which is passed to a Command handler when +it is invoked by the framework. User code will never directly create an +ExecutionEvent. + +An ExecutionEvent has the following attributes of interest: + +command + The Command object which is being invoked. + +workbench + A reference to the workbench which owns the command. + +parameters + A dictionary of user-supplied parameters to the command. + +trigger + The user object which triggered the command. + +UI Plugin +--------- +This section covers the workbench ui plugin. + +The ui plugin is a pre-defined plugin supplied by the workbench framework. +It provides ui-related functionality which is common to a large swath of +UI applications. It must be explicity registered with a workbench in order +to be used. + +The `UIManifest` class can be imported from `enaml.workbench.ui.api`. It is +a declarative enamldef and so must be imported from within an Enaml imports +context. + +The id of the ui plugin is 'enaml.workbench.ui' and it declares the following +extension points: + +'application_factory' + An Extension to this point can be used to provide a custom + application object for the workbench. The extension factory should + accept no arguments and return an Application instance. The highest + ranking extension will be chosen to create the application. + +'window_factory' + An Extension to this point can be used to provide a custom main + window for the workbench. The extension factory should accept the + workbench as an argument and return a WorkbenchWindow instance. The + highest ranking extension will be chosen to create the window. + +'branding' + An Extension to this point can be used to provide a custom window + title and icon to the primary workbench window. A Branding object can + be declared as the child of the extension, or created by the extension + factory function which accepts the workbench as an argument. The + highest ranking extension will be chosen to provide the branding. + +'actions' + Extensions to this point can be used to provide menu items and + action items to be added to the primary workbench window menu bar. The + extension can declare child MenuItem and ActionItem instances as well + as provide a factory function which returns a list of the same. + +'workspaces' + Extensions to this point can be used to provide workspaces which + can be readily swapped to provide the main content for the workbench + window. The extension factory function should accep the workbench as + an argument and return an instance of Workspace. + +'autostart' + Extensions to this point can be used to provide the id of a plugin + which should be started preemptively on application startup. The + extension should declare children of type Autostart. The plugins will + be started in order of extension rank. Warning - abusing this facility + can cause drastic slowdowns in application startup time. Only use it + if you are *absolutely* sure your plugin must be loaded on startup. + +The plugin declares the following extensions: + +'default_application_factory' + This contributes to the 'enaml.workbench.ui.application_factory' + extension point and provides a default instance of a QtApplication. + +'default_window_factory' + This contributes to the 'enaml.workbench.ui.window_factory' extension + point and provides a default instance of a WorkbenchWindow. + +'default_commands' + This contributes to the 'enaml.workbench.core.commands' extension point + and provides the default command for the plugin (described later). + +The plugin provides the following commands: + +'enaml.workbench.ui.close_window' + This command will close the primary application window. It takes + no parameters. + +'enaml.workbench.ui.close_workspace' + This command will close the currently active workspace. It takes + no parameters. + +'enaml.workbench.ui.select_workspace' + This command will select and a activate a new workspace. It takes + a single 'workspace' parameter which is the fully qualified id of + the extension point which contributes the workspace of interest. + +WorkbenchWindow +~~~~~~~~~~~~~~~ +The WorkbenchWindow is an enamldef subclass of the Enaml MainWindow widget. +It is used by the ui plugin to bind to the internal ui window model which +drives the runtime dynamism of the window. + +The will be cases where a developer wishes to create a custom workbench +window for one reason or another. This can be done subclassing the plain +WorkbenchWindow and writing a plugin which contributes a factory to the +'enaml.workbench.ui.window_factory' class. + +The WorkbenchWindow class can be imported from `enaml.workbench.ui.api`. + +Branding +~~~~~~~~ +The Branding class is a declarative class which can be used to apply a +custom window title and window icon to the primary application window. This +is a declarative class which can be defined as the child of an extension, or +returned from the factory of an extension which contributes to the +'enaml.workbench.ui.branding' extension point. + +The Branding class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest: + +title + The string to use as the primary title of the main window. + +icon + The icon to use for the icon of the main window and taskbar. + +MenuItem +~~~~~~~~ +The MenuItem class is a declarative class which can be used to declare a +menu in the primary window menu bar. + +The MenuItem class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest: + +path + A "/" separated path to the location of this item in the menu bar. + This path must be unique for the menu bar, and the parent path must + exist in the menu bar. The last token in the path is the id of this + menu item with respect to its siblings. For example, if the path for + the item is '/foo/bar/baz', then '/foo/bar' is the path for the parent + menu, and 'baz' is the id of the menu with respect to its siblings. + *The parent menu need not be defined by the same extension which + defines the menu. That is, one plugin can contribute a sub-menu to + a menu defined by another plugin.* + +group + The name of the item group defined by the parent menu to which this + menu item should be added. For a top-level menu item, the empty group + is automatically implied. + +before + The id of the sibling item before which this menu item should appear. + The sibling must exist in the same group as this menu item. + +after + The id of the sibling item after which this menu item should appear. + This sibling must exist in the same group as this menu item. + +label + The text to diplay as the label for the menu. + +visible + Whether or not the menu is visible. + +enabled + Whether or not the menu is enabled. + +A MenuItem can define conceptual groups in which other plugins may contribute +other menu items and action items. A group is defined by declaring a child +ItemGroup object on the menu item. The group will appear on screen in the +order in which they were declared. There is an implicit group with an empty +identifier into which all unclassified items are added. The implicit group +will always appear visually last on the screen. + +ItemGroup +~~~~~~~~~ +The ItemGroup class is a declarative class used to form a logical and +visual group of items in a menu. It is declared as a child of a MenuItem +and provides a concrete advertisement by the author of a MenuItem that it +expects other MenuItem and ActionItem instances to be added to that point +in the Menu. + +The ItemGroup class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest: + +id + The identifier of the group within the menu. It must be unique among + all other group siblings defined for the menu item. + +visible + Whether or not the items in the group are visible. + +enabled + Whether or not the items in the group are enabled. + +exclusive + Whether or not neighboring checkable action items in the group + should behave as exclusive checkable items. + +ActionItem +~~~~~~~~~~ +The ActionItem class is used to declare a triggerable item in a menu. It +is declared as a child of a plugin Extension object. + +The ActionItem class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest: + +path + A "/" separated path to the location of this item in the menu bar. + This path must be unique for the menu bar, and the parent path must + exist in the menu bar. The last token in the path is the id of this + action item with respect to its siblings. For example, if the path for + the item is '/foo/bar/baz', then '/foo/bar' is the path for the parent + menu, and 'baz' is the id of the action with respect to its siblings. + *The parent menu need not be defined by the same extension which + defines the action. That is, one plugin can contribute an action to a + menu defined by another plugin.* + +group + The name of the item group defined by the parent menu to which this + action item should be added. + +before + The id of the sibling item before which this action item should appear. + The sibling must exist in the same group as this action item. + +after + The id of the sibling item after which this action item should appear. + This sibling must exist in the same group as this action item. + +command + The identifier of the Command object which should be invoked when + this action item is triggered by the user. + +parameters + The dictionary of parameters which should be passed to the command + when it is invoked. + +label + The text to diplay as the label for the action. + +shortcut + The keyboard shortcut which should be bound to trigger action item. + +visible + Whether or not the action is visible. + +enabled + Whether or not the action is enabled. + +checkable + Whether or not the action is checkable. + +checked + Whether or not the action is checked. + +icon + The icon to display next to the action. + +tool_tip + The tool tip text to display when the user hovers over the action. + +status_tip + The text to display in the status bar when the user hovers over the + action. + +Workspace +~~~~~~~~~ +The Workspace class is a declarative class which is used to supply the +central window content for a ui workbench application. It contains the +attributes and method which are necessary for the ui plugin to be able +to dynamically switch workspaces at runtime. The application developer +will typically create a custom workspace class for each one of the views +that will be shown in the workbench. + +The Workspace class is declarative to allow the developer to fully +leverage the Enaml language in the course of defining their workspace. +It will typically be declared as the child of any object. + +The Workspace class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest: + +window_title + This is text which will be added to the window title *in addition* + to the title text which is supplied by a branding extension. + +content + This is an Enaml Container widget which will be used as the primary + window content. It should be created during the workspace 'start' + method and will be destroyed by the framework automatically when + the workspace is stopped. + +It has the following methods of interest: + +start + This method is called when the UI plugin starts the workspace. This + can be used to load content or any other resource which should exist + for the life of the workspace. + +stop + This method is called when the UI plugin closes the workspace. This + should be used to release any resources acquired during the lifetime + of the workspace. The content Container will be destroyed automatically + after this method returns. + +Autostart +~~~~~~~~~ +The Autostart class is a declarative class which is used to supply the +plugin id for a plugin which should be automatically started on application +startup. + +The Autostart class can be imported from `enaml.workbench.ui.api`. + +It has the following attributes of interest. + +plugin_id + This is the id of the plugin to start on application startup. The + manifest for the plugin must be registered before the ui plugin is + started. + +UI Workbench +------------ +The UIWorkbench class is a simple sublass of Workbench for creating ui +applications. This class will automatically register the pre-defined +'core' and 'ui' workbench plugins when it is started. + +The UIWorkbench class can be imported from `enaml.workbench.ui.api`. + +It has the following methods of interest: + +run + This method will load the core and ui plugins and start the + main application event loop. This is a blocking call which + will return when the application event loop exits. diff --git a/examples/workbench/first_view.enaml b/examples/workbench/first_view.enaml new file mode 100644 index 000000000..fcffe556a --- /dev/null +++ b/examples/workbench/first_view.enaml @@ -0,0 +1,53 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.widgets.api import Container, Html +from enaml.workbench.api import Extension, PluginManifest +from enaml.workbench.ui.api import ActionItem, MenuItem, ItemGroup + + +print 'Imported First View!' + + +enamldef FirstView(Container): + Html: + source = '

Hello World!

' + + +enamldef FirstManifest(PluginManifest): + """ The manifest which is registered when the view is loaded. + + This manifest contributes extra menu items to the menu bar. + + """ + id = 'sample.first' + Extension: + id = 'actions' + point = 'enaml.workbench.ui.actions' + MenuItem: + path = '/edit' + label = 'Edit' + after = 'file' + before = 'workspace' + ItemGroup: + id = 'first' + ActionItem: + path = '/edit/undo' + label = 'Undo' + group = 'first' + ActionItem: + path = '/edit/cut' + label = 'Cut' + shortcut = 'Ctrl+X' + ActionItem: + path = '/edit/copy' + label = 'Copy' + shortcut = 'Ctrl+C' + ActionItem: + path = '/edit/paste' + label = 'Paste' + shortcut = 'Ctrl+V' diff --git a/examples/workbench/sample.py b/examples/workbench/sample.py new file mode 100644 index 000000000..b42918799 --- /dev/null +++ b/examples/workbench/sample.py @@ -0,0 +1,24 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +""" A simple example plugin application. + +This example serves to demostrates the concepts described the accompanying +developer crash source document. + +""" +from enaml.workbench.ui.api import UIWorkbench + + +if __name__ == '__main__': + import enaml + with enaml.imports(): + from sample_plugin import SampleManifest + + workbench = UIWorkbench() + workbench.register(SampleManifest()) + workbench.run() diff --git a/examples/workbench/sample_plugin.enaml b/examples/workbench/sample_plugin.enaml new file mode 100644 index 000000000..8e1dcd84c --- /dev/null +++ b/examples/workbench/sample_plugin.enaml @@ -0,0 +1,123 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.workbench.api import Extension, PluginManifest +from enaml.workbench.ui.api import ActionItem, Branding, MenuItem, ItemGroup + + +def first_view_factory(workbench): + from sample_workspace import SampleWorkspace + + import enaml + with enaml.imports(): + from first_view import FirstView, FirstManifest + + space = SampleWorkspace() + space.window_title = 'First View' + space.content_def = FirstView + space.manifest_def = FirstManifest + return space + + +def second_view_factory(workbench): + from sample_workspace import SampleWorkspace + + import enaml + with enaml.imports(): + from second_view import SecondView, SecondManifest + + space = SampleWorkspace() + space.window_title = 'Second View' + space.content_def = SecondView + space.manifest_def = SecondManifest + return space + + +def third_view_factory(workbench): + from sample_workspace import SampleWorkspace + + import enaml + with enaml.imports(): + from third_view import ThirdView, ThirdManifest + + space = SampleWorkspace() + space.window_title = 'Third View' + space.content_def = ThirdView + space.manifest_def = ThirdManifest + return space + + +enamldef SampleManifest(PluginManifest): + """ The plugin manifest for the primary example plugin. + + This plugin acts as the entry point for all other plugins in this + example. It contributes the window branding, default actions, and + the workspace definitions. + + """ + id = 'sample' + Extension: + id = 'branding' + point = 'enaml.workbench.ui.branding' + Branding: + title = 'Sample Plugin App' + Extension: + id = 'actions' + point = 'enaml.workbench.ui.actions' + MenuItem: + path = '/file' + label = 'File' + ItemGroup: + id = 'user' + MenuItem: + path = '/workspace' + label = 'Workspace' + ItemGroup: + id = 'spaces' + ActionItem: + path = '/file/close' + label = 'Close' + shortcut = 'Ctrl+Q' + command = 'enaml.workbench.ui.close_window' + ActionItem: + path = '/workspace/first' + label = 'First' + shortcut = 'Ctrl+1' + group = 'spaces' + command = 'enaml.workbench.ui.select_workspace' + parameters = {'workspace': 'sample.first_view'} + ActionItem: + path = '/workspace/second' + label = 'Second' + shortcut = 'Ctrl+2' + group = 'spaces' + command = 'enaml.workbench.ui.select_workspace' + parameters = {'workspace': 'sample.second_view'} + ActionItem: + path = '/workspace/third' + label = 'Third' + shortcut = 'Ctrl+3' + group = 'spaces' + command = 'enaml.workbench.ui.select_workspace' + parameters = {'workspace': 'sample.third_view'} + ActionItem: + path = '/workspace/close' + label = 'Close Workspace' + shortcut = 'Ctrl+D' + command = 'enaml.workbench.ui.close_workspace' + Extension: + id = 'first_view' + point = 'enaml.workbench.ui.workspaces' + factory = first_view_factory + Extension: + id = 'second_view' + point = 'enaml.workbench.ui.workspaces' + factory = second_view_factory + Extension: + id = 'third_view' + point = 'enaml.workbench.ui.workspaces' + factory = third_view_factory diff --git a/examples/workbench/sample_workspace.py b/examples/workbench/sample_workspace.py new file mode 100644 index 000000000..71e2b3c65 --- /dev/null +++ b/examples/workbench/sample_workspace.py @@ -0,0 +1,56 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from atom.api import Subclass, Unicode + +from enaml.widgets.api import Container +from enaml.workbench.api import PluginManifest +from enaml.workbench.ui.api import Workspace + + +print 'Imported Sample Workspace!' + + +class SampleWorkspace(Workspace): + """ A custom Workspace class for the crash course example. + + This workspace class will instantiate the content and register an + additional plugin with the workbench when it is started. The extra + plugin can be used to add addtional functionality to the workbench + window while this workspace is active. The plugin is unregistered + when the workspace is stopped. + + """ + #: The enamldef'd Container to create when the workbench is started. + content_def = Subclass(Container) + + #: The enamldef'd PluginManifest to register on start. + manifest_def = Subclass(PluginManifest) + + #: Storage for the plugin manifest's id. + _manifest_id = Unicode() + + def start(self): + """ Start the workspace instance. + + This method will create the container content and register the + provided plugin with the workbench. + + """ + self.content = self.content_def() + manifest = self.manifest_def() + self._manifest_id = manifest.id + self.workbench.register(manifest) + + def stop(self): + """ Stop the workspace instance. + + This method will unregister the workspace's plugin that was + registered on start. + + """ + self.workbench.unregister(self._manifest_id) diff --git a/examples/workbench/second_view.enaml b/examples/workbench/second_view.enaml new file mode 100644 index 000000000..fda7511c7 --- /dev/null +++ b/examples/workbench/second_view.enaml @@ -0,0 +1,64 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.widgets.api import Container, Form, Label, Field +from enaml.workbench.api import Extension, PluginManifest +from enaml.workbench.ui.api import ActionItem, MenuItem + + +print 'Imported Second View!' + + +enamldef SecondView(Container): + padding = 0 + Form: + Label: + text = 'First Name' + Field: + pass + Label: + text = 'Last Name' + Field: + pass + Label: + text = 'Address' + Field: + pass + + +enamldef SecondManifest(PluginManifest): + """ The manifest which is registered when the view is loaded. + + This manifest contributes extra menu items to the menu bar. + + """ + id = 'sample.second' + Extension: + id = 'actions' + point = 'enaml.workbench.ui.actions' + MenuItem: + path = '/preferences' + label = 'Preferences' + after = 'file' + before = 'workspace' + MenuItem: + path = '/window' + label = 'Window' + before = 'workspace' + MenuItem: + path = '/help' + label = 'Help' + after = 'workspace' + ActionItem: + path = '/preferences/save' + label = 'Save' + ActionItem: + path = '/preferences/restore' + label = 'Restore' + ActionItem: + path = '/help/about' + label = 'About' diff --git a/examples/workbench/third_view.enaml b/examples/workbench/third_view.enaml new file mode 100644 index 000000000..9a9f59291 --- /dev/null +++ b/examples/workbench/third_view.enaml @@ -0,0 +1,107 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2013, Nucleic Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------------------ +from enaml.layout.api import HSplitLayout, InsertItem +from enaml.widgets.api import Container, DockArea, DockItem, Html +from enaml.workbench.api import Extension, PluginManifest +from enaml.workbench.core.api import Command +from enaml.workbench.ui.api import ActionItem, MenuItem + + +print 'Imported Third View!' + + +enamldef Pane(DockItem): item: + Container: + Html: + source = '

%s

' % item.title + + +enamldef ThirdView(Container): + padding = 0 + alias dock_area + DockArea: dock_area: + layout = HSplitLayout('first', 'second') + Pane: + name = 'first' + title = 'Pane 0' + Pane: + name = 'second' + title = 'Pane 1' + + +def new_item(event): + """ The command handler for the 'sample.third.new_item' command. + + """ + ui = event.workbench.get_plugin('enaml.workbench.ui') + area = ui.workspace.content.dock_area + count = len(area.dock_items()) + name = '_pane_%d' % count + title = 'Pane %d' % count + item = Pane(area, name=name, title=title) + area.update_layout(InsertItem(item=item.name)) + + +def destroy_items(event): + """ The command handler for the 'sample.third.destroy_items' command. + + """ + ui = event.workbench.get_plugin('enaml.workbench.ui') + area = ui.workspace.content.dock_area + for item in area.dock_items(): + item.destroy() + + +enamldef ThirdManifest(PluginManifest): + """ The manifest which is registered when the view is loaded. + + This manifest contributes extra menu items to the menu bar and + new commands for manipulating the dock area items. + + """ + id = 'sample.third' + Extension: + id = 'commands' + point = 'enaml.workbench.core.commands' + Command: + id = 'sample.third.new_item' + handler = new_item + Command: + id = 'sample.third.destroy_items' + handler = destroy_items + Extension: + id = 'actions' + point = 'enaml.workbench.ui.actions' + MenuItem: + path = '/debug' + label = 'Debug' + after = 'file' + MenuItem: + path = '/options' + label = 'Options' + MenuItem: + path = '/tools' + label = 'Tools' + before = 'workspace' + ActionItem: + path = '/debug/something' + label = 'Something' + ActionItem: + path = '/options/something_else' + label = 'Something Else' + ActionItem: + path = '/tools/destroy_items' + label = 'Destroy Items' + shortcut = 'Ctrl+X' + command = 'sample.third.destroy_items' + ActionItem: + path = '/file/new' + label = 'New' + shortcut = 'Ctrl+N' + group = 'user' + command = 'sample.third.new_item'