diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index f921940..4cfae2a 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -45,6 +45,7 @@ SOURCES += \ src/services/hmi_editor_service.cpp \ src/services/hmi_navigation_service.cpp \ src/services/logic_editor_service.cpp \ + src/services/logic_command_service.cpp \ src/services/hmi_runtime_service.cpp \ src/services/software_logic_executor.cpp \ src/services/offline_simulation_service.cpp \ @@ -96,6 +97,7 @@ HEADERS += \ src/services/hmi_editor_service.h \ src/services/hmi_navigation_service.h \ src/services/logic_editor_service.h \ + src/services/logic_command_service.h \ src/services/hmi_runtime_service.h \ src/services/software_logic_executor.h \ src/services/offline_simulation_service.h \ diff --git a/app/src/services/logic_command_service.cpp b/app/src/services/logic_command_service.cpp new file mode 100644 index 0000000..a10615f --- /dev/null +++ b/app/src/services/logic_command_service.cpp @@ -0,0 +1,590 @@ +#include "logic_command_service.h" + +#include "logic_editor_service.h" + +#include +#include +#include +#include +#include + +namespace { + +std::vector splitTokens(const std::string &text) +{ + std::istringstream stream(text); + std::vector tokens; + std::string token; + while (stream >> token) + { + tokens.push_back(std::move(token)); + } + return tokens; +} + +std::string upperAscii(std::string value) +{ + std::transform( + value.begin(), value.end(), value.begin(), + [](unsigned char character) + { + return static_cast(std::toupper(character)); + }); + return value; +} + +LogicCommandParseResult parseFailure(const std::string &message) +{ + return {false, message, {}}; +} + +bool requiresOperands( + const std::vector &tokens, + std::size_t count, + const std::string &example, + LogicCommandParseResult *failure) +{ + if (tokens.size() == count + 1U) + { + return true; + } + *failure = parseFailure( + tokens.front() + " 需要 " + std::to_string(count) + + " 个操作数,例如 " + example); + return false; +} + +bool parseAddress( + const std::string &text, + RegisterArea expected_area, + const std::string &instruction, + RegisterAddress *address, + LogicCommandParseResult *failure) +{ + const RegisterAddressParseResult parsed = parseRegisterAddress(text); + const char expected_prefix = expected_area == RegisterArea::M ? 'M' : 'D'; + if (!parsed.succeeded) + { + const std::string range = std::string(1, expected_prefix) + "0~" + + expected_prefix + std::to_string(RegisterAddress::kMaximumIndex); + *failure = parseFailure( + parsed.error == RegisterAddressParseError::OutOfRange + ? std::string(1, expected_prefix) + " 地址超出范围,应为 " + range + : "地址格式错误,应为 " + range); + return false; + } + if (parsed.address.area() != expected_area) + { + *failure = parseFailure( + instruction + " 只支持 " + std::string(1, expected_prefix) + " 地址"); + return false; + } + *address = parsed.address; + return true; +} + +bool parseInt16( + const std::string &text, + const std::string &instruction, + std::int16_t *value, + LogicCommandParseResult *failure); + +bool parseWordOperand( + const std::string &text, + const std::string &instruction, + WordOperand *operand, + LogicCommandParseResult *failure) +{ + const RegisterAddressParseResult parsed = parseRegisterAddress(text); + if (parsed.succeeded) + { + if (parsed.address.area() != RegisterArea::D) + { + *failure = parseFailure(instruction + " 的寄存器操作数必须使用 D 地址"); + return false; + } + *operand = WordOperand{ + WordOperandKind::Register, parsed.address, 0}; + return true; + } + if (!text.empty() + && (text.front() == 'D' || text.front() == 'd' + || text.front() == 'M' || text.front() == 'm')) + { + RegisterAddress address{RegisterArea::D, 0}; + return parseAddress( + text, RegisterArea::D, instruction, &address, failure) + && ((*operand = WordOperand{ + WordOperandKind::Register, address, 0}), true); + } + std::int16_t constant = 0; + if (!parseInt16(text, instruction, &constant, failure)) + { + return false; + } + *operand = WordOperand{ + WordOperandKind::Constant, + RegisterAddress{RegisterArea::D, 0}, + constant}; + return true; +} + +bool isLoad(LogicCommandOpcode opcode) +{ + return opcode == LogicCommandOpcode::Load + || opcode == LogicCommandOpcode::LoadInverse + || opcode == LogicCommandOpcode::LoadRising + || opcode == LogicCommandOpcode::LoadFalling + || opcode == LogicCommandOpcode::CompareEqual + || opcode == LogicCommandOpcode::CompareNotEqual + || opcode == LogicCommandOpcode::CompareLessThan + || opcode == LogicCommandOpcode::CompareLessThanOrEqual + || opcode == LogicCommandOpcode::CompareGreaterThan + || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; +} + +bool isOr(LogicCommandOpcode opcode) +{ + return opcode == LogicCommandOpcode::Or + || opcode == LogicCommandOpcode::OrInverse; +} + +bool isCondition(LogicCommandOpcode opcode) +{ + return isLoad(opcode) || isOr(opcode) + || opcode == LogicCommandOpcode::And + || opcode == LogicCommandOpcode::AndInverse; +} + +bool isConditionTarget(LogicCommandTargetKind kind) +{ + return kind != LogicCommandTargetKind::Output; +} + +bool isOutput(LogicCommandOpcode opcode) +{ + return opcode == LogicCommandOpcode::Output + || opcode == LogicCommandOpcode::Set + || opcode == LogicCommandOpcode::Reset + || opcode == LogicCommandOpcode::Move + || opcode == LogicCommandOpcode::Add + || opcode == LogicCommandOpcode::Subtract; +} + +bool parseInt16( + const std::string &text, + const std::string &instruction, + std::int16_t *value, + LogicCommandParseResult *failure) +{ + try + { + std::size_t consumed = 0; + const long parsed = std::stol(text, &consumed, 10); + if (consumed != text.size() + || parsed < std::numeric_limits::min() + || parsed > std::numeric_limits::max()) + { + throw std::out_of_range("int16"); + } + *value = static_cast(parsed); + return true; + } + catch (const std::exception &) + { + *failure = parseFailure( + instruction + " 的第二个操作数应为 -32768~32767 的常量"); + return false; + } +} + +LogicCommandResult executionFailure( + const std::string &message, LogicCommandOpcode opcode) +{ + return {false, message, {}, {}, opcode}; +} + +} // namespace + +LogicCommandService::LogicCommandService(LogicEditorService &editor_service) + : editor_service_(editor_service) +{ +} + +LogicCommandParseResult LogicCommandService::parse(const std::string &text) +{ + std::vector tokens = splitTokens(text); + if (tokens.empty()) + { + return parseFailure("请输入 PLC 指令"); + } + tokens.front() = upperAscii(tokens.front()); + const std::string &mnemonic = tokens.front(); + LogicCommandParseResult failure; + + const std::unordered_map condition_opcodes{ + {"LD", LogicCommandOpcode::Load}, + {"LDI", LogicCommandOpcode::LoadInverse}, + {"LDP", LogicCommandOpcode::LoadRising}, + {"LDF", LogicCommandOpcode::LoadFalling}, + {"AND", LogicCommandOpcode::And}, + {"ANI", LogicCommandOpcode::AndInverse}, + {"OR", LogicCommandOpcode::Or}, + {"ORI", LogicCommandOpcode::OrInverse}}; + const std::unordered_map compare_opcodes{ + {"LD=", LogicCommandOpcode::CompareEqual}, + {"LD<>", LogicCommandOpcode::CompareNotEqual}, + {"LD<", LogicCommandOpcode::CompareLessThan}, + {"LD<=", LogicCommandOpcode::CompareLessThanOrEqual}, + {"LD>", LogicCommandOpcode::CompareGreaterThan}, + {"LD>=", LogicCommandOpcode::CompareGreaterThanOrEqual}}; + const auto compare = compare_opcodes.find(mnemonic); + if (compare != compare_opcodes.end()) + { + if (!requiresOperands(tokens, 2U, mnemonic + " D0 0", &failure)) + { + return failure; + } + RegisterAddress address{RegisterArea::D, 0}; + if (!parseAddress(tokens[1], RegisterArea::D, mnemonic, &address, &failure)) + { + return failure; + } + std::int16_t value = 0; + if (!parseInt16(tokens[2], mnemonic, &value, &failure)) + { + return failure; + } + ComparisonOperator operation = ComparisonOperator::Equal; + switch (compare->second) + { + case LogicCommandOpcode::CompareEqual: + operation = ComparisonOperator::Equal; + break; + case LogicCommandOpcode::CompareNotEqual: + operation = ComparisonOperator::NotEqual; + break; + case LogicCommandOpcode::CompareLessThan: + operation = ComparisonOperator::LessThan; + break; + case LogicCommandOpcode::CompareLessThanOrEqual: + operation = ComparisonOperator::LessThanOrEqual; + break; + case LogicCommandOpcode::CompareGreaterThan: + operation = ComparisonOperator::GreaterThan; + break; + case LogicCommandOpcode::CompareGreaterThanOrEqual: + operation = ComparisonOperator::GreaterThanOrEqual; + break; + default: + break; + } + return {true, {}, {compare->second, + CompareNodeConfig{address, operation, value}}}; + } + const auto condition = condition_opcodes.find(mnemonic); + if (condition != condition_opcodes.end()) + { + if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) + { + return failure; + } + RegisterAddress address{RegisterArea::M, 0}; + if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) + { + return failure; + } + LogicNodeConfig config; + if (condition->second == LogicCommandOpcode::LoadRising + || condition->second == LogicCommandOpcode::LoadFalling) + { + config = EdgeContactNodeConfig{ + address, + condition->second == LogicCommandOpcode::LoadRising + ? EdgeMode::Rising : EdgeMode::Falling}; + } + else + { + const bool inverse = condition->second == LogicCommandOpcode::LoadInverse + || condition->second == LogicCommandOpcode::AndInverse + || condition->second == LogicCommandOpcode::OrInverse; + config = ContactNodeConfig{ + address, + inverse ? ContactMode::NormallyClosed + : ContactMode::NormallyOpen}; + } + return {true, {}, {condition->second, std::move(config)}}; + } + + const std::unordered_map coil_opcodes{ + {"OUT", LogicCommandOpcode::Output}, + {"SET", LogicCommandOpcode::Set}, + {"RST", LogicCommandOpcode::Reset}}; + const auto coil = coil_opcodes.find(mnemonic); + if (coil != coil_opcodes.end()) + { + if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) + { + return failure; + } + RegisterAddress address{RegisterArea::M, 0}; + if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) + { + return failure; + } + const CoilMode mode = coil->second == LogicCommandOpcode::Set + ? CoilMode::Set + : coil->second == LogicCommandOpcode::Reset + ? CoilMode::Reset : CoilMode::Normal; + return {true, {}, {coil->second, CoilNodeConfig{address, mode}}}; + } + + if (mnemonic == "MOV") + { + if (!requiresOperands(tokens, 2U, "MOV D0 D1", &failure)) + { + return failure; + } + RegisterAddress destination{RegisterArea::D, 0}; + WordOperand source; + if (!parseWordOperand(tokens[1], mnemonic, &source, &failure) + || !parseAddress(tokens[2], RegisterArea::D, mnemonic, &destination, &failure)) + { + return failure; + } + return { + true, {}, + {LogicCommandOpcode::Move, + MoveNodeConfig{source, destination}}}; + } + + if (mnemonic == "ADD" || mnemonic == "SUB") + { + if (!requiresOperands(tokens, 3U, mnemonic + " D0 D1 D2", &failure)) + { + return failure; + } + WordOperand left; + WordOperand right; + RegisterAddress destination{RegisterArea::D, 0}; + if (!parseWordOperand(tokens[1], mnemonic, &left, &failure) + || !parseWordOperand(tokens[2], mnemonic, &right, &failure) + || !parseAddress(tokens[3], RegisterArea::D, mnemonic, &destination, &failure)) + { + return failure; + } + const LogicCommandOpcode opcode = mnemonic == "ADD" + ? LogicCommandOpcode::Add : LogicCommandOpcode::Subtract; + return { + true, {}, + {opcode, + ArithmeticNodeConfig{ + opcode == LogicCommandOpcode::Add + ? ArithmeticOperation::Add : ArithmeticOperation::Subtract, + left, + right, + destination}}}; + } + + return parseFailure("当前指令暂不支持:" + mnemonic); +} + +const std::vector &LogicCommandService::suggestions() +{ + static const std::vector values{ + {"LD", "常开触点", "M 地址"}, + {"LDI", "常闭触点", "M 地址"}, + {"LDP", "上升沿触点", "M 地址"}, + {"LDF", "下降沿触点", "M 地址"}, + {"LD=", "D 值等于常量", "D 地址 + 常量"}, + {"LD<>", "D 值不等于常量", "D 地址 + 常量"}, + {"LD<", "D 值小于常量", "D 地址 + 常量"}, + {"LD<=", "D 值小于等于常量", "D 地址 + 常量"}, + {"LD>", "D 值大于常量", "D 地址 + 常量"}, + {"LD>=", "D 值大于等于常量", "D 地址 + 常量"}, + {"AND", "串联常开触点", "M 地址"}, + {"ANI", "串联常闭触点", "M 地址"}, + {"OR", "并联常开触点", "M 地址"}, + {"ORI", "并联常闭触点", "M 地址"}, + {"OUT", "普通线圈", "M 地址"}, + {"SET", "置位线圈", "M 地址"}, + {"RST", "复位线圈", "M 地址"}, + {"MOV", "数据传送", "D 地址 -> D 地址"}, + {"ADD", "加法", "D 地址 + D 地址 -> D 地址"}, + {"SUB", "减法", "D 地址 - D 地址 -> D 地址"}, + {"TON", "暂不支持", "T 地址", false}, + {"CTU", "暂不支持", "C 地址", false}}; + return values; +} + +LogicCommandResult LogicCommandService::execute( + const LogicCommandRequest &request) +{ + const LogicCommandParseResult parsed = parse(request.text); + if (!parsed.succeeded) + { + return executionFailure(parsed.message, parsed.command.opcode); + } + const LogicCommandOpcode opcode = parsed.command.opcode; + LogicEditorResult edited; + + if (request.target.kind == LogicCommandTargetKind::ExistingNode) + { + const LogicNode *existing = editor_service_.findNode( + request.logicId, request.target.expressionId); + if (existing == nullptr) + { + return executionFailure("未找到要编辑的逻辑节点", opcode); + } + if (existing->isCondition()) + { + if (!isLoad(opcode)) + { + return executionFailure( + "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); + } + } + else if (existing->isOutput()) + { + if (!isOutput(opcode)) + { + return executionFailure( + "已有输出节点只能替换为 OUT/SET/RST/MOV/ADD/SUB 指令", + opcode); + } + } + else + { + return executionFailure("当前节点类型不支持命令替换", opcode); + } + edited = editor_service_.updateNodeConfig( + request.logicId, request.target.expressionId, + parsed.command.config); + } + else if (isCondition(opcode)) + { + if (isLoad(opcode) && request.continuing) + { + edited = editor_service_.appendCondition( + request.logicId, {}, parsed.command.config, true); + } + else if (isOr(opcode)) + { + const std::string rung_id = request.continuing + ? request.currentRungId : request.target.rungId; + if (request.continuing) + { + edited = editor_service_.addParallelToWholeCondition( + request.logicId, rung_id, parsed.command.config, true); + } + else if (request.parallelNodeIds.empty()) + { + return executionFailure( + "OR/ORI 需要先选中同一网络中的连续条件", opcode); + } + else + { + edited = editor_service_.addParallelBranch( + request.logicId, + rung_id, + request.parallelNodeIds, + parsed.command.config, + true); + } + } + else if (request.continuing) + { + const LadderRung *rung = editor_service_.findRung( + request.logicId, request.currentRungId); + if (rung != nullptr && rung->output.has_value()) + { + return executionFailure( + "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", + opcode); + } + edited = editor_service_.appendCondition( + request.logicId, request.currentRungId, + parsed.command.config, true); + } + else + { + if (!isConditionTarget(request.target.kind)) + { + return executionFailure( + "条件指令只能输入在第 1~10 列条件区", opcode); + } + if ((opcode == LogicCommandOpcode::And + || opcode == LogicCommandOpcode::AndInverse) + && (!request.target.rungId.empty() + && (editor_service_.findRung( + request.logicId, request.target.rungId) == nullptr + || !editor_service_.findRung( + request.logicId, request.target.rungId) + ->condition.has_value()))) + { + return executionFailure( + "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); + } + switch (request.target.kind) + { + case LogicCommandTargetKind::EmptyColumn: + edited = editor_service_.insertConditionAtColumn( + request.logicId, request.target.rungId, + request.target.column, parsed.command.config, true); + break; + case LogicCommandTargetKind::BranchEmptyColumn: + edited = editor_service_.insertConditionInBranchAtColumn( + request.logicId, request.target.rungId, + request.target.expressionId, request.target.column, + parsed.command.config, true); + break; + case LogicCommandTargetKind::WireColumn: + edited = editor_service_.replaceWireColumnWithCondition( + request.logicId, request.target.rungId, + request.target.expressionId, request.target.column, + parsed.command.config, true); + break; + case LogicCommandTargetKind::GapColumn: + edited = editor_service_.replaceGapColumnWithCondition( + request.logicId, request.target.rungId, + request.target.expressionId, request.target.column, + parsed.command.config, true); + break; + case LogicCommandTargetKind::Output: + break; + case LogicCommandTargetKind::ExistingNode: + break; + } + } + } + else + { + const std::string rung_id = request.continuing + ? request.currentRungId : request.target.rungId; + if (!request.continuing + && request.target.kind != LogicCommandTargetKind::Output) + { + return executionFailure( + "输出指令只能输入在第 11 列输出区", opcode); + } + const LadderRung *rung = editor_service_.findRung( + request.logicId, rung_id); + if (request.continuing && rung != nullptr && rung->output.has_value()) + { + return executionFailure( + "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", + opcode); + } + edited = editor_service_.setOutput( + request.logicId, rung_id, parsed.command.config, true); + } + + if (!edited.succeeded) + { + return executionFailure(edited.message, opcode); + } + return { + true, {}, edited.id, + editor_service_.rungIdForNode(request.logicId, edited.id), + opcode}; +} diff --git a/app/src/services/logic_command_service.h b/app/src/services/logic_command_service.h new file mode 100644 index 0000000..129e20d --- /dev/null +++ b/app/src/services/logic_command_service.h @@ -0,0 +1,106 @@ +#pragma once + +#include "domain/control_logic_model.h" + +#include +#include + +class LogicEditorService; + +enum class LogicCommandOpcode +{ + Load, + LoadInverse, + LoadRising, + LoadFalling, + CompareEqual, + CompareNotEqual, + CompareLessThan, + CompareLessThanOrEqual, + CompareGreaterThan, + CompareGreaterThanOrEqual, + And, + AndInverse, + Or, + OrInverse, + Output, + Set, + Reset, + Move, + Add, + Subtract +}; + +struct ParsedLogicCommand +{ + LogicCommandOpcode opcode = LogicCommandOpcode::Load; + LogicNodeConfig config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; +}; + +struct LogicCommandParseResult +{ + bool succeeded = false; + std::string message; + ParsedLogicCommand command; +}; + +struct LogicCommandSuggestion +{ + std::string mnemonic; + std::string description; + std::string operand_hint; + bool supported = true; +}; + +enum class LogicCommandTargetKind +{ + EmptyColumn, + BranchEmptyColumn, + WireColumn, + GapColumn, + Output, + ExistingNode +}; + +struct LogicCommandTarget +{ + LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; + std::string rungId; + std::string expressionId; + int column = 0; +}; + +struct LogicCommandRequest +{ + std::string logicId; + std::string text; + LogicCommandTarget target; + bool continuing = false; + std::string currentRungId; + std::vector parallelNodeIds; +}; + +struct LogicCommandResult +{ + bool succeeded = false; + std::string message; + std::string id; + std::string rungId; + LogicCommandOpcode opcode = LogicCommandOpcode::Load; +}; + +// 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 +class LogicCommandService +{ +public: + explicit LogicCommandService(LogicEditorService &editor_service); + + static LogicCommandParseResult parse(const std::string &text); + static const std::vector &suggestions(); + + LogicCommandResult execute(const LogicCommandRequest &request); + +private: + LogicEditorService &editor_service_; +}; diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 55154c9..bad7481 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -68,9 +68,12 @@ std::string rungLimitMessage( : std::string{}; } -LogicNode makeNode(const std::string &id, const LogicNodeConfig &config) +LogicNode makeNode( + const std::string &id, + const LogicNodeConfig &config, + bool configured = false) { - return {id, config, false}; + return {id, config, configured}; } ConditionExpression makeContainer( @@ -165,6 +168,28 @@ int expressionColumns(const ConditionExpression &expression) return columns; } +std::optional> firstGapCell( + const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Gap) + { + return std::make_pair(expression.id, 0); + } + if (expression.kind == ConditionExpressionKind::Node + || expression.kind == ConditionExpressionKind::Wire) + { + return std::nullopt; + } + for (const ConditionExpression &child : expression.children) + { + if (const auto gap = firstGapCell(child); gap.has_value()) + { + return gap; + } + } + return std::nullopt; +} + bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) { return std::all_of( @@ -1158,13 +1183,36 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) LogicEditorResult LogicEditorService::removeRung( const std::string &logic_id, const std::string &rung_id) +{ + return removeRungs(logic_id, {rung_id}); +} + +LogicEditorResult LogicEditorService::removeRungs( + const std::string &logic_id, + const std::vector &rung_ids) { const ControlLogic *logic = findLogic(logic_id); if (logic == nullptr) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if (findRung(logic_id, rung_id) == nullptr) + if (rung_ids.empty()) + { + return failure(LogicEditorError::InvalidOperation, "请先选择要删除的梯形图网络"); + } + std::unordered_set selected_ids; + for (const std::string &rung_id : rung_ids) + { + if (!selected_ids.insert(rung_id).second) + { + return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复网络"); + } + if (findRung(logic_id, rung_id) == nullptr) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + } + } + if (selected_ids.empty()) { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } @@ -1176,10 +1224,13 @@ LogicEditorResult LogicEditorService::removeRung( target->rungs.erase( std::remove_if( target->rungs.begin(), target->rungs.end(), - [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), + [&selected_ids](const LadderRung &rung) + { + return selected_ids.find(rung.id) != selected_ids.end(); + }), target->rungs.end()); recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, rung_id}; + return {true, LogicEditorError::None, {}, rung_ids.front()}; } LogicEditorResult LogicEditorService::updateRungComment( @@ -1219,7 +1270,8 @@ LogicEditorResult LogicEditorService::updateRungComment( LogicEditorResult LogicEditorService::appendCondition( const std::string &logic_id, const std::string &rung_id, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); if (logic == nullptr) @@ -1227,7 +1279,9 @@ LogicEditorResult LogicEditorService::appendCondition( return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } const bool create_rung = rung_id.empty(); - if (!create_rung && findRung(logic_id, rung_id) == nullptr) + const LadderRung *existing_rung = create_rung + ? nullptr : findRung(logic_id, rung_id); + if (!create_rung && existing_rung == nullptr) { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } @@ -1243,11 +1297,22 @@ LogicEditorResult LogicEditorService::appendCondition( { return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用线圈节点"); } + if (existing_rung != nullptr && !existing_rung->output.has_value() + && existing_rung->condition.has_value()) + { + // 普通条件追加也优先填充删除后保留的 Gap,避免把新节点追加到网络末端 + if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) + { + return replaceGapColumnWithCondition( + logic_id, rung_id, gap->first, gap->second, config, configured); + } + } // 先生成稳定节点 ID,再把新节点接到已有表达式的串联末尾 const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); const std::string target_rung_id = create_rung ? makeUniqueRungId(*logic) : rung_id; - ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + ConditionExpression leaf = ConditionExpression::fromNode( + makeNode(node_id, config, configured)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -1358,7 +1423,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( const std::string &logic_id, const std::string &rung_id, int column, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); const LadderRung *existing_rung = findRung(logic_id, rung_id); @@ -1388,7 +1454,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( // 点击远端空网格时,用持久化横线填充前置空档,再放入真实条件节点 const int leading_wire_columns = column - occupied_columns; const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + ConditionExpression leaf = ConditionExpression::fromNode( + makeNode(node_id, config, configured)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -1459,7 +1526,8 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( const std::string &rung_id, const std::string &branch_expression_id, int column, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); const LadderRung *existing_rung = findRung(logic_id, rung_id); @@ -1503,7 +1571,7 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( ? makeUniqueWireId(*logic) : std::string{}; const std::string series_id = makeUniqueExpressionId(*logic); ConditionExpression leaf = ConditionExpression::fromNode( - makeNode(node_id, config)); + makeNode(node_id, config, configured)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -1572,6 +1640,16 @@ LogicEditorResult LogicEditorService::appendWire( { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } + if (existing_rung != nullptr && column_span == 1 + && existing_rung->condition.has_value()) + { + // 删除后的 Gap 仍保留原网格位置;无选中目标追加时优先填回第一个 Gap + if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) + { + return replaceGapColumnWithWire( + logic_id, rung_id, gap->first, gap->second); + } + } bool create_rung = rung_id.empty(); if (!create_rung && column_span == 1 && existing_rung->condition.has_value() && expressionColumns(*existing_rung->condition) @@ -1762,7 +1840,25 @@ LogicEditorResult LogicEditorService::insertWireAfter( { return child.id == target_expression_id; }); - parent->children.insert(target_iterator + 1, std::move(leaf)); + const auto insertion_position = target_iterator + 1; + if (insertion_position != parent->children.end() + && insertion_position->kind == ConditionExpressionKind::Gap) + { + // 连续添加横线时优先消耗目标后面的 Gap,保持网络总列数不变 + if (insertion_position->gap->columnSpan == 1) + { + *insertion_position = std::move(leaf); + } + else + { + --insertion_position->gap->columnSpan; + parent->children.insert(insertion_position, std::move(leaf)); + } + } + else + { + parent->children.insert(insertion_position, std::move(leaf)); + } } else { @@ -1830,7 +1926,8 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( const std::string &rung_id, const std::string &wire_expression_id, int column_offset, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); const ConditionExpression *wire = findExpression( @@ -1866,7 +1963,7 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( wire_expression_id, leading_columns)); } replacement.children.push_back( - ConditionExpression::fromNode(makeNode(node_id, config))); + ConditionExpression::fromNode(makeNode(node_id, config, configured))); if (trailing_columns > 0) { replacement.children.push_back(ConditionExpression::fromWire( @@ -1901,7 +1998,8 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( const std::string &rung_id, const std::string &gap_expression_id, int column_offset, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); const ConditionExpression *gap = findExpression( @@ -1926,7 +2024,8 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( ConditionExpression replacement; if (leading_columns == 0 && trailing_columns == 0) { - replacement = ConditionExpression::fromNode(makeNode(node_id, config)); + replacement = ConditionExpression::fromNode( + makeNode(node_id, config, configured)); } else { @@ -1938,7 +2037,7 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( gap_expression_id, leading_columns)); } replacement.children.push_back( - ConditionExpression::fromNode(makeNode(node_id, config))); + ConditionExpression::fromNode(makeNode(node_id, config, configured))); if (trailing_columns > 0) { replacement.children.push_back(ConditionExpression::fromGap( @@ -2032,7 +2131,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( const std::string &logic_id, const std::string &rung_id, const std::vector &selected_node_ids, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) @@ -2068,7 +2168,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( "并联选择中包含未知节点"); } const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + ConditionExpression leaf = ConditionExpression::fromNode( + makeNode(node_id, config, configured)); const std::string parallel_id = makeUniqueExpressionId(*logic); std::string series_id = parallel_id + "-range"; while (findExpression(logic_id, rung_id, series_id) != nullptr) @@ -2105,6 +2206,73 @@ LogicEditorResult LogicEditorService::addParallelBranch( return {true, LogicEditorError::None, {}, node_id}; } +LogicEditorResult LogicEditorService::addParallelToWholeCondition( + const std::string &logic_id, + const std::string &rung_id, + const LogicNodeConfig &config, + bool configured) +{ + const ControlLogic *logic = findLogic(logic_id); + const LadderRung *existing_rung = findRung(logic_id, rung_id); + if (logic == nullptr || existing_rung == nullptr + || !existing_rung->condition.has_value()) + { + return failure( + LogicEditorError::RungNotFound, + "OR/ORI 前必须先有一段条件"); + } + if (!isConditionConfig(config)) + { + return failure(LogicEditorError::InvalidNode, "并联支路只能使用条件节点"); + } + + const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + ConditionExpression branch = ConditionExpression::fromNode( + makeNode(node_id, config, configured)); + const std::string parallel_id = makeUniqueExpressionId(*logic); + std::string branch_series_id = parallel_id + "-branch"; + while (findExpression(logic_id, rung_id, branch_series_id) != nullptr) + { + branch_series_id += "-branch"; + } + const int original_columns = expressionColumns(*existing_rung->condition); + const std::string padding_wire_id = original_columns > 1 + ? makeUniqueWireId(*logic) : std::string{}; + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + LadderRung *rung = findEditableRung(project, logic_id, rung_id); + ConditionExpression original = std::move(*rung->condition); + if (original_columns > 1) + { + ConditionExpression padded; + padded.id = branch_series_id; + padded.kind = ConditionExpressionKind::Series; + padded.children.push_back(std::move(branch)); + padded.children.push_back(ConditionExpression::fromWire( + padding_wire_id, original_columns - 1)); + branch = std::move(padded); + } + rung->condition = makeContainer( + parallel_id, + ConditionExpressionKind::Parallel, + std::move(original), + std::move(branch)); + + std::string validation_error; + if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) + || !rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; +} + LogicEditorResult LogicEditorService::addParallelWireBranch( const std::string &logic_id, const std::string &rung_id, @@ -2544,13 +2712,13 @@ LogicEditorResult LogicEditorService::updateNodeConfig( { return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); } - if (node->config.index() != config.index()) + LogicNode candidate{node_id, config, true}; + if (node->isCondition() != candidate.isCondition()) { return failure( LogicEditorError::UnsupportedNodeChange, - "节点创建后不能修改节点类别"); + "不能把条件节点改成输出节点,或把输出节点改成条件节点"); } - LogicNode candidate{node_id, config, true}; std::string error; if (!candidate.validate(&error)) { diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index d914f87..71e7665 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -108,6 +108,10 @@ public: /** @brief 删除指定网络 */ LogicEditorResult removeRung( const std::string &logic_id, const std::string &rung_id); + /** @brief 批量删除指定网络,失败时整体回滚 */ + LogicEditorResult removeRungs( + const std::string &logic_id, + const std::vector &rung_ids); /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ LogicEditorResult updateRungComment( const std::string &logic_id, @@ -118,7 +122,8 @@ public: LogicEditorResult appendCondition( const std::string &logic_id, const std::string &rung_id, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); /** * @brief 按绝对条件列插入条件节点 * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 @@ -127,7 +132,8 @@ public: const std::string &logic_id, const std::string &rung_id, int column, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); /** * @brief 在直属并联分支的指定视觉列插入条件节点 * @@ -138,7 +144,8 @@ public: const std::string &rung_id, const std::string &branch_expression_id, int column, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); /** @brief 在网络条件末尾追加指定列宽的横线 */ LogicEditorResult appendWire( const std::string &logic_id, @@ -168,14 +175,16 @@ public: const std::string &rung_id, const std::string &wire_expression_id, int column_offset, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ LogicEditorResult replaceGapColumnWithCondition( const std::string &logic_id, const std::string &rung_id, const std::string &gap_expression_id, int column_offset, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); /** @brief 用一格横线修复断路表达式中的指定网格 */ LogicEditorResult replaceGapColumnWithWire( const std::string &logic_id, @@ -190,7 +199,14 @@ public: const std::string &logic_id, const std::string &rung_id, const std::vector &selected_node_ids, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); + /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ + LogicEditorResult addParallelToWholeCondition( + const std::string &logic_id, + const std::string &rung_id, + const LogicNodeConfig &config, + bool configured = false); /** @brief 将选中的横线表达式建立为并联旁路 */ LogicEditorResult addParallelWireBranch( const std::string &logic_id, diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 8a70931..4e51382 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -1,12 +1,23 @@ #include "logic_editor_widget.h" +#include #include #include -#include +#include +#include +#include +#include +#include #include +#include #include +#include #include +#include +#include #include +#include +#include #include #include @@ -55,6 +66,98 @@ QString wordOperandText(const WordOperand &operand) : QString::number(operand.constant); } +QString compareMnemonic(ComparisonOperator comparison) +{ + switch (comparison) + { + case ComparisonOperator::Equal: return QStringLiteral("LD="); + case ComparisonOperator::NotEqual: return QStringLiteral("LD<>"); + case ComparisonOperator::LessThan: return QStringLiteral("LD<"); + case ComparisonOperator::LessThanOrEqual: return QStringLiteral("LD<="); + case ComparisonOperator::GreaterThan: return QStringLiteral("LD>"); + case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral("LD>="); + } + return QStringLiteral("LD="); +} + +QString logicCommandText(const LogicNodeConfig &config) +{ + return std::visit( + [](const auto &value) -> QString + { + using Config = std::decay_t; + if constexpr (std::is_same_v) + { + return QStringLiteral("%1 %2") + .arg(value.mode == ContactMode::NormallyOpen + ? QStringLiteral("LD") + : QStringLiteral("LDI")) + .arg(registerAddressText(value.address)); + } + else if constexpr (std::is_same_v) + { + return QStringLiteral("%1 %2") + .arg(value.mode == EdgeMode::Rising + ? QStringLiteral("LDP") + : QStringLiteral("LDF")) + .arg(registerAddressText(value.address)); + } + else if constexpr (std::is_same_v) + { + const QString mnemonic = value.mode == CoilMode::Set + ? QStringLiteral("SET") + : value.mode == CoilMode::Reset + ? QStringLiteral("RST") : QStringLiteral("OUT"); + return QStringLiteral("%1 %2") + .arg(mnemonic) + .arg(registerAddressText(value.address)); + } + else if constexpr (std::is_same_v) + { + return QStringLiteral("%1 %2 %3") + .arg(compareMnemonic(value.comparison)) + .arg(registerAddressText(value.address)) + .arg(value.value); + } + else if constexpr (std::is_same_v) + { + return QStringLiteral("MOV %1 %2") + .arg(wordOperandText(value.source)) + .arg(registerAddressText(value.destination)); + } + else + { + return QStringLiteral("%1 %2 %3 %4") + .arg(value.operation == ArithmeticOperation::Add + ? QStringLiteral("ADD") : QStringLiteral("SUB")) + .arg(wordOperandText(value.left)) + .arg(wordOperandText(value.right)) + .arg(registerAddressText(value.destination)); + } + }, + config); +} + +bool isLoadCommandOpcode(LogicCommandOpcode opcode) +{ + return opcode == LogicCommandOpcode::Load + || opcode == LogicCommandOpcode::LoadInverse + || opcode == LogicCommandOpcode::LoadRising + || opcode == LogicCommandOpcode::LoadFalling + || opcode == LogicCommandOpcode::CompareEqual + || opcode == LogicCommandOpcode::CompareNotEqual + || opcode == LogicCommandOpcode::CompareLessThan + || opcode == LogicCommandOpcode::CompareLessThanOrEqual + || opcode == LogicCommandOpcode::CompareGreaterThan + || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; +} + +QString commandCompletionPrefix(const QString &text) +{ + return text.section(QRegularExpression(QStringLiteral("\\s+")), 0, 0) + .toUpper(); +} + void drawRegisterComment( QPainter *painter, const QString &comment, qreal top = 22.0) { @@ -218,20 +321,14 @@ void addGrid( } } -void addPlaceholder(QGraphicsScene &scene, const QRectF &cell, const QString &text) -{ - const QRectF bounds = cell.adjusted(12, 18, -12, -18); - QGraphicsRectItem *item = scene.addRect( - bounds, - QPen(kPlaceholderColor, 1.2, Qt::DashLine), - QBrush(QColor(255, 255, 255, 220))); - item->setZValue(1.0); - QGraphicsTextItem *label = scene.addText(text); - label->setDefaultTextColor(kPlaceholderColor); - label->setPos( - bounds.center().x() - label->boundingRect().width() / 2.0, - bounds.center().y() - label->boundingRect().height() / 2.0); - label->setZValue(2.0); +void addLineIfVisible( + QGraphicsScene &scene, const QLineF &line, const QPen &pen) +{ + if (line.length() <= 0.01) + { + return; + } + scene.addLine(line, pen); } } // namespace @@ -654,6 +751,46 @@ private: bool branch_active_ = false; }; +class LogicEditorWidget::OutputSlotItem final : public QGraphicsItem +{ +public: + OutputSlotItem(const std::string &rung_id, const QPointF ¢er) + : rung_id_(rung_id) + { + setPos(center); + setFlag(ItemIsSelectable, true); + setZValue(1.0); + setToolTip(LogicEditorWidget::tr("输出槽:双击输入 OUT、SET、RST、MOV、ADD 或 SUB")); + } + + QRectF boundingRect() const override + { + return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + } + + void paint( + QPainter *painter, + const QStyleOptionGraphicsItem *option, + QWidget *) override + { + const bool selected = (option->state & QStyle::State_Selected) != 0; + const QRectF bounds = boundingRect().adjusted(12, 18, -12, -18); + painter->setPen(QPen( + selected ? kSelectionBorderColor : kPlaceholderColor, + selected ? 1.8 : 1.2, + Qt::DashLine)); + painter->setBrush(QColor(255, 255, 255, 220)); + painter->drawRect(bounds); + painter->setPen(kPlaceholderColor); + painter->drawText(bounds, Qt::AlignCenter, LogicEditorWidget::tr("输出指令")); + } + + const std::string &rungId() const { return rung_id_; } + +private: + std::string rung_id_; +}; + class LogicEditorWidget::VerticalConnectorItem final : public QGraphicsItem { public: @@ -925,7 +1062,8 @@ RenderResult renderExpression( trace, &LogicTraceSnapshot::expressionPowerValues, expression.children[index - 1U].id); - scene.addLine( + addLineIfVisible( + scene, QLineF(previous.output, current.input), ladderPen(previous_active)); } @@ -988,7 +1126,8 @@ RenderResult renderExpression( trace, &LogicTraceSnapshot::expressionPowerValues, expression.children[index].id); - scene.addLine( + addLineIfVisible( + scene, QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), ladderPen(branch_active)); } @@ -998,16 +1137,87 @@ RenderResult renderExpression( } // namespace LogicEditorWidget::LogicEditorWidget(LogicEditorService &editor_service, QWidget *parent) - : QGraphicsView(parent), editor_service_(editor_service) + : QGraphicsView(parent), + editor_service_(editor_service), + command_service_(editor_service) { scene_ = new QGraphicsScene(this); setScene(scene_); setRenderHint(QPainter::Antialiasing, true); setBackgroundBrush(Qt::white); - setDragMode(QGraphicsView::RubberBandDrag); + setDragMode(QGraphicsView::NoDrag); setAlignment(Qt::AlignLeft | Qt::AlignTop); + selection_band_ = new QRubberBand(QRubberBand::Rectangle, viewport()); + selection_band_->hide(); connect(scene_, &QGraphicsScene::selectionChanged, this, &LogicEditorWidget::handleSelectionChanged); + command_editor_ = new QLineEdit(viewport()); + command_editor_->setObjectName(QStringLiteral("logicCommandInput")); + command_editor_->setPlaceholderText( + tr("输入 PLC 指令,例如 LD M0;触点用 M,数据用 D")); + command_editor_->setClearButtonEnabled(true); + command_editor_->setVisible(false); + command_editor_->setToolTip( + tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); + command_completer_ = new QCompleter(command_editor_); + auto *command_model = new QStandardItemModel(0, 3, command_completer_); + command_model->setHeaderData(0, Qt::Horizontal, tr("指令")); + command_model->setHeaderData(1, Qt::Horizontal, tr("操作数")); + command_model->setHeaderData(2, Qt::Horizontal, tr("说明")); + for (const LogicCommandSuggestion &suggestion : LogicCommandService::suggestions()) + { + auto *mnemonic = new QStandardItem( + QString::fromUtf8(suggestion.mnemonic.c_str())); + auto *operand_hint = new QStandardItem( + QString::fromUtf8(suggestion.operand_hint.c_str())); + auto *description = new QStandardItem( + QString::fromUtf8(suggestion.description.c_str())); + if (!suggestion.supported) + { + mnemonic->setEnabled(false); + operand_hint->setEnabled(false); + description->setEnabled(false); + } + for (QStandardItem *item : {mnemonic, operand_hint, description}) + { + item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); + } + QList row{mnemonic, operand_hint, description}; + command_model->appendRow(row); + } + command_completer_->setModel(command_model); + command_completer_->setCompletionColumn(0); + command_completer_->setCaseSensitivity(Qt::CaseInsensitive); + command_completer_->setCompletionMode(QCompleter::PopupCompletion); + auto *command_popup = new QTreeView; + command_popup->setRootIsDecorated(false); + command_popup->setItemsExpandable(false); + command_popup->setHeaderHidden(false); + command_popup->setUniformRowHeights(true); + command_popup->setAlternatingRowColors(true); + command_popup->setTextElideMode(Qt::ElideRight); + command_popup->setMinimumSize(620, 300); + command_popup->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + command_completer_->setPopup(command_popup); + command_popup->header()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); + command_popup->header()->setStretchLastSection(true); + command_popup->setColumnWidth(0, 96); + command_popup->setColumnWidth(1, 190); + command_editor_->setCompleter(command_completer_); + connect(command_editor_, &QLineEdit::textEdited, + this, [this](const QString &text) + { + if (command_completer_ == nullptr || command_editor_ == nullptr) + { + return; + } + command_completer_->setCompletionPrefix( + commandCompletionPrefix(text)); + command_completer_->complete(command_editor_->rect()); + }); + connect(command_editor_, &QLineEdit::returnPressed, + this, &LogicEditorWidget::commitCommandInput); + command_editor_->installEventFilter(this); } void LogicEditorWidget::setLogicId(const std::string &logic_id) @@ -1017,6 +1227,7 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) return; } logic_id_ = logic_id; + cancelCommandInput(); current_rung_id_.clear(); reloadLogic(); } @@ -1024,6 +1235,10 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) void LogicEditorWidget::setEditingEnabled(bool enabled) { editing_enabled_ = enabled; + if (!enabled) + { + cancelCommandInput(); + } setInteractive(enabled); } @@ -1053,6 +1268,7 @@ bool LogicEditorWidget::runtimeTraceEnabled() const void LogicEditorWidget::reloadLogic() { // 逻辑或选择变化后重新计算网格布局和可点击插入目标 + scene_->clearSelection(); scene_->clear(); const ControlLogic *logic = editor_service_.findLogic(logic_id_); if (logic == nullptr) @@ -1170,10 +1386,9 @@ void LogicEditorWidget::reloadLogic() } else { - addPlaceholder( - *scene_, - QRectF(output_left, grid_top, kCellWidth, kCellHeight), - tr("输出线圈")); + scene_->addItem(new OutputSlotItem( + rung.id, + QPointF(output_left + kCellWidth / 2.0, main_y))); } top += rung_height + kRungGap; ++number; @@ -1457,6 +1672,15 @@ std::string LogicEditorWidget::selectedRungId() const } rung_id = slot->rungId(); } + else if (const OutputSlotItem *slot = + dynamic_cast(item)) + { + if (!rung_id.empty() && rung_id != slot->rungId()) + { + return {}; + } + rung_id = slot->rungId(); + } } return rung_id; } @@ -1473,6 +1697,499 @@ bool LogicEditorWidget::hasSelectedRungItem() const return false; } +std::vector LogicEditorWidget::selectedRungItemIds() const +{ + std::vector ids; + for (QGraphicsItem *item : scene_->selectedItems()) + { + if (const auto *rung = dynamic_cast(item)) + { + if (std::find(ids.cbegin(), ids.cend(), rung->rungId()) == ids.cend()) + { + ids.push_back(rung->rungId()); + } + } + } + return ids; +} + +void LogicEditorWidget::mousePressEvent(QMouseEvent *event) +{ + if (event == nullptr || event->button() != Qt::LeftButton + || !editing_enabled_ || runtime_trace_enabled_) + { + QGraphicsView::mousePressEvent(event); + return; + } + + selection_origin_ = event->pos(); + selection_modifiers_ = event->modifiers(); + selection_dragging_ = false; + + QGraphicsItem *selectable_item = nullptr; + const QPointF scene_position = mapToScene(event->pos()); + const QList items = scene_->items( + scene_position, Qt::IntersectsItemShape, Qt::DescendingOrder); + for (QGraphicsItem *item : items) + { + if (item->flags() & QGraphicsItem::ItemIsSelectable) + { + selectable_item = item; + break; + } + } + + const bool toggle = (selection_modifiers_ & Qt::ControlModifier) != 0; + if (selectable_item != nullptr) + { + if (toggle) + { + selectable_item->setSelected(!selectable_item->isSelected()); + } + else if (!selectable_item->isSelected()) + { + scene_->clearSelection(); + selectable_item->setSelected(true); + } + } + else if (!toggle) + { + scene_->clearSelection(); + } + event->accept(); +} + +void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (event == nullptr || !editing_enabled_ || runtime_trace_enabled_ + || !(event->buttons() & Qt::LeftButton)) + { + QGraphicsView::mouseMoveEvent(event); + return; + } + + if (!selection_dragging_ + && (event->pos() - selection_origin_).manhattanLength() + >= QApplication::startDragDistance()) + { + selection_dragging_ = true; + if ((selection_modifiers_ & Qt::ControlModifier) == 0) + { + scene_->clearSelection(); + } + selection_band_->setGeometry( + QRect(selection_origin_, event->pos()).normalized()); + selection_band_->show(); + } + if (selection_dragging_) + { + selection_band_->setGeometry( + QRect(selection_origin_, event->pos()).normalized()); + event->accept(); + return; + } + event->accept(); +} + +void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event == nullptr || event->button() != Qt::LeftButton + || !editing_enabled_ || runtime_trace_enabled_) + { + QGraphicsView::mouseReleaseEvent(event); + return; + } + + if (selection_dragging_) + { + const QRect view_rect = QRect(selection_origin_, event->pos()).normalized(); + selection_band_->hide(); + QPolygonF scene_polygon; + scene_polygon << mapToScene(view_rect.topLeft()) + << mapToScene(view_rect.topRight()) + << mapToScene(view_rect.bottomRight()) + << mapToScene(view_rect.bottomLeft()); + QPainterPath selection_path; + selection_path.addPolygon(scene_polygon); + const Qt::ItemSelectionOperation operation = + (selection_modifiers_ & Qt::ControlModifier) != 0 + ? Qt::AddToSelection : Qt::ReplaceSelection; + scene_->setSelectionArea( + selection_path, operation, Qt::IntersectsItemShape); + selection_dragging_ = false; + } + event->accept(); +} + +void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (!editing_enabled_ || runtime_trace_enabled_ || event == nullptr) + { + QGraphicsView::mouseDoubleClickEvent(event); + return; + } + + const QPointF scene_position = mapToScene(event->pos()); + QGraphicsItem *item = scene_->itemAt(scene_position, QTransform()); + LogicCommandTarget target; + QPointF center = scene_position; + bool recognized = false; + if (const auto *slot = dynamic_cast(item)) + { + target.kind = slot->branchExpressionId().empty() + ? LogicCommandTargetKind::EmptyColumn + : LogicCommandTargetKind::BranchEmptyColumn; + target.rungId = slot->rungId(); + target.expressionId = slot->branchExpressionId(); + target.column = slot->column(); + center = slot->scenePos(); + recognized = true; + } + else if (const auto *cell = dynamic_cast(item)) + { + target.kind = LogicCommandTargetKind::WireColumn; + target.rungId = cell->rungId(); + target.expressionId = cell->expressionId(); + target.column = cell->columnOffset(); + center = cell->scenePos(); + recognized = true; + } + else if (const auto *cell = dynamic_cast(item)) + { + target.kind = LogicCommandTargetKind::GapColumn; + target.rungId = cell->rungId(); + target.expressionId = cell->expressionId(); + target.column = cell->columnOffset(); + center = cell->scenePos(); + recognized = true; + } + else if (const auto *node = dynamic_cast(item)) + { + target.kind = LogicCommandTargetKind::ExistingNode; + target.rungId = node->rungId(); + target.expressionId = node->nodeId(); + center = node->scenePos(); + recognized = true; + } + else if (const auto *slot = dynamic_cast(item)) + { + target.kind = LogicCommandTargetKind::Output; + target.rungId = slot->rungId(); + center = slot->scenePos(); + recognized = true; + } + + if (!recognized) + { + QGraphicsView::mouseDoubleClickEvent(event); + return; + } + beginCommandInput(target, center); + event->accept(); +} + +void LogicEditorWidget::beginCommandInput( + const LogicCommandTarget &target, + const QPointF &scene_center) +{ + if (!editing_enabled_ || runtime_trace_enabled_) + { + return; + } + cancelCommandInput(); + command_target_ = target; + command_continuing_ = false; + command_keep_column_ = false; + command_current_rung_id_ = target.rungId; + command_parallel_node_ids_ = selectedNodeIds(); + current_rung_id_ = target.rungId; + QString initial_text; + if (target.kind == LogicCommandTargetKind::ExistingNode) + { + const LogicNode *node = editor_service_.findNode( + logic_id_, target.expressionId); + if (node != nullptr && node->isConfigured()) + { + initial_text = logicCommandText(node->config); + } + } + command_editor_->setStyleSheet(QString{}); + command_editor_->setToolTip( + tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); + command_editor_->setText(initial_text); + command_editor_->setVisible(true); + positionCommandInput(scene_center); + command_editor_->raise(); + command_editor_->setFocus(Qt::MouseFocusReason); + command_editor_->selectAll(); + QTimer::singleShot(0, this, [this] + { + if (command_editor_ == nullptr || !command_editor_->isVisible() + || command_completer_ == nullptr) + { + return; + } + command_completer_->setCompletionPrefix( + commandCompletionPrefix(command_editor_->text())); + command_completer_->complete(command_editor_->rect()); + }); +} + +void LogicEditorWidget::commitCommandInput() +{ + if (command_editor_ == nullptr || !command_editor_->isVisible()) + { + return; + } + LogicCommandRequest request; + request.logicId = logic_id_; + request.text = command_editor_->text().toUtf8().toStdString(); + request.target = command_target_; + request.continuing = command_continuing_; + request.currentRungId = command_current_rung_id_; + request.parallelNodeIds = command_parallel_node_ids_; + const LogicCommandResult result = command_service_.execute(request); + if (!result.succeeded) + { + command_editor_->setStyleSheet( + QStringLiteral("QLineEdit { border: 2px solid #c5362e; }")); + command_editor_->setToolTip(QString::fromUtf8(result.message.c_str())); + command_editor_->setFocus(Qt::OtherFocusReason); + command_editor_->selectAll(); + emit editorError(QString::fromUtf8(result.message.c_str())); + return; + } + + command_continuing_ = true; + command_keep_column_ = result.opcode == LogicCommandOpcode::Or + || result.opcode == LogicCommandOpcode::OrInverse; + command_current_rung_id_ = result.rungId.empty() + ? command_current_rung_id_ : result.rungId; + command_parallel_node_ids_.clear(); + reloadLogic(); + selectCommandResult(result); + command_editor_->setStyleSheet(QString{}); + command_editor_->setToolTip( + tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); + + if (command_target_.kind == LogicCommandTargetKind::ExistingNode) + { + cancelCommandInput(); + return; + } + + if (isLoadCommandOpcode(result.opcode)) + { + command_target_.kind = LogicCommandTargetKind::EmptyColumn; + command_target_.rungId = command_current_rung_id_; + command_target_.expressionId.clear(); + command_target_.column = 0; + command_keep_column_ = false; + } + + if (result.opcode == LogicCommandOpcode::Output + || result.opcode == LogicCommandOpcode::Set + || result.opcode == LogicCommandOpcode::Reset + || result.opcode == LogicCommandOpcode::Move + || result.opcode == LogicCommandOpcode::Add + || result.opcode == LogicCommandOpcode::Subtract) + { + cancelCommandInput(); + return; + } + if (!beginNextCommandInput()) + { + cancelCommandInput(); + } +} + +void LogicEditorWidget::cancelCommandInput() +{ + if (command_editor_ != nullptr) + { + command_editor_->clear(); + command_editor_->setVisible(false); + command_editor_->setStyleSheet(QString{}); + } + command_continuing_ = false; + command_keep_column_ = false; + command_current_rung_id_.clear(); + command_parallel_node_ids_.clear(); +} + +bool LogicEditorWidget::findCommandTargetCenter( + const LogicCommandTarget &target, + QPointF *scene_center) const +{ + if (scene_center == nullptr) + { + return false; + } + for (QGraphicsItem *item : scene_->items()) + { + if (const auto *slot = dynamic_cast(item)) + { + if (target.kind == (slot->branchExpressionId().empty() + ? LogicCommandTargetKind::EmptyColumn + : LogicCommandTargetKind::BranchEmptyColumn) + && slot->rungId() == target.rungId + && slot->branchExpressionId() == target.expressionId + && slot->column() == target.column) + { + *scene_center = slot->scenePos(); + return true; + } + } + else if (const auto *cell = dynamic_cast(item)) + { + if (target.kind == LogicCommandTargetKind::WireColumn + && cell->rungId() == target.rungId + && cell->expressionId() == target.expressionId + && cell->columnOffset() == target.column) + { + *scene_center = cell->scenePos(); + return true; + } + } + else if (const auto *cell = dynamic_cast(item)) + { + if (target.kind == LogicCommandTargetKind::GapColumn + && cell->rungId() == target.rungId + && cell->expressionId() == target.expressionId + && cell->columnOffset() == target.column) + { + *scene_center = cell->scenePos(); + return true; + } + } + else if (const auto *node = dynamic_cast(item)) + { + if (target.kind == LogicCommandTargetKind::ExistingNode + && node->rungId() == target.rungId + && node->nodeId() == target.expressionId) + { + *scene_center = node->scenePos(); + return true; + } + } + else if (const auto *slot = dynamic_cast(item)) + { + if (target.kind == LogicCommandTargetKind::Output + && slot->rungId() == target.rungId) + { + *scene_center = slot->scenePos(); + return true; + } + } + } + return false; +} + +void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) +{ + if (command_editor_ == nullptr) + { + return; + } + const QPoint view_center = mapFromScene(scene_center); + const int width = 320; + const int height = 38; + const QRect bounds = viewport()->rect().adjusted(4, 4, -4, -4); + const int left = std::clamp( + view_center.x() - width / 2, + bounds.left(), + std::max(bounds.left(), bounds.right() - width + 1)); + const int top = std::clamp( + view_center.y() - height / 2, + bounds.top(), + std::max(bounds.top(), bounds.bottom() - height + 1)); + command_editor_->setGeometry( + left, + top, + width, + height); +} + +bool LogicEditorWidget::beginNextCommandInput() +{ + if (!command_editor_->isVisible()) + { + return false; + } + if (command_target_.kind == LogicCommandTargetKind::Output) + { + return false; + } + const int next_column = command_target_.column + + (command_keep_column_ ? 0 : 1); + if (next_column >= ProjectLimits::kMaximumConditionColumns) + { + LogicCommandTarget output_target; + output_target.kind = LogicCommandTargetKind::Output; + output_target.rungId = command_current_rung_id_; + QPointF output_center; + if (!findCommandTargetCenter(output_target, &output_center)) + { + return false; + } + command_target_ = output_target; + command_editor_->clear(); + positionCommandInput(output_center); + command_editor_->setFocus(Qt::OtherFocusReason); + return true; + } + + LogicCommandTarget next = command_target_; + next.column = next_column; + QPointF next_center; + if (!findCommandTargetCenter(next, &next_center)) + { + next.kind = next.expressionId.empty() + ? LogicCommandTargetKind::EmptyColumn + : LogicCommandTargetKind::BranchEmptyColumn; + if (!findCommandTargetCenter(next, &next_center)) + { + next.kind = LogicCommandTargetKind::EmptyColumn; + next.expressionId.clear(); + if (!findCommandTargetCenter(next, &next_center)) + { + if (next_column < ProjectLimits::kMaximumConditionColumns - 1) + { + return false; + } + LogicCommandTarget output_target; + output_target.kind = LogicCommandTargetKind::Output; + output_target.rungId = command_current_rung_id_; + if (!findCommandTargetCenter(output_target, &next_center)) + { + return false; + } + command_target_ = output_target; + command_editor_->clear(); + positionCommandInput(next_center); + command_editor_->setFocus(Qt::OtherFocusReason); + return true; + } + } + } + command_target_ = next; + command_editor_->clear(); + positionCommandInput(next_center); + command_editor_->setFocus(Qt::OtherFocusReason); + return true; +} + +void LogicEditorWidget::selectCommandResult(const LogicCommandResult &result) +{ + if (result.id.empty()) + { + return; + } + selectNode(result.id); + emit nodeSelected(QString::fromUtf8(result.id.c_str())); + emit graphChanged(); +} + LogicEditorResult LogicEditorWidget::addRung() { const LogicEditorResult result = editor_service_.addRung(logic_id_); @@ -1921,6 +2638,7 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( LogicEditorResult LogicEditorWidget::deleteSelected() { + const std::vector selected_rungs = selectedRungItemIds(); const std::vector wire_ids = selectedWireIds(); const std::vector> wire_cells = selectedWireCells(); @@ -1929,57 +2647,118 @@ LogicEditorResult LogicEditorWidget::deleteSelected() const std::vector branch_ids = selectedBranchIds(); const std::vector node_ids = selectedNodeIds(); LogicEditorResult result; - if (!gap_cells.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "所选网格已经是断路,不需要重复删除", {}}; - } - else if (!branch_ids.empty()) + + // 框选网络时,网络本身优先于其中的节点、横线和断路占位删除 + if (!selected_rungs.empty()) { - result = editor_service_.removeExpressions( - logic_id_, selectedRungId(), branch_ids); + result = editor_service_.removeRungs(logic_id_, selected_rungs); + if (result.succeeded + && std::find( + selected_rungs.cbegin(), selected_rungs.cend(), + current_rung_id_) != selected_rungs.cend()) + { + current_rung_id_ = editor_service_.firstRungId(logic_id_); + } } - else if (!wire_ids.empty()) + else { - bool output_selected = false; - for (QGraphicsItem *item : scene_->selectedItems()) + bool changed = false; + bool failed = false; + const auto apply = [&result, &changed](const LogicEditorResult &operation) { - if (const NodeItem *node = dynamic_cast(item)) + if (!operation.succeeded) { - output_selected = output_selected || !node->isConditionNode(); + result = operation; + return false; + } + changed = true; + result = operation; + return true; + }; + + // 竖线和横线必须落在同一网络;节点可以跨网络批量删除 + const std::string rung_id = selectedRungId(); + if (!branch_ids.empty()) + { + if (rung_id.empty()) + { + result = {false, LogicEditorError::InvalidOperation, + "所选竖线连接必须位于同一网络", {}}; + failed = true; + } + else if (!apply(editor_service_.removeExpressions( + logic_id_, rung_id, branch_ids))) + { + failed = true; } } - if (output_selected) + + if (!failed) { - result = {false, LogicEditorError::InvalidOperation, - "不能同时删除横线和输出节点", {}}; + std::vector existing_node_ids; + for (const std::string &node_id : node_ids) + { + if (editor_service_.findNode(logic_id_, node_id) != nullptr) + { + existing_node_ids.push_back(node_id); + } + } + if (!existing_node_ids.empty() + && !apply(editor_service_.removeNodes(logic_id_, existing_node_ids))) + { + failed = true; + } } - else + + if (!failed && !wire_ids.empty() && !rung_id.empty()) { - result = !wire_cells.empty() - ? editor_service_.disconnectWireCells( - logic_id_, selectedRungId(), wire_cells) - : editor_service_.disconnectWires( - logic_id_, selectedRungId(), wire_ids); + std::vector> existing_wire_cells; + for (const auto &cell : wire_cells) + { + const ConditionExpression *expression = editor_service_.findExpression( + logic_id_, rung_id, cell.first); + if (expression != nullptr + && expression->kind == ConditionExpressionKind::Wire) + { + existing_wire_cells.push_back(cell); + } + } + std::vector existing_wire_ids; + for (const std::string &wire_id : wire_ids) + { + const ConditionExpression *expression = editor_service_.findExpression( + logic_id_, rung_id, wire_id); + if (expression != nullptr + && expression->kind == ConditionExpressionKind::Wire) + { + existing_wire_ids.push_back(wire_id); + } + } + if (!existing_wire_ids.empty()) + { + const LogicEditorResult wire_result = !existing_wire_cells.empty() + ? editor_service_.disconnectWireCells( + logic_id_, rung_id, existing_wire_cells) + : editor_service_.disconnectWires( + logic_id_, rung_id, existing_wire_ids); + apply(wire_result); + failed = !result.succeeded; + } } - } - else if (!node_ids.empty()) - { - result = editor_service_.removeNodes(logic_id_, node_ids); - } - else if (hasSelectedRungItem()) - { - const std::string rung_id = selectedRungId(); - result = editor_service_.removeRung(logic_id_, rung_id); - if (result.succeeded && current_rung_id_ == rung_id) + + // Gap/空网格本身已经是空白,混合框选时直接忽略,不再弹出误导性提示 + if (!failed && !changed && branch_ids.empty() && node_ids.empty() + && wire_ids.empty() + && (!gap_cells.empty() || !selectedEmptySlots().empty())) { - current_rung_id_ = editor_service_.firstRungId(logic_id_); + result = {true, LogicEditorError::None, {}, {}}; + changed = true; + } + if (!failed && !changed) + { + result = {false, LogicEditorError::InvalidOperation, + "请先选择要删除的逻辑节点或网络", {}}; } - } - else - { - result = {false, LogicEditorError::InvalidOperation, - "请先选择要删除的逻辑节点或网络", {}}; } if (result.succeeded) { @@ -1997,6 +2776,68 @@ LogicEditorResult LogicEditorWidget::deleteSelected() void LogicEditorWidget::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); + if (command_editor_ != nullptr && command_editor_->isVisible()) + { + QPointF center; + if (findCommandTargetCenter(command_target_, ¢er)) + { + positionCommandInput(center); + } + } +} + +void LogicEditorWidget::scrollContentsBy(int dx, int dy) +{ + QGraphicsView::scrollContentsBy(dx, dy); + Q_UNUSED(dx); + Q_UNUSED(dy); + if (command_editor_ != nullptr && command_editor_->isVisible()) + { + QPointF center; + if (findCommandTargetCenter(command_target_, ¢er)) + { + positionCommandInput(center); + } + } +} + +bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == command_editor_ && event != nullptr) + { + if (event->type() == QEvent::KeyPress) + { + const auto *key_event = static_cast(event); + if (key_event->key() == Qt::Key_Escape) + { + cancelCommandInput(); + return true; + } + } + else if (event->type() == QEvent::FocusOut) + { + // 延迟到焦点真正切换后判断,避免点击补全弹窗时误取消输入 + QTimer::singleShot(0, this, [this] + { + if (command_editor_ == nullptr || !command_editor_->isVisible() + || command_editor_->hasFocus()) + { + return; + } + QWidget *focus = QApplication::focusWidget(); + QWidget *popup = command_completer_ == nullptr + ? nullptr : command_completer_->popup(); + if (popup != nullptr && popup->isVisible() + && focus != nullptr + && (focus == popup || popup->isAncestorOf(focus))) + { + return; + } + cancelCommandInput(); + }); + } + } + return QGraphicsView::eventFilter(watched, event); } void LogicEditorWidget::handleSelectionChanged() diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 8d67f5d..0a03f95 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -9,6 +9,7 @@ #pragma once #include "domain/control_logic_model.h" +#include "services/logic_command_service.h" #include "services/logic_editor_service.h" #include "services/software_logic_executor.h" @@ -19,6 +20,11 @@ #include class QGraphicsScene; +class QCompleter; +class QEvent; +class QLineEdit; +class QMouseEvent; +class QRubberBand; class QResizeEvent; /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ @@ -35,6 +41,7 @@ public: class VerticalConnectorItem; class RungItem; class EmptySlotItem; + class OutputSlotItem; /** 创建梯形图画布并绑定编辑服务 */ explicit LogicEditorWidget( @@ -95,8 +102,20 @@ signals: void editorError(const QString &message); protected: + /** 在任意网络区域开始鼠标框选 */ + void mousePressEvent(QMouseEvent *event) override; + /** 更新鼠标框选区域 */ + void mouseMoveEvent(QMouseEvent *event) override; + /** 完成鼠标框选并提交场景选择 */ + void mouseReleaseEvent(QMouseEvent *event) override; + /** 在条件网格或输出槽双击时打开命令输入框 */ + void mouseDoubleClickEvent(QMouseEvent *event) override; /** 在窗口大小变化后重新布局梯形图场景 */ void resizeEvent(QResizeEvent *event) override; + /** 滚动画布时同步命令输入框位置 */ + void scrollContentsBy(int dx, int dy) override; + /** 处理命令输入框的回车、取消和按键事件 */ + bool eventFilter(QObject *watched, QEvent *event) override; private: /** 处理图形场景选择变化 */ @@ -123,9 +142,31 @@ private: std::vector> selectedGapCells() const; /** 判断当前是否选中了网络内的图元 */ bool hasSelectedRungItem() const; + /** 收集当前明确选中的网络图元 */ + std::vector selectedRungItemIds() const; + /** 打开指定目标位置的命令输入框 */ + void beginCommandInput( + const LogicCommandTarget &target, + const QPointF &scene_center); + /** 提交当前命令输入;失败时保留输入框并标红 */ + void commitCommandInput(); + /** 关闭命令输入框并清理临时状态 */ + void cancelCommandInput(); + /** 根据网络、列和支路标识查找下一输入位置 */ + bool beginNextCommandInput(); + /** 按目标定位输入框的屏幕矩形 */ + void positionCommandInput(const QPointF &scene_center); + /** 返回当前场景目标的中心位置 */ + bool findCommandTargetCenter( + const LogicCommandTarget &target, + QPointF *scene_center) const; + /** 将成功命令选中并刷新属性面板 */ + void selectCommandResult(const LogicCommandResult &result); /** 梯形图编辑服务,不由控件拥有 */ LogicEditorService &editor_service_; + /** 命令语解析和结构化编辑适配服务 */ + LogicCommandService command_service_; /** 承载梯形图图元的场景 */ QGraphicsScene *scene_ = nullptr; /** 当前显示的控制逻辑标识 */ @@ -140,4 +181,26 @@ private: bool runtime_trace_enabled_ = false; /** 是否允许编辑梯形图 */ bool editing_enabled_ = true; + /** 鼠标框选覆盖层 */ + QRubberBand *selection_band_ = nullptr; + /** 框选起点(视口坐标) */ + QPoint selection_origin_; + /** 框选时是否已超过拖拽阈值 */ + bool selection_dragging_ = false; + /** 框选起始时的键盘修饰键 */ + Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; + /** 命令输入框,仅在编辑态临时创建 */ + QLineEdit *command_editor_ = nullptr; + /** 命令输入补全器 */ + QCompleter *command_completer_ = nullptr; + /** 当前命令输入目标 */ + LogicCommandTarget command_target_; + /** 是否沿当前命令序列继续输入 */ + bool command_continuing_ = false; + /** OR/ORI 只增加支路,不推进主表达式列 */ + bool command_keep_column_ = false; + /** 连续输入对应的当前网络 */ + std::string command_current_rung_id_; + /** 编辑已有网络 OR 时保留的选择范围 */ + std::vector command_parallel_node_ids_; }; diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 3070b6a..f3fcb40 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -1,6 +1,7 @@ #include "domain/project_storage.h" #include "domain/project_limits.h" #include "services/logic_editor_service.h" +#include "services/logic_command_service.h" #include "services/editor_history.h" #include "services/project_service.h" #include "support/test_support.h" @@ -130,6 +131,244 @@ const ConditionExpression *firstExpressionOfKind( return nullptr; } +void testLogicCommandParsing() +{ + const LogicCommandParseResult load = LogicCommandService::parse("ldi m4000"); + require(load.succeeded + && load.command.opcode == LogicCommandOpcode::LoadInverse + && std::get(load.command.config).address.index() == 4000 + && std::get(load.command.config).mode + == ContactMode::NormallyClosed, + "LDI must parse case-insensitively into a configured contact"); + + const LogicCommandParseResult add = LogicCommandService::parse( + "ADD D0 D1 D4000"); + require(add.succeeded + && add.command.opcode == LogicCommandOpcode::Add + && std::get(add.command.config) + .destination.index() == 4000, + "ADD must parse three D operands through the shared address rules"); + const LogicCommandParseResult compare = LogicCommandService::parse( + "LD<= D12 -3"); + require(compare.succeeded + && compare.command.opcode + == LogicCommandOpcode::CompareLessThanOrEqual + && std::get(compare.command.config).address.index() == 12 + && std::get(compare.command.config).value == -3, + "LD comparison commands must parse a D address and signed constant"); + const LogicCommandParseResult move_constant = LogicCommandService::parse( + "MOV -7 D20"); + require(move_constant.succeeded + && std::get(move_constant.command.config).source.kind + == WordOperandKind::Constant + && std::get(move_constant.command.config).source.constant == -7, + "MOV must preserve constant sources when an existing output is reopened"); + const LogicCommandParseResult add_constant = LogicCommandService::parse( + "ADD D0 1 D2"); + require(add_constant.succeeded + && std::get(add_constant.command.config).right.kind + == WordOperandKind::Constant, + "ADD must accept constant operands used by the instruction dialog"); + require(!LogicCommandService::parse("LD D0").succeeded, + "contact commands must reject D addresses"); + require(!LogicCommandService::parse("ADD D0 D1 D4001").succeeded, + "data commands must reject out-of-range D addresses"); + require(!LogicCommandService::parse("TON T0").succeeded, + "unsupported commands must be rejected explicitly"); + require(!LogicCommandService::parse("OUT M0 M1").succeeded, + "commands with the wrong operand count must be rejected"); + + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + LogicCommandService commands(editor); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(editor, logic_id); + LogicCommandRequest and_without_load; + and_without_load.logicId = logic_id; + and_without_load.text = "AND M0"; + and_without_load.target = { + LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; + require(!commands.execute(and_without_load).succeeded, + "AND without a preceding LD must be rejected"); +} + +void testContinuousLogicCommands() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + LogicCommandService commands(editor); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(editor, logic_id); + + LogicCommandRequest request; + request.logicId = logic_id; + request.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; + request.text = "LD M1"; + const LogicCommandResult load = commands.execute(request); + require(load.succeeded && load.rungId == rung_id, + "LD on an empty cell must create the first condition in that network"); + + request.continuing = true; + request.currentRungId = load.rungId; + request.text = "AND M2"; + const LogicCommandResult and_result = commands.execute(request); + require(and_result.succeeded, + "AND must append to the current command-input network"); + request.text = "OR M3"; + const LogicCommandResult or_result = commands.execute(request); + require(or_result.succeeded, + "OR must parallel the complete accumulated condition: " + + or_result.message); + + const LadderRung *rung = editor.findRung(logic_id, rung_id); + const ConditionExpression *parallel = firstExpressionOfKind( + *rung->condition, ConditionExpressionKind::Parallel); + require(parallel != nullptr && parallel->children.size() == 2U + && parallel->children.front().kind == ConditionExpressionKind::Series + && conditionNodes(parallel->children.front()) == 2 + && conditionNodes(parallel->children.back()) == 1, + "LD M1, AND M2, OR M3 must form (M1 AND M2) OR M3"); + + request.text = "OUT M4"; + const LogicCommandResult output = commands.execute(request); + rung = editor.findRung(logic_id, rung_id); + require(output.succeeded && rung != nullptr && rung->validateForRunning(), + "command-created contacts and output must be configured and runnable"); + + request.text = "LDI M5"; + const LogicCommandResult next_load = commands.execute(request); + require(next_load.succeeded && next_load.rungId != rung_id + && editor.findLogic(logic_id)->rungs.size() == 2U, + "a consecutive LD after an output must create the next network"); +} + +void testExistingNodeCommandReplacement() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + LogicCommandService commands(editor); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(editor, logic_id); + const LogicEditorResult contact_result = editor.appendCondition( + logic_id, rung_id, contact(10), true); + require(contact_result.succeeded, + "existing-node command fixture must create a contact"); + + LogicCommandRequest replace_contact; + replace_contact.logicId = logic_id; + replace_contact.text = "LDI M11"; + replace_contact.target = { + LogicCommandTargetKind::ExistingNode, + rung_id, + contact_result.id, + 0}; + const LogicCommandResult replaced_contact = commands.execute(replace_contact); + const LogicNode *contact_node = editor.findNode(logic_id, contact_result.id); + require(replaced_contact.succeeded && contact_node != nullptr + && std::holds_alternative(contact_node->config) + && std::get(contact_node->config).address.index() == 11 + && std::get(contact_node->config).mode + == ContactMode::NormallyClosed, + "an existing contact must accept a load command replacement"); + + const LogicEditorResult output_result = editor.setOutput( + logic_id, + rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, + true); + require(output_result.succeeded, + "existing-node command fixture must create an output"); + LogicCommandRequest replace_output; + replace_output.logicId = logic_id; + replace_output.text = "SET M21"; + replace_output.target = { + LogicCommandTargetKind::ExistingNode, + rung_id, + output_result.id, + 0}; + const LogicCommandResult replaced_output = commands.execute(replace_output); + const LogicNode *output_node = editor.findNode(logic_id, output_result.id); + require(replaced_output.succeeded && output_node != nullptr + && std::holds_alternative(output_node->config) + && std::get(output_node->config).address.index() == 21 + && std::get(output_node->config).mode == CoilMode::Set, + "an existing output must accept an output command replacement"); + + LogicCommandRequest wrong_type = replace_contact; + wrong_type.text = "OUT M30"; + require(!commands.execute(wrong_type).succeeded, + "an existing condition must reject an output command replacement"); +} + +void testPositionedLogicCommandsAndAtomicFailures() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + LogicCommandService commands(editor); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(editor, logic_id); + const LogicEditorResult first = editor.appendCondition( + logic_id, rung_id, contact(10), true); + const LogicEditorResult second = editor.appendCondition( + logic_id, rung_id, contact(11), true); + + LogicCommandRequest selected_or; + selected_or.logicId = logic_id; + selected_or.text = "ORI M12"; + selected_or.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 2}; + selected_or.parallelNodeIds = {first.id, second.id}; + require(commands.execute(selected_or).succeeded, + "ORI on an existing network must use the selected continuous range"); + + const std::string wire_rung = makeEmptyRung(editor, logic_id); + const LogicEditorResult wire = editor.appendWire(logic_id, wire_rung, 3); + LogicCommandRequest wire_command; + wire_command.logicId = logic_id; + wire_command.text = "LDP M20"; + wire_command.target = { + LogicCommandTargetKind::WireColumn, wire_rung, wire.id, 1}; + const LogicCommandResult inserted = commands.execute(wire_command); + require(inserted.succeeded + && editor.findNode(logic_id, inserted.id)->isConfigured(), + "a command on a wire cell must replace exactly that cell"); + + const std::string gap_rung = makeEmptyRung(editor, logic_id); + const LogicEditorResult gap_wire = editor.appendWire(logic_id, gap_rung, 2); + require(editor.disconnectWireCells(logic_id, gap_rung, {{gap_wire.id, 0}}).succeeded, + "gap command fixture must disconnect one wire cell"); + const ConditionExpression *gap = firstExpressionOfKind( + *editor.findRung(logic_id, gap_rung)->condition, + ConditionExpressionKind::Gap); + LogicCommandRequest gap_command; + gap_command.logicId = logic_id; + gap_command.text = "LD M21"; + gap_command.target = { + LogicCommandTargetKind::GapColumn, gap_rung, gap->id, 0}; + require(commands.execute(gap_command).succeeded + && gapColumns(*editor.findRung(logic_id, gap_rung)->condition) == 0, + "a command on a gap cell must repair that exact gap"); + + editor.clearHistory(); + const std::size_t rung_count = editor.findLogic(logic_id)->rungs.size(); + LogicCommandRequest invalid = gap_command; + invalid.text = "ADD D0 D1 D4001"; + require(!commands.execute(invalid).succeeded + && editor.findLogic(logic_id)->rungs.size() == rung_count + && !editor.canUndo(), + "an invalid command must not change the project or undo history"); + + LogicCommandRequest wrong_position = gap_command; + wrong_position.text = "OUT M30"; + require(!commands.execute(wrong_position).succeeded + && !editor.canUndo(), + "an output command in the condition area must be rejected atomically"); +} + void testEmptyLogicCreatesNetworksOnFirstEdit() { TestProjectStorage storage; @@ -178,6 +417,33 @@ void testEmptyLogicCreatesNetworksOnFirstEdit() "the first output must create a full-width explicit wire network"); } +void testBatchRungDeleteIsAtomic() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string first = service.addRung(logic_id).id; + const std::string second = service.addRung(logic_id).id; + const std::string third = service.addRung(logic_id).id; + require(service.findLogic(logic_id)->rungs.size() == 3U, + "the fixture must create three networks"); + + require(service.removeRungs(logic_id, {first, second}).succeeded + && service.findLogic(logic_id)->rungs.size() == 1U + && service.findRung(logic_id, third) != nullptr, + "a selected group of networks must be deleted together"); + require(service.undo().succeeded + && service.findLogic(logic_id)->rungs.size() == 3U, + "undo must restore a batch network deletion as one edit"); + + service.clearHistory(); + require(!service.removeRungs(logic_id, {first, first}).succeeded + && service.findLogic(logic_id)->rungs.size() == 3U + && !service.canUndo(), + "duplicate network selection must fail without mutation or history"); +} + void testAppendingWireMovesToNextNetworkAfterTenColumns() { TestProjectStorage storage; @@ -203,6 +469,38 @@ void testAppendingWireMovesToNextNetworkAfterTenColumns() "automatic network rollover must be undone as one edit"); } +void testAppendingWireReusesDeletedGapCells() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(service, logic_id); + const LogicEditorResult full_wire = service.appendWire( + logic_id, rung_id, ProjectLimits::kMaximumConditionColumns); + require(full_wire.succeeded, "gap reuse setup must create a full-width wire"); + require(service.disconnectWires(logic_id, rung_id, {full_wire.id}).succeeded, + "deleting the full-width wire must create reusable gaps"); + + LogicEditorResult current = service.appendWire(logic_id, rung_id); + require(current.succeeded, + "the first appended wire must reuse the first deleted gap cell"); + for (int index = 1; index < ProjectLimits::kMaximumConditionColumns; ++index) + { + current = service.insertWireAfter(logic_id, rung_id, current.id); + require(current.succeeded, + "inserting after a selected wire must reuse the next deleted gap cell"); + require(service.findLogic(logic_id)->rungs.size() == 1U, + "reusing a gap must not create a new network"); + } + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && conditionColumns(*rung->condition) + == ProjectLimits::kMaximumConditionColumns + && gapColumns(*rung->condition) == 0, + "all deleted gap cells must be restored as horizontal wires"); +} + void testStructuredEditingAndNormalization() { TestProjectStorage storage; @@ -1464,8 +1762,14 @@ int main() { try { + testLogicCommandParsing(); + testContinuousLogicCommands(); + testExistingNodeCommandReplacement(); + testPositionedLogicCommandsAndAtomicFailures(); testEmptyLogicCreatesNetworksOnFirstEdit(); + testBatchRungDeleteIsAtomic(); testAppendingWireMovesToNextNetworkAfterTenColumns(); + testAppendingWireReusesDeletedGapCells(); testStructuredEditingAndNormalization(); testRangeParallelInsertion(); testParallelBranchGridInsertion(); diff --git a/app/tests/pri/test_layers.pri b/app/tests/pri/test_layers.pri index d48a5a7..5d0310e 100644 --- a/app/tests/pri/test_layers.pri +++ b/app/tests/pri/test_layers.pri @@ -91,11 +91,13 @@ SERVICE_HMI_HEADERS = \ ../src/services/hmi_runtime_service.h SERVICE_LOGIC_SOURCES = \ - ../src/services/logic_editor_service.cpp + ../src/services/logic_editor_service.cpp \ + ../src/services/logic_command_service.cpp SERVICE_LOGIC_HEADERS = \ ../src/services/editor_history.h \ - ../src/services/logic_editor_service.h + ../src/services/logic_editor_service.h \ + ../src/services/logic_command_service.h SERVICE_OFFLINE_SOURCES = \ ../src/services/software_logic_executor.cpp \