Actions Subsystem
ClaFi Framework Developer Manual
Introduction
An Action is a named command: what it says, what it draws, what it does, and whether it
is available now. Save, Copy, Reload. One action serves as many controls as you like - a toolbar
button, a menu item, a keyboard shortcut - and all of them stay in step because there is one object
behind them.
import ClaFi.Core.Foundation;
Two words run through the whole subsystem, and everything is easier once they are apart:
- The presenter - the button in the toolbar, the item in the menu. It shows the action's text, tooltip and icon, carries its state, and delivers the click. You attach it.
- The subject - the control the action acts on. The text box Copy copies from. You never attach it: it is found, because the user chose it by putting the focus there.
Many actions have no subject at all - Save acts on a document, not on a control - and those are the simpler case. An action's own handler is what runs when nothing else claims the action, so a command with no subject needs nothing extra.
One rule is worth carrying from the start: an action stores no state. Whether it is enabled, whether it is selected - these are properties of whatever it acts on, so they are asked for every time and never kept.
Basic Usage Example
An action is an ordinary object. Hold it wherever the thing it acts on is held - a window class is the usual place - and hand it to as many controls as you like:
// a member of the window, next to the document it acts on. The window's scope owns it // and hands back a reference, so declaring it and putting its key in reach are one line. Action& m_save{ actions().add( Text{ L"Save" }, TooltipText{ L"Write the document to disk" }, Shortcut{ L'S', { .ctrl = true } }, Events{ [this](ActionClickEvent&) { m_document.save(); }, [this](GetActionStateEvent& event) { event.claim({ .enabled = m_document.modified() }); }, } ) }; // ...and in the window's constructor toolbar.add<Button>(m_save, ButtonViewMode::IconOnly); // a presenter menu.add<MenuItem>(m_save); // and another
That is the whole of it. The button shows "Save", disables itself while the document is unmodified,
and saves when clicked. The menu item does the same and prints Ctrl+S in its shortcut
column. The key works whether either of them is on screen.
Giving an Action to a Control
Who Owns an Action
The scope it is added to, which is the ordinary case. Its commands belong to that window, they go
when it goes, and there is nothing to remember. add hands back a reference for you to
keep, so one line both creates the action and puts its key in reach.
An action owned elsewhere - by a document, or shared between two windows - is registered instead of created:
actions().add(m_document.saveAction);
add is already registered; one owned elsewhere is
not until you say so. See Scopes.Handing It to a Control
An action is a construction property, so a control takes one the way it takes a text:
toolbar.add<Button>(m_save, ButtonViewMode::LeftIcon);
It can also be done afterwards, and undone:
m_save.attach(someButton); m_save.detach(someButton);
What the Control Takes From It
| The action's | Becomes the presenter's |
|---|---|
text() | Text - but only if the control has none of its own. |
tooltipText() | Tooltip, with the shortcut appended to it. |
OnPaintIcon | Icon, wherever that control paints one. |
| state | Enabled and selected. |
| - | A click on the presenter runs the action. |
shortcut() | The key it prints beside itself, if it prints one. |
What a Presenter Has to Be
Do
- Give a presenter
Interactivity::MouseOnlywhen its action has a subject, so pressing it never moves the focus. - Let menu items be as they are - a menu takes the pointer without taking the focus already, so they need nothing.
Don't
- Make a subject-taking presenter focusable. Pressing it takes the focus off the subject, so the action resolves to the button itself or to nothing - and disables under its own press.
- Attach to a control that is never clicked. See below.
interactivity() is None can never
deliver a click, so attaching one is a mistake with no correct reading. It is caught on that control's
first state query - which happens before its first paint - with
unreachable("An action was attached to a control that never delivers a click").State
An action's state is ActionState - enabled and selected - and
it is arrived at by asking, in this order, with the last answer winning:
- the first control up from the focus that claims the action - its answer;
- nothing claimed, so the action's own
GetActionStateEventhandler; - neither, so the action is enabled if it has anything to run at all.
You do not have to write a state handler. An action that carries its own work and says nothing about when it is available is available.
Control::canClick already tests enabled, so a disabled action's presenter
refuses its own click without anything being written to make that happen.
Saying the Answer Changed
Nothing is stored, so nothing goes stale on its own - but a presenter has already been painted with an answer that was true when it was asked. Three calls say what changed:
| Call | When |
|---|---|
invalidateState() | What the action reads has changed, so enabled or selected may be different. Every presenter is asked again and animates the difference. |
invalidate() | The icon would draw something new. |
invalidateText() | The text changed, so the size every presenter asked for is stale. |
void Window::documentChanged() { m_save.invalidateState(); }
Answering an Action
A control volunteers to be a subject by answering two events. Any control can - the connectors are on
Control.
Claiming
GetActionStateEvent is asked of every control from the focused one upwards.
Claiming it is how a control says the action is about it, and the first control that
claims is the subject - which is also the control the click will go to.
Claiming is a separate statement from what the state says:
event.claim({ .enabled = hasSelection() });
Do
- Claim an action you understand even when you cannot run it right now, and report it disabled. A text box with nothing selected is what Cut is about.
- Answer
ActionClickEventfor exactly the actions you claim.
Don't
- Skip the claim because you cannot act. That hands the action to whatever is further up the chain, which will answer for a control the user is not in.
- Do work in the state pass. It runs on every state invalidation; keep it to a test.
Worked Example: TextBox
The framework's own subject. Its constructor calls connectEditActions, which connects
both handlers:
onGetActionState([this](GetActionStateEvent& event) { const bool hasSelection = m_editProps.selRange.length != 0; if (&event.action == &StdActions::cut) event.claim({ .enabled = hasSelection }); else if (&event.action == &StdActions::copy) event.claim({ .enabled = !text().plainText().empty() }); else if (&event.action == &StdActions::paste) event.claim({ .enabled = !clp.empty() || Platform::clipboardHasText() }); else if (&event.action == &StdActions::del) event.claim({ .enabled = hasSelection }); }); onActionClick([this](ActionClickEvent& event) { if (&event.action == &StdActions::cut) cutSelectedText(); else if (&event.action == &StdActions::copy) copySelectedText(); else if (&event.action == &StdActions::paste) pasteClipboardText(); else if (&event.action == &StdActions::del) deleteSelectedText(); });
An action is recognised by its address. There are no ids, no tags and no type tests anywhere in the subsystem, which is why the actions a framework control answers have to be objects the framework owns - see below.
Shortcuts
Declaring One
Shortcut{ L'S', { .ctrl = true } } // Ctrl+S Shortcut{ vkF2 } // F2, no modifiers Shortcut{ vkDelete, { .shift = true } } // Shift+Delete
The virtual key code of a letter is its own uppercase character, so L'S' is the key
marked S. Shortcut::text() spells the whole thing the way the keyboard in front of the
user is labelled - modifiers included - so a German layout reads Strg+S and nothing in
your code has to know.
More Than One Key
An action takes as many shortcuts as it is given, and every one of them runs it. The first is the one it is known by: a menu line and a tooltip show that one, and the rest just work.
Action m_closeTab{ Text{ L"Close tab" }, Shortcut{ L'W', { .ctrl = true } }, // Ctrl+W, the one shown Shortcut{ Keys::F4, { .ctrl = true } } // Ctrl+F4 };
addShortcut() puts one more key after the ones an action has, and
setShortcut() makes the key it is given the only one - so setShortcut({})
leaves the action with none.
Scopes, and the Order They Are Asked
A shortcut belongs to a scope, never to a control: bound to a control it would stop working the moment that control was scrolled away or its menu closed. There are two scopes:
actions() // on a form - the commands that belong to this window AppActions::get() // the application's, and where the standard actions are registered
A key is offered around in this order:
- the control that holds the focus, then each of its parents;
- this form's scope;
- the application's scope;
- the focus navigator.
A control with a meaning of its own for the key keeps it - a shortcut is what a key means when nothing else wants it. A disabled action leaves the key alone rather than swallowing it, so the next scope still gets to answer, and so does a second action sharing the shortcut.
The Framework's Own Actions
Cut, copy, paste, delete, select all, undo and redo are objects the framework owns. They live in
ClaFi.StdActions, in the StdActions namespace:
import ClaFi.StdActions; StdActions::cut; StdActions::copy; StdActions::paste; StdActions::del; // Delete. Spelt short because `delete` is a keyword StdActions::selectAll; StdActions::undo; // Ctrl+Z StdActions::redo; // Ctrl+Y, Ctrl+Shift+Z
ApplicationBase::initialize calls StdActions::registerAll(), which puts
all seven in the application's scope. Every application has the standard keys from there; one that
wants a key for itself clears that action's shortcut.
AppActions is the scope and stays in Core, which cannot see Icons. Delete
carries Icons::DeleteIcon, so a button or a menu item that presents it draws that icon
with nothing written.They are ordinary objects. Translate them, give them icons, or clear their shortcuts by writing to them:
StdActions::copy.text().clear(); StdActions::copy.text() << tr(L"Copy"); StdActions::copy.setShortcut({}); // and now Ctrl+C is yours to use
Put them on a menu the way you would any action:
editMenu.add<MenuItem>(StdActions::copy);
They work on whatever text box has the focus, including one a grid opened for in-place editing, because the subject is found rather than wired.
Writing an Action of Your Own
If nothing else answers it, the action's own handler is the implementation and there is no subject to think about:
Action m_reload{ Text{ L"Reload" }, Shortcut{ vkF5 }, OnEvent{ [this](ActionClickEvent&) { reload(); } }, };
If it should act on whatever the user is in, write both sides - the action, and the answer in your control:
// the action, held by the window Action m_zoomIn{ Text{ L"Zoom in" }, Shortcut{ L'=', { .ctrl = true } } }; // the answer, in the view that can zoom - it sees the same object view.onGetActionState([&](GetActionStateEvent& event) { if (&event.action == &m_zoomIn) event.claim({ .enabled = view.canZoomIn() }); }); view.onActionClick([&](ActionClickEvent& event) { if (&event.action == &m_zoomIn) view.zoomIn(); });
Two views written this way share one action, and whichever the user is in answers.
Action cannot be copied or moved - every connection it holds
captures its address. Hold it by value as a member, or let a scope own it with
actions().add(...), which returns a reference. Never put actions in a container that
reallocates.How It Works
Resolving the Subject
The walk starts at the control holding the focus and climbs the parent chain - the same path a key
takes. Each control is asked GetActionStateEvent, and the first that claims ends it. A
control with no handler for that event is stepped over without an event being built, so a form of
ordinary controls costs a pointer chase per level.
The walk runs when a state is invalidated rather than per frame, and again when the action is actually run. Nothing is stored: two calls a frame apart may answer differently, which is the point - the answer belongs to whatever the user is in now.
Because a menu takes the pointer without taking the focus, a menu item's action resolves to the control in the window behind it, with nothing written to make that happen.
Attachments and Lifetime
The action holds a list of its presenters; a presenter holds nothing, and neither does a subject -
no member was added to Control for any of this. Attaching connects handlers on the
presenter's own dispatcher and records the connections.
Both directions of death are covered. When a presenter is destroyed - a menu rebuilt, a toolbar
repopulated - ~Control emits DestroyEvent and the action drops that
attachment. When the action goes first, it disconnects from every presenter still alive.
A subject needs none of this: it was found by a walk rather than registered, so there is nothing to undo when one is destroyed.
Two Events, and Identity by Address
Whatever an action needs to know it asks with GetActionStateEvent or
ActionClickEvent, and there will never be a third. That is why every action-aware control's
handler is called for every action, and why each handler's first move is to work out whether this one
is its own by comparing the address.
The alternative - an event type or an id per command - would make that distinction at compile time, and costs a declaration for every command in the application. Comparing addresses costs one branch in one place per control, and the framework ships two event types instead of a growing family.
Reference
Exported from ClaFi.Core.Foundation:
| Name | What it is |
|---|---|
Action | A named command. Non-copyable, non-movable. |
Actions | A scope a shortcut is looked up in. |
AppActions | The application's scope. AppActions::get(). |
StdActions | The namespace holding the standard commands - cut, copy, paste, del, selectAll, undo, redo - and registerAll(). |
Shortcut | { KeyCode key; KeyModifiers modifiers; }, plus empty(), matches() and text(). |
ActionState | { bool enabled; bool selected; } - what an application knows about a control, as opposed to VisualState, which is what is drawn. |
GetActionStateEvent | Can this be run, and by whom. claim(), claimed(), state(). |
ActionClickEvent | Run it. Carries form. |
GetShortcutEvent | What key runs this control, for a control that prints it. Carries shortcut. |
ActionEventBase | Base of the two action events. Carries action and control. |
Action:
| Member | What it does |
|---|---|
OnClick, onClick | Run it - the fallback when no subject claims. |
OnGetState, onGetState | Its own answer for its state. |
OnPaintIcon, onPaintIcon | Its icon. |
text(), tooltipText() | Mutable. Follow a change with invalidateText() or invalidate(). |
shortcut(), shortcuts() | The key shown for it, and every key that runs it. |
setShortcut(), addShortcut() | Make one key the only one, or put one more after the ones it has. |
subject(form) | What would answer for it in that form, or null. |
state(form), enabled(form) | Ask for the state now. |
invoke(form) | Run it, on the subject if there is one. Answers whether it ran. |
attach(), detach(), attachedCount() | Presenters. |
invalidateState(), invalidate(), invalidateText() | Say what changed. |
Actions:
| Member | What it does |
|---|---|
add(args...) | Takes an action of its own and hands it back. |
add(action) | Registers one owned elsewhere. |
remove(action) | Drops it, and destroys it if the scope owned it. |
runShortcut(key, form) | Runs the action this key belongs to. Answers whether one ran. |
Connectors on Control, for a control answering an action rather than presenting one:
| Connector | What it answers |
|---|---|
onGetActionState | Whether this control is the action's subject, and what its state is. |
onActionClick | Run it. Reaches the control that claimed the state. |
onGetShortcut | What key runs this control - answered by whatever action is attached. |
onDestroy | This control is going away. Identity only: the derived parts are already gone. |