diff --git a/CMakeLists.txt b/CMakeLists.txt
index f1fa7797f..4904a6631 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -242,5 +242,17 @@ if(BUILD_CAPI)
dasher_add_test(dasher_deterministic_tests test_deterministic.cpp)
dasher_add_test(dasher_training_tests test_training.cpp)
dasher_add_test(dasher_node_tree_tests test_node_tree.cpp)
+
+ # Control action system tests — needs internal DasherCore classes (ActionRegistry)
+ # AND C API functions, so we compile CAPI.cpp directly and link DasherCore
+ add_executable(dasher_control_action_tests
+ ${CMAKE_CURRENT_LIST_DIR}/tests/test_control_actions.cpp
+ ${CMAKE_CURRENT_LIST_DIR}/src/CAPI.cpp)
+ target_include_directories(dasher_control_action_tests PRIVATE
+ ${CMAKE_CURRENT_LIST_DIR}/src/
+ ${CMAKE_CURRENT_LIST_DIR}/tests/)
+ target_link_libraries(dasher_control_action_tests PRIVATE DasherCore pugixml)
+ target_compile_definitions(dasher_control_action_tests PRIVATE TEST_DATA_DIR="${TEST_DATA_DIR}")
+ add_test(NAME dasher_control_action_tests COMMAND dasher_control_action_tests)
endif()
endif()
diff --git a/Data/control/control.dtd b/Data/control/control.dtd
new file mode 100644
index 000000000..5458f93c6
--- /dev/null
+++ b/Data/control/control.dtd
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Data/control/control.xml b/Data/control/control.xml
new file mode 100644
index 000000000..1ea2526ac
--- /dev/null
+++ b/Data/control/control.xml
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/CUSTOM_ACTIONS.md b/docs/CUSTOM_ACTIONS.md
new file mode 100644
index 000000000..db86d087b
--- /dev/null
+++ b/docs/CUSTOM_ACTIONS.md
@@ -0,0 +1,217 @@
+# Custom Actions & Control Mode
+
+## Overview
+
+DasherCore has a **unified action system**. Both alphabet symbol nodes (typing a letter) and control nodes (editing commands like delete, move, speak) use the same `ControlAction` base class and `ActionRegistry`. Frontends can register **arbitrary custom actions** via the C API — anything from sending an API call to opening another app.
+
+**Key files:**
+- `src/DasherCore/ControlManager.h` — Action classes, registry, control node tree
+- `src/DasherCore/ControlManager.cpp` — Implementation + XML parser
+- `Data/control/control.xml` — Default control command tree
+- `src/dasher.h` — C API (`dasher_register_action`)
+- `src/CAPI.cpp` — C API implementation
+
+## How It Works
+
+```
+control.xml ─┐ ┌─→ Built-in action (StopAction, MoveAction, ...)
+ ├─→ ActionRegistry ──┤
+alphabet XML ─┘ └─→ Custom action (your callback via C API)
+
+Node entered (Do())
+ └─→ ControlAction::execute(interface)
+ ├─→ Built-in: calls interface methods (ctrlMove, Speak, etc.)
+ └─→ Custom: calls your C callback with action name + XML attributes
+```
+
+### Lifecycle
+
+1. Frontend calls `dasher_register_action()` to register custom action types
+2. `dasher_set_screen_size()` triggers `Realize()`, which creates the `CNodeCreationManager`
+3. If `BP_CONTROL_MODE` is on, `CControlManager` is created and parses `control.xml`
+4. During parsing, each XML element name is looked up in the `ActionRegistry`
+5. When the user navigates into a control node, `CContNode::Do()` executes all the node's actions
+
+### Timing constraint
+
+Custom actions must be registered **before** `dasher_set_screen_size()` for them to be available during initial `control.xml` parsing. If registered after, the action type will be registered in the registry, but already-parsed nodes won't include it until the control box is rebuilt (e.g. by toggling `BP_CONTROL_MODE` off and on).
+
+## Built-in Actions
+
+These are registered automatically by `CControlManager::registerBuiltinActions()`:
+
+| XML name | Attributes | Description |
+|---|---|---|
+| `stop` | — | Stop Dasher and trigger on-stop behaviour (speak/copy on stop) |
+| `pause` | — | Pause Dasher motion |
+| `move` | `forward`=`yes`\|`no`, `dist`=`char`\|`word`\|`sentence`\|`paragraph`\|`line`\|`page`\|`all` | Move cursor |
+| `delete` | `forward`=`yes`\|`no`, `dist`=(same as move) | Delete text |
+| `speak` | `what`=`new`\|`repeat`\|`cancel`\|`all`\|`paragraph`\|`sentence`\|`line`\|`word` | Speak text via TTS callback |
+| `copy` | `what`=(same as speak) | Copy text to clipboard |
+| `speak_cancel` | — | Cancel ongoing speech |
+
+**Alphabet XML compatibility names** (also registered for backward compatibility):
+
+| XML name | Attributes | Maps to |
+|---|---|---|
+| `stopDasherAction` | — | `StopAction` |
+| `pauseDasherAction` | — | `PauseAction` |
+| `stopTTSAction` | — | `SpeakCancelAction` |
+| `fixedTTSAction` | `text`="..." | `FixedSpeakAction` |
+
+## Custom Actions via C API
+
+### Registration
+
+```c
+// Callback signature
+typedef void (*dasher_action_callback)(const char* name,
+ int attr_count,
+ const char** attr_keys,
+ const char** attr_values,
+ void* user_data);
+
+// Register before dasher_set_screen_size()
+dasher_register_action(ctx, "my_action", my_callback, user_data);
+```
+
+### Callback parameters
+
+- **`name`**: The action element name from XML (e.g. `"my_action"`)
+- **`attr_count`**: Number of XML attributes
+- **`attr_keys`**: Array of attribute names (e.g. `["endpoint", "method"]`)
+- **`attr_values`**: Array of attribute values (e.g. `["/api/save", "POST"]`)
+- **`user_data`**: The pointer you passed to `dasher_register_action`
+
+Arrays are only valid during the callback. Copy strings if you need them later.
+
+### Example: API call action
+
+**control.xml:**
+```xml
+
+
+
+
+
+
+
+```
+
+**Frontend code (C):**
+```c
+void on_action(const char* name, int count, const char** keys, const char** vals, void* ud) {
+ MyState* state = (MyState*)ud;
+ const char* endpoint = NULL, *method = NULL;
+ for (int i = 0; i < count; i++) {
+ if (strcmp(keys[i], "endpoint") == 0) endpoint = vals[i];
+ if (strcmp(keys[i], "method") == 0) method = vals[i];
+ }
+ if (endpoint) {
+ // Make HTTP request, open another app, trigger IoT device, etc.
+ printf("Action %s: %s %s\n", name, method ? method : "GET", endpoint);
+ }
+}
+
+dasher_ctx* ctx = dasher_create(data_dir, user_dir, NULL);
+dasher_register_action(ctx, "send_api", on_action, &my_state);
+dasher_set_screen_size(ctx, 800, 600);
+// Enable control mode
+int bp_control_mode = dasher_find_parameter_key("BP_CONTROL_MODE");
+dasher_set_bool_parameter(ctx, bp_control_mode, 1);
+```
+
+### Example: Swift/Kotlin/etc.
+
+The C API is callable from any language with C FFI. In Swift:
+
+```swift
+let cb: @convention(c) (UnsafePointer?, Int32,
+ UnsafePointer?>?,
+ UnsafePointer?>?,
+ UnsafeMutableRawPointer?) -> Void = { name, count, keys, vals, ud in
+ // Handle action
+}
+dasher_register_action(ctx, "my_action", cb, nil)
+```
+
+## control.xml Format
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Elements
+
+| Element | Purpose |
+|---|---|
+| `` | Root element |
+| `` | Defines a control node. Attributes: `name` (for refs), `label` (display text), `color` (colour index) |
+| `` | Bridge back to alphabet node tree (child = `nullptr` in successors) |
+| `` | Loop back to the root control template |
+| `` | Reference to a named node (allows cycles) |
+| Any registered action name | Creates an action attached to the current node |
+
+### Colour indices
+
+| Index | Colour | Meaning |
+|---|---|---|
+| `8` | Grey | Neutral/control |
+| `240` | Green | Safe/positive actions |
+| `241` | Amber | Caution actions |
+| `242` | Red | Destructive actions |
+| `-1` | Auto | Auto-cycles through palette colours |
+| Other | Blue-purple | Default |
+
+## Enabling Control Mode
+
+Control mode is off by default. Enable it via the `BP_CONTROL_MODE` boolean parameter:
+
+```c
+int key = dasher_find_parameter_key("BP_CONTROL_MODE");
+dasher_set_bool_parameter(ctx, key, 1);
+```
+
+When enabled, a control node is grafted as an extra sibling at the root level, taking ~5% of the probability space. The user navigates into it to access editing commands and custom actions.
+
+### Slow control box
+
+`BP_SLOW_CONTROL_BOX` halves the zoom speed when inside control nodes, making precise selection easier:
+
+```c
+int slow_key = dasher_find_parameter_key("BP_SLOW_CONTROL_BOX");
+dasher_set_bool_parameter(ctx, slow_key, 1);
+```
+
+## Testing
+
+Tests are in `tests/test_control_actions.cpp` and run via `ctest` in CI.
+
+To run locally:
+```bash
+cd build && ctest -R control_actions --output-on-failure
+```
diff --git a/settings_manifest.json b/settings_manifest.json
index 945e4cf24..023214da2 100644
--- a/settings_manifest.json
+++ b/settings_manifest.json
@@ -265,6 +265,31 @@
"subgroup": "Control",
"group": "Input"
},
+ {
+ "key": "BP_CONTROL_MODE",
+ "storageName": "ControlMode",
+ "type": "bool",
+ "default": false,
+ "label": "Control Mode",
+ "description": "Show a control node in the Dasher canvas providing editing commands (delete, move, speak, etc.).",
+ "uiType": "Switch",
+ "tier": "common",
+ "subgroup": "Control",
+ "group": "Input"
+ },
+ {
+ "key": "BP_SLOW_CONTROL_BOX",
+ "storageName": "SlowControlBox",
+ "type": "bool",
+ "default": true,
+ "label": "Slow in Control Nodes",
+ "description": "Reduces speed by half when navigating inside control nodes, making it harder to trigger actions by mistake.",
+ "uiType": "Switch",
+ "tier": "advanced",
+ "subgroup": "Control",
+ "dependsOn": "BP_CONTROL_MODE",
+ "group": "Input"
+ },
{
"key": "BP_COPY_ALL_ON_STOP",
"storageName": "CopyOnStop",
diff --git a/src/CAPI.cpp b/src/CAPI.cpp
index 8547dfed9..fd5919766 100644
--- a/src/CAPI.cpp
+++ b/src/CAPI.cpp
@@ -15,6 +15,7 @@
#include "DasherCore/DasherModel.h"
#include "DasherCore/DasherNode.h"
#include "DasherCore/NodeCreationManager.h"
+#include "DasherCore/ControlManager.h"
#include "DasherCore/LanguageModelling/LMRegistry.h"
#include
@@ -241,6 +242,8 @@ class CommandScreen final : public Dasher::CDasherScreen {
void Polyline(Dasher::point* Points, int Number, int iWidth, const Dasher::ColorPalette::Color& color) override {
if (!Points || Number < 2 || color.isFullyTransparent()) return;
+ // Opcode 6: set line width for subsequent line segments
+ push(6, iWidth, 0, 0, 0, 0);
for (int i = 1; i < Number; ++i)
push(2, Points[i - 1].x, Points[i - 1].y, Points[i].x, Points[i].y, colorToARGB(color));
}
@@ -346,6 +349,13 @@ struct dasher_ctx {
dasher_parameter_callback paramCb = nullptr;
void* paramCbUserData = nullptr;
+ struct CustomActionEntry {
+ std::string name;
+ dasher_action_callback callback;
+ void* userData;
+ };
+ std::vector customActions;
+
struct Interface : public Dasher::CDashIntfScreenMsgs {
Interface(Dasher::CSettingsStore* s, dasher_ctx* owner) : CDashIntfScreenMsgs(s), m_owner(owner) {
s->OnParameterChanged.Subscribe(m_owner, [this](Dasher::Parameter param) {
@@ -447,6 +457,32 @@ struct dasher_ctx {
}
dasher_ctx* m_owner;
+
+ std::vector> GetPendingCustomActions() override {
+ std::vector> result;
+ for (auto& entry : m_owner->customActions) {
+ auto cb = entry.callback;
+ auto ud = entry.userData;
+ result.emplace_back(
+ entry.name, [cb, ud](const std::string& name, const std::map& attrs) {
+ if (!cb) return;
+ std::vector keys, values;
+ for (const auto& [k, v] : attrs) {
+ keys.push_back(k);
+ values.push_back(v);
+ }
+ std::vector keyPtrs, valPtrs;
+ keyPtrs.reserve(keys.size());
+ valPtrs.reserve(values.size());
+ for (auto& k : keys)
+ keyPtrs.push_back(k.c_str());
+ for (auto& v : values)
+ valPtrs.push_back(v.c_str());
+ cb(name.c_str(), static_cast(keyPtrs.size()), keyPtrs.data(), valPtrs.data(), ud);
+ });
+ }
+ return result;
+ }
};
};
@@ -1294,4 +1330,38 @@ DASHER_API int dasher_get_offset(dasher_ctx* ctx) {
return model->GetOffset();
}
+// ── Custom actions ─────────────────────────────────────────────────────────
+
+DASHER_API void dasher_register_action(dasher_ctx* ctx, const char* name, dasher_action_callback callback,
+ void* user_data) {
+ if (!ctx || !name || !callback) return;
+ ctx->customActions.push_back({std::string(name), callback, user_data});
+
+ // If control manager already exists, register directly for immediate use
+ if (ctx->intf) {
+ auto* cm = ctx->intf->GetControlManager();
+ if (cm) {
+ auto cb = callback;
+ auto ud = user_data;
+ cm->GetActionRegistry()->registerCustomAction(
+ std::string(name),
+ [cb, ud](const std::string& actionName, const std::map& attrs) {
+ std::vector keys, values;
+ for (const auto& [k, v] : attrs) {
+ keys.push_back(k);
+ values.push_back(v);
+ }
+ std::vector keyPtrs, valPtrs;
+ keyPtrs.reserve(keys.size());
+ valPtrs.reserve(values.size());
+ for (auto& k : keys)
+ keyPtrs.push_back(k.c_str());
+ for (auto& v : values)
+ valPtrs.push_back(v.c_str());
+ cb(actionName.c_str(), static_cast(keyPtrs.size()), keyPtrs.data(), valPtrs.data(), ud);
+ });
+ }
+ }
+}
+
} // extern "C"
diff --git a/src/DasherCore/ActionManager.h b/src/DasherCore/ActionManager.h
deleted file mode 100644
index 79f8837c8..000000000
--- a/src/DasherCore/ActionManager.h
+++ /dev/null
@@ -1,55 +0,0 @@
-#pragma once
-
-#include "Actions.h"
-#include "AlphabetManager.h"
-#include "Event.h"
-
-namespace Dasher {
-
-// This class does not actually manage that much. First of all, it just holds a bunch of events that listeners and
-// handlers can subscribe to.
-class CActionManager {
- public:
- void UnsubscribeAll(void* Listener) {
- OnCharEntered.Unsubscribe(Listener);
- OnCharRemoved.Unsubscribe(Listener);
- OnContextSpeak.Unsubscribe(Listener);
- OnSpeakCancel.Unsubscribe(Listener);
- OnCopy.Unsubscribe(Listener);
- OnDasherStop.Unsubscribe(Listener);
- OnDasherPause.Unsubscribe(Listener);
- OnDelete.Unsubscribe(Listener);
- OnMove.Unsubscribe(Listener);
- }
-
- void DelayAction(std::function action) { DelayedActions.push_back(action); }
-
- void ExecuteDelayedActions() {
- for (auto& action : DelayedActions) {
- action();
- }
- DelayedActions.clear();
- }
-
- Event OnCharEntered;
- Event OnCharRemoved; // Explicitly only does one char removal
- Event OnContextSpeak;
- Event OnFixedSpeak;
- Event OnSpeakCancel;
- Event OnKeyboard;
- Event OnSocketOutput;
- Event OnSettingChange;
- Event OnCopy;
- Event OnDasherStop;
- Event OnDasherPause;
- Event OnATSPI;
- Event OnDelete;
- Event OnMove;
-
- private:
- // Lambda Functions that are executed after the next rendering. Mostly used for actions that are triggered during
- // rendering.
- std::vector> DelayedActions;
-};
-
-} // namespace Dasher
diff --git a/src/DasherCore/Actions.cpp b/src/DasherCore/Actions.cpp
deleted file mode 100644
index 9ff00f82b..000000000
--- a/src/DasherCore/Actions.cpp
+++ /dev/null
@@ -1,99 +0,0 @@
-#include "Actions.h"
-
-#include "ActionManager.h"
-#include "AlphabetManager.h"
-#include "DasherInterfaceBase.h"
-
-using namespace Dasher;
-
-TextAction::TextAction(ActionContext context, EditDistance dist) : context(context), m_dist(dist) {}
-
-ContextSpeechAction::ContextSpeechAction(ActionContext c, EditDistance dist) : TextAction(c, dist) {}
-
-void ContextSpeechAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager,
- CSymbolNode* Trigger) {
- Manager->OnContextSpeak.Broadcast(Trigger, this, InterfaceBase);
-}
-
-void FixedSpeechAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnFixedSpeak.Broadcast(Trigger, this);
-}
-
-CopyAction::CopyAction(ActionContext c, EditDistance dist) : TextAction(c, dist) {}
-
-void CopyAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnCopy.Broadcast(Trigger, this, InterfaceBase);
-}
-
-void StopDasherAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnDasherStop.Broadcast(Trigger, this);
-}
-
-void PauseDasherAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnDasherPause.Broadcast(Trigger, this);
-}
-
-void ATSPIAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnATSPI.Broadcast(Trigger, this);
-}
-
-void SpeakCancelAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnSpeakCancel.Broadcast(Trigger, this);
-}
-
-DeleteAction::DeleteAction(bool bForwards, EditDistance dist) : m_bForwards(bForwards), m_dist(dist) {}
-
-unsigned DeleteAction::calculateNewOffset(CSymbolNode* pNode, int offsetBefore) {
- if (m_bForwards) return offsetBefore;
-
- return pNode->GetInterface()->ctrlOffsetAfterMove(offsetBefore + 1, m_bForwards, m_dist) - 1;
-}
-
-void DeleteAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnDelete.Broadcast(Trigger, this);
-}
-
-MoveAction::MoveAction(bool bForwards, EditDistance dist) : m_bForwards(bForwards), m_dist(dist) {}
-
-unsigned MoveAction::calculateNewOffset(CSymbolNode* pNode, int offsetBefore) {
- return pNode->GetInterface()->ctrlOffsetAfterMove(offsetBefore + 1, m_bForwards, m_dist) - 1;
-}
-
-void MoveAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnMove.Broadcast(Trigger, this);
-}
-
-void TextCharAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnCharEntered.Broadcast(Trigger, this);
-}
-
-void TextCharUndoAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnCharRemoved.Broadcast(Trigger, this);
-}
-
-void KeyboardAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnKeyboard.Broadcast(Trigger, this);
-}
-
-void SocketOutputAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) {
- Manager->OnSocketOutput.Broadcast(Trigger, this);
-}
-
-void ChangeSettingsAction::Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager,
- CSymbolNode* Trigger) {
- // Needs to be delayed to the end of the frame, as many parameters influence the frame rendering and should not be
- // changed during rendering of a frame
- Manager->DelayAction([Manager, Trigger, this]() { Manager->OnSettingChange.Broadcast(Trigger, this); });
-}
-
-std::string TextAction::getBasedOnDistance(CDasherInterfaceBase* p_Intf, EditDistance dist) {
- strLast = p_Intf->GetTextAroundCursor(dist);
- m_iStartOffset = p_Intf->GetAllContextLenght();
- return strLast;
-}
-
-std::string TextAction::getNewContext(CDasherInterfaceBase* p_Intf) {
- strLast = p_Intf->GetContext(m_iStartOffset, p_Intf->GetAllContextLenght() - m_iStartOffset);
- m_iStartOffset = p_Intf->GetAllContextLenght();
- return strLast;
-}
diff --git a/src/DasherCore/Actions.h b/src/DasherCore/Actions.h
deleted file mode 100644
index 4ac6c04f9..000000000
--- a/src/DasherCore/Actions.h
+++ /dev/null
@@ -1,159 +0,0 @@
-#pragma once
-#include
-#include
-#include "Parameters.h"
-
-namespace Dasher {
-class CSymbolNode;
-class CActionManager;
-class CDasherInterfaceBase;
-
-typedef enum EditDistance : unsigned int {
- EDIT_CHAR,
- EDIT_WORD,
- EDIT_SENTENCE,
- EDIT_PARAGRAPH,
- EDIT_FILE,
- EDIT_LINE,
- EDIT_PAGE,
- EDIT_SELECTION,
- EDIT_ALL,
- EDIT_NONE
-} EditDistance;
-
-class Action {
- public:
- Action() = default;
- virtual ~Action() = default;
-
- virtual unsigned calculateNewOffset(CSymbolNode* pNode, int offsetBefore) { return offsetBefore; }
- virtual void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) = 0;
-};
-
-// Baseclass for actions that use some context of the already entered text
-class TextAction : public Action {
- public:
- typedef enum ActionContext { Repeat, NewText, Distance } ActionContext;
-
- TextAction(ActionContext context, EditDistance dist);
- std::string getBasedOnDistance(CDasherInterfaceBase* p_Intf, EditDistance dist);
- std::string getNewContext(CDasherInterfaceBase* p_Intf);
-
- int m_iStartOffset = 0;
- std::string strLast;
- ActionContext context;
- EditDistance m_dist;
-};
-
-class ContextSpeechAction : public TextAction {
- public:
- ContextSpeechAction(ActionContext c, EditDistance dist = EDIT_NONE);
-
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class FixedSpeechAction : public Action {
- public:
- FixedSpeechAction(std::string text) : text(text) {};
-
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-
- std::string text;
-};
-
-class CopyAction : public TextAction {
- public:
- CopyAction(ActionContext c, EditDistance dist = EDIT_NONE);
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class StopDasherAction : public Action {
- public:
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class PauseDasherAction : public Action {
- public:
- PauseDasherAction(long timeInMs) : time(timeInMs) {}
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
- long time = -1;
-};
-
-class ATSPIAction : public Action {
- public:
- ATSPIAction(const std::string& action) : action(action) {}
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
- std::string action;
-};
-
-class SpeakCancelAction : public Action {
- public:
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class DeleteAction : public Action {
- public:
- DeleteAction(bool bForwards, EditDistance dist);
-
- unsigned calculateNewOffset(CSymbolNode* pNode, int offsetBefore) override;
-
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-
- bool m_bForwards;
- EditDistance m_dist;
-};
-
-class MoveAction : public Action {
- public:
- MoveAction(bool bForwards, EditDistance dist);
-
- unsigned calculateNewOffset(CSymbolNode* pNode, int offsetBefore) override;
-
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-
- bool m_bForwards;
- EditDistance m_dist;
-};
-
-class TextCharAction : public Action {
- public:
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class TextCharUndoAction : public TextCharAction {
- public:
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-};
-
-class KeyboardAction : public Action {
- public:
- enum pressType { KEY_PRESS, KEY_RELEASE, KEY_PRESS_RELEASE };
-
- KeyboardAction(pressType type, std::vector> keycodes)
- : type(type), keycodes(keycodes) {}
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
-
- pressType type;
- std::vector> keycodes;
-};
-
-class SocketOutputAction : public Action {
- public:
- SocketOutputAction(const std::string& socketName, const std::string& action, bool addNewLine)
- : socketName(socketName), action(action), addNewLine(addNewLine) {}
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
- std::string socketName;
- std::string action;
- bool addNewLine = true;
-};
-
-class ChangeSettingsAction : public Action {
- public:
- ChangeSettingsAction(Parameter setting, std::variant newValue)
- : parameter(setting), newValue(newValue) {}
- void Broadcast(CDasherInterfaceBase* InterfaceBase, CActionManager* Manager, CSymbolNode* Trigger) override;
- Parameter parameter;
- std::variant newValue;
-};
-
-} // namespace Dasher
diff --git a/src/DasherCore/Alphabet/AlphIO.cpp b/src/DasherCore/Alphabet/AlphIO.cpp
index 4764f7312..02e2935ce 100644
--- a/src/DasherCore/Alphabet/AlphIO.cpp
+++ b/src/DasherCore/Alphabet/AlphIO.cpp
@@ -19,6 +19,7 @@
// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "AlphIO.h"
+#include "DasherCore/ControlManager.h"
#include
#include
@@ -235,8 +236,8 @@ inline std::vector> parseKeyArray(const std::string&
}
void CAlphIO::ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character& alphabet_character,
- SGroupInfo* parentGroup, std::vector& DoActions,
- std::vector& UndoActions) {
+ SGroupInfo* parentGroup, std::vector& DoActions,
+ std::vector& UndoActions) {
if (xml_node.type() == pugi::node_null) return;
@@ -244,9 +245,11 @@ void CAlphIO::ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character&
alphabet_character.Text = xml_node.attribute("text").as_string(alphabet_character.Display.c_str());
for (auto potentialActions : xml_node.children()) {
- if (std::strcmp(potentialActions.name(), "textCharAction") == 0) {
- DoActions.push_back(new TextCharAction());
- UndoActions.push_back(new TextCharUndoAction());
+ const char* actionName = potentialActions.name();
+
+ if (std::strcmp(actionName, "textCharAction") == 0) {
+ DoActions.push_back(new TextOutputAction(alphabet_character.Text));
+ UndoActions.push_back(new TextDeleteAction(alphabet_character.Text));
if (xml_node.attribute("text").empty() && !potentialActions.attribute("unicode").empty()) {
int codepoint = potentialActions.attribute("unicode").as_int(-1);
if (codepoint > 0) {
@@ -267,52 +270,57 @@ void CAlphIO::ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character&
utf8 += static_cast(0x80 | (codepoint & 0x3F));
}
alphabet_character.Text = utf8;
+ // Re-create actions with the resolved text
+ delete DoActions.back();
+ DoActions.back() = new TextOutputAction(alphabet_character.Text);
+ delete UndoActions.back();
+ UndoActions.back() = new TextDeleteAction(alphabet_character.Text);
}
}
- } else if (std::strcmp(potentialActions.name(), "deleteTextAction") == 0 &&
+ } else if (std::strcmp(actionName, "deleteTextAction") == 0 &&
!potentialActions.attribute("distance").empty()) {
const std::string distance = potentialActions.attribute("distance").as_string();
const std::pair d = parseDistance(distance);
DoActions.push_back(new DeleteAction(d.first, d.second));
- } else if (std::strcmp(potentialActions.name(), "moveCursorAction") == 0 &&
+ } else if (std::strcmp(actionName, "moveCursorAction") == 0 &&
!potentialActions.attribute("distance").empty()) {
const std::string distance = potentialActions.attribute("distance").as_string();
const std::pair d = parseDistance(distance);
DoActions.push_back(new MoveAction(d.first, d.second));
- } else if (std::strcmp(potentialActions.name(), "fixedTTSAction") == 0 &&
- !potentialActions.attribute("text").empty()) {
- DoActions.push_back(new FixedSpeechAction(potentialActions.attribute("text").as_string()));
- } else if (std::strcmp(potentialActions.name(), "contextTTSAction") == 0) {
- DoActions.push_back(new ContextSpeechAction(TextAction::Repeat));
- } else if (std::strcmp(potentialActions.name(), "contextTTSAction") == 0) {
- const std::string context = potentialActions.attribute("context").as_string();
- const std::pair c = parseDistance(context);
- DoActions.push_back(new ContextSpeechAction(TextAction::Distance, c.second));
- } else if (std::strcmp(potentialActions.name(), "stopTTSAction") == 0) {
+ } else if (std::strcmp(actionName, "fixedTTSAction") == 0 && !potentialActions.attribute("text").empty()) {
+ DoActions.push_back(new FixedSpeakAction(potentialActions.attribute("text").as_string()));
+ } else if (std::strcmp(actionName, "contextTTSAction") == 0) {
+ if (potentialActions.attribute("context").empty()) {
+ DoActions.push_back(new SpeakAction(TextActionBase::Repeat, EDIT_NONE));
+ } else {
+ const std::string context = potentialActions.attribute("context").as_string();
+ const std::pair c = parseDistance(context);
+ DoActions.push_back(new SpeakAction(TextActionBase::Distance, c.second));
+ }
+ } else if (std::strcmp(actionName, "stopTTSAction") == 0) {
DoActions.push_back(new SpeakCancelAction());
- } else if (std::strcmp(potentialActions.name(), "copyToClipboardAction") == 0) {
+ } else if (std::strcmp(actionName, "copyToClipboardAction") == 0) {
const std::string context = potentialActions.attribute("context").as_string("all");
if (context == "new")
- DoActions.push_back(new CopyAction(TextAction::NewText));
+ DoActions.push_back(new CopyAction(TextActionBase::NewText, EDIT_NONE));
else {
const std::pair c = parseDistance(context);
- DoActions.push_back(new CopyAction(TextAction::Distance, c.second));
+ DoActions.push_back(new CopyAction(TextActionBase::Distance, c.second));
}
- } else if (std::strcmp(potentialActions.name(), "stopDasherAction") == 0) {
- DoActions.push_back(new StopDasherAction());
- } else if (std::strcmp(potentialActions.name(), "pauseDasherAction") == 0) {
- DoActions.push_back(
- new PauseDasherAction(static_cast(potentialActions.attribute("time").as_llong(-1))));
- } else if (std::strcmp(potentialActions.name(), "atspiAction") == 0) {
+ } else if (std::strcmp(actionName, "stopDasherAction") == 0) {
+ DoActions.push_back(new StopAction());
+ } else if (std::strcmp(actionName, "pauseDasherAction") == 0) {
+ DoActions.push_back(new PauseAction());
+ } else if (std::strcmp(actionName, "atspiAction") == 0) {
if (!potentialActions.attribute("action").empty())
DoActions.push_back(new ATSPIAction(potentialActions.attribute("action").as_string()));
if (!potentialActions.attribute("undoAction").empty())
- UndoActions.push_back(new ATSPIAction(potentialActions.attribute("undoAction ").as_string()));
- } else if (std::strcmp(potentialActions.name(), "keyboardAction") == 0 &&
+ UndoActions.push_back(new ATSPIAction(potentialActions.attribute("undoAction").as_string()));
+ } else if (std::strcmp(actionName, "keyboardAction") == 0 &&
(!potentialActions.attribute("key").empty() || !potentialActions.attribute("press").empty() ||
!potentialActions.attribute("release").empty())) {
std::string keycodes;
- KeyboardAction::pressType p;
+ KeyboardAction::PressType p;
if (!potentialActions.attribute("key").empty()) keycodes = potentialActions.attribute("key").as_string();
if (!potentialActions.attribute("press").empty())
@@ -330,7 +338,7 @@ void CAlphIO::ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character&
keycodes = potentialActions.attribute("undoRelease").as_string();
p = KeyboardAction::KEY_RELEASE;
UndoActions.push_back(new KeyboardAction(p, parseKeyArray(keycodes)));
- } else if (std::strcmp(potentialActions.name(), "socketOutputAction") == 0) {
+ } else if (std::strcmp(actionName, "socketOutputAction") == 0) {
const bool suppressNewLine = potentialActions.attribute("suppressNewline").as_bool(false);
if (!potentialActions.attribute("doString").empty())
DoActions.push_back(new SocketOutputAction(potentialActions.attribute("socketName").as_string(""),
@@ -340,30 +348,30 @@ void CAlphIO::ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character&
UndoActions.push_back(new SocketOutputAction(potentialActions.attribute("socketName").as_string(""),
potentialActions.attribute("undoString").as_string(""),
suppressNewLine));
- } else if (std::strcmp(potentialActions.name(), "changeSettingAction") == 0 &&
+ } else if (std::strcmp(actionName, "changeSettingAction") == 0 &&
!potentialActions.attribute("settingsName").empty()) {
const std::pair param =
Settings::GetParameter(potentialActions.attribute("settingsName").as_string());
if (!potentialActions.attribute("doValue").empty()) {
if (param.second == Settings::PARAM_STRING)
- DoActions.push_back(new ChangeSettingsAction(
+ DoActions.push_back(new ChangeSettingAction(
param.first, std::string(potentialActions.attribute("doValue").as_string())));
if (param.second == Settings::PARAM_BOOL)
DoActions.push_back(
- new ChangeSettingsAction(param.first, potentialActions.attribute("doValue").as_bool()));
+ new ChangeSettingAction(param.first, potentialActions.attribute("doValue").as_bool()));
if (param.second == Settings::PARAM_LONG)
- DoActions.push_back(new ChangeSettingsAction(
+ DoActions.push_back(new ChangeSettingAction(
param.first, static_cast(potentialActions.attribute("doValue").as_llong())));
}
if (!potentialActions.attribute("undoValue").empty()) {
if (param.second == Settings::PARAM_STRING)
- UndoActions.push_back(new ChangeSettingsAction(
+ UndoActions.push_back(new ChangeSettingAction(
param.first, std::string(potentialActions.attribute("undoValue").as_string())));
if (param.second == Settings::PARAM_BOOL)
UndoActions.push_back(
- new ChangeSettingsAction(param.first, potentialActions.attribute("undoValue").as_bool()));
+ new ChangeSettingAction(param.first, potentialActions.attribute("undoValue").as_bool()));
if (param.second == Settings::PARAM_LONG)
- UndoActions.push_back(new ChangeSettingsAction(
+ UndoActions.push_back(new ChangeSettingAction(
param.first, static_cast(potentialActions.attribute("undoValue").as_llong())));
}
}
diff --git a/src/DasherCore/Alphabet/AlphIO.h b/src/DasherCore/Alphabet/AlphIO.h
index 34f7cbbc9..9bf93489f 100644
--- a/src/DasherCore/Alphabet/AlphIO.h
+++ b/src/DasherCore/Alphabet/AlphIO.h
@@ -58,7 +58,7 @@ class Dasher::CAlphIO : public AbstractXMLParser {
CreateDefault(); // Give the user an English alphabet rather than nothing if anything goes horribly wrong.
void ReadCharAttributes(pugi::xml_node xml_node, CAlphInfo::character& alphabet_character, SGroupInfo* parentGroup,
- std::vector& DoActions, std::vector& UndoActions);
+ std::vector& DoActions, std::vector& UndoActions);
SGroupInfo* ParseGroupRecursive(pugi::xml_node& group_node, CAlphInfo* CurrentAlphabet,
SGroupInfo* previous_sibling, std::vector ancestors);
void ReverseChildList(SGroupInfo*& pList);
diff --git a/src/DasherCore/Alphabet/AlphInfo.cpp b/src/DasherCore/Alphabet/AlphInfo.cpp
index 9debff748..562da0e9b 100644
--- a/src/DasherCore/Alphabet/AlphInfo.cpp
+++ b/src/DasherCore/Alphabet/AlphInfo.cpp
@@ -20,6 +20,8 @@
#include "AlphInfo.h"
+#include "DasherCore/ControlManager.h"
+
using namespace Dasher;
const std::string CAlphInfo::s_emptyStr;
diff --git a/src/DasherCore/Alphabet/AlphInfo.h b/src/DasherCore/Alphabet/AlphInfo.h
index 02a6473b7..e99d93447 100644
--- a/src/DasherCore/Alphabet/AlphInfo.h
+++ b/src/DasherCore/Alphabet/AlphInfo.h
@@ -26,9 +26,8 @@
#include
#include
-#include "DasherCore/Actions.h"
-
namespace Dasher {
+class ControlAction;
class CAlphInfo;
class CAlphIO;
} // namespace Dasher
@@ -79,8 +78,8 @@ class Dasher::CAlphInfo : public SGroupInfo {
return s > 0 && s <= (symbol)m_vCharacters.size() && m_vCharacters[s - 1].Text == "\n";
}
- const std::vector& GetCharDoActions(symbol s) const { return m_vCharacterDoActions[s - 1]; }
- const std::vector& GetCharUndoActions(symbol s) const { return m_vCharacterUndoActions[s - 1]; }
+ const std::vector& GetCharDoActions(symbol s) const { return m_vCharacterDoActions[s - 1]; }
+ const std::vector& GetCharUndoActions(symbol s) const { return m_vCharacterUndoActions[s - 1]; }
// symbol GetStartConversionSymbol() const;
// symbol GetEndConversionSymbol() const;
@@ -160,8 +159,8 @@ class Dasher::CAlphInfo : public SGroupInfo {
float speedFactor = -1; // allows for slowdown in this box
};
std::vector m_vCharacters;
- std::vector> m_vCharacterDoActions = {};
- std::vector> m_vCharacterUndoActions = {};
+ std::vector> m_vCharacterDoActions = {};
+ std::vector> m_vCharacterUndoActions = {};
void copyCharacterFrom(const CAlphInfo* other, int idx);
};
diff --git a/src/DasherCore/AlphabetManager.cpp b/src/DasherCore/AlphabetManager.cpp
index 90e3f6752..9ab0d11b5 100644
--- a/src/DasherCore/AlphabetManager.cpp
+++ b/src/DasherCore/AlphabetManager.cpp
@@ -419,12 +419,8 @@ int CAlphNode::ExpectedNumChildren() {
void CAlphabetManager::GetProbs(vector* pProbInfo, CLanguageModel::Context context) {
const unsigned int iSymbols = m_pBaseGroup->iEnd - 1;
- // TODO - sort out size of control node - for the timebeing I'll fix the control node at 5%
- // TODO: New method (see commented code) has been removed as it wasn' working.
-
- const unsigned long iNorm(CDasherModel::NORMALIZATION);
- // the case for control mode on, generalizes to handle control mode off also,
- // as then iNorm - control_space == iNorm...
+ // Use alphabet normalization budget (NORMALIZATION minus control node space if active)
+ const unsigned long iNorm(m_pNCManager->GetAlphNodeNormalization());
const unsigned int iUniformAdd =
max(1ul, ((iNorm * m_pSettingsStore->GetLongParameter(LP_UNIFORM)) / 1000) / iSymbols);
const unsigned long iNonUniformNorm = iNorm - static_cast(iSymbols) * iUniformAdd;
@@ -646,6 +642,9 @@ void CAlphabetManager::IterateChildGroups(CAlphNode* pParent, const SGroupInfo*
pNewChild->Reparent(pParent, iLbnd, iHbnd);
}
+ // Add control node as extra sibling at base group level
+ if (pParentGroup == m_pBaseGroup) m_pNCManager->AddExtras(pParent);
+
pParent->SetFlag(CDasherNode::NF_ALLCHILDREN, true);
}
@@ -710,9 +709,9 @@ void CSymbolNode::Do() {
TrainSymbol();
const auto* alph = m_pMgr->GetAlphabet();
if (!alph) return;
- const std::vector& uA = alph->GetCharDoActions(iSymbol);
- for (Action* a : uA) {
- a->Broadcast(GetInterface(), GetInterface()->GetActionManager(), this);
+ const auto& uA = alph->GetCharDoActions(iSymbol);
+ for (auto* a : uA) {
+ a->execute(GetInterface());
}
}
@@ -725,9 +724,9 @@ void CSymbolNode::Undo() {
UntrainSymbol();
const auto* alph = m_pMgr->GetAlphabet();
if (!alph) return;
- const std::vector& uA = alph->GetCharUndoActions(iSymbol);
- for (Action* a : uA) {
- a->Broadcast(GetInterface(), GetInterface()->GetActionManager(), this);
+ const auto& uA = alph->GetCharUndoActions(iSymbol);
+ for (auto* a : uA) {
+ a->execute(GetInterface());
}
}
diff --git a/src/DasherCore/AlphabetManager.h b/src/DasherCore/AlphabetManager.h
index d7743dbec..c08961947 100644
--- a/src/DasherCore/AlphabetManager.h
+++ b/src/DasherCore/AlphabetManager.h
@@ -33,7 +33,7 @@
namespace Dasher {
class CAlphNode;
class CAlphabetManager;
-class Action;
+class ControlAction;
} // namespace Dasher
class CNodeCreationManager;
diff --git a/src/DasherCore/ControlManager.cpp b/src/DasherCore/ControlManager.cpp
new file mode 100644
index 000000000..639626966
--- /dev/null
+++ b/src/DasherCore/ControlManager.cpp
@@ -0,0 +1,542 @@
+// ControlManager.cpp
+//
+// Implementation of the unified action system and control node tree.
+//
+// Copyright (c) 2007-2024 The Dasher Team
+//
+// This file is part of Dasher. Dasher is free software; you can redistribute
+// it and/or modify it under the terms of the GNU General Public License as
+// published by the Free Software Foundation; either version 2 of the License,
+// or (at your option) any later version.
+
+#include "ControlManager.h"
+
+#include "DasherInterfaceBase.h"
+#include "DasherModel.h"
+#include "InputFilter.h"
+#include "NodeCreationManager.h"
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace Dasher;
+
+// ── ControlAction base ────────────────────────────────────────────────────
+
+int ControlAction::calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) {
+ return offsetBefore;
+}
+
+// ── CustomAction ──────────────────────────────────────────────────────────
+
+void CustomAction::execute(CDasherInterfaceBase* intf) {
+ if (m_callback) m_callback(m_name, m_attrs);
+}
+
+// ── ActionRegistry ─────────────────────────────────────────────────────────
+
+void ActionRegistry::registerFactory(const std::string& name, ActionFactory factory) {
+ m_factories[name] = {std::move(factory), nullptr};
+}
+
+void ActionRegistry::registerCustomAction(const std::string& name, CustomActionCallback callback) {
+ m_factories[name] = {nullptr, std::move(callback)};
+}
+
+ControlAction* ActionRegistry::create(const std::string& name, const std::map& attrs) const {
+ auto it = m_factories.find(name);
+ if (it == m_factories.end()) return nullptr;
+
+ if (it->second.customCallback) return new CustomAction(name, attrs, it->second.customCallback);
+
+ return it->second.factory(attrs);
+}
+
+bool ActionRegistry::hasAction(const std::string& name) const {
+ return m_factories.find(name) != m_factories.end();
+}
+
+// ── NodeTemplate ───────────────────────────────────────────────────────────
+
+int NodeTemplate::calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) const {
+ int newOffset = offsetBefore;
+ for (auto* action : m_actions)
+ newOffset = action->calculateNewOffset(intf, newOffset);
+ return newOffset;
+}
+
+void NodeTemplate::executeActions(CDasherInterfaceBase* intf) const {
+ for (auto* action : m_actions)
+ action->execute(intf);
+}
+
+// ── CContNode ──────────────────────────────────────────────────────────────
+
+CContNode::CContNode(int iOffset, int iColour, NodeTemplate* pTemplate, CControlManager* pMgr)
+ : CDasherNode(iOffset, pTemplate->m_pLabel), m_pTemplate(pTemplate), m_pMgr(pMgr), m_iColourIndex(iColour) {}
+
+CNodeManager* CContNode::mgr() const {
+ return m_pMgr;
+}
+
+double CContNode::SpeedMul() {
+ return m_pMgr->GetSettingsStore()->GetBoolParameter(BP_SLOW_CONTROL_BOX) ? 0.5 : 1.0;
+}
+
+void CContNode::PopulateChildren() {
+ const unsigned int iNChildren = static_cast(m_pTemplate->successors.size());
+ if (iNChildren == 0) return;
+
+ unsigned int iLbnd = 0, iIdx = 0;
+ int newOffset = m_pTemplate->calculateNewOffset(m_pMgr->GetInterface(), offset());
+
+ for (auto* child : m_pTemplate->successors) {
+ const unsigned int iHbnd = (++iIdx * CDasherModel::NORMALIZATION) / iNChildren;
+
+ CDasherNode* pNewNode;
+ if (child == nullptr) {
+ // escape — bridge back to alphabet
+ pNewNode = m_pMgr->GetNCManager()->GetAlphabetManager()->GetRoot(this, false, newOffset + 1);
+ } else {
+ pNewNode = new CContNode(newOffset, m_pMgr->getColour(child, this), child, m_pMgr);
+ }
+ pNewNode->Reparent(this, iLbnd, iHbnd);
+ iLbnd = iHbnd;
+ }
+}
+
+int CContNode::ExpectedNumChildren() {
+ return static_cast(m_pTemplate->successors.size());
+}
+
+void CContNode::Do() {
+ m_pTemplate->executeActions(m_pMgr->GetInterface());
+}
+
+const ColorPalette::Color& CContNode::getNodeColor(const ColorPalette*) {
+ return CControlManager::indexToColor(m_iColourIndex);
+}
+
+// ── Colour mapping ─────────────────────────────────────────────────────────
+
+const ColorPalette::Color& CControlManager::indexToColor(int iIndex) {
+ static const ColorPalette::Color controlGrey(128, 128, 128);
+ static const ColorPalette::Color controlGreen(60, 180, 75);
+ static const ColorPalette::Color controlAmber(220, 160, 0);
+ static const ColorPalette::Color controlRed(200, 50, 50);
+ static const ColorPalette::Color controlDefault(90, 90, 140);
+
+ switch (iIndex) {
+ case 8:
+ return controlGrey;
+ case 240:
+ return controlGreen;
+ case 241:
+ return controlAmber;
+ case 242:
+ return controlRed;
+ default:
+ return controlDefault;
+ }
+}
+
+// ── Built-in action implementations ────────────────────────────────────────
+
+void StopAction::execute(CDasherInterfaceBase* intf) {
+ intf->Done();
+ intf->GetActiveInputMethod()->pause();
+}
+
+void PauseAction::execute(CDasherInterfaceBase* intf) {
+ intf->GetActiveInputMethod()->pause();
+}
+
+void MoveAction::execute(CDasherInterfaceBase* intf) {
+ intf->ctrlMove(m_bForwards, m_dist);
+}
+
+int MoveAction::calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) {
+ return static_cast(
+ intf->ctrlOffsetAfterMove(static_cast(offsetBefore + 1), m_bForwards, m_dist)) -
+ 1;
+}
+
+void DeleteAction::execute(CDasherInterfaceBase* intf) {
+ intf->ctrlDelete(m_bForwards, m_dist);
+}
+
+int DeleteAction::calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) {
+ if (m_bForwards) return offsetBefore;
+ return static_cast(
+ intf->ctrlOffsetAfterMove(static_cast(offsetBefore + 1), m_bForwards, m_dist)) -
+ 1;
+}
+
+std::string TextActionBase::getText(CDasherInterfaceBase* intf) {
+ switch (m_context) {
+ case Repeat:
+ return m_strLast;
+ case NewText: {
+ int currentLen = intf->GetAllContextLenght();
+ int start = m_iStartOffset;
+ m_iStartOffset = currentLen;
+ if (currentLen <= start) return {};
+ return intf->GetContext(static_cast(start), static_cast(currentLen - start));
+ }
+ case Distance:
+ m_strLast = intf->GetTextAroundCursor(m_dist);
+ return m_strLast;
+ default:
+ return {};
+ }
+}
+
+void SpeakAction::execute(CDasherInterfaceBase* intf) {
+ std::string text = getText(intf);
+ if (!text.empty()) intf->Speak(text, false);
+}
+
+void SpeakCancelAction::execute(CDasherInterfaceBase* intf) {
+ intf->Speak("", true);
+}
+
+void CopyAction::execute(CDasherInterfaceBase* intf) {
+ std::string text = getText(intf);
+ if (!text.empty()) intf->CopyToClipboard(text);
+}
+
+void TextOutputAction::execute(CDasherInterfaceBase* intf) {
+ intf->editOutput(m_text, nullptr);
+}
+
+void TextDeleteAction::execute(CDasherInterfaceBase* intf) {
+ intf->editDelete(m_text, nullptr);
+}
+
+void FixedSpeakAction::execute(CDasherInterfaceBase* intf) {
+ if (!m_text.empty()) intf->Speak(m_text, false);
+}
+
+void ChangeSettingAction::execute(CDasherInterfaceBase* intf) {
+ // Defer to end of frame — parameter changes during rendering are unsafe
+ intf->DelayAction([intf, param = m_parameter, val = m_newValue]() {
+ auto* settings = intf->GetSettingsStore();
+ if (std::holds_alternative(val))
+ settings->SetBoolParameter(param, std::get(val));
+ else if (std::holds_alternative(val))
+ settings->SetLongParameter(param, std::get(val));
+ else if (std::holds_alternative(val))
+ settings->SetStringParameter(param, std::get(val));
+ });
+}
+
+void KeyboardAction::execute(CDasherInterfaceBase* intf) {
+ // Default: no-op. Platforms override via interface virtual methods if needed.
+}
+
+void SocketOutputAction::execute(CDasherInterfaceBase* intf) {
+ // Default: no-op. Platforms override via interface virtual methods if needed.
+}
+
+void ATSPIAction::execute(CDasherInterfaceBase* intf) {
+ // Default: no-op. Platforms override via interface virtual methods if needed.
+}
+
+// ── CControlManager ────────────────────────────────────────────────────────
+
+CControlManager::CControlManager(CSettingsStore* pSettingsStore, CDasherInterfaceBase* pInterface,
+ CNodeCreationManager* pNCManager, CMessageDisplay* pMsgs)
+ : AbstractXMLParser(pMsgs), m_pSettingsStore(pSettingsStore), m_pInterface(pInterface), m_pNCManager(pNCManager) {
+ m_pRoot = new NodeTemplate("", 8);
+ registerBuiltinActions();
+}
+
+CControlManager::~CControlManager() {
+ // Collect all unique templates for deletion (graph may have cycles via [)
+ std::set allTemplates;
+ if (m_pRoot) {
+ std::deque queue;
+ allTemplates.insert(m_pRoot);
+ queue.push_back(m_pRoot);
+ while (!queue.empty()) {
+ NodeTemplate* head = queue.front();
+ queue.pop_front();
+ for (auto* child : head->successors) {
+ if (child && allTemplates.find(child) == allTemplates.end()) {
+ allTemplates.insert(child);
+ queue.push_back(child);
+ }
+ }
+ }
+ }
+ for (auto* tmpl : allTemplates)
+ delete tmpl;
+}
+
+void CControlManager::registerBuiltinActions() {
+ // Simple actions (no parameters)
+ m_registry.registerFactory("stop", [](const auto&) { return new StopAction(); });
+ m_registry.registerFactory("pause", [](const auto&) { return new PauseAction(); });
+ m_registry.registerFactory("speak_cancel", [](const auto&) { return new SpeakCancelAction(); });
+
+ // Move/delete with dist + forward attributes
+ auto distFactory = [](auto createDist) -> ActionFactory {
+ return [createDist](const std::map& attrs) -> ControlAction* {
+ auto it = attrs.find("dist");
+ if (it == attrs.end()) {
+ it = attrs.find("distance");
+ if (it == attrs.end()) return nullptr;
+ }
+
+ // Parse "dist" value. Old control.xml uses: char, word, sentence, paragraph, page, all
+ // Alphabet XML uses: nextChar, previousChar, nextWord, etc.
+ std::string distStr = it->second;
+ bool forwards = true;
+ EditDistance dist = EDIT_CHAR;
+
+ // Check for "next"/"previous" prefix (alphabet XML style)
+ if (distStr.rfind("next", 0) == 0) {
+ forwards = true;
+ distStr = distStr.substr(4); // remove "next"
+ } else if (distStr.rfind("previous", 0) == 0) {
+ forwards = false;
+ distStr = distStr.substr(8); // remove "previous"
+ } else {
+ // control.xml style: check explicit forward attribute
+ auto fwdIt = attrs.find("forward");
+ if (fwdIt != attrs.end()) forwards = (fwdIt->second == "yes" || fwdIt->second == "true");
+ }
+
+ // Parse distance type
+ if (distStr == "Char" || distStr == "char")
+ dist = EDIT_CHAR;
+ else if (distStr == "Word" || distStr == "word")
+ dist = EDIT_WORD;
+ else if (distStr == "Line" || distStr == "line")
+ dist = EDIT_LINE;
+ else if (distStr == "Sent." || distStr == "Sentence" || distStr == "sentence")
+ dist = EDIT_SENTENCE;
+ else if (distStr == "Para." || distStr == "Paragraph" || distStr == "paragraph")
+ dist = EDIT_PARAGRAPH;
+ else if (distStr == "Page" || distStr == "page")
+ dist = EDIT_PAGE;
+ else if (distStr == "All" || distStr == "all")
+ dist = EDIT_FILE;
+ else
+ return nullptr;
+
+ return createDist(forwards, dist);
+ };
+ };
+
+ m_registry.registerFactory(
+ "move", distFactory([](bool f, EditDistance d) -> ControlAction* { return new MoveAction(f, d); }));
+
+ m_registry.registerFactory(
+ "delete", distFactory([](bool f, EditDistance d) -> ControlAction* { return new DeleteAction(f, d); }));
+
+ // Speak/copy with "what" attribute
+ auto textActionFactory = [](char mode) -> ActionFactory {
+ // mode: 's' = speak, 'c' = copy
+ return [mode](const std::map& attrs) -> ControlAction* {
+ auto it = attrs.find("what");
+ if (it == attrs.end()) it = attrs.find("context");
+
+ if (it == attrs.end()) return nullptr;
+
+ std::string what = it->second;
+
+ if (what == "cancel") return mode == 's' ? static_cast(new SpeakCancelAction()) : nullptr;
+
+ if (what == "new")
+ return mode == 's' ? static_cast(new SpeakAction(TextActionBase::NewText, EDIT_NONE))
+ : static_cast(new CopyAction(TextActionBase::NewText, EDIT_NONE));
+
+ if (what == "repeat")
+ return mode == 's' ? static_cast(new SpeakAction(TextActionBase::Repeat, EDIT_NONE))
+ : static_cast(new CopyAction(TextActionBase::Repeat, EDIT_NONE));
+
+ // Distance-based: all, page, paragraph, sentence, line, word
+ EditDistance dist = EDIT_FILE;
+ if (what == "all")
+ dist = EDIT_FILE;
+ else if (what == "page")
+ dist = EDIT_PAGE;
+ else if (what == "paragraph")
+ dist = EDIT_PARAGRAPH;
+ else if (what == "sentence")
+ dist = EDIT_SENTENCE;
+ else if (what == "line")
+ dist = EDIT_LINE;
+ else if (what == "word")
+ dist = EDIT_WORD;
+ else
+ return nullptr;
+
+ return mode == 's' ? static_cast(new SpeakAction(TextActionBase::Distance, dist))
+ : static_cast(new CopyAction(TextActionBase::Distance, dist));
+ };
+ };
+
+ m_registry.registerFactory("speak", textActionFactory('s'));
+ m_registry.registerFactory("copy", textActionFactory('c'));
+
+ // Alphabet XML action names (for backward compat with existing alphabets)
+ m_registry.registerFactory("textCharAction", [](const auto& attrs) -> ControlAction* {
+ // TextCharAction outputs the symbol text. The text is set later
+ // by AlphIO when it knows the symbol's output text.
+ return nullptr; // handled specially in AlphIO
+ });
+
+ m_registry.registerFactory("stopDasherAction", [](const auto&) -> ControlAction* { return new StopAction(); });
+ m_registry.registerFactory("pauseDasherAction", [](const auto&) -> ControlAction* { return new PauseAction(); });
+ m_registry.registerFactory("stopTTSAction", [](const auto&) -> ControlAction* { return new SpeakCancelAction(); });
+ m_registry.registerFactory("fixedTTSAction", [](const auto& attrs) -> ControlAction* {
+ auto it = attrs.find("text");
+ if (it == attrs.end()) return nullptr;
+ return new FixedSpeakAction(it->second);
+ });
+}
+
+CDasherNode* CControlManager::GetRoot(CDasherNode* pContext, int iOffset) {
+ if (!m_pRoot) return nullptr;
+ return new CContNode(iOffset, getColour(m_pRoot, pContext), m_pRoot, this);
+}
+
+void CControlManager::ChangeScreen(CDasherScreen* pScreen) {
+ if (m_pScreen == pScreen) return;
+ m_pScreen = pScreen;
+
+ // Walk the template graph and create/update labels
+ std::deque templateQueue;
+ if (m_pRoot) templateQueue.push_back(m_pRoot);
+ std::set allTemplates(templateQueue.begin(), templateQueue.end());
+
+ while (!templateQueue.empty()) {
+ NodeTemplate* head = templateQueue.front();
+ templateQueue.pop_front();
+ delete head->m_pLabel;
+ head->m_pLabel = pScreen->MakeLabel(head->m_strLabel);
+ for (auto* child : head->successors) {
+ if (!child) continue;
+ if (allTemplates.find(child) == allTemplates.end()) {
+ allTemplates.insert(child);
+ templateQueue.push_back(child);
+ }
+ }
+ }
+}
+
+int CControlManager::getColour(NodeTemplate* pTemplate, CDasherNode* pParent) {
+ if (pTemplate->m_iColour != -1) return pTemplate->m_iColour;
+ if (pParent) return (pParent->ChildCount() % 99) + 11;
+ return 11;
+}
+
+void CControlManager::updateActions() {
+ m_pRoot->successors.clear();
+ for (auto* pNode : m_parsedNodes)
+ m_pRoot->successors.push_back(pNode);
+
+ if (m_pRoot->successors.empty()) {
+ m_pMsgs->Message("Control box is empty.", false);
+ m_pRoot->successors.push_back(nullptr); //
+ m_pRoot->successors.push_back(m_pRoot); // loop back
+ }
+
+ if (m_pScreen) {
+ CDasherScreen* scr = m_pScreen;
+ m_pScreen = nullptr;
+ ChangeScreen(scr);
+ }
+}
+
+// ── XML parsing ────────────────────────────────────────────────────────────
+
+bool CControlManager::Parse(pugi::xml_document& document, const std::string filePath, bool bUser) {
+ m_parsedNodes.clear();
+
+ std::map namedNodes;
+ std::vector::iterator, std::string>> unresolvedRefs;
+ std::vector nodeStack;
+
+ std::function&)> processElement =
+ [&](pugi::xml_node& xmlNode, std::list& parentSuccessors) {
+ std::string name = xmlNode.name();
+
+ if (name == "node") {
+ std::string label, nodeName;
+ int color = -1;
+ for (pugi::xml_attribute attr : xmlNode.attributes()) {
+ std::string attrName = attr.name();
+ if (attrName == "name")
+ nodeName = attr.value();
+ else if (attrName == "label")
+ label = attr.value();
+ else if (attrName == "color")
+ color = std::atoi(attr.value());
+ }
+
+ auto* n = new NodeTemplate(label, color);
+ parentSuccessors.push_back(n);
+ nodeStack.push_back(n);
+ if (!nodeName.empty()) namedNodes[nodeName] = n;
+
+ for (pugi::xml_node child : xmlNode.children())
+ processElement(child, n->successors);
+
+ nodeStack.pop_back();
+ } else if (name == "ref") {
+ std::string target;
+ for (pugi::xml_attribute attr : xmlNode.attributes()) {
+ if (std::string(attr.name()) == "name") target = attr.value();
+ }
+ auto it = namedNodes.find(target);
+ if (it != namedNodes.end()) {
+ parentSuccessors.push_back(it->second);
+ } else {
+ parentSuccessors.push_back(nullptr);
+ unresolvedRefs.emplace_back(std::prev(parentSuccessors.end()), target);
+ }
+ } else if (name == "alph") {
+ parentSuccessors.push_back(nullptr); // escape
+ } else if (name == "root") {
+ parentSuccessors.push_back(m_pRoot); // loop back to root
+ } else if (m_registry.hasAction(name)) {
+ // Action element — add to current node's action list
+ if (!nodeStack.empty()) {
+ std::map attrs;
+ for (pugi::xml_attribute attr : xmlNode.attributes())
+ attrs[attr.name()] = attr.value();
+
+ ControlAction* action = m_registry.create(name, attrs);
+ if (action) {
+ nodeStack.back()->m_actions.push_back(action);
+ } else {
+ m_pMsgs->Message("Failed to create action: " + name, false);
+ }
+ }
+ }
+ // Unknown elements are silently ignored
+ };
+
+ pugi::xml_node controlElem = document.child("control");
+ if (!controlElem) controlElem = document.root().first_child();
+
+ if (controlElem) {
+ for (pugi::xml_node child : controlElem.children())
+ processElement(child, m_parsedNodes);
+ }
+
+ // Resolve forward references
+ for (auto& ref : unresolvedRefs) {
+ auto target = namedNodes.find(ref.second);
+ if (target != namedNodes.end()) *(ref.first) = target->second;
+ }
+
+ updateActions();
+ return true;
+}
diff --git a/src/DasherCore/ControlManager.h b/src/DasherCore/ControlManager.h
new file mode 100644
index 000000000..fcf430f1e
--- /dev/null
+++ b/src/DasherCore/ControlManager.h
@@ -0,0 +1,386 @@
+// ControlManager.h
+//
+// Unified action system + control node tree for Dasher.
+//
+// This file replaces the old Actions.h / ActionManager.h system with a single,
+// generic, extensible action framework. Both alphabet symbol nodes and control
+// nodes create and execute actions through the same ActionRegistry.
+//
+// Copyright (c) 2007-2024 The Dasher Team
+//
+// This file is part of Dasher. Dasher is free software; you can redistribute
+// it and/or modify it under the terms of the GNU General Public License as
+// published by the Free Software Foundation; either version 2 of the License,
+// or (at your option) any later version.
+
+#pragma once
+
+#include "AbstractXMLParser.h"
+#include "DasherNode.h"
+#include "NodeManager.h"
+#include "Parameters.h"
+#include "SettingsStore.h"
+
+#include
+#include ]
+#include