o3de / sig-content

8 stars 12 forks source link

Proposed RFC Feature - Editor Action Manager #51

Closed AMZN-daimini closed 2 years ago

AMZN-daimini commented 2 years ago

Summary:

The aim of this effort is to introduce a unified system to handle editor actions throughout all editor tools.

An action is an abstract UI behavior that can be invoked to interact with the editor; for example, deleting an entity, opening a script in ScriptCanvas, adding a component... Actions in the editor can be invoked in different ways (from a context menu item, a toolbar button, a key press...), but the expectation is that they will lead to the same result regardless of where they originated.

Actions, in and of themselves, are abstract. Adding them to the UI (either as a toolbar button, a menu item or a hotkey) is what makes them accessible for the user. So action management is intrinsically linked with how those actions are accessed, and this effort will have to include not just an action registration and access system, but also a way to handle binding actions to toolbars, menus and hotkeys for these operations to be standardized across the editor and all the tools.

What is the relevance of this feature?

The purpose is to standardize interactions across the Editor and its internal and external tools by introducing a system whose architecture prioritizes these tenets:

Feature design description:

The system is comprised of multiple sub-systems that handle different aspects of action management in the Editor.

-Action Manager class This is the main class that handles action registration, categorization and querying. It exposes APIs for other systems to access or trigger the actions; actions are handled by the systems that registered them. It also implements mechanisms that allow to switch between different editor modes seamlessly, or to trigger the enabled state of an action as needed. The API will also be accessible from Python, which will enable tools and tests to trigger any action registered via this system and should prevent us from having to explicitly expose request buses for each tool, or use PySide to trigger menu items via text search.

Additional systems described below rely on the main action manager class but handle the separate UI elements.

Every standalone tool will instantiate one handler for each of these classes it requires to support.

The ActionManager will provide APIs via AZ::Interface that allow any system, both internal and external (Gems) to setup actions at runtime.

Technical design description:

Action Manager

Action Contexts

To allow different tools inside the Editor to have different actions bound to them, the Action Manager architecture needs to support the concept of context.

The Action Context class is a container of actions that is associated to an editor widget. On registration, an action needs to be tied to an action context; this determines that any hotkey for that action will only be triggered when the editor focus is on a descendant of the associated editor widget for its owning context. Contexts also relate to mode switching, which allow multiple actions to be switched at once when the editor is set to a particular editing mode (see below).

A context is tied to a unique string identifier; it needs to be attached to a QWidget, and is the direct owner of a subset of actions.

Required Parameters To construct a new context, some parameters are required:

A context needs to be attached to a QWidget. This is a hard requirement for hotkey handling, but it is also going to be useful for populating user menus for toolbar and menu customization, to restrict the actions available based on the context. Nonetheless, contexts do not add any restrictions on what actions can be added to what toolbar/menu from the code side.

The Parent parameter allows the creation of a context hierarchy. This does not necessarily need to mirror the QWidget hierarchy; as such, the QWidget associated to the parent context does not necessarily need to be an ancestor of the QWidget associated to the child context. Similarly, two contexts may not be hierarchically related even if they're attached to QWidgets that are. (More details on the repercussions of this setup in the Hotkey manager section).

image

Actions

Any system should be able to define new actions. An action will essentially be a wrapped QAction object with some additional information. The intent of storing a QAction inside our action class is to re-use that same action and prevent having duplicates for each separate sub-system. This leverages Qt's existing signaling system and prevents us from having to write additional syncing code for when the Action is enabled/disabled or changes are applied to it.

An action needs to be registered to a single context. Registering an action also means registering its handler; as such, each action can only have a single handler.

image

Required Parameters To construct a new action, some parameters are required:

Enabled State handling Most actions' enabled state is tied to specific conditions. For example, we may want the "Create Entity" action on the right click context menu in the Outliner to be disabled if the selection size is > 1, or if the only entity selected is read-only. As such, we need a system in place to ensure individual actions are displayed as enabled or disabled correctly (either in the menus or in the toolbars).

While details are pending and will be finalized during implementation, this is the way this is expected to work:

Collision detection The Action Manager system should be able to detect collisions in action identifiers and AZ_Error out if multiple actions with the same name are registered. The name convention should make it harder for collisions to happen, but we should still enforce uniqueness to prevent weird behaviors. If a collision happens, the system will be able to recover and just work as intended but the second action registered with the same identifier will not be registered correctly and will thus be inaccessible - all references to that same identifier will trigger the first action.

Similarly, each action should be bound to a single context; the systems are set up to allow mixing and matching actions from different contexts in the same toolbar/menu with no limitation, however if necessary nothing's stopping a tool from registering multiple unique actions with the same callback function if needs be.

Mode Switching

Multiple Editor workflows (usually referred to as Component Modes) rely on the ability to selectively remove or add multiple actions at once when an editor mode is entered or exited. To support this pattern in a unified way that is less error-prone than the current implementation, it should be possible for tools to assign actions in a context to "modes", and then have the system automatically handle changes when the mode is switched.

Modes should be defined and handled per-context. Nested contexts inherit the modes from their ancestors.

An example follows. Note that the specifics on how a mode is defined and how actions are tied to modes are still WIP and the example is just meant to clarify the core concept.

image

Identifier naming standard
[context identifier]:[mode]

// Main O3DE Editor context
o3de.context.editor

// Main O3DE Editor default mode (created automatically)
o3de.context.editor:default

// define additional Main O3DE Editor modes
o3de.context.editor:collider
o3de.context.editor:spline

By default, any context has a "default" mode created for it that is also the initial state for that context. Newly created actions will be added to the default mode. It's possible to assign actions to one or multiple modes. Ideally, we don't need to register modes beforehand, and the first time an action is assigned to a mode, it would trigger the creation of that mode via the identifier (since the mode itself does not need any more metadata). As such, the identifiers are not binding and the system makes no assumption between a mode identifier and the context identifier.

Additionally, it is possible to assign an action to all modes (""). Those actions will always appear, regardless of the mode. While this is useful for some types of actions (for example, always include Undo/Redo in the Edit menu), some modes may actually want to have full control. As such, we also need to provide a parameter that allows modes to exclude actions marked as "".

As part of editor workflows, systems will have the ability to "switch" between modes via the ActionManager API. This will result in all actions that are not associated to the active mode being hidden, and all actions that are associated with the active mode being shown across all systems handled by the ActionManager (toolbars, menu, hotkeys).

For example:

// Create multiple actions

o3de.action.editor.newlevel                         // Create a new level
o3de.action.editor.openlevel                        // Open an existing level
o3de.action.editor.save                             // Save the focused prefab instance (and descendants)
o3de.action.editor.collider.setoffsetmode           // Collider Edit Mode - Switch to Offset mode
o3de.action.editor.collider.setrotationmode         // Collider Edit Mode - Switch to Rotation mode
o3de.action.editor.collider.setresizemode           // Collider Edit Mode - Switch to Resize mode
o3de.action.editor.spline.duplicatevertices         // Spline Edit Mode - Duplicate selected vertices
o3de.action.editor.spline.deletevertices            // Spline Edit Mode - Delete selected vertices
o3de.action.editor.spline.deselectvertices          // Spline Edit Mode - Deselect vertices
o3de.action.editor.done                             // Close Collider or Slice mode (works on both)
o3de.action.editor.undo                             // Undo last operation
o3de.action.editor.redo                             // Redo last undone operation

// We assign these actions to different modes
// API WIP
m_actionManagerInterface->SetActionModeBinding("o3de.action.editor.collider.setoffsetmode", ["o3de.context.editor:collider"]);
// SetActionModeBinding would overwrite any existing binding.

m_actionManagerInterface->SetActionModeBinding("o3de.action.editor.done", ["o3de.context.editor:collider"]);
m_actionManagerInterface->AddActionModeBinding("o3de.action.editor.done", ["o3de.context.editor:spline"]);
// AddActionModeBinding would add to any existing binding.

o3de.action.editor.collider.setoffsetmode           -> "o3de.context.editor:collider"
o3de.action.editor.collider.setrotationmode         -> "o3de.context.editor:collider"
o3de.action.editor.collider.setresizemode           -> "o3de.context.editor:collider"
o3de.action.editor.spline.duplicatevertices         -> "o3de.context.editor:spline"
o3de.action.editor.spline.deletevertices            -> "o3de.context.editor:spline"
o3de.action.editor.spline.deselectvertices          -> "o3de.context.editor:spline"
o3de.action.editor.done                             -> "o3de.context.editor:collider", "o3de.context.editor:spline"
o3de.action.editor.undo                             -> "*" (all)
o3de.action.editor.redo                             -> "*" (all)

// By default, the mode for the o3de.context.editor context will be set to 'default'

Available actions:
o3de.action.editor.newlevel                         // Create a new level
o3de.action.editor.openlevel                        // Open an existing level
o3de.action.editor.save                             // Save the focused prefab instance (and descendants)
o3de.action.editor.undo                             // Undo last operation
o3de.action.editor.redo                             // Redo last undone operation

// We then switch the o3de.context.editor context to the 'collider' mode (API WIP)

Available actions:
o3de.action.editor.collider.setoffsetmode           // Collider Edit Mode - Switch to Offset mode
o3de.action.editor.collider.setrotationmode         // Collider Edit Mode - Switch to Rotation mode
o3de.action.editor.collider.setresizemode           // Collider Edit Mode - Switch to Resize mode
o3de.action.editor.done                             // Close Collider or Slice mode (works on both)
o3de.action.editor.undo                             // Undo last operation
o3de.action.editor.redo                             // Redo last undone operation

// We then switch the o3de.actions.editor context to the 'spline' mode (API WIP)

Available actions:
o3de.action.editor.spline.duplicatevertices         // Spline Edit Mode - Duplicate selected vertices
o3de.action.editor.spline.deletevertices            // Spline Edit Mode - Delete selected vertices
o3de.action.editor.spline.deselectvertices          // Spline Edit Mode - Deselect vertices
o3de.action.editor.done                             // Close Collider or Slice mode (works on both)
o3de.action.editor.undo                             // Undo last operation
o3de.action.editor.redo                             // Redo last undone operation

// We then switch the o3de.context.editor context to the 'sample' mode (API WIP)
// Note that we didn't define this mode beforehand in any way.

Available actions:
o3de.action.editor.undo                             // Undo last operation
o3de.action.editor.redo                             // Redo last undone operation

Note that this system is different from the Enabled State handling mechanism described above.

Menus

Systems will be able to create menus by defining a unique string identifier.

We don't need to register menus beforehand, as the first time an action is assigned to a menu it will trigger the creation of a menu with that identifier (since the menu itself does not need any more metadata).

The creation of a menu from the ActionManager API does not require a context to be specified - systems will be able to mix and match actions from any context, and the responsibility to verify that the action can be correctly executed is on whoever writes the callback. For example, writing an action for the ScriptCanvas context, the callback should verify ScriptCanvas is open and a canvas is being edited before trying to access it and possibly result in a crash if it is necessary that it should be called from a different tool.

Binding an action to a menu Adding actions to menus is then just a matter of creating a binding with the following information:

// API sample - WIP
menuManagerInterface->AddActionToMenu("o3de.action.editor.undo", "o3de.menu.editor.edit", 10);

Adding other elements to menus Additional API calls should be available to add additional elements to menus as follows.

Separators

// API sample - WIP
menuManagerInterface->AddSeparatorToMenu("o3de.menu.editor.edit", 12);

Submenus When adding an action to a menu, it is also possible to have a submenu associated to it. In the case of Menus, an action with a submenu would lose the ability to be triggered.

(This space will likely need to be explored to determine whether it is preferable to have submenus associated to actions so that they can be displayed across menus and toolbars, or if they should be independent settings that only use the same architecture underneath).

// API sample - WIP
menuManagerInterface->AddActionToMenu("o3de.action.editor.undo", "o3de.menu.editor.edit", 10, o3de.menu.editor.edit.submenu");

Retrieving a menu The ActionManager API will allow systems to retrieve a QMenu* from its Menu Identifier, and display it as appropriate.

Toolbars

Systems will be able to create toolbars by defining a unique string identifier.

We don't need to register toolbars beforehand, as the first time an action is assigned to a toolbar it will trigger the creation of a toolbar with that identifier (since the toolbar itself does not need any more metadata).

We also distinguish between two types of toolbars:

The creation of a system toolbar from the ActionManager API does not require a context to be specified - systems will be able to mix and match actions from any context, and the responsibility to verify that the context the action is executed in is correct is on whoever writes the callback. For example, writing an action for the ScriptCanvas category, the callback should verify ScriptCanvas is open and a canvas is being edited before trying to access it and possibly result in a crash if called from a different tool.

Custom toolbars behave differently, see paragraph below.

Binding an action to a toolbar Adding actions to menus is then just a matter of creating a binding with the following information:

// API sample - WIP
toolbarManagerInterface->AddActionToToolbar("o3de.action.editor.undo", "o3de.toolbar.editor.viewport", 10);

Custom toolbars The creation of a custom toolbar from the UX should instead restrict users to select actions from a specified context that matches the widget owning the toolbar.

This isn't really a technical limitation per se, but more of a way to limit the scope of different factors that can be tested - the expectation is that if a developer wants to add an action to a specific toolbar, they're going to at least test that triggering it doesn't crash the Editor. That can't be guaranteed if we just allow users to add any action to any toolbar in any context.

Custom toolbars created via UX should be stored as json files with a custom file format in either the project (shared) or a local user folder. All files with that format in the user folders should just appear in the Editor at startup. While potentially the Settings Registry could be used for this purpose, this system would not bolster the overriding capabilities of that system and would actually be made more complex by it. As such, it would make sense for those to be their own format.

Hotkeys

Due to their reliance to widget focus in the Editor, hotkey management works a little differently.

Setup Current hotkey handling in the legacy Action Manager relies on the ShortcutDispatcher; that class introduces a top-down approach, interjecting hotkeys before they're propagated to the Qt widget hierarchy and triggering them as it sees fit. That approach is pretty much backwards compared to the way Qt handles its events, which bubble bottom up from the currently focused widget.

The idea for the new Action Manager is to align a bit more with the way things work in Qt. Action Contexts own the QActions and are attached to QWidgets in the Qt widget hierarchy. When a hotkey event is triggered, it bubbles up from the focused widget until it reaches one of the widgets controlled by an Action Context. Via an event filter, the context captures the event and verifies if the hotkey matches with any of the actions registered to it.

If there's a match, the corresponding action is triggered and the event is captured; If no match is found, the action is propagated to the parent Action Context (note, not the parent of the QWidget in Qt's widget hierarchy). This setup effectively masks all the widgets under an Action Context from incorrectly triggering an action from higher up in the chain. So for example, pressing Ctrl+N in the Asset Editor (which is not attached to a hotkey) will not bubble up and trigger the creation of a new level in the main editor window, since the context is not parented (despite the asset editor being a descendant of the main editor's QMainWindow.

Hotkey binding The system will not allow to define a hotkey via C++ or Python, but Gems can provide setreg files to specify hotkeys for their actions. The hotkey database is handled via json files and the SettingsRegistry (maybe a custom instance of it?) with the following hierarchy:

A core file is used as the base. The system should have multiple configurations, so that we can support different hotkey setups based on the various DCC tools available; Every gem has the ability to provide additional hotkeys via setreg files, so that additional actions are made available to the various supported setups; The project can also provide a file that adds custom hotkeys for a whole studio; Last but not least, a local user file provides the last level of customization. An in-editor UX would then be provided to allow easy customization of this 4th layer. Here are some old mockups, but we will work with UX to produce new interfaces as part of this effort. Having the hotkeys be saved as json files and handled by setreg would make the format human readable and easy to port to different workstations or share as part of the project.

The json file would look something like this:

{
   "O3DE": {
       "o3de.context.editor": {
           "*": {
               "Ctrl+S": "o3de.action.editor.save"
           },
           "default": {
               "1": "o3de.action.editor.translate"
           },
           "collider": {
               "1": "o3de.action.editor.collider.offset"
           }
       }
   },
   "Blender": {
       "o3de.context.editor": {
           "*": {
               "Ctrl+S": "o3de.action.editor.save"
           },
           "default": {
               "G": "o3de.action.editor.translate"
           },
           "collider": {
               "G": "o3de.action.editor.collider.offset"
           }
       }
   }
}

Note: this example assumes we're using a separate instance of the Settings Registry. If using the same one, these definitions would be nested into a branch of the settings registry tree.

While versatile, this setup does not guarantee that the hotkeys will be unique (since at any one time the hotkeys in the empty mode will be valid alongside the hotkeys on the active mode). At runtime, when the files are merged, we will have to detect collisions and resolve them appropriately. An appropriate resolution could be:

What are the advantages of the feature?

This feature introduces a reusable framework that will make it easier to implement and maintain a coherent UX across the Editor and all internal and external tools.

What are the disadvantages of the feature?

At this stage, the architecture may not support some functionality natively (for example, widgets in system toolbars). Workaround will be provided, and once this work is completed it should be possible to integrate such extra functionality natively.

How will this be implemented or integrated into the O3DE environment?

The plan is to start by removing the legacy action management solution (which is only used by a handful of classes) via a system toggle, and build the new solution in parallel so that we can swap it in place and deprecate the old code seamlessly. This will not affect external gems since the legacy code was not accessible outside of the Editor module.

How will users learn this feature?

The plan includes the creation of technical documentation to explain how to adopt the architecture in custom tools. Follow-up efforts to retrofit existing tools to use this architecture are also planned..

Are there any open questions?

How discoverable will the use of the Settings Registry be? As a general concern the current settings registry can be rather opaque and hard to discover.

That is a concern in multiple ways:

How do Sort keys work for item ordering in toolbars and menus?

To allow dynamically adding items to toolbars and menus at a specific position, the system supports the usage of sort keys. Actions will be displayed in ascending order; conflicts are resolved by displaying the actions in order of registration. As such, when adding items to a menu/toolbar, the system may want to just pick a single integer and add all adjacent actions to that same key; this way, even if a different gem uses the same number, the associated actions will still be adjacent to each other and in the order of registration, even if the relative position of the block compared to the other gems will not be guaranteed.

An API will also be exposed to retrieve the sort key for an action in a specific menu, so that gems wanting to add an item "right after" a specific action can just retrieve it programmatically.

cgalvan commented 2 years ago

I'm very excited for this!

I really only have one thought about the registering of actions from Python as outlined by this paragraph above :

**Behavior (function)**
The behavior that should be called when the action is triggered.
When an action is constructed, it's not automatically accessible from the UI but it can only be triggered programmatically. Creating an action, though, also makes it available for binding from any of the other action manager sub-systems - Menus, Toolbars and Hotkeys.

I don't believe we currently have the ability to marshal a function to be passed through the EditorPythonBindings. So we'd need to make that possible, or could do some kind of separate notification listener where the python module could listen to an action identifier that gets passed back and perform the function for the actions it cares about.

AMZN-daimini commented 2 years ago

I don't believe we currently have the ability to marshal a function to be passed through the EditorPythonBindings.

True, some development in this area would likely be needed. I see a couple of starting points to investigate:

Good points though, this is definitely an area we will need to explore.

AMZN-Gene commented 2 years ago

This is great! I have a potential "first customer"... me! I wanted the MultiplayerGem to add a toggle to the editor play button dropdown menu. This menu already exists, but I wanted to add a line-divider and toggle which would enable launching a multiplayer server whenever CTRL+G is pressed. There's already a feature allowing developers to play their game networked without having to start GameLauncher and ServerLauncher by hand, this is now just to expose that feature by adding it as an option to the play-game button dropdown.

Along with this, I also wanted to modify the play button label, so that at a glance users can see what options were enabled. Notice in the picture the play button label now says "Play Game (Maximized + Multiplayer)". Presumably, other Gems could add to the this for features that affect play mode.

image

After reading this RFC I think this opens the possibility for my gem to add a line-divider and toggle to play-game dropdown menu, but I'm not sure it would also be capable of changing the play-button text label since multiple systems would be competing to edit that; is that correct understanding?

AMZN-daimini commented 2 years ago

Hey Gene!

I wrote a long-ish explanation and then realized you had already hit the right spot, so... TLDR: yes you have the correct understanding :)

So, the current plan does not include the ability for systems to add custom widgets to toolbars through the ActionManager API. What will likely happen in this first iteration is that the play controls toolbar will be defined in the Action Manager, retrieved by the Main Window, which will then inject the label - thus makin it inaccessible from the Action Manager API.

In a future, follow-up effort, it would be very useful to also add WidgetAction support for toolbars and menus handled by the ActionManager, which would allow external systems to add widgets to menus that they don't "own". In this case, you'd be able to retrieve the label via its unique identifier and make changes to it from a Gem (or whatever other system that can access the API).

Even in this latter case, though, I'm not sure I would advise it - the system does not really provide collision detection in this kind of situation. So if you tried to change the label's text, and then another system tried to do it too, the resulting text would be the one that was set the last... which isn't really ideal.

In this case, we could either expose the play mode settings as checkable toolbuttons in the actual toolbar, so that they're always visible (removing the need for a label), or we would need to have another system detect the current configuration and populate that label, which would need to have the capability to be extended in a similar way.

AMZN-Gene commented 2 years ago

What if instead of a single label, that play button had a list of labels? If the Action Manager API allows me to push/pop into a list inside a drop-down menu (like how I'll be pushing a toggle called "Enable Multiplayer" into the play button drop-down menu), maybe the play button label could instead be a list of labels which get displayed. So when the "maximized/full-screen" toggle is on, we push "maximized" onto the list, so the string and play button looks like [ Play (Maximized) |>] And when multiplayer is enabled we push "Multiplayer" [ Play (Maximized) (Multiplayer) |>]. Would that work? Maybe we dont support popping though (ie when i turn off "maximized" play, the list of labels needs to remove that entry.

AMZN-daimini commented 2 years ago

What if instead of a single label, that play button had a list of labels? If the Action Manager API allows me to push/pop into a list inside a drop-down menu (like how I'll be pushing a toggle called "Enable Multiplayer" into the play button drop-down menu), maybe the play button label could instead be a list of labels which get displayed. So when the "maximized/full-screen" toggle is on, we push "maximized" onto the list, so the string and play button looks like [ Play (Maximized) |>] And when multiplayer is enabled we push "Multiplayer" [ Play (Maximized) (Multiplayer) |>]. Would that work? Maybe we dont support popping though (ie when i turn off "maximized" play, the list of labels needs to remove that entry.

That would definitely work, although the spacing may not look great. We could try that out and then just discuss it with UX!

AMZN-Gene commented 2 years ago

Does this feature allow for menu items that are nested? For example, can the Connect to Multiplayer Server option have children? image

Also, is it possible to show/hide/refresh the menu after a cvar is changed? For example, if the "Connect to Multiplayer Server" is disabled, can I hide the other multiplayer options?

AMZN-daimini commented 2 years ago

Does this feature allow for menu items that are nested? For example, can the Connect to Multiplayer Server option have children? image

Also, is it possible to show/hide/refresh the menu after a cvar is changed? For example, if the "Connect to Multiplayer Server" is disabled, can I hide the other multiplayer options?

Hey Gene! What you're describing is not a menu but a Reflected Property Editor instance - that isn't part of this RFC unfortunately. I do have a proposal for a system to handle editor settings in a similarly extensible and modular way, but is prioritized after this effort and may likely wait for the DPE (Document Property Editor) to be ready as an RPE replacement.

EDIT: The feature does allow for nested menus btw.

lsemp3d commented 2 years ago

SIG-Content approved

AMZN-Gene commented 2 years ago

I see examples of menus being linked to actions, but are checkboxes possible? Or is that also a feature of DPE (Document Property Editor)? Sorry for all the questions! image

AMZN-daimini commented 2 years ago

I see examples of menus being linked to actions, but are checkboxes possible? Sorry for all the questions! image

I don't have exact details in terms of implementation, but the functionality is in the planning for MVP yes!