<

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.

Module Import:
import ClaFi.Core.Foundation;

Two words run through the whole subsystem, and everything is easier once they are apart:

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);
Registering is what puts a shortcut in reach. Attaching an action to a button is not enough - a button that has been scrolled away, or a menu that is closed, would take the key with it. An action created by 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'sBecomes the presenter's
text()Text - but only if the control has none of its own.
tooltipText()Tooltip, with the shortcut appended to it.
OnPaintIconIcon, wherever that control paints one.
stateEnabled and selected.
-A click on the presenter runs the action.
shortcut()The key it prints beside itself, if it prints one.
A control's own text wins. The action supplies the words for a presenter that has none, which is the ordinary case; a presenter that spells its own text is stating a local exception, and gets it.

What a Presenter Has to Be

Do

  • Give a presenter Interactivity::MouseOnly when 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.
A presenter whose 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:

  1. the first control up from the focus that claims the action - its answer;
  2. nothing claimed, so the action's own GetActionStateEvent handler;
  3. 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:

CallWhen
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 ActionClickEvent for 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:

  1. the control that holds the focus, then each of its parents;
  2. this form's scope;
  3. the application's scope;
  4. 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.

While a popup is open the key goes to the popup, which runs the same pipeline and so asks its own scope and then the application's. The form behind it does not answer: the popup is the window taking input.

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.

They sit above Core because an action carries an icon. 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.
Delete carries the Delete key, and a control whose Delete key means more than the command keeps the key. A text box with nothing selected deletes forward, by a character or by a word, so it answers that key itself; the action removes the selection, and that is what its menu item and its toolbar button run.
Undo and redo say only which direction to travel. What a step is, and how many of them there are, belongs to the subject: a text box keeps its own history and reports whether there is anything either way. A control that keeps none claims neither action, so the keys fall through to whatever does.

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.

A command framework controls answer is a framework object. Anything else is yours, end to end - you declare the action and you write the control side that answers it, so both ends see the same object. A context menu that wants its own wording declares its own action rather than a second Copy.

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.

An 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:

NameWhat it is
ActionA named command. Non-copyable, non-movable.
ActionsA scope a shortcut is looked up in.
AppActionsThe application's scope. AppActions::get().
StdActionsThe 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.
GetActionStateEventCan this be run, and by whom. claim(), claimed(), state().
ActionClickEventRun it. Carries form.
GetShortcutEventWhat key runs this control, for a control that prints it. Carries shortcut.
ActionEventBaseBase of the two action events. Carries action and control.

Action:

MemberWhat it does
OnClick, onClickRun it - the fallback when no subject claims.
OnGetState, onGetStateIts own answer for its state.
OnPaintIcon, onPaintIconIts 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:

MemberWhat 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:

ConnectorWhat it answers
onGetActionStateWhether this control is the action's subject, and what its state is.
onActionClickRun it. Reaches the control that claimed the state.
onGetShortcutWhat key runs this control - answered by whatever action is attached.
onDestroyThis control is going away. Identity only: the derived parts are already gone.
All manuals Top