mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-06-10 14:02:47 +00:00
feat(automation): input.click / input.type / input.key handlers
This commit is contained in:
@@ -54,6 +54,80 @@ DumpOptions parse_dump_options(const nlohmann::json& p) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
MouseButton parse_button(const nlohmann::json& p) {
|
||||
auto b = opt_str(p, "button");
|
||||
if (b && *b == "right") return MouseButton::Right;
|
||||
if (b && *b == "middle") return MouseButton::Middle;
|
||||
return MouseButton::Left;
|
||||
}
|
||||
|
||||
std::vector<KeyModifier> parse_modifiers(const nlohmann::json& p) {
|
||||
std::vector<KeyModifier> mods;
|
||||
if (p.is_object() && p.contains("modifiers") && p.at("modifiers").is_array()) {
|
||||
for (const auto& m : p.at("modifiers")) {
|
||||
if (!m.is_string()) continue;
|
||||
const std::string s = m.get<std::string>();
|
||||
if (s == "ctrl") mods.push_back(KeyModifier::Ctrl);
|
||||
else if (s == "shift") mods.push_back(KeyModifier::Shift);
|
||||
else if (s == "alt") mods.push_back(KeyModifier::Alt);
|
||||
else if (s == "cmd" || s == "meta") mods.push_back(KeyModifier::Cmd);
|
||||
}
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
// Parse one chord token list (already split): the last token is the key, the
|
||||
// earlier ones are modifiers.
|
||||
KeyChord chord_from_tokens(const std::vector<std::string>& tokens) {
|
||||
KeyChord c;
|
||||
for (size_t i = 0; i < tokens.size(); ++i) {
|
||||
const std::string& t = tokens[i];
|
||||
const bool is_mod = (t == "ctrl" || t == "shift" || t == "alt" ||
|
||||
t == "cmd" || t == "meta");
|
||||
if (is_mod && i + 1 < tokens.size()) {
|
||||
if (t == "ctrl") c.modifiers.push_back(KeyModifier::Ctrl);
|
||||
else if (t == "shift") c.modifiers.push_back(KeyModifier::Shift);
|
||||
else if (t == "alt") c.modifiers.push_back(KeyModifier::Alt);
|
||||
else c.modifiers.push_back(KeyModifier::Cmd);
|
||||
} else {
|
||||
c.key = t; // last token (or a lone token) is the key
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
std::vector<std::string> split(const std::string& s, char delim) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char ch : s) {
|
||||
if (ch == delim) { if (!cur.empty()) out.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(ch);
|
||||
}
|
||||
if (!cur.empty()) out.push_back(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
// "keys" may be a string ("ctrl+s") or an array (["ctrl","s"]). Returns one chord.
|
||||
std::vector<KeyChord> parse_keys(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("keys"))
|
||||
throw AutomationError(kInvalidParams, "input.key requires 'keys'");
|
||||
const auto& k = params.at("keys");
|
||||
std::vector<std::string> tokens;
|
||||
if (k.is_string()) {
|
||||
tokens = split(k.get<std::string>(), '+');
|
||||
} else if (k.is_array()) {
|
||||
for (const auto& e : k)
|
||||
if (e.is_string()) tokens.push_back(e.get<std::string>());
|
||||
} else {
|
||||
throw AutomationError(kInvalidParams, "'keys' must be string or array");
|
||||
}
|
||||
if (tokens.empty())
|
||||
throw AutomationError(kInvalidParams, "'keys' is empty");
|
||||
return { chord_from_tokens(tokens) };
|
||||
}
|
||||
} // namespace
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) {
|
||||
return { {"version", kAutomationVersion},
|
||||
{"protocol", "2.0"},
|
||||
@@ -134,9 +208,49 @@ nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json& params) {
|
||||
return node_to_json(*node, /*include_children*/ true);
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
const UiNode JsonRpcDispatcher::resolve_actionable(const nlohmann::json& params,
|
||||
UiNode& tree_out) {
|
||||
if (!params.is_object() || !params.contains("target"))
|
||||
throw AutomationError(kInvalidParams, "missing 'target'");
|
||||
m_backend.refresh_ui();
|
||||
tree_out = m_backend.dump_tree(DumpOptions{});
|
||||
int count = 0;
|
||||
const UiNode* node = resolve_unique(tree_out, parse_target(params.at("target")), count);
|
||||
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
|
||||
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
|
||||
if (!node->enabled || !node->visible)
|
||||
throw AutomationError(kErrNotActionable, "target is disabled or hidden");
|
||||
return *node; // copy: stable even though tree_out outlives this call
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json& params) {
|
||||
UiNode tree;
|
||||
const UiNode node = resolve_actionable(params, tree);
|
||||
const bool dbl = params.contains("double") && params.at("double").is_boolean()
|
||||
&& params.at("double").get<bool>();
|
||||
const bool ok = m_backend.click(node, parse_button(params), dbl, parse_modifiers(params));
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("text") || !params.at("text").is_string())
|
||||
throw AutomationError(kInvalidParams, "input.type requires string 'text'");
|
||||
const std::string text = params.at("text").get<std::string>();
|
||||
// Optional target: click to focus first.
|
||||
if (params.contains("target")) {
|
||||
UiNode tree;
|
||||
const UiNode node = resolve_actionable(params, tree);
|
||||
m_backend.click(node, MouseButton::Left, false, {});
|
||||
}
|
||||
const bool ok = m_backend.type_text(text);
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json& params) {
|
||||
const bool ok = m_backend.send_keys(parse_keys(params));
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
|
||||
|
||||
@@ -49,6 +49,11 @@ private:
|
||||
nlohmann::json m_screenshot_window(const nlohmann::json& params);
|
||||
nlohmann::json m_screenshot_viewport3d(const nlohmann::json& params);
|
||||
|
||||
// Resolve a unique, actionable (enabled+visible) node from params["target"].
|
||||
// Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden).
|
||||
// `tree_out` keeps the snapshot alive; the returned node is a stable copy.
|
||||
const UiNode resolve_actionable(const nlohmann::json& params, UiNode& tree_out);
|
||||
|
||||
IUiBackend& m_backend;
|
||||
};
|
||||
|
||||
|
||||
@@ -88,3 +88,57 @@ TEST_CASE("widget.get not found -> 1001", "[automation][rpc]") {
|
||||
{"params",{{"target",{{"id","nope"}}}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("input.click resolves target and clicks it", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","input.click"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.clicked_ids.size() == 1);
|
||||
CHECK(mock.clicked_ids[0] == "btn_slice");
|
||||
CHECK(mock.click_buttons[0] == MouseButton::Left);
|
||||
}
|
||||
|
||||
TEST_CASE("input.click on disabled widget -> 1002", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","input.click"},
|
||||
{"params",{{"target",{{"id","btn_export"}}}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrNotActionable);
|
||||
CHECK(mock.clicked_ids.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("input.type with target clicks to focus then types", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","input.type"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}},{"text","hello"}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(mock.clicked_ids.size() == 1); // focused first
|
||||
REQUIRE(mock.typed_text.size() == 1);
|
||||
CHECK(mock.typed_text[0] == "hello");
|
||||
}
|
||||
|
||||
TEST_CASE("input.key parses 'ctrl+s' string form", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","input.key"},
|
||||
{"params",{{"keys","ctrl+s"}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.sent_keys.size() == 1);
|
||||
REQUIRE(mock.sent_keys[0].size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].key == "s");
|
||||
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].modifiers[0] == KeyModifier::Ctrl);
|
||||
}
|
||||
|
||||
TEST_CASE("input.key parses array form [\"ctrl\",\"s\"]", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","input.key"},
|
||||
{"params",{{"keys", json::array({"ctrl","s"})}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].key == "s");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user