diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 2d0d8ea..aec77d3 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -1559,6 +1559,25 @@ LogicEditorResult LogicEditorService::setWireCells( } } + const auto should_change = [this, &logic_id, connected]( + const std::pair &position) + { + const LadderCell *cell = findCell( + logic_id, position.first, position.second); + return cell != nullptr + && cell->kind != LadderCellKind::Node + && (connected ? cell->kind != LadderCellKind::Wire + : cell->kind != LadderCellKind::Gap); + }; + if (!std::any_of(cells.cbegin(), cells.cend(), should_change)) + { + return { + true, + LogicEditorError::None, + connected ? "横线已经存在" : "目标位置没有横线", + cells.front().first}; + } + HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index abe61ae..79ef509 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -690,6 +690,7 @@ LogicEditorWidget::LogicEditorWidget( void LogicEditorWidget::setLogicId(const std::string &logic_id) { const bool logic_changed = logic_id_ != logic_id; + clearGesture(); logic_id_ = logic_id; if (logic_changed) { @@ -792,6 +793,7 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) void LogicEditorWidget::setEditingEnabled(bool enabled) { + clearGesture(); editing_enabled_ = enabled; if (!enabled) { @@ -806,6 +808,7 @@ void LogicEditorWidget::setEditingEnabled(bool enabled) void LogicEditorWidget::setMouseWireMode(MouseWireMode mode) { + clearGesture(); mouse_wire_mode_ = editing_enabled_ ? mode : MouseWireMode::Select; selection_pressed_ = false; selection_dragging_ = false; @@ -1707,14 +1710,21 @@ void LogicEditorWidget::beginGesture(const Hit &hit) gesture_active_ = true; gesture_origin_ = hit; gesture_current_ = hit; + gesture_scene_position_valid_ = false; + viewport()->update(); } -void LogicEditorWidget::updateGesture(const Hit &hit) +void LogicEditorWidget::updateGesture( + const Hit &hit, const QPointF &scene_position) { - if (gesture_active_ && !hit.rungId.empty()) + if (!gesture_active_) { - gesture_current_ = hit; + return; } + gesture_current_ = hit; + gesture_scene_position_ = scene_position; + gesture_scene_position_valid_ = true; + viewport()->update(); } void LogicEditorWidget::finishGesture(const Hit &hit) @@ -1723,57 +1733,62 @@ void LogicEditorWidget::finishGesture(const Hit &hit) { return; } + const Hit origin = gesture_origin_; gesture_active_ = false; - if (gesture_origin_.rungId.empty() || hit.rungId.empty()) + gesture_origin_ = {}; + gesture_current_ = {}; + gesture_scene_position_valid_ = false; + viewport()->update(); + if (origin.rungId.empty() || hit.rungId.empty()) { return; } LogicEditorResult result; const bool connected = mouse_wire_mode_ == MouseWireMode::Draw; - if ((gesture_origin_.vertical || gesture_origin_.boundary) + if ((origin.vertical || origin.boundary) && (hit.vertical || hit.boundary)) { - if (!connected && gesture_origin_.vertical - && gesture_origin_.objectId == hit.objectId) + if (!connected && origin.vertical + && origin.objectId == hit.objectId) { result = editor_service_.removeVerticalConnections( - logic_id_, {gesture_origin_.objectId}); + logic_id_, {origin.objectId}); } - else if (gesture_origin_.column != hit.column) + else if (origin.column != hit.column) { result = {false, LogicEditorError::InvalidOperation, "竖线拖动必须保持在同一列边界", {}}; } else { - std::string first_rung = gesture_origin_.rungId; + std::string first_rung = origin.rungId; std::string last_rung = hit.rungId; - if (last_rung.empty() && !gesture_origin_.lowerRungId.empty()) + if (last_rung.empty() && !origin.lowerRungId.empty()) { - last_rung = gesture_origin_.lowerRungId; + last_rung = origin.lowerRungId; } result = editor_service_.setVerticalConnectionRange( logic_id_, first_rung, last_rung, - gesture_origin_.column, connected); + origin.column, connected); } } - else if (gesture_origin_.vertical || hit.vertical) + else if (origin.vertical || hit.vertical) { result = {false, LogicEditorError::InvalidOperation, "请从网格边界开始竖向拖动", {}}; } - else if (gesture_origin_.column == hit.column - && gesture_origin_.rungId != hit.rungId) + else if (origin.column == hit.column + && origin.rungId != hit.rungId) { result = editor_service_.setVerticalConnectionRange( - logic_id_, gesture_origin_.rungId, hit.rungId, - gesture_origin_.column, connected); + logic_id_, origin.rungId, hit.rungId, + origin.column, connected); } - else if (gesture_origin_.rungId == hit.rungId) + else if (origin.rungId == hit.rungId) { result = editor_service_.setHorizontalWireRange( - logic_id_, gesture_origin_.rungId, - gesture_origin_.column, hit.column, connected); + logic_id_, origin.rungId, + origin.column, hit.column, connected); } else { @@ -1795,6 +1810,134 @@ void LogicEditorWidget::finishGesture(const Hit &hit) } } +void LogicEditorWidget::clearGesture() +{ + if (!gesture_active_ && !gesture_scene_position_valid_) + { + return; + } + gesture_active_ = false; + gesture_origin_ = {}; + gesture_current_ = {}; + gesture_scene_position_ = {}; + gesture_scene_position_valid_ = false; + viewport()->update(); +} + +void LogicEditorWidget::drawGesturePreview(QPainter *painter) const +{ + if (!gesture_active_ || painter == nullptr + || gesture_origin_.rungId.empty()) + { + return; + } + + enum class PreviewKind { Invalid, Horizontal, Vertical }; + PreviewKind kind = PreviewKind::Invalid; + bool valid = false; + const Hit &origin = gesture_origin_; + const Hit ¤t = gesture_current_; + const bool connected = mouse_wire_mode_ == MouseWireMode::Draw; + + if (!current.rungId.empty()) + { + if ((origin.vertical || origin.boundary) + && (current.vertical || current.boundary)) + { + kind = PreviewKind::Vertical; + valid = origin.column == current.column + && (origin.rungId != current.rungId + || (!connected && origin.vertical + && origin.objectId == current.objectId)); + } + else if (origin.vertical || current.vertical) + { + kind = PreviewKind::Invalid; + } + else if (origin.column == current.column + && origin.rungId != current.rungId) + { + kind = PreviewKind::Vertical; + valid = origin.column >= 0 + && origin.column <= ProjectLimits::kMaximumConditionColumns; + } + else if (origin.rungId == current.rungId) + { + kind = PreviewKind::Horizontal; + valid = origin.column >= 0 && current.column >= 0 + && origin.column < ProjectLimits::kMaximumConditionColumns + && current.column < ProjectLimits::kMaximumConditionColumns; + } + } + + QColor preview_color = valid + ? connected ? QColor(QStringLiteral("#277da1")) + : QColor(QStringLiteral("#c56a1a")) + : kFaultColor; + preview_color.setAlpha(220); + QPen preview_pen(preview_color, valid ? 2.8 : 2.4, Qt::DashLine); + preview_pen.setCosmetic(true); + preview_pen.setCapStyle(Qt::SquareCap); + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(preview_pen); + painter->setBrush(Qt::NoBrush); + + if (valid && kind == PreviewKind::Horizontal) + { + const RowLayout *layout = layoutForRung(origin.rungId); + if (layout != nullptr) + { + const int first = std::min(origin.column, current.column); + const int last = std::max(origin.column, current.column); + for (int column = first; column <= last; ++column) + { + const LadderCell *cell = editor_service_.findCell( + logic_id_, origin.rungId, column); + if (cell == nullptr || cell->kind == LadderCellKind::Node + || (mouse_wire_mode_ == MouseWireMode::Erase + && cell->kind != LadderCellKind::Wire)) + { + continue; + } + const qreal left = kLeftBus + + static_cast(column) * kCellWidth; + painter->drawLine( + QPointF(left, layout->centerY), + QPointF(left + kCellWidth, layout->centerY)); + } + } + } + else if (valid && kind == PreviewKind::Vertical) + { + const RowLayout *first = layoutForRung(origin.rungId); + const RowLayout *last = layoutForRung(current.rungId); + if (first != nullptr && last != nullptr) + { + const qreal x = kLeftBus + + static_cast(origin.column) * kCellWidth; + painter->drawLine( + QPointF(x, first->centerY), + QPointF(x, last->centerY)); + } + } + else + { + const RowLayout *layout = layoutForRung(origin.rungId); + if (layout != nullptr && gesture_scene_position_valid_) + { + const qreal origin_x = kLeftBus + + (origin.vertical || origin.boundary + ? static_cast(origin.column) * kCellWidth + : (static_cast(origin.column) + 0.5) * kCellWidth); + painter->drawLine( + QPointF(origin_x, layout->centerY), + gesture_scene_position_); + } + } + painter->restore(); +} + void LogicEditorWidget::showCommandEditor(const Hit &hit) { if (!editing_enabled_ || hit.rungId.empty() @@ -2199,7 +2342,8 @@ void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) } else { - updateGesture(hitAt(mapToScene(event->pos()))); + const QPointF scene_position = mapToScene(event->pos()); + updateGesture(hitAt(scene_position), scene_position); } event->accept(); } @@ -2242,6 +2386,13 @@ void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event) event->accept(); } +void LogicEditorWidget::drawForeground( + QPainter *painter, const QRectF &rect) +{ + QGraphicsView::drawForeground(painter, rect); + drawGesturePreview(painter); +} + void LogicEditorWidget::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 3bfb4cc..ca20dc2 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -17,6 +17,7 @@ class QCompleter; class QEvent; class QLineEdit; class QMouseEvent; +class QPainter; class QRubberBand; class QResizeEvent; @@ -76,6 +77,7 @@ protected: void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; void mouseDoubleClickEvent(QMouseEvent *event) override; + void drawForeground(QPainter *painter, const QRectF &rect) override; void resizeEvent(QResizeEvent *event) override; void scrollContentsBy(int dx, int dy) override; bool eventFilter(QObject *watched, QEvent *event) override; @@ -108,8 +110,10 @@ private: std::string currentRungId() const; Hit hitAt(const QPointF &scene_position) const; void beginGesture(const Hit &hit); - void updateGesture(const Hit &hit); + void updateGesture(const Hit &hit, const QPointF &scene_position); void finishGesture(const Hit &hit); + void clearGesture(); + void drawGesturePreview(QPainter *painter) const; void showCommandEditor(const Hit &hit); void commitCommandInput(); void cancelCommandInput(); @@ -162,6 +166,8 @@ private: bool gesture_active_ = false; Hit gesture_origin_; Hit gesture_current_; + QPointF gesture_scene_position_; + bool gesture_scene_position_valid_ = false; QLineEdit *command_editor_ = nullptr; QCompleter *command_completer_ = nullptr; LogicCommandTarget command_target_; diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 1bcd973..4b99320 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -99,6 +99,16 @@ void testContinuousGridAndIndependentHorizontalWires() "horizontal wires must be persisted independently per cell"); } + fixture.editor.clearHistory(); + fixture.projects.restoreModifiedState(false); + const LogicEditorResult duplicate = fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 2, 5, true); + require( + duplicate.succeeded + && !fixture.projects.isModified() + && !fixture.editor.canUndo(), + "repeating an existing horizontal wire range must not dirty the project or history"); + const LogicEditorResult node = fixture.editor.setConditionAtColumn( fixture.logicId, rung_id, 3, contact(3), true); require(node.succeeded, "a wire cell must accept a condition node"); diff --git a/app/tests/runtime_panel_controller_tests.cpp b/app/tests/runtime_panel_controller_tests.cpp index 42650cf..14f72a3 100644 --- a/app/tests/runtime_panel_controller_tests.cpp +++ b/app/tests/runtime_panel_controller_tests.cpp @@ -685,6 +685,160 @@ void testLadderLayoutAndDragDeletion() "one undo must restore every object removed by the drag selection"); } +void testWireGesturePreviewKeepsAtomicCommit() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string first = editor.addRung(logic_id).id; + const std::string second = editor.addRung(logic_id).id; + editor.clearHistory(); + project_service.restoreModifiedState(false); + + LogicEditorWidget widget(editor); + widget.setLogicId(logic_id); + widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Draw); + widget.resize(1200, 360); + widget.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents); + + constexpr qreal left_bus = 60.0; + constexpr qreal cell_width = 96.0; + constexpr qreal first_row_center = 91.0; + constexpr qreal second_row_center = 197.0; + const QPoint first_cell = widget.mapFromScene(QPointF( + left_bus + cell_width / 2.0, first_row_center)); + const QPoint fourth_cell = widget.mapFromScene(QPointF( + left_bus + 3.0 * cell_width + cell_width / 2.0, + first_row_center)); + const QPoint invalid_target = widget.mapFromScene(QPointF( + left_bus + 3.0 * cell_width + cell_width / 2.0, + second_row_center)); + const auto press = [&widget](const QPoint &point) + { + QMouseEvent event( + QEvent::MouseButtonPress, + QPointF(point), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &event); + }; + const auto move = [&widget](const QPoint &point) + { + QMouseEvent event( + QEvent::MouseMove, + QPointF(point), + Qt::NoButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &event); + QCoreApplication::processEvents(QEventLoop::AllEvents); + }; + const auto release = [&widget](const QPoint &point) + { + QMouseEvent event( + QEvent::MouseButtonRelease, + QPointF(point), + Qt::LeftButton, + Qt::NoButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &event); + QCoreApplication::processEvents(QEventLoop::AllEvents); + }; + const auto countPreviewPixels = [&widget](bool invalid) + { + const QImage image = widget.viewport()->grab().toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) + { + for (int x = 0; x < image.width(); ++x) + { + const QColor color = image.pixelColor(x, y); + const bool matches = invalid + ? color.red() > color.green() + 45 + && color.red() > color.blue() + 25 + : color.blue() > color.red() + 50 + && color.blue() > color.green() + 20; + count += matches ? 1 : 0; + } + } + return count; + }; + + QString error_message; + QObject::connect( + &widget, + &LogicEditorWidget::editorError, + [&error_message](const QString &message) { error_message = message; }); + + press(first_cell); + move(fourth_cell); + require(countPreviewPixels(false) > 20, + "a legal drag must paint a visible temporary wire preview"); + const LadderRung *first_rung = editor.findRung(logic_id, first); + require( + std::all_of( + first_rung->cells.cbegin(), + first_rung->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Gap; + }) + && !project_service.isModified() + && !editor.canUndo(), + "the temporary preview must not modify the project or history"); + + move(invalid_target); + require(countPreviewPixels(true) > 20, + "an invalid drag direction must switch the preview to an error color"); + release(invalid_target); + first_rung = editor.findRung(logic_id, first); + require( + !error_message.isEmpty() + && std::all_of( + first_rung->cells.cbegin(), + first_rung->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Gap; + }) + && editor.findRung(logic_id, second) != nullptr + && !project_service.isModified() + && !editor.canUndo(), + "releasing an invalid gesture must discard its complete preview"); + + error_message.clear(); + press(first_cell); + move(fourth_cell); + require(!project_service.isModified() && !editor.canUndo(), + "a legal gesture must remain temporary until mouse release"); + release(fourth_cell); + first_rung = editor.findRung(logic_id, first); + require( + error_message.isEmpty() + && first_rung->cells[0].kind == LadderCellKind::Wire + && first_rung->cells[1].kind == LadderCellKind::Wire + && first_rung->cells[2].kind == LadderCellKind::Wire + && first_rung->cells[3].kind == LadderCellKind::Wire + && project_service.isModified() + && editor.canUndo(), + "releasing a legal gesture must commit its complete wire range once"); + require(editor.undo().succeeded && !editor.canUndo(), + "one undo must remove the complete committed gesture"); + first_rung = editor.findRung(logic_id, first); + require( + std::all_of( + first_rung->cells.cbegin(), + first_rung->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Gap; + }), + "undo must restore every cell changed by the gesture"); +} + void testCursorAdvanceAndInlineCommandInput() { TestProjectStorage storage; @@ -1077,6 +1231,7 @@ int main(int argc, char *argv[]) testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); testLogicEditorGridSelectionAndDeletion(); testLadderLayoutAndDragDeletion(); + testWireGesturePreviewKeepsAtomicCommit(); testCursorAdvanceAndInlineCommandInput(); testVerticalWireShortcutAdvancesDownward(); testSegmentLevelTraceProjection();