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 +#include +#include +#include + +class CNodeCreationManager; + +namespace Dasher { + +class CDasherInterfaceBase; +class CControlManager; + +// ── EditDistance ─────────────────────────────────────────────────────────── +// Moved here from Actions.h. Used by move/delete/speak/copy actions. + +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; + +// ── Action system ────────────────────────────────────────────────────────── + +/// Base class for all actions. An action is executed when a node is entered +/// (Do) or potentially reversed (Undo). Actions are created by the +/// ActionRegistry from XML attributes. +class ControlAction { + public: + virtual ~ControlAction() = default; + + /// Called when the node containing this action is entered. + virtual void execute(CDasherInterfaceBase* intf) = 0; + + /// Compute the new text offset after this action runs. + /// Default: offset unchanged. + virtual int calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore); +}; + +/// Factory function type: creates a ControlAction from XML key-value attributes. +using ActionFactory = std::function&)>; + +/// Type for custom action callbacks registered by frontends. +/// Receives the action name and all XML attributes. +using CustomActionCallback = + std::function& attrs)>; + +/// Wraps a frontend-registered custom action into a ControlAction. +class CustomAction : public ControlAction { + public: + CustomAction(const std::string& name, const std::map& attrs, + CustomActionCallback callback) + : m_name(name), m_attrs(attrs), m_callback(std::move(callback)) {} + + void execute(CDasherInterfaceBase* intf) override; + + private: + std::string m_name; + std::map m_attrs; + CustomActionCallback m_callback; +}; + +/// Registry of action factories. Maps action names (as they appear in XML) +/// to factory functions. Both control.xml and alphabet.xml use this. +class ActionRegistry { + public: + ActionRegistry() = default; + + /// Register a factory for a named action type. + void registerFactory(const std::string& name, ActionFactory factory); + + /// Register a custom action from the C API. + void registerCustomAction(const std::string& name, CustomActionCallback callback); + + /// Create an action instance from XML name + attributes. + /// Returns nullptr if the name is not registered. + ControlAction* create(const std::string& name, const std::map& attrs) const; + + /// Check if a name is registered. + bool hasAction(const std::string& name) const; + + private: + struct FactoryEntry { + ActionFactory factory; + CustomActionCallback customCallback; // if non-null, wraps as CustomAction + }; + std::map m_factories; +}; + +// ── Built-in action classes ──────────────────────────────────────────────── +// These call CDasherInterfaceBase methods directly, no event system needed. + +/// Stop Dasher and trigger on-stop behaviour. +class StopAction : public ControlAction { + public: + void execute(CDasherInterfaceBase* intf) override; +}; + +/// Pause Dasher motion. +class PauseAction : public ControlAction { + public: + void execute(CDasherInterfaceBase* intf) override; +}; + +/// Move the cursor forward or backward by a given distance. +class MoveAction : public ControlAction { + public: + MoveAction(bool bForwards, EditDistance dist) : m_bForwards(bForwards), m_dist(dist) {} + + int calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) override; + void execute(CDasherInterfaceBase* intf) override; + + private: + bool m_bForwards; + EditDistance m_dist; +}; + +/// Delete text forward or backward by a given distance. +class DeleteAction : public ControlAction { + public: + DeleteAction(bool bForwards, EditDistance dist) : m_bForwards(bForwards), m_dist(dist) {} + + int calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) override; + void execute(CDasherInterfaceBase* intf) override; + + private: + bool m_bForwards; + EditDistance m_dist; +}; + +/// Base for actions that use text context (speak/copy). +class TextActionBase : public ControlAction { + public: + enum ActionContext { Repeat, NewText, Distance }; + + TextActionBase(ActionContext context, EditDistance dist) : m_context(context), m_dist(dist) {} + + protected: + std::string getText(CDasherInterfaceBase* intf); + + ActionContext m_context; + EditDistance m_dist; + int m_iStartOffset = 0; + std::string m_strLast; +}; + +/// Speak text based on context mode. +class SpeakAction : public TextActionBase { + public: + using TextActionBase::TextActionBase; + void execute(CDasherInterfaceBase* intf) override; +}; + +/// Cancel ongoing speech. +class SpeakCancelAction : public ControlAction { + public: + void execute(CDasherInterfaceBase* intf) override; +}; + +/// Copy text to clipboard based on context mode. +class CopyAction : public TextActionBase { + public: + using TextActionBase::TextActionBase; + void execute(CDasherInterfaceBase* intf) override; +}; + +/// Output a fixed text character (the default behaviour for alphabet symbols). +class TextOutputAction : public ControlAction { + public: + TextOutputAction(std::string text) : m_text(std::move(text)) {} + void execute(CDasherInterfaceBase* intf) override; + + private: + std::string m_text; +}; + +/// Delete a fixed text character (undo for alphabet symbols). +class TextDeleteAction : public ControlAction { + public: + TextDeleteAction(std::string text) : m_text(std::move(text)) {} + void execute(CDasherInterfaceBase* intf) override; + + private: + std::string m_text; +}; + +/// Speak a fixed string. +class FixedSpeakAction : public ControlAction { + public: + FixedSpeakAction(std::string text) : m_text(std::move(text)) {} + void execute(CDasherInterfaceBase* intf) override; + + private: + std::string m_text; +}; + +/// Change a Dasher setting (deferred to end of frame). +class ChangeSettingAction : public ControlAction { + public: + ChangeSettingAction(Parameter setting, std::variant newValue) + : m_parameter(setting), m_newValue(std::move(newValue)) {} + + void execute(CDasherInterfaceBase* intf) override; + + private: + Parameter m_parameter; + std::variant m_newValue; +}; + +/// Send keyboard key events (for ATSPI / accessibility integration). +class KeyboardAction : public ControlAction { + public: + enum PressType { KEY_PRESS, KEY_RELEASE, KEY_PRESS_RELEASE }; + + KeyboardAction(PressType type, std::vector> keycodes) + : m_type(type), m_keycodes(std::move(keycodes)) {} + + void execute(CDasherInterfaceBase* intf) override; + + private: + [[maybe_unused]] PressType m_type; + [[maybe_unused]] std::vector> m_keycodes; +}; + +/// Output to a socket. +class SocketOutputAction : public ControlAction { + public: + SocketOutputAction(std::string socketName, std::string action, bool addNewLine) + : m_socketName(std::move(socketName)), m_action(std::move(action)), m_addNewLine(addNewLine) {} + + void execute(CDasherInterfaceBase* intf) override; + + private: + [[maybe_unused]] std::string m_socketName; + [[maybe_unused]] std::string m_action; + [[maybe_unused]] bool m_addNewLine; +}; + +/// ATSPI accessibility action. +class ATSPIAction : public ControlAction { + public: + ATSPIAction(std::string action) : m_action(std::move(action)) {} + void execute(CDasherInterfaceBase* intf) override; + + private: + std::string m_action; +}; + +// ── Control node tree ────────────────────────────────────────────────────── + +/// Template describing a control node: label, colour, successors, and actions. +/// NodeTemplates form a shared directed graph via the successors list. +/// A nullptr entry in successors means an escape (bridge to alphabet). +class NodeTemplate { + public: + NodeTemplate(const std::string& strLabel, int iColour) + : m_strLabel(strLabel), m_iColour(iColour), m_pLabel(nullptr) {} + + ~NodeTemplate() { + delete m_pLabel; + for (auto* action : m_actions) + delete action; + } + + const std::string m_strLabel; + const int m_iColour; + std::list successors; ///< nullptr = escape + std::vector m_actions; + CDasherScreen::Label* m_pLabel; + + int calculateNewOffset(CDasherInterfaceBase* intf, int offsetBefore) const; + void executeActions(CDasherInterfaceBase* intf) const; +}; + +/// A node in the control tree. Children distributed uniformly (no LM). +class CContNode : public CDasherNode { + public: + CContNode(int iOffset, int iColour, NodeTemplate* pTemplate, CControlManager* pMgr); + + CNodeManager* mgr() const override; + CDasherScreen::Label* getLabel() override { return m_pTemplate->m_pLabel; } + bool bShove() override { return false; } + double SpeedMul() override; + + void PopulateChildren() override; + int ExpectedNumChildren() override; + void Do() override; + + const ColorPalette::Color& getLabelColor(const ColorPalette* palette) override { + return palette ? palette->GetNamedColor(NamedColor::defaultLabel) : ColorPalette::noColor; + } + const ColorPalette::Color& getOutlineColor(const ColorPalette* palette) override { + return palette ? palette->GetNamedColor(NamedColor::defaultOutline) : ColorPalette::noColor; + } + const ColorPalette::Color& getNodeColor(const ColorPalette*) override; + + NodeTemplate* templateNode() const { return m_pTemplate; } + int colourIndex() const { return m_iColourIndex; } + + private: + NodeTemplate* m_pTemplate; + CControlManager* m_pMgr; + int m_iColourIndex; +}; + +/// Node manager for control nodes. Owns the action registry, the root +/// template, and parses control.xml. +class CControlManager : public CNodeManager, public AbstractXMLParser { + public: + CControlManager(CSettingsStore* pSettingsStore, CDasherInterfaceBase* pInterface, CNodeCreationManager* pNCManager, + CMessageDisplay* pMsgs); + ~CControlManager(); + + /// Create a root control node for grafting under an alphabet node. + /// Returns nullptr if no root template is configured. + CDasherNode* GetRoot(CDasherNode* pContext, int iOffset); + + /// Create/update screen labels for all templates. + void ChangeScreen(CDasherScreen* pScreen); + + /// Pick a colour index for a template, auto-cycling if unspecified. + int getColour(NodeTemplate* pTemplate, CDasherNode* pParent); + + /// Rebuild root template's successors from parsed XML. + void updateActions(); + + // Accessors + CDasherInterfaceBase* GetInterface() { return m_pInterface; } + CSettingsStore* GetSettingsStore() { return m_pSettingsStore; } + CNodeCreationManager* GetNCManager() { return m_pNCManager; } + NodeTemplate* GetRootTemplate() { return m_pRoot; } + ActionRegistry* GetActionRegistry() { return &m_registry; } + + // AbstractXMLParser + bool Parse(pugi::xml_document& document, const std::string filePath, bool bUser) override; + + private: + CSettingsStore* m_pSettingsStore; + CDasherInterfaceBase* m_pInterface; + CNodeCreationManager* m_pNCManager; + CDasherScreen* m_pScreen = nullptr; + + ActionRegistry m_registry; + NodeTemplate* m_pRoot = nullptr; + std::list m_parsedNodes; + + /// Register all built-in actions in the registry. + void registerBuiltinActions(); + + public: + /// Map a colour index to a ColorPalette::Color. + static const ColorPalette::Color& indexToColor(int iIndex); +}; + +} // namespace Dasher diff --git a/src/DasherCore/DasherInterfaceBase.cpp b/src/DasherCore/DasherInterfaceBase.cpp index 3830f8118..a5fadf617 100644 --- a/src/DasherCore/DasherInterfaceBase.cpp +++ b/src/DasherCore/DasherInterfaceBase.cpp @@ -60,7 +60,6 @@ static std::string alphabetIdToFilename(const std::string& alphId) { // Declare our global file logging object -#include "ActionManager.h" #include "FileUtils.h" #include "SmoothingFilter.h" #include "DasherCore/FileLogger.h" @@ -74,72 +73,22 @@ const int g_iLogOptions = logTimeStamp | logDateStamp; using namespace Dasher; +CControlManager* CDasherInterfaceBase::GetControlManager() { + return m_pNCManager ? m_pNCManager->GetControlManager() : nullptr; +} + CDasherInterfaceBase::CDasherInterfaceBase(CSettingsStore* pSettingsStore) : m_pDasherModel(std::make_unique()), m_pFramerate(std::make_unique(pSettingsStore)), - m_pSettingsStore(pSettingsStore), m_pModuleManager(std::make_unique()), - m_pActionManager(std::make_unique()) { + m_pSettingsStore(pSettingsStore), m_pModuleManager(std::make_unique()) { m_pSettingsStore->OnParameterChanged.Subscribe(this, [this](Parameter p) { HandleParameterChange(p); }); // Global logging object we can use from anywhere - // Skip in low-memory mode (keyboard extensions) to avoid sandbox write violations + // Skip in low-memory mode (keyboard extension) to avoid sandbox write violations if (!m_bLowMemoryMode) { m_pGlobalApplicationLog = std::make_unique("dasher.log", g_iLogLevel, g_iLogOptions); } - // Register for all events that we are "responsible" for - GetActionManager()->OnCharEntered.Subscribe( - this, [this](CSymbolNode* Trigger, TextCharAction*) { editOutput(Trigger->outputText(), Trigger); }); - GetActionManager()->OnCharRemoved.Subscribe( - this, [this](CSymbolNode* Trigger, TextCharUndoAction*) { editDelete(Trigger->outputText(), Trigger); }); - GetActionManager()->OnContextSpeak.Subscribe( - this, [this](CSymbolNode*, ContextSpeechAction* Action, CDasherInterfaceBase* Intf) { - // Should be moved into own module/class - switch (Action->context) { - case TextAction::Repeat: - Speak(Action->strLast, false); - break; - case TextAction::NewText: - Speak(Action->getNewContext(Intf), false); - break; - case TextAction::Distance: - Speak(Action->getBasedOnDistance(Intf, Action->m_dist), false); - } - }); - GetActionManager()->OnSpeakCancel.Subscribe(this, [this](CSymbolNode*, SpeakCancelAction*) { Speak("", true); }); - GetActionManager()->OnDasherPause.Subscribe( - this, [this](CSymbolNode*, PauseDasherAction*) { GetActiveInputMethod()->pause(); }); - GetActionManager()->OnDasherStop.Subscribe(this, [this](CSymbolNode*, StopDasherAction*) { - Done(); - GetActiveInputMethod()->pause(); - }); - GetActionManager()->OnCopy.Subscribe(this, [this](CSymbolNode*, CopyAction* Action, CDasherInterfaceBase* Intf) { - // Should be moved into own module/class - switch (Action->context) { - case TextAction::Repeat: - CopyToClipboard(Action->strLast); - break; - case TextAction::NewText: - CopyToClipboard(Action->getNewContext(Intf)); - break; - case TextAction::Distance: - CopyToClipboard(Action->getBasedOnDistance(Intf, Action->m_dist)); - break; - } - }); - GetActionManager()->OnDelete.Subscribe( - this, [this](CSymbolNode*, const DeleteAction* Action) { ctrlDelete(Action->m_bForwards, Action->m_dist); }); - GetActionManager()->OnMove.Subscribe( - this, [this](CSymbolNode*, const MoveAction* Action) { ctrlMove(Action->m_bForwards, Action->m_dist); }); - GetActionManager()->OnSettingChange.Subscribe(this, [this](CSymbolNode*, const ChangeSettingsAction* Action) { - if (std::holds_alternative(Action->newValue)) - m_pSettingsStore->SetBoolParameter(Action->parameter, std::get(Action->newValue)); - if (std::holds_alternative(Action->newValue)) - m_pSettingsStore->SetLongParameter(Action->parameter, std::get(Action->newValue)); - if (std::holds_alternative(Action->newValue)) - m_pSettingsStore->SetStringParameter(Action->parameter, std::get(Action->newValue)); - }); - OnEditEvent.Subscribe(this, [this](CEditEvent::EditEventType type, const std::string& strText, CDasherNode*) { if (this->GetGameModule() || !m_pSettingsStore->GetBoolParameter(BP_SPEAK_WORDS) || !this->SupportsSpeech()) return; @@ -226,7 +175,6 @@ CDasherInterfaceBase::~CDasherInterfaceBase() { // Deregistering here allows for reusing a settings instance m_pSettingsStore->OnParameterChanged.Unsubscribe(this); m_pSettingsStore->OnPreParameterChange.Unsubscribe(this); - GetActionManager()->UnsubscribeAll(this); OnEditEvent.Unsubscribe(this); // Word Speak Event // //WriteTrainFileFull();??? @@ -294,6 +242,14 @@ void CDasherInterfaceBase::HandleParameterChange(Parameter parameter) { m_defaultPolicy.reset( new AmortizedPolicy(m_pDasherModel.get(), m_pSettingsStore->GetLongParameter(LP_NODE_BUDGET))); break; + case BP_CONTROL_MODE: + // Rebuild control box first (deletes old CControlManager/templates), + // then rebuild node tree — order is critical to avoid dangling + // template pointers in live CContNodes during AddExtras(). + m_pNCManager->CreateControlBox(); + SetOffset(m_pDasherModel->GetOffset(), true); + ScheduleRedraw(); + break; default: break; } @@ -491,7 +447,13 @@ void CDasherInterfaceBase::NewFrame(unsigned long iTime, bool bForceRedraw) { bReentered = false; - GetActionManager()->ExecuteDelayedActions(); + ExecuteDelayedActions(); +} + +void CDasherInterfaceBase::ExecuteDelayedActions() { + for (auto& action : m_delayedActions) + action(); + m_delayedActions.clear(); } void CDasherInterfaceBase::onUnpause(unsigned long lTime) { diff --git a/src/DasherCore/DasherInterfaceBase.h b/src/DasherCore/DasherInterfaceBase.h index bc1b4801d..f6b2c719e 100644 --- a/src/DasherCore/DasherInterfaceBase.h +++ b/src/DasherCore/DasherInterfaceBase.h @@ -38,7 +38,10 @@ #include "ModuleManager.h" #include "FrameRate.h" #include "DasherModel.h" +#include "ControlManager.h" +#include #include +#include namespace Dasher { class CDasherScreen; @@ -318,10 +321,27 @@ class Dasher::CDasherInterfaceBase : public CMessageDisplay, private NoClones { CInputFilter* GetActiveInputMethod() { return m_pInputFilter; } const CAlphInfo* GetActiveAlphabet(); CModuleManager* GetModuleManager() { return m_pModuleManager.get(); } - CActionManager* GetActionManager() { return m_pActionManager.get(); } CDasherModel* GetModel() { return m_pDasherModel.get(); } CDasherView* GetView() { return m_pDasherView.get(); } + /// Returns the settings store (for actions that change settings). + CSettingsStore* GetSettingsStore() { return m_pSettingsStore; } + + /// Returns the control manager (nullptr if control mode is off or not yet realized). + Dasher::CControlManager* GetControlManager(); + + /// Returns custom action registrations for the C API. + /// Called by CNodeCreationManager before control.xml parsing. + /// Override in subclasses to provide frontend-registered custom actions. + virtual std::vector> GetPendingCustomActions() { return {}; } + + /// Queue a deferred action to execute at end of the current frame. + /// Used by ChangeSettingAction to avoid unsafe parameter changes mid-render. + void DelayAction(std::function action) { m_delayedActions.push_back(std::move(action)); } + + /// Execute and clear all queued deferred actions. Called from NewFrame(). + void ExecuteDelayedActions(); + void StartShutdown(); void ScheduleRedraw() { m_bRedrawScheduled = true; }; @@ -496,7 +516,6 @@ class Dasher::CDasherInterfaceBase : public CMessageDisplay, private NoClones { CDasherInput* m_pInput = nullptr; CInputFilter* m_pInputFilter = nullptr; std::unique_ptr m_pModuleManager; - std::unique_ptr m_pActionManager; std::unique_ptr m_AlphIO; std::unique_ptr m_ColorIO; std::unique_ptr m_pNCManager; @@ -524,6 +543,9 @@ class Dasher::CDasherInterfaceBase : public CMessageDisplay, private NoClones { /// Low-memory mode flag: load only selected alphabet + minimal modules. bool m_bLowMemoryMode = false; + /// Queue of deferred actions (for ChangeSettingAction etc.) + std::vector> m_delayedActions; + /// @} }; /// @} diff --git a/src/DasherCore/FileUtils.cpp b/src/DasherCore/FileUtils.cpp index 255249b0d..3b3bbc3be 100644 --- a/src/DasherCore/FileUtils.cpp +++ b/src/DasherCore/FileUtils.cpp @@ -26,9 +26,10 @@ int Dasher::FileUtils::GetFileSize(const std::string& strFileName) { } void Dasher::FileUtils::ScanFiles(AbstractParser* parser, const std::string& strPattern) { - // Full real path given -> parse only that file + // Absolute path to a real file -> parse only that file std::error_code error_code; // just used for not throwing errors - if (std::filesystem::exists(strPattern, error_code) && std::filesystem::is_regular_file(strPattern, error_code)) { + std::filesystem::path p(strPattern); + if (p.is_absolute() && std::filesystem::exists(p, error_code) && std::filesystem::is_regular_file(p, error_code)) { parser->ParseFile(strPattern, IsFileWriteable(strPattern)); return; } @@ -58,7 +59,11 @@ void Dasher::FileUtils::ScanFiles(AbstractParser* parser, const std::string& str } bool Dasher::FileUtils::WriteUserDataFile(const std::string& filename, const std::string& strNewText, bool append) { - std::ofstream File(filename, (append) ? std::ios_base::app : std::ios_base::out); + std::filesystem::path fullPath(filename); + if (fullPath.is_relative() && !s_dataDirectory.empty()) { + fullPath = std::filesystem::path(s_dataDirectory) / filename; + } + std::ofstream File(fullPath, (append) ? std::ios_base::app : std::ios_base::out); if (File.is_open()) { File << strNewText; diff --git a/src/DasherCore/NodeCreationManager.cpp b/src/DasherCore/NodeCreationManager.cpp index d5e639df7..e07ce1c76 100644 --- a/src/DasherCore/NodeCreationManager.cpp +++ b/src/DasherCore/NodeCreationManager.cpp @@ -2,6 +2,7 @@ #include "DasherInterfaceBase.h" #include "NodeCreationManager.h" +#include "ControlManager.h" #include "FileUtils.h" #include "MandarinAlphMgr.h" #include "RoutingAlphMgr.h" @@ -119,11 +120,14 @@ CNodeCreationManager::CNodeCreationManager(CSettingsStore* pSettingsStore, CDash } HandleParameterChange(LP_ORIENTATION); + + CreateControlBox(); } CNodeCreationManager::~CNodeCreationManager() { delete m_pAlphabetManager; delete m_pTrainer; + delete m_pControlManager; m_pSettingsStore->OnParameterChanged.Unsubscribe(this); } @@ -132,9 +136,46 @@ void CNodeCreationManager::ChangeScreen(CDasherScreen* pScreen) { if (m_pScreen == pScreen) return; m_pScreen = pScreen; m_pAlphabetManager->MakeLabels(pScreen); + if (m_pControlManager) m_pControlManager->ChangeScreen(pScreen); } void CNodeCreationManager::ImportTrainingText(const std::string& strPath) { ProgressNotifier pn(m_pInterface, m_pTrainer); pn.ParseFile(strPath, true); +} + +void CNodeCreationManager::HandleParameterChange(Dasher::Parameter parameter) { + // BP_CONTROL_MODE is handled by CDasherInterfaceBase calling + // CreateControlBox() explicitly before SetOffset(), to ensure the + // control manager state is updated before the node tree is rebuilt. +} + +void CNodeCreationManager::CreateControlBox() { + delete m_pControlManager; + m_pControlManager = nullptr; + + unsigned long iControlSpace; + if (m_pSettingsStore->GetBoolParameter(Dasher::BP_CONTROL_MODE)) { + m_pControlManager = new Dasher::CControlManager(m_pSettingsStore, m_pInterface, this, + static_cast(m_pInterface)); + // Register frontend-provided custom actions before parsing control.xml + for (auto& [name, callback] : m_pInterface->GetPendingCustomActions()) + m_pControlManager->GetActionRegistry()->registerCustomAction(name, std::move(callback)); + Dasher::FileUtils::ScanFiles(m_pControlManager, "control.xml"); + if (m_pScreen) m_pControlManager->ChangeScreen(m_pScreen); + iControlSpace = Dasher::CDasherModel::NORMALIZATION / 20; + } else { + iControlSpace = 0; + } + m_iAlphNorm = Dasher::CDasherModel::NORMALIZATION - iControlSpace; +} + +void CNodeCreationManager::AddExtras(Dasher::CDasherNode* pParent) { + if (m_pControlManager) { + Dasher::CDasherNode* ctl = m_pControlManager->GetRoot(pParent, pParent->offset()); + if (ctl) { + unsigned int iLbnd = pParent->GetChildren().back()->Hbnd(); + ctl->Reparent(pParent, iLbnd, Dasher::CDasherModel::NORMALIZATION); + } + } } \ No newline at end of file diff --git a/src/DasherCore/NodeCreationManager.h b/src/DasherCore/NodeCreationManager.h index 6be7a29e7..63c95debe 100644 --- a/src/DasherCore/NodeCreationManager.h +++ b/src/DasherCore/NodeCreationManager.h @@ -2,6 +2,7 @@ #include "AlphabetManager.h" #include "ConversionManager.h" +#include "DasherModel.h" #include "Trainer.h" #include "SettingsStore.h" @@ -11,6 +12,7 @@ namespace Dasher { class CDasherNode; class CDasherInterfaceBase; class CDasherScreen; +class CControlManager; class CControlBoxIO; } // namespace Dasher @@ -26,13 +28,15 @@ class CNodeCreationManager { /// Tells us the screen on which all created node labels must be rendered void ChangeScreen(Dasher::CDasherScreen* pScreen); - void HandleParameterChange(Dasher::Parameter parameter) {} + void HandleParameterChange(Dasher::Parameter parameter); /// /// Get a root node of a particular type /// Dasher::CAlphabetManager* GetAlphabetManager() { return m_pAlphabetManager; } + Dasher::CControlManager* GetControlManager() { return m_pControlManager; } + /// /// Get a reference to the current alphabet /// @@ -41,16 +45,28 @@ class CNodeCreationManager { void ImportTrainingText(const std::string& strPath); + /// Amount of probability space assigned to alphabet nodes + /// (NORMALIZATION minus control node space, if control mode is on). + unsigned long GetAlphNodeNormalization() { return m_iAlphNorm; } + + /// Called from AlphabetManager::IterateChildGroups to add the control node + /// as an extra sibling at the base group level. + void AddExtras(Dasher::CDasherNode* pParent); + + /// Create or destroy the control manager based on BP_CONTROL_MODE. + void CreateControlBox(); + private: Dasher::CTrainer* m_pTrainer; - Dasher::CDasherInterfaceBase* m_pInterface; - Dasher::CAlphabetManager* m_pAlphabetManager; + Dasher::CControlManager* m_pControlManager = nullptr; + + /// Probability space for alphabet nodes (after subtracting control space) + unsigned long m_iAlphNorm = Dasher::CDasherModel::NORMALIZATION; /// Screen to use to create node labels Dasher::CDasherScreen* m_pScreen; - Dasher::CSettingsStore* m_pSettingsStore; }; /// @} diff --git a/src/DasherCore/Parameters.cpp b/src/DasherCore/Parameters.cpp index b3ab413cc..0570bb4ce 100644 --- a/src/DasherCore/Parameters.cpp +++ b/src/DasherCore/Parameters.cpp @@ -1,5 +1,5 @@ // ============================================================================= -// AUTOGENERATED FILE — DO NOT EDIT DIRECTLY +// AUTOGENERATED FILE DO NOT EDIT DIRECTLY // Generated by: python3 Scripts/generate_parameters.py // Source: settings_manifest.json // ============================================================================= @@ -118,6 +118,15 @@ const std::unordered_map parameter_defaults = Parameter_Value{"SlowStart", PARAM_BOOL, Persistence::PERSISTENT, false, "When enabled, Dasher will gradually accelerate to the desired speed from a standstill.", "Slow Start", Settings::UIControlType::Switch, false, "BP_SLOW_START", "Control", "Input"}}, + {BP_CONTROL_MODE, + Parameter_Value{"ControlMode", PARAM_BOOL, Persistence::PERSISTENT, false, + "Show a control node in the Dasher canvas providing editing commands (delete, move, speak, etc.).", + "Control Mode", Settings::UIControlType::Switch, false, "BP_CONTROL_MODE", "Control", "Input"}}, + {BP_SLOW_CONTROL_BOX, + Parameter_Value{ + "SlowControlBox", PARAM_BOOL, Persistence::PERSISTENT, true, + "Reduces speed by half when navigating inside control nodes, making it harder to trigger actions by mistake.", + "Slow in Control Nodes", Settings::UIControlType::Switch, true, "BP_SLOW_CONTROL_BOX", "Control", "Input"}}, {BP_COPY_ALL_ON_STOP, Parameter_Value{"CopyOnStop", PARAM_BOOL, Persistence::PERSISTENT, false, "Copy all text to clipboard whenever we stop.", "Copy on Stop", Settings::UIControlType::None, @@ -214,10 +223,11 @@ const std::unordered_map parameter_defaults = "LP_START_MODE", "Control", "Input"}}, - {LP_UNIFORM, Parameter_Value{"UniformTimes1000", PARAM_LONG, Persistence::PERSISTENT, 50l, - "Uniform probability weight (×1000). Higher values make less-probable symbols larger.", - "Uniform Probability", Settings::UIControlType::Slider, 0, 1000, 1, 10, true, - "LP_UNIFORM", "Advanced", "Input"}}, + {LP_UNIFORM, + Parameter_Value{"UniformTimes1000", PARAM_LONG, Persistence::PERSISTENT, 50l, + "Uniform probability weight (×1000). Higher values make less-probable symbols larger.", + "Uniform Probability", Settings::UIControlType::Slider, 0, 1000, 1, 10, true, "LP_UNIFORM", + "Advanced", "Input"}}, {LP_MOUSEPOSDIST, Parameter_Value{"MousePositionBoxDistance", PARAM_LONG, Persistence::PERSISTENT, 50l, "MousePositionBoxDistance.", "Mouse Position Distance", Settings::UIControlType::Step, 0, 500, 1, 10, true, "LP_MOUSEPOSDIST", diff --git a/src/DasherCore/Parameters.h b/src/DasherCore/Parameters.h index 3dcd2eeb5..74942c112 100644 --- a/src/DasherCore/Parameters.h +++ b/src/DasherCore/Parameters.h @@ -37,6 +37,8 @@ enum Parameter { BP_GAME_HELP_DRAW_PATH, BP_TWO_PUSH_RELEASE_TIME, BP_SIMULATE_TRANSPARENCY, + BP_CONTROL_MODE, + BP_SLOW_CONTROL_BOX, END_OF_BPS, LP_ORIENTATION, diff --git a/src/dasher.h b/src/dasher.h index e7ef749dc..2624ed9c3 100644 --- a/src/dasher.h +++ b/src/dasher.h @@ -371,6 +371,29 @@ DASHER_API void dasher_set_string_override(dasher_ctx* ctx, const char* key, con // Returned pointer is valid until the next API call. DASHER_API const char* dasher_get_localized_string(dasher_ctx* ctx, const char* key); +// ── Custom actions ───────────────────────────────────────────────────────── +// +// Register a custom action type that can be referenced from control.xml. +// When a control node containing is entered, the +// callback fires with the action name and all XML attributes as parallel +// key/value arrays. +// +// Must be called BEFORE dasher_set_screen_size() for the action to be +// available during initial control.xml parsing. If called after, the action +// will be registered but existing parsed nodes won't include it until the +// control box is rebuilt (e.g. by toggling BP_CONTROL_MODE). +// +// Example control.xml usage: +// +// +// + +typedef void (*dasher_action_callback)(const char* name, int attr_count, const char** attr_keys, + const char** attr_values, void* user_data); + +DASHER_API void dasher_register_action(dasher_ctx* ctx, const char* name, dasher_action_callback callback, + void* user_data); + // ── Test / diagnostic hooks ──────────────────────────────────────────────── // // These functions expose internal engine state for testing and golden-output diff --git a/tests/test_control_actions.cpp b/tests/test_control_actions.cpp new file mode 100644 index 000000000..5efa03b9b --- /dev/null +++ b/tests/test_control_actions.cpp @@ -0,0 +1,277 @@ +// Tests for the unified action system (ControlManager / ActionRegistry) +// Tests both internal C++ classes and the C API integration. +// +// Build: linked against DasherCore internally (needs ActionRegistry, ControlAction) + +#include "test_common.h" + +#include "DasherCore/ControlManager.h" + +#include +#include + +using namespace Dasher; + +// ── ActionRegistry unit tests ────────────────────────────────────────────── + +TEST(action_registry_empty) { + ActionRegistry registry; + ASSERT(!registry.hasAction("nonexistent")); + + std::map emptyAttrs; + ASSERT(registry.create("nonexistent", emptyAttrs) == nullptr); + + printf(" action_registry_empty passed\n"); +} + +TEST(action_registry_factory) { + ActionRegistry registry; + + // Register a factory that creates a StopAction + registry.registerFactory("stop", [](const auto&) { return new StopAction(); }); + + ASSERT(registry.hasAction("stop")); + + std::map attrs; + ControlAction* action = registry.create("stop", attrs); + ASSERT(action != nullptr); + delete action; + + printf(" action_registry_factory passed\n"); +} + +TEST(action_registry_custom_action) { + ActionRegistry registry; + + // Track callback invocation + static std::string receivedName; + static std::map receivedAttrs; + static int callbackCount = 0; + callbackCount = 0; + + registry.registerCustomAction("my_action", + [](const std::string& name, const std::map& attrs) { + receivedName = name; + receivedAttrs = attrs; + callbackCount++; + }); + + ASSERT(registry.hasAction("my_action")); + + std::map attrs = {{"key1", "val1"}, {"key2", "val2"}}; + ControlAction* action = registry.create("my_action", attrs); + ASSERT(action != nullptr); + + // Execute — CustomAction::execute doesn't use the interface pointer + action->execute(nullptr); + delete action; + + ASSERT_EQ(callbackCount, 1); + ASSERT_STR_EQ(receivedName.c_str(), "my_action"); + ASSERT_EQ(receivedAttrs.size(), (size_t)2); + ASSERT_STR_EQ(receivedAttrs["key1"].c_str(), "val1"); + ASSERT_STR_EQ(receivedAttrs["key2"].c_str(), "val2"); + + printf(" action_registry_custom_action passed\n"); +} + +TEST(action_registry_overwrite) { + ActionRegistry registry; + + int callCount1 = 0, callCount2 = 0; + + registry.registerCustomAction("action_a", [&](const auto&, const auto&) { callCount1++; }); + + // Overwrite with a different callback + registry.registerCustomAction("action_a", [&](const auto&, const auto&) { callCount2++; }); + + std::map attrs; + ControlAction* action = registry.create("action_a", attrs); + ASSERT(action != nullptr); + action->execute(nullptr); + delete action; + + // Only the second callback should fire + ASSERT_EQ(callCount1, 0); + ASSERT_EQ(callCount2, 1); + + printf(" action_registry_overwrite passed\n"); +} + +// ── C API integration tests ──────────────────────────────────────────────── + +// Shared callback data for C API tests (must be at file scope for C callbacks) +struct ActionCallbackData { + std::string name; + std::map attrs; + int callCount = 0; + void reset() { + name.clear(); + attrs.clear(); + callCount = 0; + } +}; + +static ActionCallbackData g_callbackData; + +static void action_callback(const char* name, int count, const char** keys, const char** vals, void* /*ud*/) { + g_callbackData.name = name ? name : ""; + g_callbackData.attrs.clear(); + for (int i = 0; i < count; i++) { + g_callbackData.attrs[keys[i]] = vals[i]; + } + g_callbackData.callCount++; +} + +TEST(capi_register_action_null_safety) { + // All these should handle null gracefully without crashing + static int callbackCalled = 0; + auto cb = [](const char*, int, const char**, const char**, void*) { callbackCalled++; }; + + dasher_register_action(nullptr, "test_action", cb, nullptr); + dasher_register_action(nullptr, nullptr, cb, nullptr); + dasher_register_action(nullptr, "test_action", nullptr, nullptr); + + ASSERT_EQ(callbackCalled, 0); + + printf(" capi_register_action_null_safety passed\n"); +} + +TEST(capi_register_action_before_realize) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx != nullptr); + + // Register a custom action before realization + static int callbackCalled = 0; + callbackCalled = 0; + + dasher_register_action( + ctx, "test_custom_action", [](const char*, int, const char**, const char**, void*) { callbackCalled++; }, + nullptr); + + // Now realize + dasher_set_screen_size(ctx, 800, 600); + + // Enable control mode + int bp_control_mode = dasher_find_parameter_key("BP_CONTROL_MODE"); + ASSERT(bp_control_mode >= 0); + dasher_set_bool_parameter(ctx, bp_control_mode, 1); + + // Run a frame to ensure control box is built + int* commands = nullptr; + int cmd_count = 0; + char** strings = nullptr; + int str_count = 0; + dasher_frame(ctx, 1000, &commands, &cmd_count, &strings, &str_count); + + // The context should work without crashing + // The control node should be present (root child count > 0) + int root_children = dasher_get_root_child_count(ctx); + ASSERT(root_children > 0); + + dasher_destroy(ctx); + printf(" capi_register_action_before_realize passed\n"); +} + +TEST(capi_register_action_after_realize) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx != nullptr); + + // Realize first + dasher_set_screen_size(ctx, 800, 600); + + // Enable control mode + int bp_control_mode = dasher_find_parameter_key("BP_CONTROL_MODE"); + ASSERT(bp_control_mode >= 0); + dasher_set_bool_parameter(ctx, bp_control_mode, 1); + + // Run a frame + int* commands = nullptr; + int cmd_count = 0; + char** strings = nullptr; + int str_count = 0; + dasher_frame(ctx, 1000, &commands, &cmd_count, &strings, &str_count); + + // Register custom action after realization — should not crash + dasher_register_action(ctx, "late_action", [](const char*, int, const char**, const char**, void*) {}, nullptr); + + // Should be able to run more frames without issues + dasher_frame(ctx, 2000, &commands, &cmd_count, &strings, &str_count); + + dasher_destroy(ctx); + printf(" capi_register_action_after_realize passed\n"); +} + +TEST(capi_control_mode_enables_successfully) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx != nullptr); + dasher_set_screen_size(ctx, 800, 600); + + int bp_control_mode = dasher_find_parameter_key("BP_CONTROL_MODE"); + ASSERT(bp_control_mode >= 0); + + // Enable control mode — this rebuilds the control box and node tree + dasher_set_bool_parameter(ctx, bp_control_mode, 1); + ASSERT_EQ(dasher_get_bool_parameter(ctx, bp_control_mode), 1); + + // Run a frame — should not crash or hang + int* commands = nullptr; + int cmd_count = 0; + char** strings = nullptr; + int str_count = 0; + dasher_frame(ctx, 1000, &commands, &cmd_count, &strings, &str_count); + ASSERT(cmd_count > 0); + + dasher_destroy(ctx); + printf(" capi_control_mode_enables_successfully passed\n"); +} + +TEST(capi_action_callback_receives_attrs) { + dasher_ctx* ctx = create_isolated_context(); + ASSERT(ctx != nullptr); + + g_callbackData.reset(); + + dasher_register_action(ctx, "attr_test", action_callback, &g_callbackData); + + dasher_set_screen_size(ctx, 800, 600); + + // The action is registered in the registry. We can verify it exists by + // checking that enabling control mode and running doesn't crash. + int bp_control_mode = dasher_find_parameter_key("BP_CONTROL_MODE"); + dasher_set_bool_parameter(ctx, bp_control_mode, 1); + + int* commands = nullptr; + int cmd_count = 0; + char** strings = nullptr; + int str_count = 0; + dasher_frame(ctx, 1000, &commands, &cmd_count, &strings, &str_count); + + // The callback won't fire just from parsing — it fires when a node + // containing the action is navigated into. We've verified the registration + // doesn't crash and the context works with control mode enabled. + // The ActionRegistry unit tests above verify callback firing directly. + + dasher_destroy(ctx); + printf(" capi_action_callback_receives_attrs passed\n"); +} + +int main(int argc, char* argv[]) { + printf("Running control action system tests...\n\n"); + + // ActionRegistry unit tests + test_action_registry_empty(); + test_action_registry_factory(); + test_action_registry_custom_action(); + test_action_registry_overwrite(); + + // C API integration tests + test_capi_register_action_null_safety(); + test_capi_register_action_before_realize(); + test_capi_register_action_after_realize(); + test_capi_control_mode_enables_successfully(); + test_capi_action_callback_receives_attrs(); + + printf("\n All control action tests passed!\n"); + return 0; +} diff --git a/tests/test_draw_commands.cpp b/tests/test_draw_commands.cpp index 9fb4df793..05a170be9 100644 --- a/tests/test_draw_commands.cpp +++ b/tests/test_draw_commands.cpp @@ -59,7 +59,7 @@ TEST(draw_opcodes_in_range) { int ops = cmd_count / 6; for (int i = 0; i < ops; i++) { int opcode = cmds[i * 6]; - ASSERT(opcode >= 0 && opcode <= 5); + ASSERT(opcode >= 0 && opcode <= 6); } } @@ -166,6 +166,7 @@ TEST(draw_coordinates_in_bounds) { case 2: case 3: case 4: + case 6: break; case 5: ASSERT(c > 0);